From 2413884af53a0259cf15ccfca89e17fed00941f3 Mon Sep 17 00:00:00 2001 From: Michael McAuliffe Date: Sat, 4 Feb 2023 20:48:03 -0800 Subject: [PATCH] V2.1 (#536) Resolves #527 Resolves #525 Resolves #522 Resolves #520 Resolves #511 Resolves #540 Resolves #541 --- .github/workflows/main.yml | 72 +- .pre-commit-config.yaml | 8 +- ci/mfa_publish.yml | 3 +- .../source/_static/css/{style.css => mfa.css} | 101 +- docs/source/_static/interrogate_badge.svg | 6 +- docs/source/_templates/autosummary/class.rst | 2 + .../_templates/autosummary/function.rst | 2 + docs/source/_templates/sidebar-nav-bs.html | 9 - .../changelog/changelog_2.0_pre_release.rst | 2 +- docs/source/changelog/changelog_2.1.rst | 20 + docs/source/changelog/index.md | 72 + docs/source/changelog/index.rst | 152 -- docs/source/changelog/news_1.1.rst | 71 + docs/source/changelog/news_2.0.rst | 66 + docs/source/changelog/news_2.1.rst | 48 + docs/source/conf.py | 26 +- docs/source/first_steps/example.rst | 8 +- docs/source/installation.rst | 11 +- .../reference/acoustic_modeling/helper.rst | 14 + .../reference/acoustic_modeling/index.rst | 2 +- docs/source/reference/alignment/helper.rst | 4 + docs/source/reference/database/index.rst | 11 +- docs/source/reference/diarization/helper.rst | 17 + docs/source/reference/diarization/index.rst | 12 + docs/source/reference/diarization/main.rst | 10 + docs/source/reference/dictionary/helper.rst | 6 - docs/source/reference/helper/command_line.rst | 27 - docs/source/reference/helper/config.rst | 5 +- docs/source/reference/helper/data.rst | 3 +- docs/source/reference/helper/exceptions.rst | 1 + docs/source/reference/helper/helper.rst | 1 + docs/source/reference/helper/index.rst | 1 - docs/source/reference/helper/utils.rst | 1 - .../reference/language_modeling/helper.rst | 15 + docs/source/reference/segmentation/helper.rst | 2 +- docs/source/reference/segmentation/main.rst | 2 +- docs/source/reference/top_level_index.rst | 1 + .../source/reference/transcription/helper.rst | 2 - docs/source/reference/validation/helper.rst | 14 - docs/source/user_guide/commands.rst | 2 +- docs/source/user_guide/concepts/hmm.rst | 9 + .../user_guide/configuration/diarization.rst | 26 + docs/source/user_guide/configuration/g2p.rst | 3 - .../source/user_guide/configuration/index.rst | 9 +- .../user_guide/corpus_creation/anchor.rst | 12 +- .../corpus_creation/classify_speakers.rst | 17 - .../corpus_creation/create_segments.rst | 15 +- .../corpus_creation/diarize_speakers.rst | 37 + .../user_guide/corpus_creation/index.rst | 14 +- .../corpus_creation/train_ivector.rst | 8 +- .../corpus_creation/training_dictionary.rst | 326 +-- .../corpus_creation/training_lm.rst | 6 +- .../corpus_creation/transcribing.rst | 6 +- docs/source/user_guide/corpus_structure.rst | 16 +- docs/source/user_guide/data_validation.rst | 11 +- docs/source/user_guide/dictionary.rst | 17 +- .../user_guide/dictionary_validation.rst | 5 +- .../implementations/alignment_evaluation.md | 39 + .../user_guide/implementations/fine_tune.md | 11 + .../user_guide/implementations/index.md | 18 + .../implementations/lexicon_probabilities.md | 351 +++ .../implementations/phone_groups.md | 2 + .../implementations/phone_models.md | 9 + .../implementations/phonological_rules.md | 2 + docs/source/user_guide/index.rst | 3 + docs/source/user_guide/models/index.rst | 7 +- docs/source/user_guide/performance.rst | 154 ++ .../workflows/adapt_acoustic_model.rst | 6 +- .../source/user_guide/workflows/alignment.rst | 48 +- .../workflows/dictionary_generating.rst | 6 +- .../source/user_guide/workflows/g2p_train.rst | 6 +- .../workflows/train_acoustic_model.rst | 6 +- environment.yml | 35 +- montreal_forced_aligner/__main__.py | 4 +- montreal_forced_aligner/abc.py | 428 ++-- .../acoustic_modeling/base.py | 228 +- .../acoustic_modeling/lda.py | 109 +- .../acoustic_modeling/monophone.py | 104 +- .../pronunciation_probabilities.py | 274 +- .../acoustic_modeling/sat.py | 92 +- .../acoustic_modeling/trainer.py | 430 +++- .../acoustic_modeling/triphone.py | 69 +- montreal_forced_aligner/alignment/adapting.py | 141 +- montreal_forced_aligner/alignment/base.py | 1165 ++++++--- montreal_forced_aligner/alignment/mixins.py | 241 +- .../alignment/multiprocessing.py | 2055 ++++++++++++--- .../alignment/pretrained.py | 256 +- .../command_line/__init__.py | 77 +- montreal_forced_aligner/command_line/adapt.py | 198 +- montreal_forced_aligner/command_line/align.py | 217 +- .../command_line/anchor.py | 24 +- .../command_line/classify_speakers.py | 93 - .../command_line/configure.py | 120 + .../command_line/create_segments.py | 135 +- .../command_line/diarize_speakers.py | 126 + montreal_forced_aligner/command_line/g2p.py | 156 +- .../command_line/history.py | 39 + montreal_forced_aligner/command_line/mfa.py | 1084 +------- montreal_forced_aligner/command_line/model.py | 275 +-- .../command_line/train_acoustic_model.py | 189 +- .../command_line/train_dictionary.py | 159 +- .../command_line/train_g2p.py | 127 +- .../command_line/train_ivector_extractor.py | 130 +- .../command_line/train_lm.py | 142 +- .../command_line/transcribe.py | 204 +- montreal_forced_aligner/command_line/utils.py | 286 ++- .../command_line/validate.py | 267 +- montreal_forced_aligner/config.py | 242 +- .../corpus/acoustic_corpus.py | 1032 ++++---- montreal_forced_aligner/corpus/base.py | 550 ++++- montreal_forced_aligner/corpus/classes.py | 62 - montreal_forced_aligner/corpus/features.py | 1645 +++++++++++-- montreal_forced_aligner/corpus/helper.py | 3 +- .../corpus/ivector_corpus.py | 432 +++- .../corpus/multiprocessing.py | 754 ++++-- montreal_forced_aligner/corpus/text_corpus.py | 65 +- montreal_forced_aligner/data.py | 595 ++++- montreal_forced_aligner/db.py | 1351 +++++++--- .../diarization/__init__.py | 0 .../diarization/multiprocessing.py | 858 +++++++ .../diarization/speaker_diarizer.py | 1578 ++++++++++++ .../dictionary/__init__.py | 1 - montreal_forced_aligner/dictionary/mixins.py | 222 +- .../dictionary/multispeaker.py | 1363 ++++++---- montreal_forced_aligner/exceptions.py | 49 +- montreal_forced_aligner/g2p/__init__.py | 9 +- montreal_forced_aligner/g2p/generator.py | 188 +- montreal_forced_aligner/g2p/mixins.py | 5 - .../g2p/phonetisaurus_trainer.py | 206 +- montreal_forced_aligner/g2p/trainer.py | 87 +- montreal_forced_aligner/helper.py | 157 +- .../ivector/multiprocessing.py | 346 +++ montreal_forced_aligner/ivector/trainer.py | 945 +++---- .../language_modeling/multiprocessing.py | 427 ++++ .../language_modeling/trainer.py | 306 ++- montreal_forced_aligner/models.py | 95 +- montreal_forced_aligner/online/alignment.py | 432 ++-- montreal_forced_aligner/segmenter.py | 461 ---- montreal_forced_aligner/speaker_classifier.py | 227 -- montreal_forced_aligner/textgrid.py | 51 +- .../transcription/multiprocessing.py | 680 +++-- .../transcription/transcriber.py | 2193 +++++++++-------- montreal_forced_aligner/utils.py | 433 +++- montreal_forced_aligner/vad/__init__.py | 0 .../vad/multiprocessing.py | 183 ++ montreal_forced_aligner/vad/segmenter.py | 412 ++++ montreal_forced_aligner/vad/vad_trainer.py | 0 .../validation/corpus_validator.py | 850 +------ .../validation/dictionary_validator.py | 19 +- rtd_environment.yml | 29 +- setup.cfg | 28 +- tests/conftest.py | 269 +- tests/data/am/acoustic_g2p_output_model.zip | Bin 570747 -> 539108 bytes tests/data/configs/basic_align_config.yaml | 1 - tests/data/configs/basic_ipa_config.yaml | 1 - tests/data/configs/basic_segment_config.yaml | 1 - tests/data/configs/basic_train_config.yaml | 1 - .../configs/different_punctuation_config.yaml | 1 - tests/data/configs/g2p_config.yaml | 1 - tests/data/configs/ivector_train.yaml | 1 - tests/data/configs/lda_sat_train.yaml | 1 - tests/data/configs/lda_train.yaml | 1 - tests/data/configs/mono_align.yaml | 1 - tests/data/configs/mono_train.yaml | 1 - tests/data/configs/no_punctuation_config.yaml | 1 - tests/data/configs/pitch_tri_train.yaml | 2 +- tests/data/configs/pron_train.yaml | 1 - tests/data/configs/sat_train.yaml | 3 +- tests/data/configs/test_groups.yaml | 59 + tests/data/configs/test_rules.yaml | 5 + tests/data/configs/train_g2p_acoustic.yaml | 3 +- tests/data/configs/train_g2p_config.yaml | 1 - tests/data/configs/transcribe.yaml | 1 - tests/data/configs/tri_train.yaml | 1 - tests/data/configs/xsampa_train.yaml | 1 - .../{abstract.txt => test_abstract.txt} | 0 .../{acoustic.txt => test_acoustic.txt} | 0 .../{basic.txt => test_basic.txt} | 0 ...chinese_dict.txt => test_chinese_dict.txt} | 0 ...tations.txt => test_extra_annotations.txt} | 0 .../{frclitics.txt => test_frclitics.txt} | 0 tests/data/dictionaries/test_hindi.txt | 3 + tests/data/dictionaries/test_japanese.txt | 5 + ...y.txt => test_mixed_format_dictionary.txt} | 12 +- ...tionary.txt => test_tabbed_dictionary.txt} | 2 +- ...namese_ipa.txt => test_vietnamese_ipa.txt} | 0 .../{xsampa.txt => test_xsampa.txt} | 0 tests/data/lab/common_voice_en_22058266.lab | 2 +- tests/data/lab/devanagari.lab | 1 + tests/data/lab/french_clitics.lab | 1 + tests/data/lab/japanese.lab | 1 + tests/data/lab/multilingual_ipa.txt | 2 +- tests/data/lab/multilingual_ipa_2.txt | 2 +- tests/data/lab/multilingual_ipa_5.txt | 2 +- tests/data/lab/punctuated.lab | 2 +- .../data/textgrid/multilingual_ipa_3.TextGrid | 1 - tests/test_abc.py | 1 - tests/test_acoustic_modeling.py | 109 +- tests/test_alignment_pretrained.py | 38 +- tests/test_commandline_adapt.py | 32 +- tests/test_commandline_align.py | 196 +- tests/test_commandline_classify_speakers.py | 35 - tests/test_commandline_configure.py | 114 +- tests/test_commandline_create_segments.py | 59 +- tests/test_commandline_diarize_speakers.py | 146 ++ tests/test_commandline_g2p.py | 182 +- tests/test_commandline_history.py | 48 +- tests/test_commandline_lm.py | 95 +- tests/test_commandline_model.py | 259 +- tests/test_commandline_train.py | 50 +- tests/test_commandline_train_dict.py | 33 +- tests/test_commandline_train_ivector.py | 22 +- tests/test_commandline_transcribe.py | 62 +- tests/test_commandline_validate.py | 80 +- tests/test_config.py | 28 +- tests/test_corpus.py | 457 ++-- tests/test_dict.py | 231 +- tests/test_g2p.py | 36 +- tests/test_gui.py | 22 +- tests/test_helper.py | 101 + tests/test_validate.py | 32 +- tox.ini | 17 +- 222 files changed, 22322 insertions(+), 11844 deletions(-) rename docs/source/_static/css/{style.css => mfa.css} (59%) delete mode 100644 docs/source/_templates/sidebar-nav-bs.html create mode 100644 docs/source/changelog/changelog_2.1.rst create mode 100644 docs/source/changelog/index.md delete mode 100644 docs/source/changelog/index.rst create mode 100644 docs/source/changelog/news_1.1.rst create mode 100644 docs/source/changelog/news_2.0.rst create mode 100644 docs/source/changelog/news_2.1.rst create mode 100644 docs/source/reference/diarization/helper.rst create mode 100644 docs/source/reference/diarization/index.rst create mode 100644 docs/source/reference/diarization/main.rst delete mode 100644 docs/source/reference/helper/command_line.rst create mode 100644 docs/source/user_guide/configuration/diarization.rst delete mode 100644 docs/source/user_guide/corpus_creation/classify_speakers.rst create mode 100644 docs/source/user_guide/corpus_creation/diarize_speakers.rst create mode 100644 docs/source/user_guide/implementations/alignment_evaluation.md create mode 100644 docs/source/user_guide/implementations/fine_tune.md create mode 100644 docs/source/user_guide/implementations/index.md create mode 100644 docs/source/user_guide/implementations/lexicon_probabilities.md create mode 100644 docs/source/user_guide/implementations/phone_groups.md create mode 100644 docs/source/user_guide/implementations/phone_models.md create mode 100644 docs/source/user_guide/implementations/phonological_rules.md create mode 100644 docs/source/user_guide/performance.rst delete mode 100644 montreal_forced_aligner/command_line/classify_speakers.py create mode 100644 montreal_forced_aligner/command_line/configure.py create mode 100644 montreal_forced_aligner/command_line/diarize_speakers.py create mode 100644 montreal_forced_aligner/command_line/history.py create mode 100644 montreal_forced_aligner/diarization/__init__.py create mode 100644 montreal_forced_aligner/diarization/multiprocessing.py create mode 100644 montreal_forced_aligner/diarization/speaker_diarizer.py create mode 100644 montreal_forced_aligner/ivector/multiprocessing.py create mode 100644 montreal_forced_aligner/language_modeling/multiprocessing.py delete mode 100644 montreal_forced_aligner/segmenter.py delete mode 100644 montreal_forced_aligner/speaker_classifier.py create mode 100644 montreal_forced_aligner/vad/__init__.py create mode 100644 montreal_forced_aligner/vad/multiprocessing.py create mode 100644 montreal_forced_aligner/vad/segmenter.py create mode 100644 montreal_forced_aligner/vad/vad_trainer.py create mode 100644 tests/data/configs/test_groups.yaml create mode 100644 tests/data/configs/test_rules.yaml rename tests/data/dictionaries/{abstract.txt => test_abstract.txt} (100%) rename tests/data/dictionaries/{acoustic.txt => test_acoustic.txt} (100%) mode change 100755 => 100644 rename tests/data/dictionaries/{basic.txt => test_basic.txt} (100%) mode change 100755 => 100644 rename tests/data/dictionaries/{chinese_dict.txt => test_chinese_dict.txt} (100%) mode change 100755 => 100644 rename tests/data/dictionaries/{extra_annotations.txt => test_extra_annotations.txt} (100%) mode change 100755 => 100644 rename tests/data/dictionaries/{frclitics.txt => test_frclitics.txt} (100%) mode change 100755 => 100644 create mode 100644 tests/data/dictionaries/test_hindi.txt create mode 100644 tests/data/dictionaries/test_japanese.txt rename tests/data/dictionaries/{tabbed_dictionary.txt => test_mixed_format_dictionary.txt} (89%) rename tests/data/dictionaries/{mixed_format_dictionary.txt => test_tabbed_dictionary.txt} (99%) rename tests/data/dictionaries/{vietnamese_ipa.txt => test_vietnamese_ipa.txt} (100%) rename tests/data/dictionaries/{xsampa.txt => test_xsampa.txt} (100%) create mode 100644 tests/data/lab/devanagari.lab create mode 100644 tests/data/lab/french_clitics.lab create mode 100644 tests/data/lab/japanese.lab delete mode 100644 tests/test_commandline_classify_speakers.py create mode 100644 tests/test_commandline_diarize_speakers.py create mode 100644 tests/test_helper.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 649b9476..b1d7a9d4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,28 +9,68 @@ on: types: rebuild +env: + CACHE_NUMBER: 0 # increase to reset cache manually + concurrency: group: run_tests-${{ github.ref }} cancel-in-progress: true jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + include: + - os: ubuntu-latest + label: linux-64 + prefix: /usr/share/miniconda3/envs/my-env + + #- os: macos-latest + # label: osx-64 + # prefix: /Users/runner/miniconda3/envs/my-env + + #- os: windows-latest + # label: win-64 + # prefix: C:\Miniconda3\envs\my-env + + name: ${{ matrix.label }} + runs-on: ${{ matrix.os }} steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" + - uses: actions/checkout@v2 + + - name: Cache MFA models + uses: actions/cache@v3 + env: + cache-name: cache-mfa-models with: - python-version: "3.8" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions tox-conda twine build setuptools setuptools_scm[toml] wheel - - name: sdist - run: python setup.py sdist - - name: Test with tox - run: tox - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v2.0.3" + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + path: | + ~/Documents/MFA + + - name: Setup Mambaforge + uses: conda-incubator/setup-miniconda@v2 + with: + miniforge-variant: Mambaforge + miniforge-version: latest + activate-environment: my-env + use-mamba: true + - name: Set cache date + run: echo "DATE=$(date +'%Y%m%d')" >> $GITHUB_ENV + + - uses: actions/cache@v2 with: - file: ./coverage.xml - fail_ci_if_error: true + path: ${{ matrix.prefix }} + key: ${{ matrix.label }}-conda-${{ hashFiles('environment.yml') }}-${{ env.DATE }}-${{ env.CACHE_NUMBER }} + id: cache + + - name: Update environment + run: mamba env update -n my-env -f environment.yml + if: steps.cache.outputs.cache-hit != 'true' + + - name: Run tests + shell: bash -l {0} + run: pytest -x ./tests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1d68fb46..6c8603a5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,15 +2,9 @@ default_language_version: python: python3.8 repos: - repo: https://github.com/psf/black - rev: 21.8b0 + rev: 22.10.0 hooks: - id: black - - repo: https://github.com/asottile/blacken-docs - rev: v1.11.0 - hooks: - - id: blacken-docs - additional_dependencies: - - black==20.8b1 - repo: https://gitlab.com/pycqa/flake8 rev: 4.0.1 hooks: diff --git a/ci/mfa_publish.yml b/ci/mfa_publish.yml index adbc7975..b9373482 100644 --- a/ci/mfa_publish.yml +++ b/ci/mfa_publish.yml @@ -12,9 +12,11 @@ dependencies: - pyyaml - kaldi - sox + - ffmpeg - openfst - baumwelch - ngram + - praatio - pynini - setuptools - setuptools_scm[toml] @@ -22,4 +24,3 @@ dependencies: - pip: - build - twine - - praatio >= 5.0 diff --git a/docs/source/_static/css/style.css b/docs/source/_static/css/mfa.css similarity index 59% rename from docs/source/_static/css/style.css rename to docs/source/_static/css/mfa.css index 57e7ab74..a925241e 100644 --- a/docs/source/_static/css/style.css +++ b/docs/source/_static/css/mfa.css @@ -23,6 +23,84 @@ font-style: italic; src: url(../fonts/GentiumPlus-BoldItalic.woff2); } + + +:root { + --base-blue: #003566; + --dark-blue: #001D3D; + --very-dark-blue: #000814; + --light-blue: #0E63B3; + --very-light-blue: #7AB5E6; + --base-yellow: #FFC300; + --dark-yellow: #E3930D; + --light-yellow: #FFD60A; +} + +html[data-theme="light"] { + --sd-color-primary: var(--base-blue); + --mfa-admonition-text-color: #cecece; + --sd-color-dark: var(--base-blue); + --sd-color-primary-text: #FFC300; + --sd-color-primary-highlight: #FFC300; + --pst-color-primary: var(--base-blue); + --pst-color-warning: var(--light-yellow); + --pst-color-info: var(--light-blue); + --pst-color-admonition-default: var(--light-blue); + + --pst-color-link: var(--light-blue); + --pst-color-link-hover: var(--dark-blue); + + --pst-color-active-navigation: var(--dark-blue); + --pst-color-hover-navigation: var(--base-yellow); + + --pst-color-navbar-link: var(--base-blue); + --pst-color-navbar-link-hover: var(--pst-color-hover-navigation); + --pst-color-navbar-link-active: var(--pst-color-active-navigation); + + --pst-color-sidebar-link: var(--base-blue); + --pst-color-sidebar-caption: var(--base-blue); + --pst-color-sidebar-link-hover: var(--pst-color-hover-navigation); + --pst-color-sidebar-link-active: var(--pst-color-active-navigation); + + --pst-color-toc-link: var(--base-blue); + --pst-color-toc-link-hover: var(--pst-color-hover-navigation); + --pst-color-toc-link-active: var(--pst-color-active-navigation); +} +/******************************************************************************* +* dark theme +* +* all the variables used for dark theme coloring +*/ +html[data-theme="dark"] { + --sd-color-primary: var(--base-blue); + --sd-color-card-text: var(--base-yellow); + --sd-color-dark: var(--base-blue); + --sd-color-primary-text: var(--base-yellow); + --sd-color-primary-highlight: var(--base-yellow); + --pst-color-primary: var(--base-yellow); + --pst-color-warning: var(--light-yellow); + --pst-color-info: var(--light-blue); +--mfa-admonition-text-color: var(--pst-color-text-base); + --pst-color-link: var(--very-light-blue); + --pst-color-link-hover: var(--light-yellow); + + --pst-color-active-navigation: var(--base-yellow); + --pst-color-hover-navigation: var(--very-light-blue); + + --pst-color-navbar-link: var(--light-blue); + --pst-color-navbar-link-hover: var(--pst-color-hover-navigation); + --pst-color-navbar-link-active: var(--pst-color-active-navigation); + + --pst-color-sidebar-link: var(--base-yellow); + --pst-color-sidebar-caption: var(--base-yellow); + --pst-color-sidebar-link-hover: var(--pst-color-hover-navigation); + --pst-color-sidebar-link-active: var(--pst-color-active-navigation); + + --pst-color-toc-link: var(--base-yellow); + --pst-color-toc-link-hover: var(--pst-color-hover-navigation); + --pst-color-toc-link-active: var(--pst-color-active-navigation); +} + .container, .container-xl, .container-lg { max-width: 2400px !important; } @@ -102,10 +180,10 @@ html[data-theme="dark"] { --very-light-blue: #7AB5E6; --base-yellow: #FFC300; --light-yellow: #FFD60A; - --sd-color-primary: #003566; - --sd-color-dark: #003566; - --sd-color-primary-text: #FFC300; - --sd-color-primary-highlight: #FFC300; + --sd-color-primary: var(--base-blue); + --sd-color-dark: var(--base-blue); + --sd-color-primary-text: var(--base-yellow); + --sd-color-primary-highlight: var(--base-blue); --pst-color-primary: var(--base-yellow); --pst-color-warning: var(--light-yellow); --pst-color-info: var(--light-blue); @@ -135,7 +213,7 @@ font-weight: bold; } .sd-btn-primary:hover{ -color: var(--sd-color-primary) !important; +background-color: var(--light-blue) !important; } .i-navigation{ color: var(--sd-color-primary); @@ -160,12 +238,16 @@ div[class*="highlight-"] { text-align: left; } -ipa-inline { +.ipa-inline { font-family: "GentiumPlusW"; font-size: 1.1em; font-weight: 500; } +.ipa-highlight, .ipa-inline { +color: var(--pst-color-inline-code); +} + .supported { background-color: #E9F6EC; } @@ -177,6 +259,9 @@ background-color: #FBEAEC; color: inherit; } -dt:target { -background-color: #FFD60A; +html[data-theme="light"] dt:target { +background-color: var(--base-yellow); +} +html[data-theme="dark"] dt:target{ + background-color: var(--dark-blue); } diff --git a/docs/source/_static/interrogate_badge.svg b/docs/source/_static/interrogate_badge.svg index 17dfab32..d06dae70 100644 --- a/docs/source/_static/interrogate_badge.svg +++ b/docs/source/_static/interrogate_badge.svg @@ -1,5 +1,5 @@ - interrogate: 99.2% + interrogate: 96.0% @@ -12,8 +12,8 @@ interrogate interrogate - 99.2% - 99.2% + 96.0% + 96.0% diff --git a/docs/source/_templates/autosummary/class.rst b/docs/source/_templates/autosummary/class.rst index 1389abf5..09a866dc 100644 --- a/docs/source/_templates/autosummary/class.rst +++ b/docs/source/_templates/autosummary/class.rst @@ -1,3 +1,5 @@ +:html_theme.sidebar_secondary.remove: + {{ objname }} {{ underline }} diff --git a/docs/source/_templates/autosummary/function.rst b/docs/source/_templates/autosummary/function.rst index f5676ee8..1cad9657 100644 --- a/docs/source/_templates/autosummary/function.rst +++ b/docs/source/_templates/autosummary/function.rst @@ -1,3 +1,5 @@ +:html_theme.sidebar_secondary.remove: + {{ objname }} {{ underline }} diff --git a/docs/source/_templates/sidebar-nav-bs.html b/docs/source/_templates/sidebar-nav-bs.html deleted file mode 100644 index 6737425d..00000000 --- a/docs/source/_templates/sidebar-nav-bs.html +++ /dev/null @@ -1,9 +0,0 @@ -
- {{ generate_nav_html("sidebar", - maxdepth=theme_navigation_depth|int, - collapse=theme_collapse_navigation|tobool, - includehidden=True, - titles_only=True) }} -
- diff --git a/docs/source/changelog/changelog_2.0_pre_release.rst b/docs/source/changelog/changelog_2.0_pre_release.rst index 83bb1bdc..0014103d 100644 --- a/docs/source/changelog/changelog_2.0_pre_release.rst +++ b/docs/source/changelog/changelog_2.0_pre_release.rst @@ -178,7 +178,7 @@ Beta releases - Added :class:`~montreal_forced_aligner.corpus.multiprocessing.Job` class as well to make it easier to generate and keep track of information about different processes - Updated installation style to be more dependent on conda-forge packages - - Kaldi and MFA are now on conda-forge! |:tada:| + - Kaldi and MFA are now on conda-forge! :fas:`party-horn` - Added a :code:`mfa model` command for inspecting, listing, downloading, and saving pretrained models, see :ref:`pretrained_models` for more information. - Fixed a bug where saving command history with errors would throw an error of its own diff --git a/docs/source/changelog/changelog_2.1.rst b/docs/source/changelog/changelog_2.1.rst new file mode 100644 index 00000000..cbf0c4da --- /dev/null +++ b/docs/source/changelog/changelog_2.1.rst @@ -0,0 +1,20 @@ + +.. _changelog_2.1: + +************* +2.1 Changelog +************* + +2.1.0 +===== + +- Drop support for SQLite as a database backend +- Fixed a bug where TextGrid parsing errors would cause MFA to crash rather than ignore those files +- Updated CLI to use :xref:`click` rather than argparse +- Added :code:`--use_phone_model` flag for :code:`mfa align` and :code:`mfa validate` commands. See :ref:`phone_models` for more details. +- Added :code:`--phone_confidence` flag for :code:`mfa validate` commands. See :ref:`phone_models` for more details. +- Added modeling of :code:`cutoff` phones via :code:`--use_cutoff_model` which adds progressive truncations of the next word, if it's not unknown or a non-speech word (silence, laughter, etc). See :ref:`cutoff_modeling` for more details. +- Added support for using :xref:`speechbrain`'s VAD model in :ref:`create_segments` +- Overhaul and update :ref:`train_ivector` +- Overhaul and update :ref:`diarize_speakers` +- Added support for using :xref:`speechbrain`'s SpeakerRecognition model in :ref:`diarize_speakers` diff --git a/docs/source/changelog/index.md b/docs/source/changelog/index.md new file mode 100644 index 00000000..0c0f6dfb --- /dev/null +++ b/docs/source/changelog/index.md @@ -0,0 +1,72 @@ + +.. _news: + + +# News + +## Roadmap + +```{warning} + Please bear in mind that all plans below are tentative and subject to change. +``` + +### Version 2.1 + +* Generalize phone group specification for user specification + * Phone groups are allophonic variation, common neutralization etc. + * English {ipa_inline}`[ɾ]` is grouped with {ipa_inline}`[t]` and {ipa_inline}`[d]`, but Spanish {ipa_inline}`[ɾ]` is grouped with {ipa_inline}`[r]`, {ipa_inline}`[ð]` is grouped with {ipa_inline}`[d]` +* Update to use PyKaldi for interfacing with Kaldi components rather than relying on piped CLI commands to Kaldi binaries + * This change should also allow for more nnet3 functionality to be available (i.e., for segmentation and speaker diarization below). The {code}`nnet3` scripts rely on python code in the Kaldi {code}`egs/wsj/s5/steps` folder that is not currently exported as part of the Kaldi feedstock on conda forge. +* Update segmentation functionality + * At the moment, only a simple speech activity detection (SAD) algorithm is implemented that uses amplitude of the signal and thresholds for speech vs non-speech + * For 2.1, I plan to implement new SAD training capability as well as release a pretrained SAD model trained across all the current training data for every language with a pretrained acoustic model +* Update speaker diarization functionality + * Support x-vector models as well as ivector models + * Properly implement and train PLDA models for diarization +* Update dictionary model format to move away from the current plain-text lexicons to a more robust compressed format + * With extra meta data and capabilities in the form of phonological rules and phone groupings, it makes more sense to package those with the lexicon rather than the acoustic model + * Another option would be to package up the lexicon (and maybe G2P models) with the acoustic model into a complete MFA model + * As part of any update, I would expand the {ref}`MFA model CLI ` with functionality for adding new pronunciations to internal lexicons + * Something like {code}`mfa model update /path/to/g2pped_file.txt` + +Not tied to 2.1, but in the near-ish term I would like to: + +* Retrain existing acoustic models with new phone groups and rules features +* Begin work on expanding to new languages + * Japanese (in progress) + * Arabic + * Tamil +* Localize documentation + * I'll initially do a pass at localizing the documentation to Japanese and see if I can crowd source other languages (and fixing my initial Japanese pass) +* Finally release Anchor compatible with the latest versions of MFA +* Update pitch feature calculation to use speaker-adjusted min and max f0 ranges + +### Future + +* Moving away from Kaldi-based dependencies + * Kaldi is not being actively developed and I don't have much of a desire to depend on it long term + * Most actively developed ASR toolkits and libraries are based around neural networks + * I'm not the biggest fan of using these for alignment, as most of the research is geared towards improving end-to-end signal to orthographic text models that don't have intermediate representations of phones + * That said, if alignment were the task that was being optimized for rather than some "word error rate" style metric, then alignment performance could improve significantly + * One particular direction would be towards sample-based or waveform-based alignment rather than frame-based + * Frame-based methods are time-smeared, so providing an exact time for voicing onset or stop closure is murky + * Phoneticians use spectrograms for gross boundaries, but more accurate manual alignments are determined based on the waveform + * Perhaps combining a model that performs language-independent boundary insertion combined with per-language models to combine resulting segments might perform better ({ipa_inline}`[a]` + {ipa_inline}`[j]` becomes {ipa_inline}`[aj]` in English, but not in other languages like Japanese, Spanish, or Portuguese, etc) + * Additionally, neural networks might allow for better modeling of phone symbols, so embedding {ipa_inline}`[pʲ]` could result in a more compositional "voiceless bilabial stop plus palatalization" + * Other options for toolkits to support MFA are + * [SpeechBrain](https://speechbrain.github.io/) + * Custom PyTorch code + * Custom tensorflow code + +```{toctree} +:hidden: +:maxdepth: 1 + +news_2.1.rst +changelog_2.1.rst +news_2.0.rst +changelog_2.0.rst +changelog_2.0_pre_release.rst +news_1.1.rst +changelog_1.0.rst +``` diff --git a/docs/source/changelog/index.rst b/docs/source/changelog/index.rst deleted file mode 100644 index babf197c..00000000 --- a/docs/source/changelog/index.rst +++ /dev/null @@ -1,152 +0,0 @@ - -.. _news: - -**** -News -**** - -.. _whats_new_2_0: - -What's new in 2.0 -================= - -Version 2.0 of the Montreal Forced Aligner represents several overhauls to installation and management -of commands. See :ref:`changelog_2.0` for a more specific changes. - -.. _2_0_installation_update: - -Installation style ------------------- - -Up until now, MFA has used a frozen executable model for releases, which involves packaging MFA code along with a Python -interpreter, some system libraries, and compiled third party executables from Kaldi, OpenFST, OpenNgram, and Phonetisaurus. -The main issues with this style of distribution revolve around inefficiencies in the build system and a lack of ability to -customize the runtime for different environments and versions. - -Moving forward, MFA will: - -- Use standard Python packaging and be available for import in Python -- Rely on :xref:`conda_forge` for handling dependencies -- Switch to using Pynini instead of Phonetisaurus for G2P purposes, which should ease distribution and installation -- Have a :ref:`2_0_unified_cli` with subcommands for each command line function that will be available upon installation, as well as exposing the full MFA api for use in other Python scripts -- Allow for faster bug fixes that do not require repackaging and releasing frozen binaries across all platforms - -.. _2_0_unified_cli: - -Unified command line interface ------------------------------- - -Previously, MFA has used multiple separate frozen CLI programs to perform specific actions. However, as -more functionality has been added with G2P models, validation, managing pretrained models, and training -different types of models, it has become unwieldy to have separate commands for each. As such, going -forward: - -- There will be a single :code:`mfa` command line utility that will be available once it is installed via pip/conda. -- Running :code:`mfa -h` will list the subcommands that can be run, along with their descriptions, see :ref:`commands` for details. - -.. _2_0_anchor_gui: - -Anchor annotator GUI --------------------- - -Added a basic annotation GUI with features for: - -- Listing processed utterances in the corpus with the ability to see which utterances have words not found in your pronunciation dictionary -- Allowing for audio playback of utterances and modification of utterance text -- Listing entries in an imported pronunciation dictionary -- Updating/adding dictionary entries -- Updating transcriptions - -See also :ref:`anchor` for more information on using the annotation GUI. - -.. _2.0_transcription: - -Transcription -------------- - -MFA now supports: - -- Transcribing a corpus of sound files using an acoustic model, dictionary, and language model, see :ref:`transcribing` for - more information. -- Training language models from corpora that have text transcriptions, see :ref:`training_lm` for more information -- Training pronunciation probability dictionaries from alignments, for use in alignment or transcription, see :ref:`training_dictionary` for more information - -.. _whats_new_1_1: - -What's new in 1.1 -================= - -Version 1.1 of the Montreal Forced Aligner represents several overhauls to the workflow and ability to customize model training and alignment. - -.. attention:: - - With the development of 2.0, the below sections are out of date. - -.. _1_1_training_configurations: - -Training configurations ------------------------ - -A major new feature is the ability to specify and customize configuration for training and alignment. Prior to 1.1, the training procedure for new models was: - -- Monophone training -- Triphone training -- Speaker-adapted triphone training (could be disabled) - -The parameters for each of these training blocks were fixed and not changeable. - -In 1.1, the following training procedures are available: - -- Monophone training -- Triphone training -- LDA+MLLT training -- Speaker-adapted triphone training -- Ivector extractor training - -Each of these blocks (as well as their inclusion) can be customized through a YAML config file. In addition to training parameters, -global alignment and feature configuration parameters are available. See :ref:`configuration` for more details. - - -.. _1_1_data_validation: - -Data validation ---------------- - -In version 1.0, data validation was done as part of alignment, with user input whether alignment should be stopped if -problems were detected. In version 1.1, all data validation is done through a separate executable :code:`mfa_validate_dataset` -(see :ref:`validating_data` for more details on usage). Validating the dataset consists of: - -- Checking for out of vocabulary items between the dictionary and the corpus -- Checking for read errors in transcription files -- Checking for transcriptions without sound files and sound files without transcriptions -- Checking for issues in feature generation (can be disabled for speed) -- Checking for issues in aligning a simple monophone model (can be disabled for speed) -- Checking for transcription errors using a simple unigram language model of common words and words in the transcript - (disabled by default) - -The user should now run :code:`mfa_validate_dataset` first and fix any issues that they perceive as important. -The alignment executables will print a warning if any of these issues are present, but will perform alignment without -prompting for user input. - -.. _1_1_dictionary_generation: - -Updated dictionary generation ------------------------------ - -The functionality of :code:`mfa_generate_dictionary` has been expanded. - -- Rather than having a :code:`--no_dict` option for alignment executables, the orthographic transcription functionality is now - used when a G2P model is not provided to :code:`mfa_generate_dictionary` -- When a corpus directory is specified as the input path, all words will be parsed rather than just those from transcription - files with an associated sound file -- When a text file is specified as the input path, all words in the text file will be run through G2P, allowing for a - simpler pipeline for generating transcriptions from out of vocabulary items - - -.. toctree:: - :maxdepth: 1 - :hidden: - - changelog_2.0.rst - changelog_2.0_pre_release.rst - changelog_1.0.rst diff --git a/docs/source/changelog/news_1.1.rst b/docs/source/changelog/news_1.1.rst new file mode 100644 index 00000000..a8f583e0 --- /dev/null +++ b/docs/source/changelog/news_1.1.rst @@ -0,0 +1,71 @@ + +.. _whats_new_1_1: + +What's new in 1.1 +================= + +Version 1.1 of the Montreal Forced Aligner represents several overhauls to the workflow and ability to customize model training and alignment. + +.. attention:: + + With the development of 2.0, the below sections are out of date. + +.. _1_1_training_configurations: + +Training configurations +----------------------- + +A major new feature is the ability to specify and customize configuration for training and alignment. Prior to 1.1, the training procedure for new models was: + +- Monophone training +- Triphone training +- Speaker-adapted triphone training (could be disabled) + +The parameters for each of these training blocks were fixed and not changeable. + +In 1.1, the following training procedures are available: + +- Monophone training +- Triphone training +- LDA+MLLT training +- Speaker-adapted triphone training +- Ivector extractor training + +Each of these blocks (as well as their inclusion) can be customized through a YAML config file. In addition to training parameters, +global alignment and feature configuration parameters are available. See :ref:`configuration` for more details. + + +.. _1_1_data_validation: + +Data validation +--------------- + +In version 1.0, data validation was done as part of alignment, with user input whether alignment should be stopped if +problems were detected. In version 1.1, all data validation is done through a separate executable :code:`mfa_validate_dataset` +(see :ref:`validating_data` for more details on usage). Validating the dataset consists of: + +- Checking for out of vocabulary items between the dictionary and the corpus +- Checking for read errors in transcription files +- Checking for transcriptions without sound files and sound files without transcriptions +- Checking for issues in feature generation (can be disabled for speed) +- Checking for issues in aligning a simple monophone model (can be disabled for speed) +- Checking for transcription errors using a simple unigram language model of common words and words in the transcript + (disabled by default) + +The user should now run :code:`mfa_validate_dataset` first and fix any issues that they perceive as important. +The alignment executables will print a warning if any of these issues are present, but will perform alignment without +prompting for user input. + +.. _1_1_dictionary_generation: + +Updated dictionary generation +----------------------------- + +The functionality of :code:`mfa_generate_dictionary` has been expanded. + +- Rather than having a :code:`--no_dict` option for alignment executables, the orthographic transcription functionality is now + used when a G2P model is not provided to :code:`mfa_generate_dictionary` +- When a corpus directory is specified as the input path, all words will be parsed rather than just those from transcription + files with an associated sound file +- When a text file is specified as the input path, all words in the text file will be run through G2P, allowing for a + simpler pipeline for generating transcriptions from out of vocabulary items diff --git a/docs/source/changelog/news_2.0.rst b/docs/source/changelog/news_2.0.rst new file mode 100644 index 00000000..4b9878e8 --- /dev/null +++ b/docs/source/changelog/news_2.0.rst @@ -0,0 +1,66 @@ + +.. _whats_new_2_0: + +What's new in 2.0 +================= + +Version 2.0 of the Montreal Forced Aligner represents several overhauls to installation and management +of commands. See :ref:`changelog_2.0` for a more specific changes. + +.. _2_0_installation_update: + +Installation style +------------------ + +Up until now, MFA has used a frozen executable model for releases, which involves packaging MFA code along with a Python +interpreter, some system libraries, and compiled third party executables from Kaldi, OpenFST, OpenNgram, and Phonetisaurus. +The main issues with this style of distribution revolve around inefficiencies in the build system and a lack of ability to +customize the runtime for different environments and versions. + +Moving forward, MFA will: + +- Use standard Python packaging and be available for import in Python +- Rely on :xref:`conda_forge` for handling dependencies +- Switch to using Pynini instead of Phonetisaurus for G2P purposes, which should ease distribution and installation +- Have a :ref:`2_0_unified_cli` with subcommands for each command line function that will be available upon installation, as well as exposing the full MFA api for use in other Python scripts +- Allow for faster bug fixes that do not require repackaging and releasing frozen binaries across all platforms + +.. _2_0_unified_cli: + +Unified command line interface +------------------------------ + +Previously, MFA has used multiple separate frozen CLI programs to perform specific actions. However, as +more functionality has been added with G2P models, validation, managing pretrained models, and training +different types of models, it has become unwieldy to have separate commands for each. As such, going +forward: + +- There will be a single :code:`mfa` command line utility that will be available once it is installed via pip/conda. +- Running :code:`mfa -h` will list the subcommands that can be run, along with their descriptions, see :ref:`commands` for details. + +.. _2_0_anchor_gui: + +Anchor annotator GUI +-------------------- + +Added a basic annotation GUI with features for: + +- Listing processed utterances in the corpus with the ability to see which utterances have words not found in your pronunciation dictionary +- Allowing for audio playback of utterances and modification of utterance text +- Listing entries in an imported pronunciation dictionary +- Updating/adding dictionary entries +- Updating transcriptions + +See also :ref:`anchor` for more information on using the annotation GUI. + +.. _2.0_transcription: + +Transcription +------------- + +MFA now supports: + +- Transcribing a corpus of sound files using an acoustic model, dictionary, and language model, see :ref:`transcribing` for + more information. +- Training language models from corpora that have text transcriptions, see :ref:`training_lm` for more information +- Training pronunciation probability dictionaries from alignments, for use in alignment or transcription, see :ref:`training_dictionary` for more information diff --git a/docs/source/changelog/news_2.1.rst b/docs/source/changelog/news_2.1.rst new file mode 100644 index 00000000..b359616c --- /dev/null +++ b/docs/source/changelog/news_2.1.rst @@ -0,0 +1,48 @@ + +.. _whats_new_2_1: + +What's new in 2.1 +================= + +Version 2.1 of the Montreal Forced Aligner changes the command line interface to use :xref:`click`, transition fully to use postgresql as the backend, expand functionality for segmentation and speaker diarization, and support the latest alpha release of Anchor, along with other improvements like fine-tuning alignments and generating phone-level confidences. See :ref:`changelog_2.1` for a more specific changes. + +.. _2_1_click: + +Click +----- + +MFA 2.1 uses :xref:`click` instead of Python's default argparse. The primary benefit for this is in better validation of arguments and the ability to prompt the user if they forget an argument like :code:`dictionary_path`. + +.. _2_1_postgresql: + +Dependency on postgresql +------------------------ + +In 2.0, the default database backend was SQLite, which allowed for rapid development, but it lacks more advanced functionality in other database backends. PostgreSQL was supported as well, but it requires a persistent running server rather a single database file for SQLite. As more advanced functionality has been shifted to SQL over Python to speed up querying and processes, it made sense to drop SQLite support in favor of pure PostgreSQL. + +.. _2_1_segmentation: + +Segmentation with SpeechBrain +----------------------------- + +In addition to the simple energy-based VAD used in Kaldi, MFA is capable of using :xref:`speechbrain`'s VAD model to generate better segmentation of long audio files. See :ref:`create_segments` for more information. + +.. note:: + SpeechBrain is not installed by default with ``conda install montreal-forced-aligner``, so please refer to :ref:`installation` for more details. + +.. _2_1_speaker_diarization: + +Speaker diarization +------------------- + +Speaker diarization has been overhauled to make use of pretrained ivector models or :xref:`speechbrain`'s `https://speechbrain.readthedocs.io/en/latest/API/speechbrain.pretrained.interfaces.html#speechbrain.pretrained.interfaces.SpeakerRecognition `_. Additionally, with the :ref:`2_1_postgresql` in MFA 2.1, we have integrated with :xref:`pgvector`, which allows for storage and querying of utterance and speaker ivectors. + +.. note:: + SpeechBrain is not installed by default with ``conda install montreal-forced-aligner``, so please refer to :ref:`installation` for more details. + +.. _2_1_anchor_gui: + +Anchor annotator GUI +-------------------- + +See also :ref:`anchor` for more information on using the annotation GUI. diff --git a/docs/source/conf.py b/docs/source/conf.py index 70f5226e..c3697e9b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -38,27 +38,32 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) extensions = [ + "sphinx_needs", "sphinx.ext.autodoc", "sphinx.ext.autosummary", "sphinx.ext.coverage", "sphinx.ext.mathjax", "sphinx.ext.intersphinx", "sphinx.ext.extlinks", + "myst_parser", "external_links", # "numpydoc", "sphinx.ext.napoleon", "sphinx_design", "sphinx.ext.viewcode", - "sphinxcontrib.autoprogram", - "sphinxemoji.sphinxemoji", + "sphinx_click", # "sphinx_autodoc_typehints", ] +myst_enable_extensions = ["colon_fence"] +locale_dirs = ["locale/"] # path is example but recommended. +gettext_compact = False # optional. panels_add_bootstrap_css = False intersphinx_mapping = { "sqlalchemy": ("https://docs.sqlalchemy.org/en/14/", None), "numpy": ("https://numpy.org/doc/stable/", None), "python": ("https://docs.python.org/3", None), "Bio": ("https://biopython.org/docs/latest/api/", None), + "click": ("https://click.palletsprojects.com/en/8.1.x/", None), } rst_prolog = """ .. role:: ipa_inline @@ -73,6 +78,10 @@ xref_links = { "mfa_models": ("MFA Models", "https://mfa-models.readthedocs.io/"), "anchor": ("Anchor Annotator", "https://anchor-annotator.readthedocs.io/en/latest/"), + "speechbrain": ("SpeechBrain", "https://speechbrain.github.io/"), + "scikit-learn": ("scikit-learn", "https://scikit-learn.org/stable/index.html"), + "click": ("click", "https://click.palletsprojects.com/en/8.1.x/"), + "pgvector": ("pgvector", "https://github.com/pgvector/pgvector"), "pretrained_acoustic_models": ( "MFA acoustic models", "https://mfa-models.readthedocs.io/en/latest/acoustic/index.html", @@ -174,10 +183,8 @@ "MultispeakerDictionary": "montreal_forced_aligner.dictionary.MultispeakerDictionary", "Trainer": "montreal_forced_aligner.abc.Trainer", "Aligner": "montreal_forced_aligner.abc.Aligner", - "FeatureConfig": "montreal_forced_aligner.config.FeatureConfig", "multiprocessing.context.Process": "multiprocessing.Process", "mp.Process": "multiprocessing.Process", - "Namespace": "argparse.Namespace", "MetaDict": "dict[str, Any]", } @@ -338,10 +345,10 @@ # "image_dark": "logo-dark.svg", }, "google_analytics_id": "UA-73068199-4", - "show_nav_level": 1, - "navigation_depth": 4, - "show_toc_level": 2, - "collapse_navigation": False, + # "show_nav_level": 1, + # "navigation_depth": 4, + # "show_toc_level": 2, + # "collapse_navigation": True, } html_context = { # "github_url": "https://github.com", # or your GitHub Enterprise interprise @@ -380,7 +387,7 @@ html_static_path = ["_static"] html_css_files = [ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/fontawesome.min.css", - "css/style.css", + "css/mfa.css", ] # Add any extra paths that contain custom files (such as robots.txt or @@ -403,6 +410,7 @@ # Custom sidebar templates, maps document names to template names. # # html_sidebars = { '**': ['globaltoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'], } + html_sidebars = {"**": ["search-field.html", "sidebar-nav-bs.html", "sidebar-ethical-ads.html"]} # Additional templates that should be rendered to pages, maps page names to # template names. diff --git a/docs/source/first_steps/example.rst b/docs/source/first_steps/example.rst index 09320594..e35ec81c 100644 --- a/docs/source/first_steps/example.rst +++ b/docs/source/first_steps/example.rst @@ -34,9 +34,9 @@ Set up ------ 1. Ensure you have installed MFA via :ref:`installation`. -2. Ensure you have downloaded the pretrained model via :code:`mfa model download acoustic english` -3. Download the prepared LibriSpeech dataset (`LibriSpeech data set`_) and extract it somewhere on your computer -4. Download the LibriSpeech lexicon (`LibriSpeech lexicon`_) and save it somewhere on your computer +2. Ensure you have downloaded the pretrained model via :code:`mfa model download acoustic english_mfa` +3. Ensure you have downloaded the pretrained US english dictionary via :code:`mfa model download dictionary english_us_mfa` +4. Download the prepared LibriSpeech dataset (`LibriSpeech data set`_) and extract it somewhere on your computer Alignment @@ -50,7 +50,7 @@ In the same environment that you've installed MFA, enter the following command i .. code-block:: bash - mfa align /path/to/librispeech/dataset /path/to/librispeech/lexicon.txt english ~/Documents/aligned_librispeech + mfa align /path/to/librispeech/dataset english_us_ma english_mfa ~/Documents/aligned_librispeech Aligning through training ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/installation.rst b/docs/source/installation.rst index e05c4a77..b9935b62 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -5,7 +5,7 @@ Installation .. important:: - Kaldi and MFA are now built on :xref:`conda_forge` |:tada:|, so installation of third party binaries is wholly through conda from 2.0.0b4 onwards. Installing MFA via conda will pick up Kaldi as well. + Kaldi and MFA are now built on :xref:`conda_forge` :fas:`party-horn`, so installation of third party binaries is wholly through conda from 2.0.0b4 onwards. Installing MFA via conda will pick up Kaldi as well. All platforms @@ -18,6 +18,15 @@ All platforms 3. Ensure you're in the new environment created (:code:`conda activate aligner`) +Installing SpeechBrain +---------------------- + +1. Ensure you are in the conda environment created above +2. Install PyTorch + a. CPU: :code:`conda install pytorch torchvision torchaudio cpuonly -c pytorch` + b. GPU: :code:`conda install pytorch torchvision torchaudio pytorch-cuda=11.7 -c pytorch -c nvidia` +3. Install Speechbrain via pip: :code:`pip install speechbrain` + Upgrading from non-conda version ================================ diff --git a/docs/source/reference/acoustic_modeling/helper.rst b/docs/source/reference/acoustic_modeling/helper.rst index 37f24781..1b201073 100644 --- a/docs/source/reference/acoustic_modeling/helper.rst +++ b/docs/source/reference/acoustic_modeling/helper.rst @@ -49,6 +49,13 @@ Multiprocessing workers and functions AccStatsTwoFeatsFunction +.. currentmodule:: montreal_forced_aligner.acoustic_modeling.trainer + +.. autosummary:: + :toctree: generated/ + + TransitionAccFunction + Multiprocessing argument classes -------------------------------- @@ -83,3 +90,10 @@ Multiprocessing argument classes :toctree: generated/ AccStatsTwoFeatsArguments + +.. currentmodule:: montreal_forced_aligner.acoustic_modeling.trainer + +.. autosummary:: + :toctree: generated/ + + TransitionAccArguments diff --git a/docs/source/reference/acoustic_modeling/index.rst b/docs/source/reference/acoustic_modeling/index.rst index c06580dd..262b3a45 100644 --- a/docs/source/reference/acoustic_modeling/index.rst +++ b/docs/source/reference/acoustic_modeling/index.rst @@ -8,7 +8,7 @@ Acoustic models .. note:: - As part of the training procedure, alignments are generated, and so can be exported at the end (the same as training an acoustic model and then using it with the :class:`~montreal_forced_aligner.alignment.pretrained.PretrainedAligner`. See :meth:`~montreal_forced_aligner.alignment.CorpusAligner.export_files` for the method and :func:`~montreal_forced_aligner.command_line.run_train_acoustic_model` for the command line function. + As part of the training procedure, alignments are generated, and so can be exported at the end (the same as training an acoustic model and then using it with the :class:`~montreal_forced_aligner.alignment.pretrained.PretrainedAligner`. See :meth:`~montreal_forced_aligner.alignment.CorpusAligner.export_files` for the method and :ref:`train_acoustic_model` for the command line function. .. currentmodule:: montreal_forced_aligner.models diff --git a/docs/source/reference/alignment/helper.rst b/docs/source/reference/alignment/helper.rst index 34dc4f3d..02826940 100644 --- a/docs/source/reference/alignment/helper.rst +++ b/docs/source/reference/alignment/helper.rst @@ -21,11 +21,13 @@ Multiprocessing workers and functions :toctree: generated/ AlignFunction + FineTuneFunction CompileTrainGraphsFunction compile_information_func AccStatsFunction AlignmentExtractionFunction ExportTextGridProcessWorker + PhoneConfidenceFunction Multiprocessing argument classes @@ -42,3 +44,5 @@ Multiprocessing argument classes CompileInformationArguments AlignmentExtractionArguments ExportTextGridArguments + FineTuneArguments + PhoneConfidenceArguments diff --git a/docs/source/reference/database/index.rst b/docs/source/reference/database/index.rst index 04a1e4a0..5281fd89 100644 --- a/docs/source/reference/database/index.rst +++ b/docs/source/reference/database/index.rst @@ -12,15 +12,22 @@ MFA uses a SQLite database to cache information during training/alignment runs. :toctree: generated/ Dictionary + Dialect Word Pronunciation Phone + Grapheme File TextFile SoundFile Speaker - SpeakerOrdering Utterance WordInterval PhoneInterval - ReferencePhoneInterval + CorpusWorkflow + PhonologicalRule + RuleApplication + Job + M2MSymbol + M2M2Job + Word2Job diff --git a/docs/source/reference/diarization/helper.rst b/docs/source/reference/diarization/helper.rst new file mode 100644 index 00000000..7b19d518 --- /dev/null +++ b/docs/source/reference/diarization/helper.rst @@ -0,0 +1,17 @@ + +Helper functions +================ + +.. currentmodule:: montreal_forced_aligner.diarization.multiprocessing + +.. autosummary:: + :toctree: generated/ + + PldaClassificationFunction + PldaClassificationArguments + ComputeEerFunction + ComputeEerArguments + SpeechbrainEmbeddingFunction + SpeechbrainClassificationFunction + SpeechbrainArguments + cluster_matrix diff --git a/docs/source/reference/diarization/index.rst b/docs/source/reference/diarization/index.rst new file mode 100644 index 00000000..d1ac2bae --- /dev/null +++ b/docs/source/reference/diarization/index.rst @@ -0,0 +1,12 @@ + +.. _diarization_api: + +Speaker diarization +=================== + +Speaker diarization is the procedure to assign speaker labels to utterances. MFA can train and use ivector models (see :ref:`train_ivector`) or use :xref:`speechbrain`'s pretrained speaker classifier. + +.. toctree:: + + main + helper diff --git a/docs/source/reference/diarization/main.rst b/docs/source/reference/diarization/main.rst new file mode 100644 index 00000000..556550cf --- /dev/null +++ b/docs/source/reference/diarization/main.rst @@ -0,0 +1,10 @@ + +Speaker Diarization +=================== + +.. currentmodule:: montreal_forced_aligner.diarization.speaker_diarizer + +.. autosummary:: + :toctree: generated/ + + SpeakerDiarizer diff --git a/docs/source/reference/dictionary/helper.rst b/docs/source/reference/dictionary/helper.rst index 7a18fc1e..1f778fde 100644 --- a/docs/source/reference/dictionary/helper.rst +++ b/docs/source/reference/dictionary/helper.rst @@ -41,12 +41,6 @@ Helper SanitizeFunction SplitWordsFunction -.. currentmodule:: montreal_forced_aligner.dictionary.multispeaker - -.. autosummary:: - :toctree: generated/ - - MultispeakerSanitizationFunction Pronunciation probability functionality ======================================= diff --git a/docs/source/reference/helper/command_line.rst b/docs/source/reference/helper/command_line.rst deleted file mode 100644 index 84552c53..00000000 --- a/docs/source/reference/helper/command_line.rst +++ /dev/null @@ -1,27 +0,0 @@ -Command line functions -====================== - -.. currentmodule:: montreal_forced_aligner.command_line - -.. autosummary:: - :toctree: generated/ - - main - create_parser - validate_model_arg - run_transcribe_corpus - run_validate_corpus - run_train_lm - run_train_g2p - run_align_corpus - run_train_dictionary - run_anchor - run_adapt_model - run_train_acoustic_model - run_train_ivector_extractor - run_g2p - run_create_segments - run_classify_speakers - run_model - save_model - inspect_model diff --git a/docs/source/reference/helper/config.rst b/docs/source/reference/helper/config.rst index a2870265..07cdc888 100644 --- a/docs/source/reference/helper/config.rst +++ b/docs/source/reference/helper/config.rst @@ -3,9 +3,10 @@ .. autosummary:: :toctree: generated/ + MfaConfiguration + MfaProfile + get_temporary_directory generate_config_path generate_command_history_path load_command_history update_command_history - update_global_config - load_global_config diff --git a/docs/source/reference/helper/data.rst b/docs/source/reference/helper/data.rst index 404429f3..5188c58a 100644 --- a/docs/source/reference/helper/data.rst +++ b/docs/source/reference/helper/data.rst @@ -11,6 +11,7 @@ WordData WordType PhoneType + WorkflowType DatabaseImportData PronunciationProbabilityCounter - CtmInterval -- Data class for representing intervals in Kaldi's CTM files + CtmInterval diff --git a/docs/source/reference/helper/exceptions.rst b/docs/source/reference/helper/exceptions.rst index 2ee5192e..901ba9ba 100644 --- a/docs/source/reference/helper/exceptions.rst +++ b/docs/source/reference/helper/exceptions.rst @@ -32,3 +32,4 @@ ModelTypeNotSupportedError PronunciationAcousticMismatchError PronunciationOrthographyMismatchError + RootDirectoryError diff --git a/docs/source/reference/helper/helper.rst b/docs/source/reference/helper/helper.rst index 23417e2c..4de57e96 100644 --- a/docs/source/reference/helper/helper.rst +++ b/docs/source/reference/helper/helper.rst @@ -15,3 +15,4 @@ compare_labels overlap_scoring align_phones + CustomFormatter diff --git a/docs/source/reference/helper/index.rst b/docs/source/reference/helper/index.rst index 55fe2abc..cbbfc254 100644 --- a/docs/source/reference/helper/index.rst +++ b/docs/source/reference/helper/index.rst @@ -6,7 +6,6 @@ Helper .. toctree:: - command_line abc config data diff --git a/docs/source/reference/helper/utils.rst b/docs/source/reference/helper/utils.rst index b63586fd..590d7d30 100644 --- a/docs/source/reference/helper/utils.rst +++ b/docs/source/reference/helper/utils.rst @@ -11,4 +11,3 @@ thirdparty_binary log_kaldi_errors parse_logs - CustomFormatter diff --git a/docs/source/reference/language_modeling/helper.rst b/docs/source/reference/language_modeling/helper.rst index a13ef25a..7a842f2a 100644 --- a/docs/source/reference/language_modeling/helper.rst +++ b/docs/source/reference/language_modeling/helper.rst @@ -1,6 +1,9 @@ Helper functionality ==================== +Mixins +------ + .. currentmodule:: montreal_forced_aligner.language_modeling.trainer .. autosummary:: @@ -9,3 +12,15 @@ Helper functionality LmTrainerMixin -- Mixin for language model training LmCorpusTrainerMixin -- Mixin for language model training on a corpus LmDictionaryCorpusTrainerMixin -- Mixin for language model training on a corpus with a pronunciation dictionary + + +Helper +------ + +.. currentmodule:: montreal_forced_aligner.language_modeling.multiprocessing + +.. autosummary:: + :toctree: generated/ + + TrainSpeakerLmFunction + TrainSpeakerLmArguments diff --git a/docs/source/reference/segmentation/helper.rst b/docs/source/reference/segmentation/helper.rst index 4ab38292..97d6e34c 100644 --- a/docs/source/reference/segmentation/helper.rst +++ b/docs/source/reference/segmentation/helper.rst @@ -2,7 +2,7 @@ Helper functions ================ -.. currentmodule:: montreal_forced_aligner.segmenter +.. currentmodule:: montreal_forced_aligner.vad.multiprocessing .. autosummary:: :toctree: generated/ diff --git a/docs/source/reference/segmentation/main.rst b/docs/source/reference/segmentation/main.rst index e502e93c..ed15a3b3 100644 --- a/docs/source/reference/segmentation/main.rst +++ b/docs/source/reference/segmentation/main.rst @@ -2,7 +2,7 @@ Segmenter ========= -.. currentmodule:: montreal_forced_aligner.segmenter +.. currentmodule:: montreal_forced_aligner.vad.segmenter .. autosummary:: :toctree: generated/ diff --git a/docs/source/reference/top_level_index.rst b/docs/source/reference/top_level_index.rst index 468efb9f..7fc04b83 100644 --- a/docs/source/reference/top_level_index.rst +++ b/docs/source/reference/top_level_index.rst @@ -8,3 +8,4 @@ Workflows g2p/index transcription/index segmentation/index + diarization/index diff --git a/docs/source/reference/transcription/helper.rst b/docs/source/reference/transcription/helper.rst index ee3f2123..525cd928 100644 --- a/docs/source/reference/transcription/helper.rst +++ b/docs/source/reference/transcription/helper.rst @@ -42,8 +42,6 @@ Speaker-independent transcription LmRescoreArguments CarpaLmRescoreFunction CarpaLmRescoreArguments - ScoreFunction - ScoreArguments Speaker-adapted transcription ----------------------------- diff --git a/docs/source/reference/validation/helper.rst b/docs/source/reference/validation/helper.rst index 7f129ff0..997bc88a 100644 --- a/docs/source/reference/validation/helper.rst +++ b/docs/source/reference/validation/helper.rst @@ -10,17 +10,3 @@ Mixins :toctree: generated/ ValidationMixin - - -Helper ------- - -.. currentmodule:: montreal_forced_aligner.validation.corpus_validator - -.. autosummary:: - :toctree: generated/ - - TrainSpeakerLmFunction - TrainSpeakerLmArguments - TestUtterancesFunction - TestUtterancesArguments diff --git a/docs/source/user_guide/commands.rst b/docs/source/user_guide/commands.rst index a8df37d1..6b2107c7 100644 --- a/docs/source/user_guide/commands.rst +++ b/docs/source/user_guide/commands.rst @@ -38,7 +38,7 @@ Corpus creation "``mfa create_segments``", "Use voice activity detection to create segments", :ref:`create_segments` "``mfa train_ivector``", "Train an ivector extractor for speaker classification", :ref:`train_ivector` - "``mfa classify_speakers``", "Use ivector extractor to classify files or cluster them", :ref:`classify_speakers` + "``mfa diarize_speakers``", "Use ivector extractor to classify files or cluster them", :ref:`diarize_speakers` "``mfa transcribe``", "Generate transcriptions using an acoustic model, dictionary, and language model", :ref:`transcribing` "``mfa train_lm``", "Train a language model from a text corpus or from an existing language model", :ref:`training_lm` "``mfa anchor``", "Run the Anchor annotator utility (if installed) for editing and managing corpora", :ref:`anchor` diff --git a/docs/source/user_guide/concepts/hmm.rst b/docs/source/user_guide/concepts/hmm.rst index e9da1e4a..f4b9aebd 100644 --- a/docs/source/user_guide/concepts/hmm.rst +++ b/docs/source/user_guide/concepts/hmm.rst @@ -13,5 +13,14 @@ Hidden Markov Models Standard topology ----------------- +Kaldi topology reference +------------------------ + +Please see: + +- https://kaldi-asr.org/doc/hmm.html +- https://kaldi-asr.org/doc/tree_internals.html +- https://kaldi-asr.org/doc/tree_externals.html + MFA topology ------------ diff --git a/docs/source/user_guide/configuration/diarization.rst b/docs/source/user_guide/configuration/diarization.rst new file mode 100644 index 00000000..2a0ea81a --- /dev/null +++ b/docs/source/user_guide/configuration/diarization.rst @@ -0,0 +1,26 @@ + +.. _configuration_diarization: + +Diarization options +=================== + +.. csv-table:: + :widths: 20, 20, 60 + :header: "Parameter", "Default value", "Notes" + :stub-columns: 1 + + "cluster_type", ``optics``, "Clustering algorithm in :xref:`scikit-learn` to use, one of ``optics``, ``dbscan``, ``affinity``, ``agglomerative``, ``spectral, ``kmeans``" + "expected_num_speakers", 0, "Number of speaker clusters to find, must be > 1 for ``agglomerative``, ``spectral``, and ``kmeans``" + "sparse_threshold", 0.5, "Threshold on distance to limit precomputed sparse matrix" + +.. _default_diarization_config: + +Default diarization config file +------------------------------- + +.. code-block:: yaml + + cluster_type: optics + energy_mean_scale: 0.5 + max_segment_length: 30 + min_pause_duration: 0.05 diff --git a/docs/source/user_guide/configuration/g2p.rst b/docs/source/user_guide/configuration/g2p.rst index 11c58c62..256e684c 100644 --- a/docs/source/user_guide/configuration/g2p.rst +++ b/docs/source/user_guide/configuration/g2p.rst @@ -18,7 +18,6 @@ Global options "clitic_markers", "'''’", "Characters to treat as clitic markers, will be collapsed to the first character in the string" "compound_markers", "\-", "Characters to treat as marker in compound words (i.e., doesn't need to be preserved like for clitics)" "num_pronunciations", 1, "Number of pronunciations to generate" - "use_mp", True, "Flag for whether to use multiprocessing" .. _train_g2p_config: @@ -57,7 +56,6 @@ Default G2P training config file clitic_markers: "'’" compound_markers: "-" num_pronunciations: 1 # Used if running in validation mode - use_mp: True order: 7 random_starts: 25 seed: 1917 @@ -81,4 +79,3 @@ Default dictionary generation config file clitic_markers: "'’" compound_markers: "-" num_pronunciations: 1 - use_mp: True diff --git a/docs/source/user_guide/configuration/index.rst b/docs/source/user_guide/configuration/index.rst index dfe80459..d1d1923c 100644 --- a/docs/source/user_guide/configuration/index.rst +++ b/docs/source/user_guide/configuration/index.rst @@ -10,14 +10,16 @@ MFA root directory MFA uses a temporary directory for commands that can be specified in running commands with ``--temp_directory`` (see below), and it also uses a directory to store global configuration settings and saved models. By default this root directory is ``~/Documents/MFA``, but if you would like to put this somewhere else, you can set the environment variable ``MFA_ROOT_DIR`` to use that. MFA will raise an error on load if it's unable to write to the root directory. +.. _configure_cli: + Global configuration ==================== Global configuration for MFA can be updated via the ``mfa configure`` subcommand. Once the command is called with a flag, it will set a default value for any future runs (though, you can overwrite most settings when you call other commands). -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: configure +.. click:: montreal_forced_aligner.command_line.configure:configure_cli + :prog: mfa configure + :nested: full Configuring specific commands ============================= @@ -80,3 +82,4 @@ You can then also override these options on the command like, i.e. ``--beam 10 - transcription.rst segment.rst ivector.rst + diarization diff --git a/docs/source/user_guide/corpus_creation/anchor.rst b/docs/source/user_guide/corpus_creation/anchor.rst index d2c7eb7d..5575532a 100644 --- a/docs/source/user_guide/corpus_creation/anchor.rst +++ b/docs/source/user_guide/corpus_creation/anchor.rst @@ -19,10 +19,14 @@ To use the annotator, first install the anchor subpackage: conda install montreal-forced-aligner[anchor] -This will install MFA if hasn't been along with all the packages that Anchor requires. Once installed, Anchor can be started with the following MFA subcommand: +This will install MFA if hasn't been along with all the packages that Anchor requires. Once installed, Anchor can be started with the following MFA subcommand `mfa anchor`. -.. code-block:: bash +See the `Anchor Annotator documentation`_ for more information. - mfa anchor +Command reference +================= -See the `Anchor Annotator documentation`_ for more information. + +.. click:: montreal_forced_aligner.command_line.anchor:anchor_cli + :prog: mfa anchor + :nested: full diff --git a/docs/source/user_guide/corpus_creation/classify_speakers.rst b/docs/source/user_guide/corpus_creation/classify_speakers.rst deleted file mode 100644 index ff170afa..00000000 --- a/docs/source/user_guide/corpus_creation/classify_speakers.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. _classify_speakers: - -Cluster speakers ``(mfa classify_speakers)`` -============================================ - -The Montreal Forced Aligner can use trained ivector models (see :ref:`train_ivector` for more information about trainingthese models) to classify or cluster utterances according to speakers. - -.. warning:: - - This feature is not fully implemented, and is still under construction. - -Command reference ------------------ - -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: classify_speakers diff --git a/docs/source/user_guide/corpus_creation/create_segments.rst b/docs/source/user_guide/corpus_creation/create_segments.rst index db99dbe1..292fe3e0 100644 --- a/docs/source/user_guide/corpus_creation/create_segments.rst +++ b/docs/source/user_guide/corpus_creation/create_segments.rst @@ -1,24 +1,23 @@ + .. _create_segments: Create segments ``(mfa create_segments)`` ========================================= -The Montreal Forced Aligner can use Voice Activity Detection (VAD) capabilities from Kaldi to generate segments from +The Montreal Forced Aligner can use Voice Activity Detection (VAD) capabilities from :xref:`speechbrain` to generate segments from a longer sound file. .. note:: - The default configuration for VAD uses configuration values based on quiet speech. The algorithm is based on energy, - so if your recordings are more noisy, you may need to adjust the configuration. See :ref:`configuration_segmentation` - for more information on changing these parameters. - + On Windows, if you get an ``OSError/WinError 1314`` during the run, follow `these instructions `_ to enable symbolic link creation permissions. Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: create_segments +.. click:: montreal_forced_aligner.command_line.create_segments:create_segments_cli + :prog: mfa create_segments + :nested: full + Configuration reference ----------------------- diff --git a/docs/source/user_guide/corpus_creation/diarize_speakers.rst b/docs/source/user_guide/corpus_creation/diarize_speakers.rst new file mode 100644 index 00000000..3192d35e --- /dev/null +++ b/docs/source/user_guide/corpus_creation/diarize_speakers.rst @@ -0,0 +1,37 @@ +.. _diarize_speakers: + +Speaker diarization ``(mfa diarize_speakers)`` +============================================== + +The Montreal Forced Aligner can use trained ivector models (see :ref:`train_ivector` for more information about training these models) to classify or cluster utterances according to speakers. + +Following ivector extraction, MFA stores utterance and speaker ivectors in PLDA-transformed space. Storing the PLDA transformation ensures that the transformation is performed only once when ivectors are initially extracted, rather than done each time scoring occurs. The dimensionality of the PLDA-transformed ivectors is 50, by default, but this can be changed through the :ref:`configure_cli` command. + +.. seealso:: + + The PLDA transformation and scoring generally follows `Probabilistic Linear Discriminant Analysis (PLDA) Explained by Prachi Singh `_ and `the associated code `_. + +A number of clustering algorithms from `scikit-learn `_ are available to use as input, along with the default `hdbscan `_. Specifying ``--use_plda`` will use PLDA scoring, as opposed to Euclidean distance in PLDA-transformed space. PLDA scoring is likely better, but does have the drawback of computing the full distance matrix for ``hdbscan``, ``affinity``, ``agglomerative``, ``spectral``, ``dbscan``, and ``optics``. + +.. warning:: + + Some experimentation in clustering is likely necessary, and in general, should be run in a very supervised manner. Different recording conditions and noise in particular utterances can affect the ivectors. Please see the speaker diarization functionality of :xref:`anchor` for a way to run MFA's diarization in a supervised manner. + + Also, do note that much of the speaker diarization functionality in MFA is implemented particularly for Anchor, as it's not quite as constrained a problem as forced alignment. As such, please consider speaker diarization from the command line as alpha functionality, there are likely to be issues. + +Command reference +----------------- + +.. click:: montreal_forced_aligner.command_line.diarize_speakers:diarize_speakers_cli + :prog: mfa diarize_speakers + :nested: full + +Configuration reference +----------------------- + +- :ref:`configuration_diarization` + +API reference +------------- + +- :ref:`diarization_api` diff --git a/docs/source/user_guide/corpus_creation/index.rst b/docs/source/user_guide/corpus_creation/index.rst index d767e9f5..fafa019a 100644 --- a/docs/source/user_guide/corpus_creation/index.rst +++ b/docs/source/user_guide/corpus_creation/index.rst @@ -18,10 +18,10 @@ MFA now contains several command line utilities for helping to create corpora fr .. toctree:: :hidden: - create_segments.rst - train_ivector.rst - classify_speakers.rst - transcribing.rst - training_lm.rst - training_dictionary.rst - anchor.rst + create_segments + train_ivector + diarize_speakers + transcribing + training_lm + training_dictionary + anchor diff --git a/docs/source/user_guide/corpus_creation/train_ivector.rst b/docs/source/user_guide/corpus_creation/train_ivector.rst index fa0dee09..69662ff9 100644 --- a/docs/source/user_guide/corpus_creation/train_ivector.rst +++ b/docs/source/user_guide/corpus_creation/train_ivector.rst @@ -3,7 +3,7 @@ Train an ivector extractor ``(mfa train_ivector)`` ================================================== -The Montreal Forced Aligner can train :term:`ivector extractors` using an acoustic model for generating alignments. As part of this training process, a classifier is built in that can be used as part of :ref:`classify_speakers`. +The Montreal Forced Aligner can train :term:`ivector extractors` using an acoustic model for generating alignments. As part of this training process, a classifier is built in that can be used as part of :ref:`diarize_speakers`. .. warning:: @@ -12,9 +12,9 @@ The Montreal Forced Aligner can train :term:`ivector extractors` using an acoust Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: train_ivector +.. click:: montreal_forced_aligner.command_line.train_ivector_extractor:train_ivector_cli + :prog: mfa train_ivector + :nested: full Configuration reference ----------------------- diff --git a/docs/source/user_guide/corpus_creation/training_dictionary.rst b/docs/source/user_guide/corpus_creation/training_dictionary.rst index 0472a625..c2c7f254 100644 --- a/docs/source/user_guide/corpus_creation/training_dictionary.rst +++ b/docs/source/user_guide/corpus_creation/training_dictionary.rst @@ -25,330 +25,14 @@ Consider the following :term:`WFST` with two pronunciations of "because" from th In the above figure, there are are two final states, with 0 corresponding to a word preceded by ``non-silence`` and 1 corresponding to a word preceded by ``silence``. The costs associated with each transition are negative log-probabilities, so that less likely paths cost more. The state 0 refers to the beginning of speech, so the paths to the silence and non silence state are equal in this case. The cost for ending on silence is lower at -0.77 than ending on non-silence with a cost of 1.66, meaning that most utterances in the training data had trailing silence at the end of the recordings. -.. _train_pronunciation_probability: - -Pronunciation probability -------------------------- - -Pronunciation probabilities are estimated based on the counts of a specific pronunciations normalized by the count of the most frequent pronunciation. Counts are estimated using add-one smoothing. - -.. math:: - - p(w.p_{i} | w) = \frac{c(w.p_{i} | w)}{max_{1\le i \le N_{w}}c(w.p_{i} | w)} - -The reason for using max normalization is to not penalize words with many pronunciations. Even though the probabilities no longer sum to 1, the log of the probabilities is used as summed costs in the :term:`lexicon FST`, so summing to 1 within a word is not problematic. - -If a word is not seen in the training data, pronunciation probabilities are not estimated for its pronunciations. - -.. _train_silence_probability: - -Silence probability and correction factors ------------------------------------------- - -Words differ in their likelihood to appear before or after silence. In English, a word like "the" is more likely to appear after silence than a word like "us". An pronoun in the accusative case like "us" is not grammatical at the start of a sentence or phrase, whereas "the" starts sentences and phrases regularly. That is not to say that a speaker would not pause before saying "us" for paralinguistic effect or due to a disfluency or simple pause, it's just less likely to occur after silence than "the". - -By the same token, silence following "the" is also less likely than for "us" due to syntax, but pauses are more likely to follow some pronunciations of "the" than others. For instance, if a speaker produces a full vowel variant like :ipa_inline:`[ð i]`, a pause is more likely to follow than a reduced variant like :ipa_inline:`[ð ə]`. The reduced variant will be more likely overall, but it often occurs in running connected speech at normal speech rates. The full vowel variant is more likely to occur in less connected speech, such as when the speaker is planning upcoming speech or speaking more slowly. Accounting for the likelihood of silence before and after a variant allows the model to output a variant that is less likely overall, but more likely given the context. - -However, when we take into account more context, it is not just the single word that determines the likelihood of silence, but also the preceding/following words. The difficulty in estimating the overall likelihood of silence is that the lexicon FST is predicated on each word being independent and composable with any preceding/following word. Thus, for each word, we estimate a probability of silence following (independent of the following words), and two correction factors for silence and non-silence before. The two correction factors take into account the general likelihood of silence following each of the preceding words and gives two factors that represent "is silence or not silence more likely than we would expect given the previous word". These factors are only an approximation, however they do help in alignment. - -.. note:: - - Probabilities of multi-word sequences are the domain of the :term:`grammar FST`, please refer :ref:`grammar FST concept section `. - -MFA uses three variables to capture the probabilities of silence before and after a pronunciation. The most straightforward is ``probability of silence following``, which is calculated as the count of instances where the word was followed by silence divided by the overall count of that pronunciation, with a smoothing factor. Reproducing equation 3 of `Chen et al (2015)`_: - -.. math:: - - P(s_{r} | w.p) = \frac{C(w.p \: s) + \lambda_{2}P(s)}{C(w.p) + \lambda_{2}} - -Given that we're using a lexicon where words are assumed to be completely independent, modelling the silence before the pronunciation is a little tricky. The approach used in `Chen et al (2015)`_ is to estimate two correction factors for silence and non-silence before the pronunciation. These correction factors capture that for a given pronunciation, it is more or less likely than average to have silence. The factors are estimated as follows, reproducing equations 4-6 from `Chen et al (2015)`_: - - -.. math:: - - F(s_{l} | w.p) = \frac{C(s \: w.p) + \lambda_{3}}{\tilde{C}(s \: w.p) + \lambda_{3}} - - F(ns_{l} | w.p) = \frac{C(ns \: w.p) + \lambda_{3}}{\tilde{C}(ns \: w.p) + \lambda_{3}} - - \tilde{C}(s \: w.p) = \sum_{v} C(v \: w.p) P(s_r|v) - -The estimate count :math:`\tilde{C}` represents a "mean" count of silence or non-silence preceding a given pronunciation, taking into account the likelihood of silence from the preceding pronunciation. The correction factors are weights on the FST transitions from silence and non-silence state. - -Consider the following :term:`FST` with three pronunciations of "lot" from the `English US MFA dictionary`_. - - - .. figure:: ../../_static/lot.svg - :align: center - :alt: :term:`FST` for three pronunciations of "lot" in the English US dictionary - - - - -Example -------- - -As an example, consider the following English and Japanese sentences: - -.. tab-set:: - - .. tab-item:: English - :sync: english - - The red fox has read many books, but there's always more to read. - - Normalized: - - the red fox has read many books but there 's always more to read - - .. tab-item:: Japanese - :sync: japanese - - アカギツネさんは本を読んだことがたくさんありますけれども、読むべき本はまだまだいっぱい残っています。 - - Normalized: - - アカギツネ さん は 本 を 読んだ こと が たくさん あり ます けれども 読む べき 本 は まだまだ いっぱい 残って い ます - -For each of the above sentences (please pardon my Japanese), I recorded a normal speaking rate version and a fast speaking rate version. The two speech rates induce variation in pronunciation, as well as different pause placement. We'll then walk through the calculations that result in the final trained lexicon. - -.. tab-set:: - - .. tab-item:: English - :sync: english - - .. raw:: html - -
- -
- - .. figure:: ../../_static/sound_files/english_slow.svg - :align: center - :alt: Waveform, spectrogram, and aligned labels for the slow reading of the English text - - .. raw:: html - -
- -
- - .. figure:: ../../_static/sound_files/english_fast.svg - :align: center - :alt: Waveform, spectrogram, and aligned labels for the fast reading of the English text - - .. tab-item:: Japanese - :sync: japanese - - .. raw:: html - -
- -
- - .. figure:: ../../_static/sound_files/japanese_slow.svg - :align: center - :alt: Waveform, spectrogram, and aligned labels for the slow reading of the Japanese text - - .. raw:: html - -
- -
- - .. figure:: ../../_static/sound_files/japanese_fast.svg - :align: center - :alt: Waveform, spectrogram, and aligned labels for the fast reading of the Japanese text - -For alignment, we use the following pronunciation dictionaries, taking pronunciation variants from the `English US MFA dictionary`_ and the `Japanese MFA dictionary`_. - -.. tab-set:: - - .. tab-item:: English - :sync: english - - In addition to lexical variants for the present and past tense of "read", function words have several variants listed. The genitive marker "'s" has variants to account for stem-final voicing (:ipa_inline:`[s]` and :ipa_inline:`[z]`) and stem-final alveolar obstruents (:ipa_inline:`[ɪ z]`). The negative conjuction "but" has variants for the pronunciation of the vowel and final :ipa_inline:`/t/` as :ipa_inline:`[ʔ]` or :ipa_inline:`[ɾ]`. Likewise, the preposition "to" has variants for the initial :ipa_inline:`/t/` and vowel reductions. The definite determiner "the" and distal demonstrative "there" have variants for stopping :ipa_inline:`/ð/` to :ipa_inline:`[d̪]`, along with reductions for vowels. - - .. csv-table:: English US pronunciation dictionary - :widths: 30, 70 - :header: "Word","Pronunciation" - - "'s","s" - "'s","z" - "'s","ɪ z" - "always","ɒː ɫ w ej z" - "always","ɑː ɫ w ej z" - "always","ɒː w ej z" - "always","ɑː w ej z" - "books","b ʊ k s" - "but","b ɐ t" - "but","b ɐ ʔ" - "but","b ə ɾ" - "fox","f ɑː k s" - "has","h æ s" - "has","h æ z" - "many","m ɛ ɲ i" - "more","m ɒː ɹ" - "read","ɹ iː d" - "read","ɹ ɛ d" - "red","ɹ ɛ d" - "the","d̪ iː" - "the","d̪ iː ʔ" - "the","d̪ ə" - "the","iː" - "the","iː ʔ" - "the","l ə" - "the","n ə" - "the","s ə" - "the","ð iː" - "the","ð iː ʔ" - "the","ð ə" - "the","ə" - "there","d̪ ɚ" - "there","d̪ ɛ ɹ" - "there","ð ɚ" - "there","ð ɛ ɹ" - "to","t ə" - "to","tʰ ʉː" - "to","tʰ ʊ" - "to","ɾ ə" - - - .. tab-item:: Japanese - :sync: japanese - - The main pronunciation variants are in the topic particle "は", the object particle "を", the adjective "たくさん", and the "but" conjunction "けれども". The particles are always pronounced as :ipa_inline:`[w a]` and :ipa_inline:`[o]` and never as their hiragana readings :ipa_inline:`[h a]` and :ipa_inline:`[w o]`, respectively. For "ました", I've included various levels of devoicing for :ipa_inline:`/i/` between the voiceless obstruents from full voiced :ipa_inline:`[i]`, to devoiced :ipa_inline:`[i̥]` to deleted. - - .. csv-table:: Japanese pronunciation dictionary - :widths: 30, 70 - :header: "Word","Pronunciation" - - "アカギツネ","a k a ɟ i ts ɨ n e" - "さん","s a ɴ" - "は","h a" - "は","w a" - "本","h o ɴ" - "を","o" - "を","w o" - "読んだ","j o n d a" - "こと","k o t o" - "が","ɡ a" - "あり","a ɾ i" - "ます","m a s ɨ" - "ます","m a s ɨ̥" - "ます","m a s" - "たくさん","t a k ɯ̥ s a ɴ" - "たくさん","t a k s a ɴ" - "たくさん","t a k ɯ s a ɴ" - "けれども","k e ɾ e d o m o" - "けれども","k e d o m o" - "けれども","k e d o" - -The basic steps to calculating pronunciation and silence probabilities is as follows: - -1. Generate word-pronunciation pairs (along with silence labels) from the alignment lattices -2. Use these pairs as input to :ref:`calculating pronunciation probability ` and :ref:`calculating silence probability `. See the results table below for a walk-through of results for various words across the two reading passage styles. - -.. tab-set:: - - .. tab-item:: English - :sync: english - - - .. csv-table:: Trained English US pronunciation dictionary - :widths: 10, 18,18,18,18,18 - :header: "Word", "Pronunciation probability", "Probability of silence after", "Correction for silence before", "Correction for non-silence before","Pronunciation" - - "'s",0.33,0.18,1.0,1.0,"s" - "'s",0.99,0.09,0.92,1.05,"z" - "'s",0.33,0.18,1.0,1.0,"ɪ z" - "always",0.99,0.09,0.92,1.05,"ɒː ɫ w ej z" - "always",0.33,0.18,1.0,1.0,"ɑː ɫ w ej z" - "always",0.33,0.18,1.0,1.0,"ɒː w ej z" - "always",0.33,0.18,1.0,1.0,"ɑː w ej z" - "books",0.99,0.34,0.92,1.05,"b ʊ k s" - "but",0.99,0.46,1.28,0.75,"b ɐ t" - "but",0.99,0.12,0.85,1.13,"b ɐ ʔ" - "but",0.5,0.18,1.0,1.0,"b ə ɾ" - "fox",0.99,0.09,0.92,1.05,"f ɑː k s" - "has",0.33,0.18,1.0,1.0,"h æ s" - "has",0.99,0.09,0.92,1.05,"h æ z" - "many",0.99,0.09,0.92,1.05,"m ɛ ɲ i" - "many",0.33,0.18,1.0,1.0,"mʲ ɪ ɲ i" - "more",0.99,0.09,0.92,1.05,"m ɒː ɹ" - "read",0.99,0.59,0.92,1.05,"ɹ iː d" - "read",0.99,0.09,0.92,1.05,"ɹ ɛ d" - "red",0.99,0.09,0.89,1.06,"ɹ ɛ d" - "the",0.5,0.18,1.0,1.0,"d̪ iː" - "the",0.5,0.18,1.0,1.0,"d̪ iː ʔ" - "the",0.5,0.18,1.0,1.0,"d̪ ə" - "the",0.5,0.18,1.0,1.0,"iː" - "the",0.5,0.18,1.0,1.0,"iː ʔ" - "the",0.5,0.18,1.0,1.0,"l ə" - "the",0.5,0.18,1.0,1.0,"n ə" - "the",0.5,0.18,1.0,1.0,"s ə" - "the",0.99,0.12,1.49,0.67,"ð iː" - "the",0.5,0.18,1.0,1.0,"ð iː ʔ" - "the",0.99,0.12,1.49,0.67,"ð ə" - "the",0.5,0.18,1.0,1.0,"ə" - "there",0.33,0.18,1.0,1.0,"d̪ ɚ" - "there",0.33,0.18,1.0,1.0,"d̪ ɛ ɹ" - "there",0.33,0.18,1.0,1.0,"ð ɚ" - "there",0.99,0.09,1.37,0.65,"ð ɛ ɹ" - "to",0.99,0.09,0.92,1.05,"t ə" - "to",0.33,0.18,1.0,1.0,"tʰ ʉː" - "to",0.33,0.18,1.0,1.0,"tʰ ʊ" - "to",0.33,0.18,1.0,1.0,"ɾ ə" - - **Pronunciation probabilities** - - Using the alignments above for the two speech rates, the word "red" has 0.99 pronunciation probability as that's the only pronunciation variant. The word "read" pronounced as :ipa_inline:`[ɹ ɛ d]` has 0.99 probability, as will the pronunciation as :ipa_inline:`[ɹ iː d]`, as they both appeared once in the sentence (and twice across the two speech rates), but note that it is not 0.5, as the probabilities are max-normalized. Both full and reduced forms of "but" (:ipa_inline:`[b ɐ t]` and :ipa_inline:`[b ɐ ʔ]`) have pronunciation probability of 0.99, as they each occur once across the passages. - - .. note:: - - I'm not sure why the :ipa_inline:`[b ɐ ʔ]` variant is chosen over the :ipa_inline:`[b ə ɾ]`, this will require future investigation to figure out a root cause. - - All other words will have one pronunciation with 0.99, if they have one realized pronunciation, unrealized pronunciations will have a smoothed probability close to 0, based on the number of pronunciations. - - .. note:: - - "Unrealized pronunciations" refer to pronunciation variants that are not represented in training data, i.e., for the word "to", only the :ipa_inline:`[t ə]` was used, so :ipa_inline:`[tʰ ʉː]`, :ipa_inline:`[tʰ ʊ]`, and :ipa_inline:`[ɾ ə]` are unrealized. - - **Probabilities of having silence following** - - The word "books" has a probability of silence following at 0.34, as it only occurs before silence in the slower speech rate sentence. You might expect it to have a silence probability of 0.5, but recall from the equation of :math:`P(s_{r} | w.p)`, the smoothing factor is influenced by the overall rate of silence following words, which is quite low for the sentences with connected speech. - - The pronunciation of "read" as :ipa_inline:`[ɹ iː d]` has a higher probability of silence following of 0.59, as both instances of that pronunciation are followed by silence at the end of the sentence. The pronunciation of "read" as :ipa_inline:`[ɹ ɛ d]` will have a probability of following silence of 0.09, as the only instances are in the middle of speech in the first clause. - - **Probabilities of having silence preceding** - - Both pronunciations present of word "the" (:ipa_inline:`[ð iː]` and :ipa_inline:`[ð ə]`) have a silence preceding correction factor (1.49) greater than the non-silence correction factor (0.67), as it only appears after silence in both speech rates. With the non-silence correction factor below 1, the cost in the FST of transitioning out of the non-silence state will be much higher than transitioning out of the silence state. When the silence correction factor is greater than 1, the pronunciation is more likely following silence than you would expect given all the previous words, which will reduce the cost of transitioning out of the silence state. - - The fuller form of the word "but" (:ipa_inline:`[b ɐ t]`) has a silence preceding correction factor (1.28) greater than the non-silence correction factor (0.75), so the full form will have lower cost transitioning out of the silence state and than the non-silence state. On the other hand, the more reduced form :ipa_inline:`[b ɐ ʔ]` has the opposite patten, with a silence before correction factor (0.85) greater than the non-silence correction factor (1.13), so the reduced form will have a lower cost transitioning out of the non-silence state than the silence state. - - - .. tab-item:: Japanese - :sync: japanese - - .. warning:: - - The Japanese walk-through of the pronunciation probability results is still under construction. +.. seealso:: -The resulting trained dictionary can then be used as a dictionary for :ref:`alignment ` or :ref:`transcription `. + See :ref:`probabilistic_lexicons` for more information on probabilities in lexicons. Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: train_dictionary +.. click:: montreal_forced_aligner.command_line.train_dictionary:train_dictionary_cli + :prog: mfa train_dictionary + :nested: full diff --git a/docs/source/user_guide/corpus_creation/training_lm.rst b/docs/source/user_guide/corpus_creation/training_lm.rst index 58a19f74..f9497871 100644 --- a/docs/source/user_guide/corpus_creation/training_lm.rst +++ b/docs/source/user_guide/corpus_creation/training_lm.rst @@ -13,9 +13,9 @@ MFA has a utility function for training ARPA-format ngram :term:`language models Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: train_lm +.. click:: montreal_forced_aligner.command_line.train_lm:train_lm_cli + :prog: mfa train_lm + :nested: full Configuration reference ----------------------- diff --git a/docs/source/user_guide/corpus_creation/transcribing.rst b/docs/source/user_guide/corpus_creation/transcribing.rst index e9700ac9..7cb72853 100644 --- a/docs/source/user_guide/corpus_creation/transcribing.rst +++ b/docs/source/user_guide/corpus_creation/transcribing.rst @@ -31,9 +31,9 @@ Transcriptions can be compared to a gold-standard references by transcribing a c Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: transcribe +.. click:: montreal_forced_aligner.command_line.transcribe:transcribe_corpus_cli + :prog: mfa transcribe + :nested: full Configuration reference ----------------------- diff --git a/docs/source/user_guide/corpus_structure.rst b/docs/source/user_guide/corpus_structure.rst index 23d5c9a0..d7f76b76 100644 --- a/docs/source/user_guide/corpus_structure.rst +++ b/docs/source/user_guide/corpus_structure.rst @@ -84,7 +84,7 @@ by TextGrids that specify orthographic transcriptions for short intervals of speech. - .. figure:: ../../_static/librispeech_textgrid.png + .. figure:: ../_static/librispeech_textgrid.png :align: center :alt: Input TextGrid in Praat with intervals for each utterance and a single tier for a speaker @@ -95,7 +95,7 @@ By default, each tier corresponds to a speaker (speaker "237" in the above examp align speech for multiple speakers per sound file using this format. - .. figure:: ../../_static/multiple_speakers_textgrid.png + .. figure:: ../_static/multiple_speakers_textgrid.png :align: center :alt: Input TextGrid in Praat with intervals for each utterance and tiers for each speaker @@ -106,7 +106,7 @@ channel, and the second half of speaker tiers are associated with the second cha The output from aligning will be a TextGrid with word and phone tiers for each speaker. - .. figure:: ../../_static/multiple_speakers_output_textgrid.png + .. figure:: ../_static/multiple_speakers_output_textgrid.png :align: center :alt: TextGrid in Praat following alignment with interval tiers for each speaker's words and phones @@ -115,7 +115,7 @@ each speaker. Intervals in the TextGrid less than 100 milliseconds will not be aligned. Sound files ------------ +=========== The default format for sound files in Kaldi is ``.wav``. However, if MFA is installed via conda, you should have :code:`sox` and/or :code:`ffmpeg` available which will pipe sound files of various formats to Kaldi in wav format. Running :code:`sox` by itself will a list of formats that it supports. Of interest to speech researchers, the version on conda-forge supports non-standard :code:`wav` formats, :code:`aiff`, :code:`flac`, :code:`ogg`, and :code:`vorbis`. @@ -125,17 +125,19 @@ The default format for sound files in Kaldi is ``.wav``. However, if MFA is ins Likewise, :code:`opus` files can be processed using ``ffmpeg`` on all platforms + Note that formats other than ``.wav`` have extra processing to convert them to ``.wav`` format before processing, particularly on Windows where ``ffmpeg`` is relied upon over ``sox``. See :ref:`wav_conversion` for more details. + Sampling rate -============= +------------- Feature generation for MFA uses a consistent frequency range (20-7800 Hz). Files that are higher or lower sampling rate than 16 kHz will be up- or down-sampled by default to 16 kHz during the feature generation procedure, which may produce artifacts for upsampled files. You can modify this default sample rate as part of configuring features (see :ref:`feature_config` for more details). Bit depth -========= +--------- Kaldi can only process 16-bit WAV files. Higher bit depths (24 and 32 bit) are getting more common for recording, so MFA will automatically convert higher bit depths via :code:`sox` or :code:`ffmpeg`. Duration -======== +-------- In general, audio segments (sound files for Prosodylab-aligner format or intervals for the TextGrid format) should be less than 30 seconds for best performance (the shorter the faster). We recommend using breaks like breaths or silent pauses (i.e., not associated with a stop closure) to separate the audio segments. For longer segments, setting the beam and retry beam higher than their defaults will allow them to be aligned. The default beam/retry beam is very conservative 10/40, so something like 400/1000 will allow for much longer sequences to be aligned. Though also note that the higher the beam value, the slower alignment will be as well. See :ref:`configuration_global` for more details. diff --git a/docs/source/user_guide/data_validation.rst b/docs/source/user_guide/data_validation.rst index 19ba3605..3a1dcd02 100644 --- a/docs/source/user_guide/data_validation.rst +++ b/docs/source/user_guide/data_validation.rst @@ -23,10 +23,12 @@ and logs any of the following issues: - Any files that have deviations from their original transcription to decoded transcriptions using a simple language model when ``--test_transcriptions`` is supplied - Ngram language models for each speaker are generated and merged with models for each utterance for use in decoding utterances, which may help you find transcription or data inconsistency issues in the corpus -.. warning:: +.. _phone_confidence: - As the functionality in ``--test_transcriptions`` relies on ngram language modelling, it is not available natively on Windows outside of the Windows Subsystem for Linux. +Phone confidence +================ +The phone confidence functionality of the validation utility is similar to :ref:`phone_models` in that both are trying to represent the "goodness" of the phone label for the given interval. Where phone models use the acoustic model in combination with a phone language model, phone confidence simply calculates the likelihoods of each phone for each frame .. _running_the_validator: @@ -37,7 +39,6 @@ Running the corpus validation utility Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:parser +.. click:: montreal_forced_aligner.command_line.mfa:mfa_cli :prog: mfa - :start_command: validate - :groups: + :commands: validate diff --git a/docs/source/user_guide/dictionary.rst b/docs/source/user_guide/dictionary.rst index baa41887..09a95818 100644 --- a/docs/source/user_guide/dictionary.rst +++ b/docs/source/user_guide/dictionary.rst @@ -13,14 +13,14 @@ Pronunciation dictionary format ******************************* -.. _text_normalization: - .. warning:: As of 2.0.5, dictionaries have a firmer format of requiring tab-delimited columns (words, pronunciations, etc), and space-delimited pronunciations to avoid confusions in automatically interpreting dictionary format for phonesets that include numbers like X-SAMPA. If your dictionary uses spaces as the delimiter between orthography and pronunciations, you can re-encode it with tabs in a text editor that has regex search and replace support. The regex pattern :code:`^(\S+)\s+` replaced with :code:`\1\t` or :code:`$1\t`, depending on the text editor in question, will replace the first whitespace in every line with a tab. +.. _text_normalization: + Text normalization and dictionary lookup ======================================== @@ -171,7 +171,7 @@ The first float column is the probability of the pronunciation, the next float i .. note:: - You can include entries that only have pronunciations or pronunciation probabilities mixed with those with silence probabilities. If an entry doesn't have a pronunciation probability, it will default to ``1.0`` (assumes equal weight between pronunciation variants as above). If an entry does not have the three silence numbers, then the probability following silence will use the default (:ref:`defaults to 0.5 for non-pretrained models `, or :ref:`whatever probability was estimated during training `), along with no correction for when the pronunciation follows silence or non-silence. + You can include entries that only have pronunciations or pronunciation probabilities mixed with those with silence probabilities. If an entry doesn't have a pronunciation probability, it will default to ``1.0`` (assumes equal weight between pronunciation variants as above). If an entry does not have the three silence numbers, then the probability following silence will use the default (:ref:`defaults to 0.5 for non-pretrained models `, or :ref:`whatever probability was estimated during training `), along with no correction for when the pronunciation follows silence or non-silence. Non-speech annotations ====================== @@ -186,6 +186,17 @@ to align annotations like laughter, coughing, etc. {LG} spn {SL} sil +.. _cutoff_modeling: + +Modeling cutoffs and hesitations +================================ + +Often in spontaneous speech, speakers will produce truncated or cut-off words of the following word/words. To help model this specific case, using the flag :code:`--use_cutoff_model` will enable a mode where pronunciations are generated for cutoff words matching one of the following criteria: + +1. The cutoff word matches the pattern of :code:`{start_bracket}(cutoff|hes)`, where :code:`{start_bracket}` is the set of all left side brackets defined in :code:`brackets` (:ref:`configuration_dictionary`). The following word must not be an OOV or non-speech word (silence, laughter, another cutoff, etc). +2. The cutoff word matches the pattern of :code:`{start_bracket}(cutoff|hes)[-_](word){end_bracket}`, where start and end brackets are defined in :code:`brackets` (:ref:`configuration_dictionary`). The :code:`word` will be used in place of the following word above, but needs to be present in the dictionary, otherwise the target word for the cutoff will default back to the following word. + +The generated pronunciations .. _speaker_dictionaries: diff --git a/docs/source/user_guide/dictionary_validation.rst b/docs/source/user_guide/dictionary_validation.rst index 077bff19..ee11daee 100644 --- a/docs/source/user_guide/dictionary_validation.rst +++ b/docs/source/user_guide/dictionary_validation.rst @@ -15,7 +15,6 @@ Running the dictionary validation utility Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:parser +.. click:: montreal_forced_aligner.command_line.mfa:mfa_cli :prog: mfa - :start_command: validate_dictionary - :groups: + :commands: validate_dictionary diff --git a/docs/source/user_guide/implementations/alignment_evaluation.md b/docs/source/user_guide/implementations/alignment_evaluation.md new file mode 100644 index 00000000..39b7dda3 --- /dev/null +++ b/docs/source/user_guide/implementations/alignment_evaluation.md @@ -0,0 +1,39 @@ + +(alignment_evaluation)= +# Evaluating alignments + +Alignments can be compared to a gold-standard reference set by specifying the `--reference_directory` below. MFA will load all TextGrids and parse them as if they were exported by MFA (i.e., phone and speaker tiers per speaker). The phone intervals will be aligned using the {mod}`Bio.pairwise2` alignment algorithm. If the reference TextGrids use a different phone set, then a custom mapping yaml file can be specified via the `--custom_mapping_path`. As an example, the Buckeye reference alignments used in [Update on Montreal Forced Aligner performance](https://memcauliffe.com/update-on-montreal-forced-aligner-performance.html) use its own ARPA-based phone set that removes stress integers, is lower case, and has syllabic sonorants. To map alignments generated with the `english` model and dictionary that use standard ARPA, a yaml file like the following allows for a better alignment of reference phones to aligned phones. + +:::yaml +N: [en, n] +M: [em, m] +L: [el, l] +AA0: aa +AE0: ae +AH0: ah +AO0: ao +AW0: aw +::: + +Using the above file, both {ipa_inline}`en` and {ipa_inline}`n` phones in the Buckeye corpus will not be penalized when matched with {ipa_inline}`N` phones output by MFA. + +In addition to any custom mapping, phone boundaries are used in the cost function for the {mod}`Bio.pairwise2` alignment algorithm as follows: + +:::{math} +Overlap \: cost = -1 * \biggl(\lvert begin_{aligned} - begin_{ref} \rvert + \lvert end_{aligned} - end_{ref} \rvert + \begin{cases} + 0, & label_{1} = label_{2} \\ + 2, & otherwise + \end{cases}\biggr) +::: + +The two metrics calculated for each utterance are overlap score and phone error rate. Overlap score is calculated similarly to the above cost function for each phone (excluding phones that are aligned to silence or were inserted/deleted) and averaged over the utterance: + +:::{math} +Alignment \: score = \frac{Overlap \: cost}{2} +::: + +Phone error rate is calculated as: + +:::{math} +Phone \: error \: rate = \frac{insertions + deletions + (2 * substitutions)} {length_{ref}} +::: diff --git a/docs/source/user_guide/implementations/fine_tune.md b/docs/source/user_guide/implementations/fine_tune.md new file mode 100644 index 00000000..c079efdb --- /dev/null +++ b/docs/source/user_guide/implementations/fine_tune.md @@ -0,0 +1,11 @@ + +(fine_tune_alignments)= + +# Fine-tuning alignments + +By default and standard in ASR, the frame step between feature frames is set to 10 ms, which limits the accuracy of MFA to a minimum of 0.01 seconds. When the `--fine_tune` flag is specified, the aligner does an extra fine-tuning step following alignment. The audio surrounding each interval's initial boundary is extracted with a frame step of 1 ms (0.001s) and is aligned using a simple phone dictionary combined with a transcript of the previous phone and the current phone. Extracting the phone alignment gives the possibility of higher degrees of accuracy (down to 1ms). + +:::{warning} + +The actual accuracy bound is not clear as each frame uses the surrounding 25ms to generate features, so each frame necessary incorporates time-smeared acoustic information. +::: diff --git a/docs/source/user_guide/implementations/index.md b/docs/source/user_guide/implementations/index.md new file mode 100644 index 00000000..299fcee2 --- /dev/null +++ b/docs/source/user_guide/implementations/index.md @@ -0,0 +1,18 @@ + +(tutorials_index)= +# Implementation details + +:::{warning} +This section is under construction! +::: + +```{toctree} +:hidden: + +phone_groups +phonological_rules +lexicon_probabilities +alignment_evaluation +fine_tune +phone_models +``` diff --git a/docs/source/user_guide/implementations/lexicon_probabilities.md b/docs/source/user_guide/implementations/lexicon_probabilities.md new file mode 100644 index 00000000..26c0c7f5 --- /dev/null +++ b/docs/source/user_guide/implementations/lexicon_probabilities.md @@ -0,0 +1,351 @@ + +(probabilistic_lexicons)= +# Probabilistic lexicons + +(pronunciation_probabilities)= +## Pronunciation probabilities + +Pronunciation probabilities are estimated based on the counts of a specific pronunciations normalized by the count of the most frequent pronunciation. Counts are estimated using add-one smoothing. + +:::{math} +p(w.p_{i} | w) = \frac{c(w.p_{i} | w)}{max_{1\le i \le N_{w}}c(w.p_{i} | w)} +::: + +The reason for using max normalization is to not penalize words with many pronunciations. Even though the probabilities no longer sum to 1, the log of the probabilities is used as summed costs in the {term}`lexicon FST`, so summing to 1 within a word is not problematic. + +If a word is not seen in the training data, pronunciation probabilities are not estimated for its pronunciations. + + +(silence_probability)= +## Silence probability and correction factors + +Words differ in their likelihood to appear before or after silence. In English, a word like "the" is more likely to appear after silence than a word like "us". An pronoun in the accusative case like "us" is not grammatical at the start of a sentence or phrase, whereas "the" starts sentences and phrases regularly. That is not to say that a speaker would not pause before saying "us" for paralinguistic effect or due to a disfluency or simple pause, it's just less likely to occur after silence than "the". + +By the same token, silence following "the" is also less likely than for "us" due to syntax, but pauses are more likely to follow some pronunciations of "the" than others. For instance, if a speaker produces a full vowel variant like {ipa_inline}`[ð i]`, a pause is more likely to follow than a reduced variant like {ipa_inline}`[ð ə]`. The reduced variant will be more likely overall, but it often occurs in running connected speech at normal speech rates. The full vowel variant is more likely to occur in less connected speech, such as when the speaker is planning upcoming speech or speaking more slowly. Accounting for the likelihood of silence before and after a variant allows the model to output a variant that is less likely overall, but more likely given the context. + +However, when we take into account more context, it is not just the single word that determines the likelihood of silence, but also the preceding/following words. The difficulty in estimating the overall likelihood of silence is that the lexicon FST is predicated on each word being independent and composable with any preceding/following word. Thus, for each word, we estimate a probability of silence following (independent of the following words), and two correction factors for silence and non-silence before. The two correction factors take into account the general likelihood of silence following each of the preceding words and gives two factors that represent "is silence or not silence more likely than we would expect given the previous word". These factors are only an approximation, however they do help in alignment. + +:::{note} +Probabilities of multi-word sequences are the domain of the {term}`grammar FST`, please refer [grammar FST concept section](grammar_fst)`. +::: + +MFA uses three variables to capture the probabilities of silence before and after a pronunciation. The most straightforward is ``probability of silence following``, which is calculated as the count of instances where the word was followed by silence divided by the overall count of that pronunciation, with a smoothing factor. Reproducing equation 3 of [Chen et al (2015)](https://www.danielpovey.com/files/2015_interspeech_silprob.pdf): + +:::{math} + P(s_{r} | w.p) = \frac{C(w.p \: s) + \lambda_{2}P(s)}{C(w.p) + \lambda_{2}} +::: + +Given that we're using a lexicon where words are assumed to be completely independent, modelling the silence before the pronunciation is a little tricky. The approach used in [Chen et al (2015)](https://www.danielpovey.com/files/2015_interspeech_silprob.pdf) is to estimate two correction factors for silence and non-silence before the pronunciation. These correction factors capture that for a given pronunciation, it is more or less likely than average to have silence. The factors are estimated as follows, reproducing equations 4-6 from [Chen et al (2015)](https://www.danielpovey.com/files/2015_interspeech_silprob.pdf): + + +:::{math} +F(s_{l} | w.p) = \frac{C(s \: w.p) + \lambda_{3}}{\tilde{C}(s \: w.p) + \lambda_{3}} + +F(ns_{l} | w.p) = \frac{C(ns \: w.p) + \lambda_{3}}{\tilde{C}(ns \: w.p) + \lambda_{3}} + +\tilde{C}(s \: w.p) = \sum_{v} C(v \: w.p) P(s_r|v) +::: + +The estimate count {math}`\tilde{C}` represents a "mean" count of silence or non-silence preceding a given pronunciation, taking into account the likelihood of silence from the preceding pronunciation. The correction factors are weights on the FST transitions from silence and non-silence state. + +Consider the following {term}`FST` with three pronunciations of "lot" from the [English US MFA dictionary](https://mfa-models.readthedocs.io/en/latest/dictionary/English/English%20%28US%29%20MFA%20dictionary%20v2_0_0a.html#English%20(US)%20MFA%20dictionary%20v2_0_0a). + + +:::{figure} ../../_static/lot.svg +:align: center + +{term}`FST` for three pronunciations of "lot" in the English US dictionary +::: + + + +## Example + +As an example, consider the following English and Japanese sentences: + +::::{tab-set} + +:::{tab-item} English +:sync: english +The red fox has read many books, but there's always more to read. +Normalized: +the red fox has read many books but there 's always more to read +::: + +:::{tab-item} Japanese +:sync: japanese +アカギツネさんは本を読んだことがたくさんありますけれども、読むべき本はまだまだいっぱい残っています。 +Normalized: +アカギツネ さん は 本 を 読んだ こと が たくさん あり ます けれども 読む べき 本 は まだまだ いっぱい 残って い ます +::: + +:::: + +For each of the above sentences (please pardon my Japanese), I recorded a normal speaking rate version and a fast speaking rate version. The two speech rates induce variation in pronunciation, as well as different pause placement. We'll then walk through the calculations that result in the final trained lexicon. + +:::::{tab-set} + +::::{tab-item} English +:sync: english + +:::{raw} html + +
+ +
+::: + +:::{figure} ../../_static/sound_files/english_slow.svg +:align: center +Waveform, spectrogram, and aligned labels for the slow reading of the English text +::: + +:::{raw} html +
+ +
+::: + +:::{figure} ../../_static/sound_files/english_fast.svg +:align: center +Waveform, spectrogram, and aligned labels for the fast reading of the English text +::: + +:::: + +::::{tab-item} Japanese +:sync: japanese + +:::{raw} html +
+ +
+::: + +:::{figure} ../../_static/sound_files/japanese_slow.svg +:align: center +Waveform, spectrogram, and aligned labels for the slow reading of the Japanese text +::: + +:::{raw} html +
+ +
+::: + +:::{figure} ../../_static/sound_files/japanese_fast.svg +:align: center + +Waveform, spectrogram, and aligned labels for the fast reading of the Japanese text +::: + +:::: + +::::: + +For alignment, we use the following pronunciation dictionaries, taking pronunciation variants from the [English US MFA dictionary](https://mfa-models.readthedocs.io/en/latest/dictionary/English/English%20%28US%29%20MFA%20dictionary%20v2_0_0a.html#English%20(US)%20MFA%20dictionary%20v2_0_0a) and the [Japanese MFA dictionary](https://mfa-models.readthedocs.io/en/latest/dictionary/Japanese/Japanese%20MFA%20dictionary%20v2_0_0.html#Japanese%20MFA%20dictionary%20v2_0_0). + +:::::{tab-set} + +::::{tab-item} English +:sync: english + +In addition to lexical variants for the present and past tense of "read", function words have several variants listed. The genitive marker "'s" has variants to account for stem-final voicing ({ipa_inline}`[s]` and {ipa_inline}`[z]`) and stem-final alveolar obstruents ({ipa_inline}`[ɪ z]`). The negative conjuction "but" has variants for the pronunciation of the vowel and final {ipa_inline}`/t/` as {ipa_inline}`[ʔ]` or {ipa_inline}`[ɾ]`. Likewise, the preposition "to" has variants for the initial {ipa_inline}`/t/` and vowel reductions. The definite determiner "the" and distal demonstrative "there" have variants for stopping {ipa_inline}`/ð/` to {ipa_inline}`[d̪]`, along with reductions for vowels. + +:::{csv-table} English US pronunciation dictionary +:widths: 30,70 +:header-rows: 1 +:stub-columns: 1 +:class: table-striped table-bordered dataTable table +"Word","Pronunciation" +"'s","s" +"'s","z" +"'s","ɪ z" +"always","ɒː ɫ w ej z" +"always","ɑː ɫ w ej z" +"always","ɒː w ej z" +"always","ɑː w ej z" +"books","b ʊ k s" +"but","b ɐ t" +"but","b ɐ ʔ" +"but","b ə ɾ" +"fox","f ɑː k s" +"has","h æ s" +"has","h æ z" +"many","m ɛ ɲ i" +"more","m ɒː ɹ" +"read","ɹ iː d" +"read","ɹ ɛ d" +"red","ɹ ɛ d" +"the","d̪ iː" +"the","d̪ iː ʔ" +"the","d̪ ə" +"the","iː" +"the","iː ʔ" +"the","l ə" +"the","n ə" +"the","s ə" +"the","ð iː" +"the","ð iː ʔ" +"the","ð ə" +"the","ə" +"there","d̪ ɚ" +"there","d̪ ɛ ɹ" +"there","ð ɚ" +"there","ð ɛ ɹ" +"to","t ə" +"to","tʰ ʉː" +"to","tʰ ʊ" +"to","ɾ ə" +::: + +:::: + +::::{tab-item} Japanese +:sync: japanese + +The main pronunciation variants are in the topic particle "は", the object particle "を", the adjective "たくさん", and the "but" conjunction "けれども". The particles are always pronounced as {ipa_inline}`[w a]` and {ipa_inline}`[o]` and never as their hiragana readings {ipa_inline}`[h a]` and {ipa_inline}`[w o]`, respectively. For "ました", I've included various levels of devoicing for {ipa_inline}`/i/` between the voiceless obstruents from full voiced {ipa_inline}`[i]`, to devoiced {ipa_inline}`[i̥]` to deleted. + +:::{csv-table} Japanese pronunciation dictionary +:widths: 30, 70 +:header-rows: 1 +:stub-columns: 1 +:class: table-striped table-bordered +"Word","Pronunciation" +"アカギツネ","a k a ɟ i ts ɨ n e" +"さん","s a ɴ" +"は","h a" +"は","w a" +"本","h o ɴ" +"を","o" +"を","w o" +"読んだ","j o n d a" +"こと","k o t o" +"が","ɡ a" +"あり","a ɾʲ i" +"ます","m a s ɨ" +"ます","m a s ɨ̥" +"ます","m a s" +"たくさん","t a k ɯ̥ s a ɴ" +"たくさん","t a k s a ɴ" +"たくさん","t a k ɯ s a ɴ" +"けれども","k e ɾ e d o m o" +"けれども","k e d o m o" +"けれども","k e d o" +::: + +:::: + +::::: +The basic steps to calculating pronunciation and silence probabilities is as follows: + +1. Generate word-pronunciation pairs (along with silence labels) from the alignment lattices +2. Use these pairs as input to [calculating pronunciation probability](pronunciation_probabilities) and [calculating silence probability](silence_probability). See the results table below for a walk-through of results for various words across the two reading passage styles. + +:::::{tab-set} + +::::{tab-item} English + :sync: english + + +:::{csv-table} Trained English US pronunciation dictionary +:widths: 10,18,18,18,18,18 +:header-rows: 1 +:stub-columns: 1 +:class: table-striped table-bordered +"Word", "Pronunciation probability", "Probability of silence after", "Correction for silence before", "Correction for non-silence before","Pronunciation" +"'s",0.33,0.18,1.0,1.0,"s" +"'s",0.99,0.09,0.92,1.05,"z" +"'s",0.33,0.18,1.0,1.0,"ɪ z" +"always",0.99,0.09,0.92,1.05,"ɒː ɫ w ej z" +"always",0.33,0.18,1.0,1.0,"ɑː ɫ w ej z" +"always",0.33,0.18,1.0,1.0,"ɒː w ej z" +"always",0.33,0.18,1.0,1.0,"ɑː w ej z" +"books",0.99,0.34,0.92,1.05,"b ʊ k s" +"but",0.99,0.46,1.28,0.75,"b ɐ t" +"but",0.99,0.12,0.85,1.13,"b ɐ ʔ" +"but",0.5,0.18,1.0,1.0,"b ə ɾ" +"fox",0.99,0.09,0.92,1.05,"f ɑː k s" +"has",0.33,0.18,1.0,1.0,"h æ s" +"has",0.99,0.09,0.92,1.05,"h æ z" +"many",0.99,0.09,0.92,1.05,"m ɛ ɲ i" +"many",0.33,0.18,1.0,1.0,"mʲ ɪ ɲ i" +"more",0.99,0.09,0.92,1.05,"m ɒː ɹ" +"read",0.99,0.59,0.92,1.05,"ɹ iː d" +"read",0.99,0.09,0.92,1.05,"ɹ ɛ d" +"red",0.99,0.09,0.89,1.06,"ɹ ɛ d" +"the",0.5,0.18,1.0,1.0,"d̪ iː" +"the",0.5,0.18,1.0,1.0,"d̪ iː ʔ" +"the",0.5,0.18,1.0,1.0,"d̪ ə" +"the",0.5,0.18,1.0,1.0,"iː" +"the",0.5,0.18,1.0,1.0,"iː ʔ" +"the",0.5,0.18,1.0,1.0,"l ə" +"the",0.5,0.18,1.0,1.0,"n ə" +"the",0.5,0.18,1.0,1.0,"s ə" +"the",0.99,0.12,1.49,0.67,"ð iː" +"the",0.5,0.18,1.0,1.0,"ð iː ʔ" +"the",0.99,0.12,1.49,0.67,"ð ə" +"the",0.5,0.18,1.0,1.0,"ə" +"there",0.33,0.18,1.0,1.0,"d̪ ɚ" +"there",0.33,0.18,1.0,1.0,"d̪ ɛ ɹ" +"there",0.33,0.18,1.0,1.0,"ð ɚ" +"there",0.99,0.09,1.37,0.65,"ð ɛ ɹ" +"to",0.99,0.09,0.92,1.05,"t ə" +"to",0.33,0.18,1.0,1.0,"tʰ ʉː" +"to",0.33,0.18,1.0,1.0,"tʰ ʊ" +"to",0.33,0.18,1.0,1.0,"ɾ ə" +::: + +**Pronunciation probabilities** + +Using the alignments above for the two speech rates, the word "red" has 0.99 pronunciation probability as that's the only pronunciation variant. The word "read" pronounced as {ipa_inline}`[ɹ ɛ d]` has 0.99 probability, as will the pronunciation as {ipa_inline}`[ɹ iː d]`, as they both appeared once in the sentence (and twice across the two speech rates), but note that it is not 0.5, as the probabilities are max-normalized. Both full and reduced forms of "but" ({ipa_inline}`[b ɐ t]` and {ipa_inline}`[b ɐ ʔ]`) have pronunciation probability of 0.99, as they each occur once across the passages. + +:::{note} + +I'm not sure why the {ipa_inline}`[b ɐ ʔ]` variant is chosen over the {ipa_inline}`[b ə ɾ]`, this will require future investigation to figure out a root cause. +::: + +All other words will have one pronunciation with 0.99, if they have one realized pronunciation, unrealized pronunciations will have a smoothed probability close to 0, based on the number of pronunciations. + +:::{note} + + "Unrealized pronunciations" refer to pronunciation variants that are not represented in training data, i.e., for the word "to", only the {ipa_inline}`[t ə]` was used, so {ipa_inline}`[tʰ ʉː]`, {ipa_inline}`[tʰ ʊ]`, and {ipa_inline}`[ɾ ə]` are unrealized. +::: + +**Probabilities of having silence following** + +The word "books" has a probability of silence following at 0.34, as it only occurs before silence in the slower speech rate sentence. You might expect it to have a silence probability of 0.5, but recall from the equation of {math}`P(s_{r} | w.p)`, the smoothing factor is influenced by the overall rate of silence following words, which is quite low for the sentences with connected speech. + +The pronunciation of "read" as {ipa_inline}`[ɹ iː d]` has a higher probability of silence following of 0.59, as both instances of that pronunciation are followed by silence at the end of the sentence. The pronunciation of "read" as {ipa_inline}`[ɹ ɛ d]` will have a probability of following silence of 0.09, as the only instances are in the middle of speech in the first clause. + +**Probabilities of having silence preceding** + +Both pronunciations present of word "the" ({ipa_inline}`[ð iː]` and {ipa_inline}`[ð ə]`) have a silence preceding correction factor (1.49) greater than the non-silence correction factor (0.67), as it only appears after silence in both speech rates. With the non-silence correction factor below 1, the cost in the FST of transitioning out of the non-silence state will be much higher than transitioning out of the silence state. When the silence correction factor is greater than 1, the pronunciation is more likely following silence than you would expect given all the previous words, which will reduce the cost of transitioning out of the silence state. + +The fuller form of the word "but" ({ipa_inline}`[b ɐ t]`) has a silence preceding correction factor (1.28) greater than the non-silence correction factor (0.75), so the full form will have lower cost transitioning out of the silence state and than the non-silence state. On the other hand, the more reduced form {ipa_inline}`[b ɐ ʔ]` has the opposite patten, with a silence before correction factor (0.85) greater than the non-silence correction factor (1.13), so the reduced form will have a lower cost transitioning out of the non-silence state than the silence state. + +:::: + +::::{tab-item} Japanese + :sync: japanese + +:::{warning} + +The Japanese walk-through of the pronunciation probability results is still under construction. +::: + +:::: + +::::: + +The resulting trained dictionary can then be used as a dictionary for [alignment](pretrained_alignment) or [transcription](transcribing). diff --git a/docs/source/user_guide/implementations/phone_groups.md b/docs/source/user_guide/implementations/phone_groups.md new file mode 100644 index 00000000..12ebe576 --- /dev/null +++ b/docs/source/user_guide/implementations/phone_groups.md @@ -0,0 +1,2 @@ + +# Phone groups diff --git a/docs/source/user_guide/implementations/phone_models.md b/docs/source/user_guide/implementations/phone_models.md new file mode 100644 index 00000000..7f4e4730 --- /dev/null +++ b/docs/source/user_guide/implementations/phone_models.md @@ -0,0 +1,9 @@ + +(phone_models)= +# Phone model alignments + +With the `--use_phone_model` flag, an ngram language model for phones will be constructed and used to generate phone transcripts with alignments. The phone language model uses bigrams and higher orders (up to 4), with no unigrams included to speed up transcription (and because the phonotactics of languages highly constrain the possible sequences of phones). The phone language model is trained on phone transcriptions extracted from alignments and includes silence and OOV phones. + +The phone transcription additionally uses speaker-adaptation transforms from the regular alignment as well to speed up transcription. From the phone transcription lattices, we extract phone-level alignments along with confidence score using {kaldi_src}`lattice-to-ctm-conf`. + +The alignments extracted from phone transcriptions are compared to the baseline alignments using the procedure outlined in {ref}`alignment_evaluation` above. diff --git a/docs/source/user_guide/implementations/phonological_rules.md b/docs/source/user_guide/implementations/phonological_rules.md new file mode 100644 index 00000000..7fcf06a0 --- /dev/null +++ b/docs/source/user_guide/implementations/phonological_rules.md @@ -0,0 +1,2 @@ + +# Phonological rules diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index 8ef91a76..45619a72 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -130,9 +130,12 @@ We acknowledge funding from Social Sciences and Humanities Research Council (SSH corpus_structure dictionary data_validation + performance dictionary_validation workflows/index corpus_creation/index configuration/index models/index + implementations/index + concepts/index glossary diff --git a/docs/source/user_guide/models/index.rst b/docs/source/user_guide/models/index.rst index ea4793ac..508405dd 100644 --- a/docs/source/user_guide/models/index.rst +++ b/docs/source/user_guide/models/index.rst @@ -18,9 +18,10 @@ for downloading these is :code:`mfa model download ` where ``model_t Please see the :xref:`mfa_models` site for information and statistics about various models. + Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: model +.. click:: montreal_forced_aligner.command_line.model:model_cli + :prog: mfa model + :nested: full diff --git a/docs/source/user_guide/performance.rst b/docs/source/user_guide/performance.rst new file mode 100644 index 00000000..d7f2edd1 --- /dev/null +++ b/docs/source/user_guide/performance.rst @@ -0,0 +1,154 @@ + + +.. _performance: + +*************************** +Troubleshooting performance +*************************** + +There are a number of optimizations that you can do to your corpus to speed up MFA or make it more accurate. + +Speed optimizations +=================== + +.. _wav_conversion: + +Convert to basic wav files +-------------------------- + +In general, 16kHz, 16-bit wav files are lingua franca of audio, though they are uncompressed and can take up a lot of space. However, if space is less an issue than processing time, converting all your files to wav format before running MFA will result in faster processing times (both load and feature generation). + +Script example +`````````````` + +.. warning:: + + This script modifies audio files in place and deletes the original file. Please back up your data before running it if you only have one copy. + +.. code-block:: python + + import os + import subprocess + import sys + + corpus_directory = '/path/to/corpus' + + file_extensions = ['.flac', '.mp3', '.wav', '.aiff'] + + def wavify_sound_files(): + for speaker in os.listdir(corpus_directory): + speaker_dir = os.path.join(corpus_directory, speaker) + if not os.path.isdir(speaker_dir): + continue + for file in os.listdir(speaker_dir): + for ext in file_extensions: + if file.endswith(ext): + path = os.path.join(speaker_dir, file) + if ext == '.wav': + resampled_file = path.replace(ext, f'_fixed{ext}') + else: + resampled_file = path.replace(ext, f'.wav') + if sys.platform == 'win32' or ext in {'.opus', '.ogg'}: + command = ['ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error', '-nostats', '-i', path '-acodec' 'pcm_s16le' '-f' 'wav', '-ar', '16000', resampled_file] + else: + command = ['sox', path, '-t', 'wav' '-r', '16000', '-b', '16', resampled_file] + subprocess.check_call(command) + os.remove(path) + os.rename(resampled_file, path) + + if __name__ == '__main__': + wavify_sound_files() + +.. note:: + + This script assumes that the corpus is already adheres to MFA's supported :ref:`corpus_structure` (with speaker directories of their files under the corpus root), and that you are in the conda environment for MFA. + +Downsample to 16kHz +------------------- + +Both Kaldi and SpeechBrain operate on 16kHz as the primary sampling rate. If your files have a sampling rate greater than 16kHz, then every time they are processed (either as part of MFCC generation in Kaldi, or in running SpeechBrain's VAD/Speaker classification models), there will be extra computation as they are downsampled to 16kHz. + +.. note:: + + As always, I recommend having an immutable copy of the original corpus that is backed up and archived separate from the copy that is being processed. + + +Script example +`````````````` + +.. warning:: + + This script modifies the sample rate in place and deletes the original file. Please back up your data before running it if you only have one copy. + +.. code-block:: python + + import os + import subprocess + + corpus_directory = '/path/to/corpus' + + file_extensions = ['.wav', '.flac'] + + def fix_sample_rate(): + + for speaker in os.listdir(corpus_directory): + speaker_dir = os.path.join(corpus_directory, speaker) + if not os.path.isdir(speaker_dir): + continue + for file in os.listdir(speaker_dir): + for ext in file_extensions: + if file.endswith(ext): + path = os.path.join(speaker_dir, file) + resampled_file = path.replace(ext, f'_resampled{ext}') + subprocess.check_call(['sox', path, '-r', '16000', resampled_file]) + os.remove(path) + os.rename(resampled_file, path) + + if __name__ == '__main__': + fix_sample_rate() + +.. note:: + + This script assumes that the corpus is already adheres to MFA's supported :ref:`corpus_structure` (with speaker directories of their files under the corpus root), and that you are in the conda environment for MFA. + +Change bit depth of wav files to 16bit +-------------------------------------- + +Kaldi does not support ``.wav`` files that are not 16 bit, so any files that are 24 or 32 bit will be processed by ``sox``. Changing the bit depth of processed wav files ahead of time will save this computation when MFA processes the corpus. + + +Script example +`````````````` + +.. warning:: + + This script modifies the bit depth in place and deletes the original file. Please back up your data before running it if you only have one copy. + +.. code-block:: python + + import os + import subprocess + + corpus_directory = '/path/to/corpus' + + + def fix_bit_depth(): + + for speaker in os.listdir(corpus_directory): + speaker_dir = os.path.join(corpus_directory, speaker) + if not os.path.isdir(speaker_dir): + continue + for file in os.listdir(speaker_dir): + if file.endswith('.wav'): + path = os.path.join(speaker_dir, file) + resampled_file = path.replace(ext, f'_resampled{ext}') + subprocess.check_call(['sox', path, '-b', '16', resampled_file]) + os.remove(path) + os.rename(resampled_file, path) + + if __name__ == '__main__': + fix_bit_depth() + +.. note:: + + This script assumes that the corpus is already adheres to MFA's supported :ref:`corpus_structure`, and that you are in the conda environment for MFA. diff --git a/docs/source/user_guide/workflows/adapt_acoustic_model.rst b/docs/source/user_guide/workflows/adapt_acoustic_model.rst index ad7a751d..313b4a92 100644 --- a/docs/source/user_guide/workflows/adapt_acoustic_model.rst +++ b/docs/source/user_guide/workflows/adapt_acoustic_model.rst @@ -9,9 +9,9 @@ A recent 2.0 functionality for MFA is to adapt pretrained :term:`acoustic models Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: adapt +.. click:: montreal_forced_aligner.command_line.adapt:adapt_model_cli + :prog: mfa adapt + :nested: full Configuration reference ----------------------- diff --git a/docs/source/user_guide/workflows/alignment.rst b/docs/source/user_guide/workflows/alignment.rst index 8cf5f5dc..3c98ca74 100644 --- a/docs/source/user_guide/workflows/alignment.rst +++ b/docs/source/user_guide/workflows/alignment.rst @@ -6,52 +6,18 @@ Align with an acoustic model ``(mfa align)`` This is the primary workflow of MFA, where you can use pretrained :term:`acoustic models` to align your dataset. There are a number of :xref:`pretrained_acoustic_models` to use, but you can also adapt a pretrained model to your data (see :ref:`adapt_acoustic_model`) or train an acoustic model from scratch using your dataset (see :ref:`train_acoustic_model`). -Evaluation mode ---------------- - -Alignments can be compared to a gold-standard reference set by specifying the ``--reference_directory`` below. MFA will load all TextGrids and parse them as if they were exported by MFA (i.e., phone and speaker tiers per speaker). The phone intervals will be aligned using the :mod:`Bio.pairwise2` alignment algorithm. If the reference TextGrids use a different phone set, then a custom mapping yaml file can be specified via the ``--custom_mapping_path``. As an example, the Buckeye reference alignments used in `Update on Montreal Forced Aligner performance `_ use its own ARPA-based phone set that removes stress integers, is lower case, and has syllabic sonorants. To map alignments generated with the ``english`` model and dictionary that use standard ARPA, a yaml file like the following allows for a better alignment of reference phones to aligned phones. - -.. code-block:: yaml - - N: [en, n] - M: [em, m] - L: [el, l] - AA0: aa - AE0: ae - AH0: ah - AO0: ao - AW0: aw - -Using the above file, both ``en`` and ``n`` phones in the Buckeye corpus will not be penalized when matched with ``N`` phones output by MFA. - -In addition to any custom mapping, phone boundaries are used in the cost function for the :mod:`Bio.pairwise2` alignment algorithm as follows: - -.. math:: - - Overlap \: cost = -1 * \biggl(\lvert begin_{aligned} - begin_{ref} \rvert + \lvert end_{aligned} - end_{ref} \rvert + \begin{cases} - 0, & label_{1} = label_{2} \\ - 2, & otherwise - \end{cases}\biggr) - -The two metrics calculated for each utterance are overlap score and phone error rate. Overlap score is calculated similarly to the above cost function for each phone (excluding phones that are aligned to silence or were inserted/deleted) and averaged over the utterance: - -.. math:: - - Alignment \: score = \frac{Overlap \: cost}{2} - -Phone error rate is calculated as: - -.. math:: - - Phone \: error \: rate = \frac{insertions + deletions + (2 * substitutions)} {length_{ref}} +.. seealso:: + * :ref:`alignment_evaluation` for details on how to evaluate alignments against a gold standard. + * :ref:`fine_tune_alignments` for implementation details on how alignments are fine tuned. + * :ref:`phone_models` for implementation details on using phone bigram models for generating alignments. Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: align +.. click:: montreal_forced_aligner.command_line.align:align_corpus_cli + :prog: mfa align + :nested: full Configuration reference ----------------------- diff --git a/docs/source/user_guide/workflows/dictionary_generating.rst b/docs/source/user_guide/workflows/dictionary_generating.rst index 12f1dc2f..70de2dd5 100644 --- a/docs/source/user_guide/workflows/dictionary_generating.rst +++ b/docs/source/user_guide/workflows/dictionary_generating.rst @@ -33,9 +33,9 @@ See :ref:`dict_generating_example` for an example of how to use G2P functionalit Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: g2p +.. click:: montreal_forced_aligner.command_line.g2p:g2p_cli + :prog: mfa g2p + :nested: full Configuration reference ----------------------- diff --git a/docs/source/user_guide/workflows/g2p_train.rst b/docs/source/user_guide/workflows/g2p_train.rst index 31692fe9..5debfc00 100644 --- a/docs/source/user_guide/workflows/g2p_train.rst +++ b/docs/source/user_guide/workflows/g2p_train.rst @@ -35,9 +35,9 @@ The original Pynini implementation of pair ngram models for G2P was motivated pr Command reference ----------------- -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: train_g2p +.. click:: montreal_forced_aligner.command_line.train_g2p:train_g2p_cli + :prog: mfa train_g2p + :nested: full Configuration reference ----------------------- diff --git a/docs/source/user_guide/workflows/train_acoustic_model.rst b/docs/source/user_guide/workflows/train_acoustic_model.rst index 9e390486..61b98e6b 100644 --- a/docs/source/user_guide/workflows/train_acoustic_model.rst +++ b/docs/source/user_guide/workflows/train_acoustic_model.rst @@ -214,9 +214,9 @@ Command reference ================= -.. autoprogram:: montreal_forced_aligner.command_line.mfa:create_parser() - :prog: mfa - :start_command: train +.. click:: montreal_forced_aligner.command_line.train_acoustic_model:train_acoustic_model_cli + :prog: mfa train + :nested: full Configuration reference ======================= diff --git a/environment.yml b/environment.yml index 3dd089ec..92a3abae 100644 --- a/environment.yml +++ b/environment.yml @@ -1,8 +1,10 @@ channels: - conda-forge - - defaults + - pytorch + - nvidia dependencies: - python>=3.8 + - numpy - librosa - tqdm - requests @@ -10,14 +12,37 @@ dependencies: - ansiwrap - pyyaml - dataclassy - - kaldi + - kaldi=*=*cpu* - sox - ffmpeg - pynini - openfst + - hdbscan - baumwelch - - setuptools_scm - ngram - praatio - - biopython - - sqlalchemy>=1.4 + - biopython=1.79 + - sqlalchemy>=2.0 + - pgvector + - pgvector-python + - postgresql + - psycopg2 + - click + - pytorch + - torchaudio + - setuptools_scm + - pytest + - pytest-mypy + - mock + - coverage + - coveralls + - interrogate + - numba + - kneed + - matplotlib + - seaborn + - pip + - pip: + - build + - twine + - speechbrain diff --git a/montreal_forced_aligner/__main__.py b/montreal_forced_aligner/__main__.py index 0481abd2..e6e7a6fb 100644 --- a/montreal_forced_aligner/__main__.py +++ b/montreal_forced_aligner/__main__.py @@ -1,3 +1,3 @@ -from .command_line.mfa import main +from montreal_forced_aligner.command_line.mfa import mfa_cli -main() +mfa_cli() diff --git a/montreal_forced_aligner/abc.py b/montreal_forced_aligner/abc.py index e3fce504..fe699361 100644 --- a/montreal_forced_aligner/abc.py +++ b/montreal_forced_aligner/abc.py @@ -31,13 +31,14 @@ import yaml from sqlalchemy.orm import Session +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.exceptions import KaldiProcessingError, MultiprocessingError from montreal_forced_aligner.helper import comma_join, load_configuration, mfa_open if TYPE_CHECKING: - from argparse import Namespace - from montreal_forced_aligner.data import MfaArguments + from montreal_forced_aligner.data import MfaArguments, WorkflowType + from montreal_forced_aligner.db import CorpusWorkflow, MfaSqlBase __all__ = [ "MfaModel", @@ -54,6 +55,7 @@ # Configuration types MetaDict = Dict[str, Any] +logger = logging.getLogger("mfa") class KaldiFunction(metaclass=abc.ABCMeta): @@ -63,18 +65,21 @@ class KaldiFunction(metaclass=abc.ABCMeta): def __init__(self, args: MfaArguments): self.args = args - self.db_path = self.args.db_path + self.db_string = self.args.db_string self.job_name = self.args.job_name self.log_path = self.args.log_path def run(self) -> typing.Generator: - """Run the function, calls :meth:`~KaldiFunction._run` with error handling""" + """Run the function, calls subclassed object's ``_run`` with error handling""" + self.db_engine = sqlalchemy.create_engine(self.db_string) try: yield from self._run() except Exception: exc_type, exc_value, exc_traceback = sys.exc_info() error_text = "\n".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) raise MultiprocessingError(self.job_name, error_text) + finally: + self.db_engine.dispose() def _run(self) -> None: """Internal logic for running the worker""" @@ -103,27 +108,20 @@ def check_call(self, proc: subprocess.Popen): class TemporaryDirectoryMixin(metaclass=abc.ABCMeta): """ Abstract mixin class for MFA temporary directories - - Parameters - ---------- - temporary_directory: str, optional - Path to store temporary files """ def __init__( self, - temporary_directory: str = None, **kwargs, ): super().__init__(**kwargs) - if not temporary_directory: - from .config import get_temporary_directory - - temporary_directory = get_temporary_directory() - self.temporary_directory = temporary_directory self._corpus_output_directory = None self._dictionary_output_directory = None self._language_model_output_directory = None + self._acoustic_model_output_directory = None + self._g2p_model_output_directory = None + self._ivector_extractor_output_directory = None + self._current_workflow = None @property @abc.abstractmethod @@ -143,6 +141,10 @@ def output_directory(self) -> str: """Root temporary directory""" ... + def clean_working_directory(self) -> None: + """Clean up previous runs""" + shutil.rmtree(self.output_directory, ignore_errors=True) + @property def corpus_output_directory(self) -> str: """Temporary directory containing all corpus information""" @@ -161,6 +163,11 @@ def dictionary_output_directory(self) -> str: return self._dictionary_output_directory return os.path.join(self.output_directory, "dictionary") + @property + def model_output_directory(self) -> str: + """Temporary directory containing all dictionary information""" + return os.path.join(self.output_directory, "models") + @dictionary_output_directory.setter def dictionary_output_directory(self, directory: str) -> None: self._dictionary_output_directory = directory @@ -170,12 +177,23 @@ def language_model_output_directory(self) -> str: """Temporary directory containing all dictionary information""" if self._language_model_output_directory: return self._language_model_output_directory - return os.path.join(self.output_directory, "language_model") + return os.path.join(self.model_output_directory, "language_model") @language_model_output_directory.setter def language_model_output_directory(self, directory: str) -> None: self._language_model_output_directory = directory + @property + def acoustic_model_output_directory(self) -> str: + """Temporary directory containing all dictionary information""" + if self._acoustic_model_output_directory: + return self._acoustic_model_output_directory + return os.path.join(self.model_output_directory, "acoustic_model") + + @acoustic_model_output_directory.setter + def acoustic_model_output_directory(self, directory: str) -> None: + self._acoustic_model_output_directory = directory + class DatabaseMixin(TemporaryDirectoryMixin, metaclass=abc.ABCMeta): """ @@ -187,6 +205,8 @@ def __init__( **kwargs, ): super().__init__(**kwargs) + self.db_backend = GLOBAL_CONFIG.database_backend + self._db_engine = None self._db_path = None @@ -194,11 +214,56 @@ def initialize_database(self) -> None: """ Initialize the database with database schema """ + retcode = subprocess.call( + [ + "createdb", + f"--port={GLOBAL_CONFIG.current_profile.database_port}", + self.identifier, + ], + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ) + exist_check = retcode != 0 + with self.db_engine.connect() as conn: + conn.execute(sqlalchemy.text("CREATE EXTENSION IF NOT EXISTS vector")) + conn.execute(sqlalchemy.text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) + conn.execute(sqlalchemy.text("CREATE EXTENSION IF NOT EXISTS pg_stat_statements")) + conn.commit() + if exist_check: + return from montreal_forced_aligner.db import MfaSqlBase os.makedirs(self.output_directory, exist_ok=True) + MfaSqlBase.metadata.create_all(self.db_engine) + def remove_database(self): + if getattr(self, "_session", None) is not None: + try: + self._session.commit() + except Exception: + self._session.rollback() + finally: + self._session.close() + self._session = None + if getattr(self, "_db_engine", None) is not None: + self._db_engine.dispose() + self._db_engine = None + time.sleep(1) + try: + subprocess.call( + [ + "dropdb", + f"--port={GLOBAL_CONFIG.current_profile.database_port}", + "-f", + self.identifier, + ], + stderr=None if GLOBAL_CONFIG.current_profile.verbose else subprocess.DEVNULL, + stdout=None if GLOBAL_CONFIG.current_profile.verbose else subprocess.DEVNULL, + ) + except Exception: + pass + @property def db_engine(self) -> sqlalchemy.engine.Engine: """Database engine""" @@ -206,14 +271,67 @@ def db_engine(self) -> sqlalchemy.engine.Engine: self._db_engine = self.construct_engine() return self._db_engine + def get_next_primary_key(self, database_table: MfaSqlBase): + with self.session() as session: + pk = session.query(sqlalchemy.func.max(database_table.id)).scalar() + if not pk: + pk = 0 + return pk + 1 + + def create_new_current_workflow(self, workflow_type: WorkflowType, name: str = None): + from montreal_forced_aligner.db import CorpusWorkflow + + with self.session() as session: + if not name: + name = workflow_type.name + self._current_workflow = name + + session.query(CorpusWorkflow).update({"current": False}) + new_workflow = ( + session.query(CorpusWorkflow).filter(CorpusWorkflow.name == name).first() + ) + if not new_workflow: + new_workflow = CorpusWorkflow( + name=name, + workflow_type=workflow_type, + working_directory=os.path.join(self.output_directory, name), + current=True, + ) + log_dir = os.path.join(new_workflow.working_directory, "log") + os.makedirs(log_dir, exist_ok=True) + session.add(new_workflow) + else: + new_workflow.current = True + session.commit() + + def set_current_workflow(self, identifier): + from montreal_forced_aligner.db import CorpusWorkflow + + with self.session() as session: + session.query(CorpusWorkflow).update({CorpusWorkflow.current: False}) + wf = session.query(CorpusWorkflow).filter(CorpusWorkflow.name == identifier).first() + wf.current = True + self._current_workflow = identifier + session.commit() + + @property + def current_workflow(self) -> CorpusWorkflow: + from montreal_forced_aligner.db import CorpusWorkflow + + with self.session() as session: + wf = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + return wf + @property - def db_path(self) -> str: - """Path to SQLite database file""" - if self._db_path is not None: - return self._db_path - return os.path.join(self.output_directory, f"{self.identifier}.db") + def db_string(self): + """Connection string for the database""" + return f"postgresql+psycopg2://localhost:{GLOBAL_CONFIG.current_profile.database_port}/{self.identifier}" - def construct_engine(self, same_thread=True, read_only=False) -> sqlalchemy.engine.Engine: + def construct_engine(self, read_only=False, **kwargs) -> sqlalchemy.engine.Engine: """ Construct a database engine @@ -229,13 +347,7 @@ def construct_engine(self, same_thread=True, read_only=False) -> sqlalchemy.engi :class:`~sqlalchemy.engine.Engine` SqlAlchemy engine """ - connect_args = {} - if not same_thread: - connect_args["check_same_thread"] = False - string = f"sqlite:///{self.db_path}" - if read_only: - string = f"sqlite:///file:{self.db_path}?mode=ro&nolock=1&uri=true" - return sqlalchemy.create_engine(string, connect_args=connect_args) + return sqlalchemy.create_engine(self.db_string, future=True, **kwargs) def session(self, **kwargs) -> Session: """ @@ -259,17 +371,6 @@ class MfaWorker(metaclass=abc.ABCMeta): """ Abstract class for MFA workers - Parameters - ---------- - use_mp: bool - Flag to run in multiprocessing mode, defaults to True - debug: bool - Flag to run in debug mode, defaults to False - verbose: bool - Flag to run in verbose mode, defaults to False - quiet: bool - Flag for whether to suppress printing to the terminal - Attributes ---------- dirty: bool @@ -278,66 +379,10 @@ class MfaWorker(metaclass=abc.ABCMeta): def __init__( self, - use_mp: bool = True, - debug: bool = False, - verbose: bool = False, - quiet: bool = False, **kwargs, ): super().__init__(**kwargs) - self.debug = debug - self.verbose = verbose - self.use_mp = use_mp self.dirty = False - self.quiet = quiet - - def log_debug(self, message: str = "") -> None: - """ - Print a debug message - - Parameters - ---------- - message: str - Debug message to log - """ - if not self.quiet and self.verbose: - print(message) - - def log_error(self, message: str = "") -> None: - """ - Print an error message - - Parameters - ---------- - message: str - Error message to log - """ - if not self.quiet: - print(message) - - def log_info(self, message: str = "") -> None: - """ - Print an info message - - Parameters - ---------- - message: str - Info message to log - """ - if not self.quiet: - print(message) - - def log_warning(self, message: str = "") -> None: - """ - Print a warning message - - Parameters - ---------- - message: str - Warning message to log - """ - if not self.quiet: - print(message) @classmethod def extract_relevant_parameters(cls, config: MetaDict) -> Tuple[MetaDict, List[str]]: @@ -410,10 +455,6 @@ def get_configuration_parameters(cls) -> Dict[str, Type]: def configuration(self) -> MetaDict: """Configuration parameters""" return { - "debug": self.debug, - "verbose": self.verbose, - "quiet": self.quiet, - "use_mp": self.use_mp, "dirty": self.dirty, } @@ -457,49 +498,52 @@ class TopLevelMfaWorker(MfaWorker, TemporaryDirectoryMixin, metaclass=abc.ABCMet def __init__( self, - num_jobs: int = 3, - clean: bool = False, **kwargs, ): kwargs, skipped = type(self).extract_relevant_parameters(kwargs) super().__init__(**kwargs) - self.num_jobs = num_jobs - self.clean = clean self.initialized = False self.start_time = time.time() self.setup_logger() if skipped: - self.log_warning(f"Skipped the following configuration keys: {comma_join(skipped)}") + logger.warning(f"Skipped the following configuration keys: {comma_join(skipped)}") def __del__(self): """Ensure that loggers are cleaned up on delete""" - logger = logging.getLogger(self.identifier) + logger = logging.getLogger("mfa") handlers = logger.handlers[:] for handler in handlers: handler.close() logger.removeHandler(handler) - @abc.abstractmethod def setup(self) -> None: - """Abstract method for setting up a top-level worker""" - ... + """Setup for worker""" + self.check_previous_run() + if GLOBAL_CONFIG.clean: + self.clean_working_directory() + if hasattr(self, "remove_database"): + self.remove_database() + if hasattr(self, "initialize_database"): + self.initialize_database() @property def working_directory(self) -> str: """Alias for a folder that contains worker information, separate from the data directory""" - return self.workflow_directory + return os.path.join(self.output_directory, self._current_workflow) @classmethod - def parse_args(cls, args: Optional[Namespace], unknown_args: Optional[List[str]]) -> MetaDict: + def parse_args( + cls, args: Optional[Dict[str, Any]], unknown_args: Optional[List[str]] + ) -> MetaDict: """ Class method for parsing configuration parameters from command line arguments Parameters ---------- - args: :class:`~argparse.Namespace` - Arguments parsed by argparse + args: dict[str, Any] + Parsed arguments unknown_args: list[str] - Optional list of arguments that were not parsed by argparse + Optional list of arguments that were not parsed Returns ------- @@ -522,29 +566,25 @@ def parse_args(cls, args: Optional[Namespace], unknown_args: Optional[List[str]] val = unknown_args[i + 1] unknown_dict[name] = val for name, param_type in param_types.items(): - if (name.endswith("_directory") and name != "audio_directory") or name.endswith( - "_path" + if (name.endswith("_directory") and name != "audio_directory") or ( + name.endswith("_path") and name not in {"rules_path", "groups_path"} ): continue - if args is not None and hasattr(args, name) and getattr(args, name) is not None: - params[name] = param_type(getattr(args, name)) + if args is not None and name in args and args[name] is not None: + params[name] = param_type(args[name]) elif name in unknown_dict: params[name] = param_type(unknown_dict[name]) - if param_type == bool: + if param_type == bool and not isinstance(unknown_dict[name], bool): if unknown_dict[name].lower() == "false": params[name] = False - if getattr(args, "disable_mp", False): - params["use_mp"] = False - elif getattr(args, "disable_textgrid_cleanup", False): - params["cleanup_textgrids"] = False return params @classmethod def parse_parameters( cls, config_path: Optional[str] = None, - args: Optional[Namespace] = None, - unknown_args: Optional[List[str]] = None, + args: Optional[Dict[str, Any]] = None, + unknown_args: Optional[typing.Iterable[str]] = None, ) -> MetaDict: """ Parse configuration parameters from a config file and command line arguments @@ -553,10 +593,10 @@ def parse_parameters( ---------- config_path: str, optional Path to yaml configuration file - args: :class:`~argparse.Namespace`, optional - Arguments parsed by argparse - unknown_args: list[str], optional - List of unknown arguments from argparse + args: dict[str, Any] + Parsed arguments + unknown_args: list[str] + Optional list of arguments that were not parsed Returns ------- @@ -573,16 +613,10 @@ def parse_parameters( global_params.update(cls.parse_args(args, unknown_args)) return global_params - @property - @abc.abstractmethod - def workflow_identifier(self) -> str: - """Identifier of the worker's workflow""" - ... - @property def worker_config_path(self) -> str: """Path to worker's configuration in the working directory""" - return os.path.join(self.output_directory, f"{self.workflow_identifier}.yaml") + return os.path.join(self.output_directory, f"{self.data_source_identifier}.yaml") def cleanup(self) -> None: """ @@ -601,14 +635,14 @@ def cleanup(self) -> None: self._db_engine.dispose() self._db_engine = None if self.dirty: - self.log_error("There was an error in the run, please see the log.") + logger.error("There was an error in the run, please see the log.") else: - self.log_info(f"Done! Everything took {time.time() - self.start_time} seconds") - logger = logging.getLogger(self.identifier) + logger.info(f"Done! Everything took {time.time() - self.start_time:.3f} seconds") handlers = logger.handlers[:] for handler in handlers: - handler.close() - logger.removeHandler(handler) + if isinstance(handler, logging.FileHandler): + handler.close() + logger.removeHandler(handler) self.save_worker_config() except (NameError, ValueError): # already cleaned up pass @@ -637,21 +671,10 @@ def _validate_previous_configuration(self, conf: MetaDict) -> bool: clean = True current_version = get_mfa_version() if conf["dirty"]: - self.log_debug("Previous run ended in an error (maybe ctrl-c?)") - clean = False - if "type" in conf: - command = conf["type"] - elif "command" in conf: - command = conf["command"] - else: - command = self.workflow_identifier - if command != self.workflow_identifier: - self.log_debug( - f"Previous run was a different subcommand than {self.workflow_identifier} (was {command})" - ) + logger.debug("Previous run ended in an error (maybe ctrl-c?)") clean = False if conf.get("version", current_version) != current_version: - self.log_debug( + logger.debug( f"Previous run was on {conf['version']} version (new run: {current_version})" ) clean = False @@ -663,7 +686,7 @@ def _validate_previous_configuration(self, conf: MetaDict) -> bool: "language_model_path", ]: if conf.get(key, None) != getattr(self, key, None): - self.log_debug( + logger.debug( f"Previous run used a different {key.replace('_', ' ')} than {getattr(self, key, None)} (was {conf.get(key, None)})" ) clean = False @@ -683,7 +706,7 @@ def check_previous_run(self) -> bool: conf = load_configuration(self.worker_config_path) clean = self._validate_previous_configuration(conf) if not clean: - self.log_warning( + 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." ) @@ -692,100 +715,46 @@ def check_previous_run(self) -> bool: @property def identifier(self) -> str: """Combined identifier of the data source and workflow""" - return f"{self.data_source_identifier}_{self.workflow_identifier}" + return self.data_source_identifier @property def output_directory(self) -> str: """Root temporary directory to store all of this worker's files""" - return os.path.join(self.temporary_directory, self.identifier) - - @property - def workflow_directory(self) -> str: - """Temporary directory to save work specific to the worker (i.e., not data)""" - return os.path.join(self.output_directory, self.workflow_identifier) + return os.path.join(GLOBAL_CONFIG.temporary_directory, self.identifier) @property def log_file(self) -> str: """Path to the worker's log file""" - return os.path.join(self.output_directory, f"{self.workflow_identifier}.log") + return os.path.join(self.output_directory, f"{self.data_source_identifier}.log") def setup_logger(self) -> None: """ Construct a logger for a command line run """ - from .utils import configure_logger, get_mfa_version + from montreal_forced_aligner.config import GLOBAL_CONFIG + from montreal_forced_aligner.helper import configure_logger + from montreal_forced_aligner.utils import get_mfa_version current_version = get_mfa_version() # Remove previous directory if versions are different + clean = False if os.path.exists(self.worker_config_path): conf = load_configuration(self.worker_config_path) if conf.get("version", current_version) != current_version: - self.clean = True - if self.clean: - shutil.rmtree(self.output_directory, ignore_errors=True) - os.makedirs(self.workflow_directory, exist_ok=True) - logger = configure_logger( - self.identifier, log_file=self.log_file, quiet=self.quiet, verbose=self.verbose - ) - logger.debug( - f"Beginning run for {self.workflow_identifier} on {self.data_source_identifier}" - ) - if self.use_mp: - logger.debug(f"Using multiprocessing with {self.num_jobs}") + clean = True + os.makedirs(self.output_directory, exist_ok=True) + configure_logger("mfa", log_file=self.log_file) + logger = logging.getLogger("mfa") + logger.debug(f"Beginning run for {self.data_source_identifier}") + logger.debug(f'Using "{GLOBAL_CONFIG.current_profile_name}" profile') + if GLOBAL_CONFIG.use_mp: + logger.debug(f"Using multiprocessing with {GLOBAL_CONFIG.num_jobs}") else: - logger.debug(f"NOT using multiprocessing with {self.num_jobs}") + logger.debug(f"NOT using multiprocessing with {GLOBAL_CONFIG.num_jobs}") logger.debug(f"Set up logger for MFA version: {current_version}") - if self.clean: + if clean or GLOBAL_CONFIG.clean: logger.debug("Cleaned previous run") - def log_debug(self, message: str = "") -> None: - """ - Log a debug message. This function is a wrapper around the :meth:`logging.Logger.debug` - - Parameters - ---------- - message: str - Debug message to log - """ - logger = logging.getLogger(self.identifier) - logger.debug(message) - - def log_info(self, message: str = "") -> None: - """ - Log an info message. This function is a wrapper around the :meth:`logging.Logger.info` - - Parameters - ---------- - message: str - Info message to log - """ - logger = logging.getLogger(self.identifier) - logger.info(message) - - def log_warning(self, message: str = "") -> None: - """ - Log a warning message. This function is a wrapper around the :meth:`logging.Logger.warning` - - Parameters - ---------- - message: str - Warning message to log - """ - logger = logging.getLogger(self.identifier) - logger.warning(message) - - def log_error(self, message: str = "") -> None: - """ - Log an error message. This function is a wrapper around the :meth:`logging.Logger.error` - - Parameters - ---------- - message: str - Error message to log - """ - logger = logging.getLogger(self.identifier) - logger.error(message) - class ExporterMixin(metaclass=abc.ABCMeta): """ @@ -836,10 +805,6 @@ class FileExporterMixin(ExporterMixin, metaclass=abc.ABCMeta): Flag for whether to clean up exported TextGrids """ - def __init__(self, cleanup_textgrids: bool = True, **kwargs): - self.cleanup_textgrids = cleanup_textgrids - super().__init__(**kwargs) - @abc.abstractmethod def export_files(self, output_directory: str) -> None: """ @@ -983,3 +948,4 @@ def meta(self) -> MetaDict: @abc.abstractmethod def add_meta_file(self, trainer: TrainerMixin) -> None: """Add metadata to the model""" + ... diff --git a/montreal_forced_aligner/acoustic_modeling/base.py b/montreal_forced_aligner/acoustic_modeling/base.py index ca1e423a..f4630fc5 100644 --- a/montreal_forced_aligner/acoustic_modeling/base.py +++ b/montreal_forced_aligner/acoustic_modeling/base.py @@ -5,12 +5,11 @@ import multiprocessing as mp import os import re -import shutil import subprocess import time from abc import abstractmethod from queue import Empty -from typing import TYPE_CHECKING, Dict, List +from typing import TYPE_CHECKING, List import sqlalchemy.engine import tqdm @@ -19,9 +18,10 @@ from montreal_forced_aligner.abc import MfaWorker, ModelExporterMixin, TrainerMixin from montreal_forced_aligner.alignment import AlignMixin from montreal_forced_aligner.alignment.multiprocessing import AccStatsArguments, AccStatsFunction +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpusPronunciationMixin from montreal_forced_aligner.corpus.features import FeatureConfigMixin -from montreal_forced_aligner.db import Utterance +from montreal_forced_aligner.db import CorpusWorkflow, Utterance from montreal_forced_aligner.exceptions import KaldiProcessingError from montreal_forced_aligner.helper import mfa_open from montreal_forced_aligner.models import AcousticModel @@ -41,6 +41,9 @@ __all__ = ["AcousticModelTrainingMixin"] +logger = logging.getLogger("mfa") + + class AcousticModelTrainingMixin( AlignMixin, TrainerMixin, FeatureConfigMixin, MfaWorker, ModelExporterMixin ): @@ -115,9 +118,9 @@ def __init__( self.final_gaussian_iteration = 0 # Gets set later @property - def db_path(self) -> str: - """Root worker's path to database file""" - return self.worker.db_path + def db_string(self) -> str: + """Root worker's database connection string""" + return self.worker.db_string def acc_stats_arguments(self) -> List[AccStatsArguments]: """ @@ -128,21 +131,35 @@ def acc_stats_arguments(self) -> List[AccStatsArguments]: list[:class:`~montreal_forced_aligner.alignment.multiprocessing.AccStatsArguments`] Arguments for processing """ - feat_strings = self.worker.construct_feature_proc_strings() - return [ - AccStatsArguments( - j.name, - self.db_path, - os.path.join(self.working_directory, "log", f"acc.{self.iteration}.{j.name}.log"), - j.dictionary_ids, - feat_strings[j.name], - j.construct_path_dictionary(self.working_directory, "ali", "ark"), - j.construct_path_dictionary(self.working_directory, str(self.iteration), "acc"), - self.model_path, + arguments = [] + for j in self.jobs: + feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + arguments.append( + AccStatsArguments( + j.id, + self.db_string, + os.path.join( + self.working_directory, "log", f"acc.{self.iteration}.{j.id}.log" + ), + j.dictionary_ids, + feat_strings, + j.construct_path_dictionary(self.working_directory, "ali", "ark"), + j.construct_path_dictionary( + self.working_directory, str(self.iteration), "acc" + ), + self.model_path, + ) ) - for j in self.jobs - if j.has_data - ] + return arguments @property def previous_aligner(self) -> AcousticCorpusPronunciationMixin: @@ -165,50 +182,6 @@ def utterances(self, session: Session = None) -> sqlalchemy.orm.Query: """ return self.worker.utterances(session) - def log_debug(self, message: str = "") -> None: - """ - Log a debug message. This function is a wrapper around the worker's :meth:`logging.Logger.debug` - - Parameters - ---------- - message: str - Debug message to log - """ - self.worker.log_debug(message) - - def log_error(self, message: str = "") -> None: - """ - Log an info message. This function is a wrapper around the worker's :meth:`logging.Logger.info` - - Parameters - ---------- - message: str - Info message to log - """ - self.worker.log_error(message) - - def log_warning(self, message: str = "") -> None: - """ - Log a warning message. This function is a wrapper around the worker's :meth:`logging.Logger.warning` - - Parameters - ---------- - message: str - Warning message to log - """ - self.worker.log_warning(message) - - def log_info(self, message: str = "") -> None: - """ - Log an error message. This function is a wrapper around the worker's :meth:`logging.Logger.error` - - Parameters - ---------- - message: str - Error message to log - """ - self.worker.log_info(message) - @property def jobs(self) -> List[Job]: """Top-level worker's job objects""" @@ -223,16 +196,6 @@ def session(self, **kwargs) -> sqlalchemy.orm.session.Session: """Top-level worker's database session""" return self.worker.session(**kwargs) - def construct_feature_proc_strings( - self, speaker_independent: bool = False - ) -> List[Dict[str, str]]: - """Top-level worker's feature strings""" - return self.worker.construct_feature_proc_strings(speaker_independent) - - def construct_base_feature_string(self, all_feats: bool = False) -> str: - """Top-level worker's base feature string""" - return self.worker.construct_base_feature_string(all_feats) - @property def data_directory(self) -> str: """Get the current data directory based on subset""" @@ -250,46 +213,37 @@ def num_current_utterances(self) -> int: return self.subset return self.worker.num_utterances + @property + def workflow(self): + with self.session() as session: + wf = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.name == self.identifier) + .first() + ) + return wf + def initialize_training(self) -> None: """Initialize training""" begin = time.time() - dirty_path = os.path.join(self.working_directory, "dirty") - done_path = os.path.join(self.working_directory, "done") - self.log_info(f"Initializing training for {self.identifier}...") + logger.info(f"Initializing training for {self.identifier}...") if self.subset and self.subset >= self.worker.num_utterances: - self.log_warning( + logger.warning( "Subset specified is larger than the dataset, " "using full corpus for this training block." ) self.subset = 0 self.worker.current_subset = 0 - try: - self._trainer_initialization() - except Exception as e: - with mfa_open(dirty_path, "w"): - pass - if isinstance(e, KaldiProcessingError): - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) - raise + os.makedirs(self.working_log_directory, exist_ok=True) + self._trainer_initialization() self.iteration = 1 self.worker.current_trainer = self self.compute_calculated_properties() self.current_gaussians = self.initial_gaussians - if self.initialized: - self.log_info( - f"{self.identifier} training already initialized, skipping initialization." - ) - if os.path.exists(done_path): - self.training_complete = True - return - if os.path.exists(dirty_path): # if there was an error, let's redo from scratch - shutil.rmtree(self.working_directory) - os.makedirs(self.working_log_directory, exist_ok=True) - self.log_info("Initialization complete!") - self.log_debug(f"Initialization for {self.identifier} took {time.time() - begin} seconds") + logger.info("Initialization complete!") + logger.debug( + f"Initialization for {self.identifier} took {time.time() - begin:.3f} seconds" + ) @abstractmethod def _trainer_initialization(self) -> None: @@ -319,7 +273,7 @@ def working_log_directory(self) -> str: @property def model_path(self) -> str: """Current acoustic model path""" - if self.training_complete: + if self.workflow.done: return self.next_model_path return os.path.join(self.working_directory, f"{self.iteration}.mdl") @@ -331,14 +285,14 @@ def alignment_model_path(self) -> str: @property def next_model_path(self) -> str: """Next iteration's acoustic model path""" - if self.training_complete: + if self.workflow.done: return os.path.join(self.working_directory, "final.mdl") return os.path.join(self.working_directory, f"{self.iteration + 1}.mdl") @property def next_occs_path(self) -> str: """Next iteration's occs file path""" - if self.training_complete: + if self.workflow.done: return os.path.join(self.working_directory, "final.occs") return os.path.join(self.working_directory, f"{self.iteration + 1}.occs") @@ -370,12 +324,10 @@ def acc_stats(self) -> None: :kaldi_steps:`train_deltas` Reference Kaldi script """ - self.log_info("Accumulating statistics...") + logger.info("Accumulating statistics...") arguments = self.acc_stats_arguments() - with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -474,9 +426,9 @@ def acc_stats(self) -> None: log_like = avg_like_sum / avg_like_frames if average_logdet_frames: log_like += average_logdet_sum / average_logdet_frames - self.log_debug(f"Likelihood for iteration {self.iteration}: {log_like}") + logger.debug(f"Likelihood for iteration {self.iteration}: {log_like}") - if not self.debug: + if not GLOBAL_CONFIG.debug: for f in acc_files: os.remove(f) @@ -484,13 +436,13 @@ def align_iteration(self) -> None: """Run alignment for a training iteration""" begin = time.time() self.align_utterances(training=True) - self.log_debug( + logger.debug( f"Generating alignments for iteration {self.iteration} took {time.time()-begin} seconds" ) - self.log_debug(f"Analyzing information for alignment in iteration {self.iteration}...") + logger.debug(f"Analyzing information for alignment in iteration {self.iteration}...") begin = time.time() self.compile_information() - self.log_debug( + logger.debug( f"Analyzing iteration {self.iteration} alignments took {time.time()-begin} seconds" ) @@ -527,34 +479,32 @@ def train(self) -> None: :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` If there were any errors in running Kaldi binaries """ - done_path = os.path.join(self.working_directory, "done") - dirty_path = os.path.join(self.working_directory, "dirty") os.makedirs(self.working_log_directory, exist_ok=True) + wf = self.worker.current_workflow + if wf.done: + return try: self.initialize_training() - if self.training_complete: - return + begin = time.time() for iteration in range(1, self.num_iterations + 1): - self.log_info( - f"{self.identifier} - Iteration {iteration} of {self.num_iterations}" - ) + logger.info(f"{self.identifier} - Iteration {iteration} of {self.num_iterations}") self.iteration = iteration self.train_iteration() self.finalize_training() except Exception as e: - with mfa_open(dirty_path, "w"): - pass - if isinstance(e, KaldiProcessingError): - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + if not isinstance(e, KeyboardInterrupt): + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update( + {"dirty": True} + ) + session.commit() + if isinstance(e, KaldiProcessingError): + log_kaldi_errors(e.error_logs) + e.update_log_file() raise - with mfa_open(done_path, "w"): - pass - self.log_info("Training complete!") - self.log_debug(f"Training took {time.time() - begin} seconds") + logger.info("Training complete!") + logger.debug(f"Training took {time.time() - begin:.3f} seconds") @property def exported_model_path(self) -> str: @@ -584,7 +534,7 @@ def finalize_training(self) -> None: os.path.join(self.working_directory, "final.alimdl"), ) self.export_model(self.exported_model_path) - if not self.debug: + if not GLOBAL_CONFIG.debug: for i in range(1, self.num_iterations + 1): model_path = os.path.join(self.working_directory, f"{i}.mdl") try: @@ -598,7 +548,10 @@ def finalize_training(self) -> None: for file in os.listdir(self.working_directory): if any(file.startswith(x) for x in ["fsts.", "trans.", "ali."]): os.remove(os.path.join(self.working_directory, file)) - self.training_complete = True + wf = self.worker.current_workflow + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update({"done": True}) + session.commit() self.worker.current_trainer = None @property @@ -616,6 +569,10 @@ def phone_type(self) -> str: """Phone type, not implemented for BaseTrainer""" raise NotImplementedError + @property + def use_g2p(self): + return self.worker.use_g2p + @property def meta(self) -> MetaDict: """Generate metadata for the acoustic model that was trained""" @@ -636,6 +593,7 @@ def meta(self) -> MetaDict: utterance_count, duration, average_log_likelihood = summary.first() data = { "phones": sorted(self._generate_non_positional_list(self.non_silence_phones)), + "phone_groups": self.worker.phone_groups, "version": get_mfa_version(), "architecture": self.architecture, "train_date": str(datetime.now()), @@ -654,8 +612,8 @@ def meta(self) -> MetaDict: "oov_word": self.worker.oov_word, "bracketed_word": self.worker.bracketed_word, "laughter_word": self.worker.laughter_word, - "position_dependent_phones": self.worker.position_dependent_phones, "clitic_marker": self.worker.clitic_marker, + "position_dependent_phones": self.worker.position_dependent_phones, }, "features": self.feature_options, "oov_phone": self.worker.oov_phone, diff --git a/montreal_forced_aligner/acoustic_modeling/lda.py b/montreal_forced_aligner/acoustic_modeling/lda.py index 7984c722..fe5fab43 100644 --- a/montreal_forced_aligner/acoustic_modeling/lda.py +++ b/montreal_forced_aligner/acoustic_modeling/lda.py @@ -1,6 +1,7 @@ """Class definitions for LDA trainer""" from __future__ import annotations +import logging import multiprocessing as mp import os import re @@ -14,6 +15,7 @@ from montreal_forced_aligner.abc import KaldiFunction from montreal_forced_aligner.acoustic_modeling.triphone import TriphoneTrainer +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.data import MfaArguments from montreal_forced_aligner.helper import mfa_open from montreal_forced_aligner.utils import ( @@ -35,6 +37,8 @@ "LdaAccStatsArguments", ] +logger = logging.getLogger("mfa") + class LdaAccStatsArguments(MfaArguments): """Arguments for :func:`~montreal_forced_aligner.acoustic_modeling.lda.LdaAccStatsFunction`""" @@ -298,22 +302,34 @@ def lda_acc_stats_arguments(self) -> List[LdaAccStatsArguments]: list[:class:`~montreal_forced_aligner.acoustic_modeling.lda.LdaAccStatsArguments`] Arguments for processing """ - feat_strings = self.worker.construct_feature_proc_strings() - return [ - LdaAccStatsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"lda_acc_stats.{j.name}.log"), - j.dictionary_ids, - feat_strings[j.name], - j.construct_path_dictionary(self.previous_aligner.working_directory, "ali", "ark"), - self.previous_aligner.alignment_model_path, - self.lda_options, - j.construct_path_dictionary(self.working_directory, "lda", "acc"), + arguments = [] + for j in self.jobs: + feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + arguments.append( + LdaAccStatsArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"lda_acc_stats.{j.id}.log"), + j.dictionary_ids, + feat_strings, + j.construct_path_dictionary( + self.previous_aligner.working_directory, "ali", "ark" + ), + self.previous_aligner.alignment_model_path, + self.lda_options, + j.construct_path_dictionary(self.working_directory, "lda", "acc"), + ) ) - for j in self.jobs - if j.has_data - ] + return arguments def calc_lda_mllt_arguments(self) -> List[CalcLdaMlltArguments]: """ @@ -324,24 +340,34 @@ def calc_lda_mllt_arguments(self) -> List[CalcLdaMlltArguments]: list[:class:`~montreal_forced_aligner.acoustic_modeling.lda.CalcLdaMlltArguments`] Arguments for processing """ - feat_strings = self.worker.construct_feature_proc_strings() - return [ - CalcLdaMlltArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join( - self.working_log_directory, f"lda_mllt.{self.iteration}.{j.name}.log" - ), - j.dictionary_ids, - feat_strings[j.name], - j.construct_path_dictionary(self.working_directory, "ali", "ark"), - self.model_path, - self.lda_options, - j.construct_path_dictionary(self.working_directory, "lda", "macc"), + arguments = [] + for j in self.jobs: + feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + arguments.append( + CalcLdaMlltArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join( + self.working_log_directory, f"lda_mllt.{self.iteration}.{j.id}.log" + ), + j.dictionary_ids, + feat_strings, + j.construct_path_dictionary(self.working_directory, "ali", "ark"), + self.model_path, + self.lda_options, + j.construct_path_dictionary(self.working_directory, "lda", "macc"), + ) ) - for j in self.jobs - if j.has_data - ] + return arguments @property def train_type(self) -> str: @@ -355,6 +381,8 @@ def lda_options(self) -> MetaDict: "lda_dimension": self.lda_dimension, "random_prune": self.random_prune, "silence_csl": self.silence_csl, + "splice_left_context": self.splice_left_context, + "splice_right_context": self.splice_right_context, } def compute_calculated_properties(self) -> None: @@ -383,10 +411,8 @@ 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=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -480,12 +506,10 @@ def calc_lda_mllt(self) -> None: Reference Kaldi script """ - self.log_info("Re-calculating LDA...") + logger.info("Re-calculating LDA...") arguments = self.calc_lda_mllt_arguments() - with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -569,6 +593,9 @@ def train_iteration(self) -> None: Run a single LDA training iteration """ if os.path.exists(self.next_model_path): + if self.iteration <= self.final_gaussian_iteration: + self.increment_gaussians() + self.iteration += 1 return if self.iteration in self.realignment_iterations: self.align_iteration() diff --git a/montreal_forced_aligner/acoustic_modeling/monophone.py b/montreal_forced_aligner/acoustic_modeling/monophone.py index 02fa68dd..c76523c1 100644 --- a/montreal_forced_aligner/acoustic_modeling/monophone.py +++ b/montreal_forced_aligner/acoustic_modeling/monophone.py @@ -1,19 +1,22 @@ """Class definitions for Monophone trainer""" from __future__ import annotations +import logging import multiprocessing as mp import os import re import subprocess import typing from queue import Empty -from typing import Dict, List import tqdm +from sqlalchemy.orm import Session, joinedload, subqueryload from montreal_forced_aligner.abc import KaldiFunction from montreal_forced_aligner.acoustic_modeling.base import AcousticModelTrainingMixin +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.data import MfaArguments +from montreal_forced_aligner.db import CorpusWorkflow, Job from montreal_forced_aligner.exceptions import KaldiProcessingError from montreal_forced_aligner.helper import mfa_open from montreal_forced_aligner.utils import KaldiProcessWorker, Stopped, thirdparty_binary @@ -23,16 +26,14 @@ __all__ = ["MonophoneTrainer", "MonoAlignEqualFunction", "MonoAlignEqualArguments"] +logger = logging.getLogger("mfa") + class MonoAlignEqualArguments(MfaArguments): """Arguments for :func:`~montreal_forced_aligner.acoustic_modeling.monophone.MonoAlignEqualFunction`""" - dictionaries: List[str] - feature_strings: Dict[str, str] - fst_ark_paths: Dict[str, str] - ali_ark_paths: Dict[str, str] - acc_paths: Dict[str, str] model_path: str + feature_options: MetaDict class MonoAlignEqualFunction(KaldiFunction): @@ -62,25 +63,44 @@ class MonoAlignEqualFunction(KaldiFunction): def __init__(self, args: MonoAlignEqualArguments): super().__init__(args) - self.dictionaries = args.dictionaries - self.feature_strings = args.feature_strings - self.fst_ark_paths = args.fst_ark_paths self.model_path = args.model_path - self.ali_ark_paths = args.ali_ark_paths - self.acc_paths = args.acc_paths + self.feature_options = args.feature_options def _run(self) -> typing.Generator[typing.Tuple[int, int]]: """Run the function""" - with mfa_open(self.log_path, "w") as log_file: - for dict_id in self.dictionaries: - fst_path = self.fst_ark_paths[dict_id] - ali_path = self.ali_ark_paths[dict_id] - acc_path = self.acc_paths[dict_id] + + with mfa_open(self.log_path, "w") as log_file, Session(self.db_engine) as session: + job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + workflow: CorpusWorkflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + for dict_id in job.dictionary_ids: + feature_string = job.construct_feature_proc_string( + workflow.working_directory, + dict_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + + fst_ark_path = job.construct_path( + workflow.working_directory, "fsts", "ark", dict_id + ) + ali_path = job.construct_path(workflow.working_directory, "ali", "ark", dict_id) + acc_path = job.construct_path(workflow.working_directory, "0", "acc", dict_id) align_proc = subprocess.Popen( [ thirdparty_binary("align-equal-compiled"), - f"ark:{fst_path}", - self.feature_strings[dict_id], + f"ark:{fst_ark_path}", + feature_string, f"ark:{ali_path}", ], stderr=log_file, @@ -92,7 +112,7 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: thirdparty_binary("gmm-acc-stats-ali"), "--binary=true", self.model_path, - self.feature_strings[dict_id], + feature_string, f"ark:{ali_path}", acc_path, ], @@ -147,7 +167,7 @@ def __init__( self.power = power self.last_gaussian_increase_iteration = 0 - def mono_align_equal_arguments(self) -> List[MonoAlignEqualArguments]: + def mono_align_equal_arguments(self) -> typing.List[MonoAlignEqualArguments]: """ Generate Job arguments for :func:`~montreal_forced_aligner.acoustic_modeling.monophone.MonoAlignEqualFunction` @@ -156,21 +176,15 @@ def mono_align_equal_arguments(self) -> List[MonoAlignEqualArguments]: list[:class:`~montreal_forced_aligner.acoustic_modeling.monophone.MonoAlignEqualArguments`] Arguments for processing """ - feat_strings = self.worker.construct_feature_proc_strings() return [ MonoAlignEqualArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"mono_align_equal.{j.name}.log"), - j.dictionary_ids, - feat_strings[j.name], - j.construct_path_dictionary(self.working_directory, "fsts", "ark"), - j.construct_path_dictionary(self.working_directory, "ali", "ark"), - j.construct_path_dictionary(self.working_directory, "0", "acc"), + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"mono_align_equal.{j.id}.log"), self.model_path, + self.feature_options, ) for j in self.jobs - if j.has_data ] def compute_calculated_properties(self) -> None: @@ -223,12 +237,10 @@ def mono_align_equal(self) -> None: Reference Kaldi script """ - self.log_info("Generating initial alignments...") + logger.info("Generating initial alignments...") arguments = self.mono_align_equal_arguments() - with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -265,11 +277,8 @@ def mono_align_equal(self) -> None: else: raise e if error_logs: - import logging - - logger = logging.getLogger(self.identifier) e = KaldiProcessingError(e.error_logs) - e.update_log_file(logger) + e.update_log_file() raise e else: for args in arguments: @@ -280,8 +289,9 @@ def mono_align_equal(self) -> None: log_path = os.path.join(self.working_log_directory, "update.0.log") with mfa_open(log_path, "w") as log_file: acc_files = [] - for x in arguments: - acc_files.extend(sorted(x.acc_paths.values())) + for j in self.jobs: + for dict_id in j.dictionary_ids: + acc_files.append(j.construct_path(self.working_directory, "0", "acc", dict_id)) sum_proc = subprocess.Popen( [thirdparty_binary("gmm-sum-accs"), "-"] + acc_files, stderr=log_file, @@ -305,7 +315,7 @@ def mono_align_equal(self) -> None: est_proc.communicate() if est_proc.returncode != 0: raise KaldiProcessingError([log_path]) - if not self.debug: + if not GLOBAL_CONFIG.debug: for f in acc_files: os.remove(f) @@ -315,10 +325,16 @@ def _trainer_initialization(self) -> None: return self.iteration = 0 tree_path = os.path.join(self.working_directory, "tree") - feat_dim = self.worker.get_feat_dim() - feature_string = self.worker.construct_base_feature_string() + feature_string = self.jobs[0].construct_feature_proc_string( + self.working_directory, + self.jobs[0].dictionary_ids[0], + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) shared_phones_path = os.path.join(self.worker.phones_dir, "sets.int") init_log_path = os.path.join(self.working_log_directory, "init.log") temp_feats_path = os.path.join(self.working_directory, "temp_feats") diff --git a/montreal_forced_aligner/acoustic_modeling/pronunciation_probabilities.py b/montreal_forced_aligner/acoustic_modeling/pronunciation_probabilities.py index 694ed0b9..3f7bf885 100644 --- a/montreal_forced_aligner/acoustic_modeling/pronunciation_probabilities.py +++ b/montreal_forced_aligner/acoustic_modeling/pronunciation_probabilities.py @@ -1,24 +1,30 @@ """Class definitions for PronunciationProbabilityTrainer""" import json -import multiprocessing as mp +import logging import os +import re import shutil import time import typing -from queue import Empty import tqdm from sqlalchemy.orm import joinedload from montreal_forced_aligner.acoustic_modeling.base import AcousticModelTrainingMixin -from montreal_forced_aligner.alignment.multiprocessing import GeneratePronunciationsFunction -from montreal_forced_aligner.db import Dictionary, Pronunciation, Utterance, Word +from montreal_forced_aligner.alignment.multiprocessing import ( + GeneratePronunciationsArguments, + GeneratePronunciationsFunction, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.db import CorpusWorkflow, Dictionary, Pronunciation, Utterance, Word from montreal_forced_aligner.g2p.trainer import PyniniTrainerMixin from montreal_forced_aligner.helper import mfa_open -from montreal_forced_aligner.utils import KaldiProcessWorker, Stopped +from montreal_forced_aligner.utils import parse_dictionary_file, run_kaldi_function __all__ = ["PronunciationProbabilityTrainer"] +logger = logging.getLogger("mfa") + class PronunciationProbabilityTrainer(AcousticModelTrainingMixin, PyniniTrainerMixin): """ @@ -83,18 +89,6 @@ def alignment_model_path(self) -> str: return path return self.model_path - @property - def working_directory(self) -> str: - """Training directory""" - if self.pronunciations_complete: - return super(PronunciationProbabilityTrainer, self).working_directory - return self.previous_aligner.working_directory - - @property - def num_jobs(self) -> int: - """Number of jobs from the root worker""" - return self.worker.num_jobs - @property def phone_symbol_table_path(self) -> str: """Worker's phone symbol table""" @@ -115,16 +109,47 @@ def output_path(self) -> str: """Path to temporary file to store training data""" return os.path.join(self.working_directory, f"output_{self._data_source}.txt") + @property + def output_alignment_path(self) -> str: + """Path to temporary file to store training data""" + return os.path.join(self.working_directory, f"output_{self._data_source}_alignment.txt") + + def generate_pronunciations_arguments(self) -> typing.List[GeneratePronunciationsArguments]: + """ + Generate Job arguments for :func:`~montreal_forced_aligner.alignment.multiprocessing.GeneratePronunciationsFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.alignment.multiprocessing.GeneratePronunciationsArguments`] + Arguments for processing + """ + + return [ + GeneratePronunciationsArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"generate_pronunciations.{j.id}.log"), + self.model_path, + True, + ) + for j in self.jobs + ] + + def align_g2p(self, output_path=None) -> None: + """Runs the entire alignment regimen.""" + self._lexicon_covering(output_path=output_path) + self._alignments() + self._encode() + def train_g2p_lexicon(self) -> None: """Generate a G2P lexicon based on aligned transcripts""" - arguments = self.worker.generate_pronunciations_arguments() + arguments = self.generate_pronunciations_arguments() working_dir = super(PronunciationProbabilityTrainer, self).working_directory texts = {} with self.worker.session() as session: query = session.query(Utterance.id, Utterance.normalized_character_text) query = query.filter(Utterance.ignored == False) # noqa - initial_brackets = "".join(x[0] for x in self.worker.brackets) - query = query.filter(~Utterance.oovs.regexp_match(f"(^| )[^{initial_brackets}]")) + # query = query.filter(Utterance.oovs != '', Utterance.oovs != None) if self.subset: query = query.filter_by(in_subset=True) for utt_id, text in query: @@ -149,65 +174,36 @@ def train_g2p_lexicon(self) -> None: ) for x in self.worker.dictionary_lookup.values() } - with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(arguments): - args.for_g2p = True - function = GeneratePronunciationsFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - dict_id, utt_id, phones = result - utt_id = int(utt_id.split("-")[-1]) - pbar.update(1) - if utt_id not in texts or not texts[utt_id]: - continue - - print(phones, file=output_files[dict_id]) - print(f" {texts[utt_id]} ", file=input_files[dict_id]) + output_alignment_files = { + x: open( + os.path.join( + working_dir, f"output_{self.worker.dictionary_base_names[x]}_alignment.txt" + ), + "w", + encoding="utf8", + newline="", + ) + for x in self.worker.dictionary_lookup.values() + } + with tqdm.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 + ): + if utt_id not in texts or not texts[utt_id]: + continue - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v - else: - self.log_debug("Not using multiprocessing...") - for args in arguments: - args.for_g2p = True - function = GeneratePronunciationsFunction(args) - for dict_id, utt_id, phones in function.run(): - utt_id = int(utt_id.split("-")[-1]) - if utt_id not in texts or not texts[utt_id]: - continue - print(phones, file=output_files[dict_id]) - print(f" {texts[utt_id]} ", file=input_files[dict_id]) - pbar.update(1) + print(phones, file=output_alignment_files[dict_id]) + print( + re.sub(r"\s+", " ", phones.replace("#1", "").replace("#2", "")).strip(), + file=output_files[dict_id], + ) + print(texts[utt_id], file=input_files[dict_id]) for f in input_files.values(): f.close() for f in output_files.values(): f.close() + for f in output_alignment_files.values(): + f.close() self.pronunciations_complete = True os.makedirs(self.working_log_directory, exist_ok=True) dictionaries = session.query(Dictionary) @@ -221,21 +217,53 @@ def train_g2p_lexicon(self) -> None: self.input_token_type = self.grapheme_symbol_table_path self.output_token_type = self.phone_symbol_table_path for d in dictionaries: - self.log_info(f"Training G2P for {d.name}...") + logger.info(f"Training G2P for {d.name}...") self._data_source = self.worker.dictionary_base_names[d.id] + begin = time.time() if os.path.exists(self.far_path) and os.path.exists(self.encoder_path): - self.log_info("Alignment already done, skipping!") + logger.info("Alignment already done, skipping!") else: self.align_g2p() - self.log_debug( - f"Aligning utterances for {d.name} took {time.time() - begin} seconds" + logger.debug( + f"Aligning utterances for {d.name} took {time.time() - begin:.3f} seconds" ) begin = time.time() self.generate_model() - self.log_debug(f"Generating model for {d.name} took {time.time() - begin} seconds") + logger.debug( + f"Generating model for {d.name} took {time.time() - begin:.3f} seconds" + ) os.rename(d.lexicon_fst_path, d.lexicon_fst_path + ".backup") - shutil.copy(self.fst_path, d.lexicon_fst_path) + os.rename(self.fst_path, d.lexicon_fst_path) + + if not GLOBAL_CONFIG.current_profile.debug: + os.remove(self.output_path) + os.remove(self.input_far_path) + os.remove(self.output_far_path) + for f in os.listdir(self.working_directory): + if any(f.endswith(x) for x in [".fst", ".like", ".far", ".enc"]): + os.remove(os.path.join(self.working_directory, f)) + + begin = time.time() + self.align_g2p(self.output_alignment_path) + logger.debug( + f"Aligning utterances for {d.name} took {time.time() - begin:.3f} seconds" + ) + begin = time.time() + self.generate_model() + logger.debug( + f"Generating model for {d.name} took {time.time() - begin:.3f} seconds" + ) + os.rename(d.align_lexicon_path, d.align_lexicon_path + ".backup") + os.rename(self.fst_path, d.align_lexicon_path) + if not GLOBAL_CONFIG.current_profile.debug: + os.remove(self.output_alignment_path) + os.remove(self.input_path) + os.remove(self.input_far_path) + os.remove(self.output_far_path) + for f in os.listdir(self.working_directory): + if any(f.endswith(x) for x in [".fst", ".like", ".far", ".enc"]): + os.remove(os.path.join(self.working_directory, f)) d.use_g2p = True session.commit() self.worker.use_g2p = True @@ -251,18 +279,25 @@ def export_model(self, output_model_path: str) -> None: """ AcousticModelTrainingMixin.export_model(self, output_model_path) + def setup(self): + wf = self.worker.current_workflow + previous_directory = self.previous_aligner.working_directory + for j in self.jobs: + for p in j.construct_path_dictionary(previous_directory, "ali", "ark").values(): + shutil.copy(p, p.replace(previous_directory, wf.working_directory)) + for f in ["final.mdl", "final.alimdl", "final.occs", "lda.mat"]: + p = os.path.join(previous_directory, f) + if os.path.exists(p): + shutil.copy(p, p.replace(previous_directory, wf.working_directory)) + def train_pronunciation_probabilities(self) -> None: """ Train pronunciation probabilities based on previous alignment """ - working_dir = super(PronunciationProbabilityTrainer, self).working_directory - done_path = os.path.join(working_dir, "done") - dirty_path = os.path.join(working_dir, "dirty") - if os.path.exists(dirty_path): # if there was an error, let's redo from scratch - shutil.rmtree(working_dir) - os.makedirs(working_dir, exist_ok=True) - if os.path.exists(done_path): - self.log_info( + wf = self.worker.current_workflow + os.makedirs(os.path.join(wf.working_directory, "log"), exist_ok=True) + if wf.done: + logger.info( "Pronunciation probability estimation already done, loading saved probabilities..." ) self.training_complete = True @@ -286,7 +321,9 @@ def train_pronunciation_probabilities(self) -> None: initial_silence_prob_sum = 0 final_silence_correction_sum = 0 final_non_silence_correction_sum = 0 + with self.worker.session() as session: + dictionaries = session.query(Dictionary).all() for d in dictionaries: pronunciations = ( @@ -296,28 +333,26 @@ def train_pronunciation_probabilities(self) -> None: .filter(Word.dictionary_id == d.id) ) cache = {(x.word.word, x.pronunciation): x for x in pronunciations} - new_dictionary_path = os.path.join(working_dir, f"{d.id}.dict") - with mfa_open(new_dictionary_path, "r") as f: - for line in f: - line = line.strip() - line = line.split() - word = line.pop(0) - prob = float(line.pop(0)) - silence_after_prob = None - silence_before_correct = None - non_silence_before_correct = None - if self.silence_probabilities: - silence_after_prob = float(line.pop(0)) - silence_before_correct = float(line.pop(0)) - non_silence_before_correct = float(line.pop(0)) - pron = " ".join(line) - p = cache[(word, pron)] - p.probability = prob - p.silence_after_probability = silence_after_prob - p.silence_before_correction = silence_before_correct - p.non_silence_before_correction = non_silence_before_correct - - silence_info_path = os.path.join(working_dir, f"{d.id}_silence_info.json") + new_dictionary_path = os.path.join(self.working_directory, f"{d.id}.dict") + for ( + word, + pron, + prob, + silence_after_prob, + silence_before_correct, + non_silence_before_correct, + ) in parse_dictionary_file(new_dictionary_path): + if (word, " ".join(pron)) not in cache: + continue + p = cache[(word, " ".join(pron))] + p.probability = prob + p.silence_after_probability = silence_after_prob + p.silence_before_correction = silence_before_correct + p.non_silence_before_correction = non_silence_before_correct + + silence_info_path = os.path.join( + self.working_directory, f"{d.id}_silence_info.json" + ) with mfa_open(silence_info_path, "r") as f: data = json.load(f) if self.silence_probabilities: @@ -329,6 +364,7 @@ def train_pronunciation_probabilities(self) -> None: initial_silence_prob_sum += d.initial_silence_probability final_silence_correction_sum += d.final_silence_correction final_non_silence_correction_sum += d.final_non_silence_correction + if self.silence_probabilities: self.worker.silence_probability = silence_prob_sum / len(dictionaries) self.worker.initial_silence_probability = initial_silence_prob_sum / len( @@ -343,26 +379,30 @@ def train_pronunciation_probabilities(self) -> None: session.commit() self.worker.write_lexicon_information() return + self.setup() if self.train_g2p: self.train_g2p_lexicon() else: - self.worker.compute_pronunciation_probabilities(self.silence_probabilities) + os.makedirs(self.working_log_directory, exist_ok=True) + self.worker.compute_pronunciation_probabilities() self.worker.write_lexicon_information() with self.worker.session() as session: for d in session.query(Dictionary): - dict_path = os.path.join(working_dir, f"{d.id}.dict") + dict_path = os.path.join(self.working_directory, f"{d.id}.dict") + self.worker.export_trained_rules(self.working_directory) self.worker.export_lexicon( d.id, dict_path, probability=True, - silence_probabilities=self.silence_probabilities, ) - silence_info_path = os.path.join(working_dir, f"{d.id}_silence_info.json") + silence_info_path = os.path.join( + self.working_directory, f"{d.id}_silence_info.json" + ) with mfa_open(silence_info_path, "w") as f: json.dump(d.silence_probability_info, f) - self.training_complete = True - with mfa_open(done_path, "w"): - pass + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update({"done": True}) + session.commit() def train_iteration(self) -> None: """Training iteration""" diff --git a/montreal_forced_aligner/acoustic_modeling/sat.py b/montreal_forced_aligner/acoustic_modeling/sat.py index 5fc8e78b..302c9b18 100644 --- a/montreal_forced_aligner/acoustic_modeling/sat.py +++ b/montreal_forced_aligner/acoustic_modeling/sat.py @@ -1,6 +1,7 @@ """Class definitions for Speaker Adapted Triphone trainer""" from __future__ import annotations +import logging import multiprocessing as mp import os import re @@ -14,6 +15,7 @@ import tqdm from montreal_forced_aligner.acoustic_modeling.triphone import TriphoneTrainer +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.data import MfaArguments from montreal_forced_aligner.exceptions import KaldiProcessingError from montreal_forced_aligner.helper import mfa_open @@ -29,6 +31,9 @@ __all__ = ["SatTrainer", "AccStatsTwoFeatsFunction", "AccStatsTwoFeatsArguments"] +logger = logging.getLogger("mfa") + + class AccStatsTwoFeatsArguments(MfaArguments): """Arguments for :func:`~montreal_forced_aligner.acoustic_modeling.sat.AccStatsTwoFeatsFunction`""" @@ -163,23 +168,41 @@ def acc_stats_two_feats_arguments(self) -> List[AccStatsTwoFeatsArguments]: list[:class:`~montreal_forced_aligner.acoustic_modeling.sat.AccStatsTwoFeatsArguments`] Arguments for processing """ - feat_strings = self.worker.construct_feature_proc_strings() - si_feat_strings = self.worker.construct_feature_proc_strings(speaker_independent=True) - return [ - AccStatsTwoFeatsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"acc_stats_two_feats.{j.name}.log"), - j.dictionary_ids, - j.construct_path_dictionary(self.working_directory, "ali", "ark"), - j.construct_path_dictionary(self.working_directory, "two_feat_acc", "ark"), - self.model_path, - feat_strings[j.name], - si_feat_strings[j.name], + arguments = [] + for j in self.jobs: + feat_strings = {} + si_feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + si_feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + False, + ) + arguments.append( + AccStatsTwoFeatsArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"acc_stats_two_feats.{j.id}.log"), + j.dictionary_ids, + j.construct_path_dictionary(self.working_directory, "ali", "ark"), + j.construct_path_dictionary(self.working_directory, "two_feat_acc", "ark"), + self.model_path, + feat_strings, + si_feat_strings, + ) ) - for j in self.jobs - if j.has_data - ] + return arguments def calc_fmllr(self) -> None: """Calculate fMLLR transforms for the current iteration""" @@ -204,8 +227,8 @@ def compute_calculated_properties(self) -> None: def _trainer_initialization(self) -> None: """Speaker adapted training initialization""" if self.initialized: - self.speaker_independent = False - self.worker.speaker_independent = False + self.uses_speaker_adaptation = True + self.worker.uses_speaker_adaptation = True return if os.path.exists(os.path.join(self.previous_aligner.working_directory, "lda.mat")): shutil.copyfile( @@ -213,8 +236,6 @@ def _trainer_initialization(self) -> None: os.path.join(self.working_directory, "lda.mat"), ) for j in self.jobs: - if not j.has_data: - continue for path in j.construct_path_dictionary( self.previous_aligner.working_directory, "trans", "ark" ).values(): @@ -224,14 +245,12 @@ def _trainer_initialization(self) -> None: continue break else: - self.speaker_independent = True - self.worker.speaker_independent = True + self.uses_speaker_adaptation = False + self.worker.uses_speaker_adaptation = False self.calc_fmllr() - self.speaker_independent = False - self.worker.speaker_independent = False + self.uses_speaker_adaptation = True + self.worker.uses_speaker_adaptation = True for j in self.jobs: - if not j.has_data: - continue transform_paths = j.construct_path_dictionary( self.previous_aligner.working_directory, "trans", "ark" ) @@ -266,11 +285,8 @@ def finalize_training(self) -> None: assert os.path.exists(self.alignment_model_path) except Exception as e: if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise def train_iteration(self) -> None: @@ -278,6 +294,8 @@ def train_iteration(self) -> None: Run a single training iteration """ if os.path.exists(self.next_model_path): + if self.iteration <= self.final_gaussian_iteration: + self.increment_gaussians() self.iteration += 1 return if self.iteration in self.realignment_iterations: @@ -318,14 +336,12 @@ def create_align_model(self) -> None: :kaldi_steps:`train_sat` Reference Kaldi script """ - self.log_info("Creating alignment model for speaker-independent features...") + logger.info("Creating alignment model for speaker-independent features...") begin = time.time() arguments = self.acc_stats_two_feats_arguments() - with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -399,7 +415,7 @@ def create_align_model(self) -> None: ) est_proc.communicate() parse_logs(self.working_log_directory) - if not self.debug: + if not GLOBAL_CONFIG.debug: for f in acc_files: os.remove(f) - self.log_debug(f"Alignment model creation took {time.time() - begin}") + logger.debug(f"Alignment model creation took {time.time() - begin:.3f} seconds") diff --git a/montreal_forced_aligner/acoustic_modeling/trainer.py b/montreal_forced_aligner/acoustic_modeling/trainer.py index 9b85256b..90699625 100644 --- a/montreal_forced_aligner/acoustic_modeling/trainer.py +++ b/montreal_forced_aligner/acoustic_modeling/trainer.py @@ -1,29 +1,139 @@ """Class definitions for trainable aligners""" from __future__ import annotations +import collections +import json +import logging +import multiprocessing as mp import os +import re import shutil +import subprocess import time +import typing +from queue import Empty from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -from montreal_forced_aligner.abc import ModelExporterMixin, TopLevelMfaWorker -from montreal_forced_aligner.alignment.base import CorpusAligner -from montreal_forced_aligner.db import Dictionary +import tqdm +from sqlalchemy.orm import Session, joinedload, subqueryload + +from montreal_forced_aligner.abc import KaldiFunction, ModelExporterMixin, TopLevelMfaWorker +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.data import MfaArguments, WorkflowType +from montreal_forced_aligner.db import CorpusWorkflow, Dictionary, Job from montreal_forced_aligner.exceptions import ConfigError, KaldiProcessingError from montreal_forced_aligner.helper import load_configuration, mfa_open, parse_old_features from montreal_forced_aligner.models import AcousticModel, DictionaryModel -from montreal_forced_aligner.utils import log_kaldi_errors +from montreal_forced_aligner.transcription.transcriber import TranscriberMixin +from montreal_forced_aligner.utils import ( + KaldiProcessWorker, + Stopped, + log_kaldi_errors, + thirdparty_binary, +) if TYPE_CHECKING: - from argparse import Namespace + from dataclasses import dataclass from montreal_forced_aligner.abc import MetaDict from montreal_forced_aligner.acoustic_modeling.base import AcousticModelTrainingMixin + from montreal_forced_aligner.acoustic_modeling.pronunciation_probabilities import ( + PronunciationProbabilityTrainer, + ) +else: + from dataclassy import dataclass + +__all__ = ["TrainableAligner", "TransitionAccFunction", "TransitionAccArguments"] + + +logger = logging.getLogger("mfa") + -__all__ = ["TrainableAligner"] +@dataclass +class TransitionAccArguments(MfaArguments): + """Arguments for :class:`~montreal_forced_aligner.acoustic_modeling.trainer.TransitionAccFunction`""" + model_path: str + + +class TransitionAccFunction(KaldiFunction): + """ + Multiprocessing function to accumulate transition stats + + See Also + -------- + :kaldi_src:`ali-to-post` + Relevant Kaldi binary + :kaldi_src:`post-to-tacc` + Relevant Kaldi binary -class TrainableAligner(CorpusAligner, TopLevelMfaWorker, ModelExporterMixin): + Parameters + ---------- + args: :class:`~montreal_forced_aligner.acoustic_modeling.trainer.TransitionAccArguments` + Arguments for the function + """ + + done_pattern = re.compile( + r"^LOG \(post-to-tacc.*Done computing transition stats over (?P\d+) utterances.*$" + ) + + def __init__(self, args: TransitionAccArguments): + super().__init__(args) + self.model_path = args.model_path + + def _run(self) -> typing.Generator[typing.Tuple[int, str]]: + """Run the function""" + + with mfa_open(self.log_path, "w") as log_file, Session(self.db_engine) as session: + job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + workflow: CorpusWorkflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + for dict_id in job.dictionary_ids: + ali_path = job.construct_path(workflow.working_directory, "ali", "ark", dict_id) + + tacc_path = job.construct_path(workflow.working_directory, "t", "acc", dict_id) + + ali_post_proc = subprocess.Popen( + [ + thirdparty_binary("ali-to-post"), + f"ark:{ali_path}", + "ark:-", + ], + stdout=subprocess.PIPE, + env=os.environ, + stderr=log_file, + ) + + tacc_proc = subprocess.Popen( + [ + thirdparty_binary("post-to-tacc"), + self.model_path, + "ark:-", + tacc_path, + ], + stdin=ali_post_proc.stdout, + env=os.environ, + stderr=subprocess.PIPE, + encoding="utf8", + ) + for line in tacc_proc.stderr: + log_file.write(line) + m = self.done_pattern.match(line.strip()) + if m: + progress_update = int(m.group("utterances")) + yield progress_update + self.check_call(tacc_proc) + + +class TrainableAligner(TranscriberMixin, TopLevelMfaWorker, ModelExporterMixin): """ Train acoustic model @@ -68,7 +178,7 @@ def __init__( for k, v in kwargs.items() if not k.endswith("_directory") and not k.endswith("_path") - and k not in ["clean", "num_jobs", "speaker_characters"] + and k not in ["speaker_characters"] } self.final_identifier = None self.current_subset: int = 0 @@ -82,7 +192,9 @@ def __init__( ) self.phone_set_type = self.dictionary_model.phone_set_type os.makedirs(self.output_directory, exist_ok=True) - self.training_configs: Dict[str, AcousticModelTrainingMixin] = {} + self.training_configs: Dict[ + str, typing.Union[AcousticModelTrainingMixin, PronunciationProbabilityTrainer] + ] = {} if training_configuration is None: training_configuration = TrainableAligner.default_training_configurations() for k, v in training_configuration: @@ -99,23 +211,23 @@ def default_training_configurations(cls) -> List[Tuple[str, Dict[str, Any]]]: { "subset": 20000, "boost_silence": 1.25, - "num_leaves": 2000, + "num_leaves": 2500, "max_gaussians": 10000, }, ) ) training_params.append( - ("lda", {"subset": 20000, "num_leaves": 2500, "max_gaussians": 15000}) + ("lda", {"subset": 20000, "num_leaves": 3000, "max_gaussians": 15000}) ) training_params.append( - ("sat", {"subset": 20000, "num_leaves": 2500, "max_gaussians": 15000}) + ("sat", {"subset": 20000, "num_leaves": 4000, "max_gaussians": 15000}) ) training_params.append( - ("sat", {"subset": 50000, "num_leaves": 4200, "max_gaussians": 40000}) + ("sat", {"subset": 50000, "num_leaves": 5000, "max_gaussians": 40000}) ) training_params.append(("pronunciation_probabilities", {"subset": 50000})) training_params.append( - ("sat", {"subset": 150000, "num_leaves": 5000, "max_gaussians": 100000}) + ("sat", {"subset": 150000, "num_leaves": 6000, "max_gaussians": 100000}) ) training_params.append( ( @@ -142,8 +254,8 @@ def default_training_configurations(cls) -> List[Tuple[str, Dict[str, Any]]]: def parse_parameters( cls, config_path: Optional[str] = None, - args: Optional[Namespace] = None, - unknown_args: Optional[List[str]] = None, + args: Optional[Dict[str, Any]] = None, + unknown_args: Optional[typing.Iterable[str]] = None, ) -> MetaDict: """ Parse configuration parameters from a config file and command line arguments @@ -152,10 +264,10 @@ def parse_parameters( ---------- config_path: str, optional Path to yaml configuration file - args: :class:`~argparse.Namespace`, optional - Arguments parsed by argparse - unknown_args: list[str], optional - List of unknown arguments from argparse + args: dict[str, Any] + Parsed arguments + unknown_args: list[str] + Optional list of arguments that were not parsed Returns ------- @@ -193,34 +305,49 @@ def parse_parameters( global_params.update(cls.parse_args(args, unknown_args)) return global_params + def setup_trainers(self): + self.write_training_information() + with self.session() as session: + workflows: typing.Dict[str, CorpusWorkflow] = { + x.name: x for x in session.query(CorpusWorkflow) + } + for i, (identifier, config) in enumerate(self.training_configs.items()): + if isinstance(config, str): + continue + config.non_silence_phones = self.non_silence_phones + ali_identifier = f"{identifier}_ali" + if identifier not in workflows: + self.create_new_current_workflow( + WorkflowType.acoustic_training, name=identifier + ) + self.create_new_current_workflow(WorkflowType.alignment, name=ali_identifier) + else: + wf = workflows[identifier] + if wf.dirty and not wf.done: + shutil.rmtree(wf.working_directory, ignore_errors=True) + ali_wf = workflows[ali_identifier] + if ali_wf.dirty and not ali_wf.done: + shutil.rmtree(ali_wf.working_directory, ignore_errors=True) + if i == 0: + wf.current = True + session.commit() + def setup(self) -> None: """Setup for acoustic model training""" - + super().setup() + self.ignore_empty_utterances = True if self.initialized: return - self.check_previous_run() try: self.load_corpus() - self.write_training_information() - for config in self.training_configs.values(): - if isinstance(config, str): - continue - config.non_silence_phones = self.non_silence_phones + self.setup_trainers() except Exception as e: if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise self.initialized = True - @property - def workflow_identifier(self) -> str: - """Acoustic model training identifier""" - return "train_acoustic_model" - @property def configuration(self) -> MetaDict: """Configuration for the worker""" @@ -276,6 +403,7 @@ def add_config(self, train_type: str, params: MetaDict) -> None: k: v for k, v in p.items() if k in MonophoneTrainer.get_configuration_parameters() } config = MonophoneTrainer(identifier=identifier, worker=self, **p) + elif train_type == "triphone": p = {k: v for k, v in p.items() if k in TriphoneTrainer.get_configuration_parameters()} config = TriphoneTrainer(identifier=identifier, worker=self, **p) @@ -313,9 +441,9 @@ def export_model(self, output_model_path: str) -> None: export_directory = os.path.dirname(output_model_path) if export_directory: os.makedirs(export_directory, exist_ok=True) - silence_probs = self.training_configs[ - "pronunciation_probabilities" - ].silence_probabilities + # self.export_trained_rules( + # self.training_configs[self.final_identifier].working_directory + # ) with self.session() as session: for d in session.query(Dictionary): base_name = self.dictionary_base_names[d.id] @@ -341,6 +469,13 @@ def export_model(self, output_model_path: str) -> None: self.dictionary_base_names[d.id] + ".fst", ), ) + shutil.copyfile( + d.align_lexicon_path, + os.path.join( + self.training_configs[self.final_identifier].working_directory, + self.dictionary_base_names[d.id] + "_align.fst", + ), + ) else: output_dictionary_path = os.path.join( export_directory, base_name + ".dict" @@ -349,10 +484,9 @@ def export_model(self, output_model_path: str) -> None: d.id, output_dictionary_path, probability=True, - silence_probabilities=silence_probs, ) self.training_configs[self.final_identifier].export_model(output_model_path) - self.log_info(f"Saved model to {output_model_path}") + logger.info(f"Saved model to {output_model_path}") @property def tree_path(self) -> str: @@ -368,7 +502,7 @@ def train(self) -> None: begin = time.time() for trainer in self.training_configs.values(): if self.current_subset is None and trainer.optional: - self.log_info( + logger.info( "Exiting training early to save time as the corpus is below the subset size for later training stages" ) break @@ -377,29 +511,192 @@ def train(self) -> None: else: self.current_subset = None trainer.subset = 0 + self.subset_directory(self.current_subset) if previous is not None: + self.set_current_workflow(f"{previous.identifier}_ali") self.current_aligner = previous os.makedirs(self.working_directory, exist_ok=True) self.current_acoustic_model = AcousticModel( previous.exported_model_path, self.working_directory ) - self.align() + + self.set_current_workflow(trainer.identifier) if trainer.identifier.startswith("pronunciation_probabilities"): trainer.train_pronunciation_probabilities() else: trainer.train() - previous = trainer self.final_identifier = trainer.identifier - self.log_info(f"Completed training in {time.time()-begin} seconds!") - self.current_subset = None self.current_aligner = previous + self.set_current_workflow(f"{previous.identifier}_ali") + os.makedirs(self.working_log_directory, exist_ok=True) self.current_acoustic_model = AcousticModel( previous.exported_model_path, self.working_directory ) + self.acoustic_model = AcousticModel(previous.exported_model_path, self.working_directory) + self.align() + self.finalize_training() + counts_path = os.path.join(self.working_directory, "phone_pdf.counts") + new_counts_path = os.path.join(previous.working_directory, "phone_pdf.counts") + if not os.path.exists(new_counts_path): + shutil.copyfile(counts_path, new_counts_path) + + phone_lm_path = os.path.join(self.phones_dir, "phone_lm.fst") + new_phone_lm_path = os.path.join(previous.working_directory, "phone_lm.fst") + if not os.path.exists(new_phone_lm_path) and os.path.exists(phone_lm_path): + shutil.copyfile(phone_lm_path, new_phone_lm_path) + logger.info(f"Completed training in {time.time()-begin} seconds!") + + def transition_acc_arguments(self) -> List[TransitionAccArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.acoustic_modeling.trainer.TransitionAccArguments` + + Returns + ------- + list[:class:`~montreal_forced_aligner.acoustic_modeling.trainer.TransitionAccArguments`] + Arguments for processing + """ + + return [ + TransitionAccArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"test_utterances.{j.id}.log"), + self.model_path, + ) + for j in self.jobs + ] + + def compute_phone_pdf_counts(self) -> None: + """ + Calculate the counts of pdfs corresponding to phones + """ + try: + + logger.info("Accumulating transition stats...") + + begin = time.time() + 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: + if GLOBAL_CONFIG.use_mp: + error_dict = {} + return_queue = mp.Queue() + stopped = Stopped() + procs = [] + for i, args in enumerate(arguments): + function = TransitionAccFunction(args) + p = KaldiProcessWorker(i, return_queue, function, stopped) + procs.append(p) + p.start() + while True: + try: + result = return_queue.get(timeout=1) + if stopped.stop_check(): + continue + except Empty: + for proc in procs: + if not proc.finished.stop_check(): + break + else: + break + continue + if isinstance(result, KaldiProcessingError): + error_dict[result.job_name] = result + continue + pbar.update(result) + for p in procs: + p.join() + if error_dict: + for v in error_dict.values(): + raise v + else: + logger.debug("Not using multiprocessing...") + for args in arguments: + function = TransitionAccFunction(args) + for result in function.run(): + pbar.update(result) + t_accs = [] + for j in self.jobs: + for dict_id in j.dictionary_ids: + t_accs.append(j.construct_path(self.working_directory, "t", "acc", dict_id)) + subprocess.check_call( + [ + thirdparty_binary("vector-sum"), + "--binary=false", + *t_accs, + os.path.join(self.working_directory, "final.tacc"), + ], + stderr=subprocess.DEVNULL, + ) + for f in t_accs: + os.remove(f) + smoothing = 1 + show_proc = subprocess.Popen( + [ + thirdparty_binary("show-transitions"), + self.phone_symbol_table_path, + self.model_path, + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + encoding="utf8", + env=os.environ, + ) + phone_pdfs = {} + phone, pdf = None, None + max_pdf = 0 + max_phone = 0 + for line in show_proc.stdout: + line = line.strip() + m = re.match( + r"^Transition-state.*phone = (?P[^ ]+) .*pdf = (?P\d+)$", line + ) + if m: + phone = m.group("phone") + pdf = int(m.group("pdf")) + if pdf > max_pdf: + max_pdf = pdf + if self.phone_mapping[phone] > max_phone: + max_phone = self.phone_mapping[phone] + else: + m = re.search(r"Transition-id = (?P\d+)", line) + if m: + transition_id = int(m.group("transition_id")) + phone_pdfs[transition_id] = (phone, pdf) + with mfa_open(os.path.join(self.working_directory, "final.tacc"), "r") as f: + data = f.read().strip().split()[1:-1] + + transition_counts = { + i: smoothing + int(float(x)) for i, x in enumerate(data) if i != 0 + } + assert len(transition_counts) == len(phone_pdfs) + pdf_counts = collections.Counter() + pdf_phone_counts = collections.Counter() + phone_pdf_mapping = collections.defaultdict(collections.Counter) + for transition_id, (phone, pdf) in phone_pdfs.items(): + pdf_counts[pdf] += transition_counts[transition_id] + pdf_phone_counts[(phone, pdf)] += transition_counts[transition_id] + phone_pdf_mapping[phone][pdf] += transition_counts[transition_id] + with mfa_open(os.path.join(self.working_directory, "phone_pdf.counts"), "w") as f: + json.dump(phone_pdf_mapping, f, ensure_ascii=False) + logger.debug(f"Accumulating transition stats took {time.time() - begin:.3f} seconds") + logger.info("Finished accumulating transition stats!") + + except Exception as e: + if isinstance(e, KaldiProcessingError): + log_kaldi_errors(e.error_logs) + e.update_log_file() + raise + + def finalize_training(self): + self.compute_phone_pdf_counts() + self.collect_alignments() + self.train_phone_lm() def export_files( self, @@ -414,6 +711,10 @@ def export_files( ---------- output_directory: str Directory to save to + output_format: str, optional + Format to save alignments, one of 'long_textgrids' (the default), 'short_textgrids', or 'json', passed to praatio + include_original_text: bool + Flag for including the original text of the corpus files as a tier """ self.align() super(TrainableAligner, self).export_files( @@ -449,13 +750,13 @@ def align(self) -> None: :kaldi_steps:`align_fmllr` Reference Kaldi script """ - done_path = os.path.join(self.working_directory, "done") - if os.path.exists(done_path): - self.log_debug(f"Skipping {self.current_aligner.identifier} alignments") + wf = self.current_workflow + if wf.done: + logger.debug(f"Skipping {self.current_aligner.identifier} alignments") return try: self.current_acoustic_model.export_model(self.working_directory) - self.speaker_independent = True + self.uses_speaker_adaptation = False self.compile_train_graphs() self.align_utterances() if self.current_acoustic_model.meta["features"]["uses_speaker_adaptation"]: @@ -469,34 +770,39 @@ def align(self) -> None: if missing_transforms: assert self.alignment_model_path.endswith(".alimdl") self.calc_fmllr() - self.speaker_independent = False + self.uses_speaker_adaptation = True assert self.alignment_model_path.endswith(".mdl") self.align_utterances() if self.current_subset: - self.log_debug( + logger.debug( f"Analyzing alignment diagnostics for {self.current_aligner.identifier} on {self.current_subset} utterances" ) else: - self.log_debug( + logger.debug( f"Analyzing alignment diagnostics for {self.current_aligner.identifier} on the full corpus" ) self.compile_information() - with mfa_open(done_path, "w"): - pass + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update( + {"done": True} + ) + session.commit() except Exception as e: + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update( + {"dirty": True} + ) + session.commit() if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise @property def alignment_model_path(self) -> str: """Current alignment model path""" path = os.path.join(self.working_directory, "final.alimdl") - if os.path.exists(path) and self.speaker_independent: + if os.path.exists(path) and not self.uses_speaker_adaptation: return path return self.model_path diff --git a/montreal_forced_aligner/acoustic_modeling/triphone.py b/montreal_forced_aligner/acoustic_modeling/triphone.py index cc033813..7ce47280 100644 --- a/montreal_forced_aligner/acoustic_modeling/triphone.py +++ b/montreal_forced_aligner/acoustic_modeling/triphone.py @@ -1,6 +1,7 @@ """Class definitions for TriphoneTrainer""" from __future__ import annotations +import logging import multiprocessing as mp import os import re @@ -12,6 +13,7 @@ import tqdm from montreal_forced_aligner.acoustic_modeling.base import AcousticModelTrainingMixin +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.data import MfaArguments from montreal_forced_aligner.helper import mfa_open from montreal_forced_aligner.utils import ( @@ -35,6 +37,8 @@ "ConvertAlignmentsArguments", ] +logger = logging.getLogger("mfa") + class TreeStatsArguments(MfaArguments): """Arguments for :func:`~montreal_forced_aligner.acoustic_modeling.triphone.tree_stats_func`""" @@ -211,23 +215,39 @@ def tree_stats_arguments(self) -> List[TreeStatsArguments]: list[:class:`~montreal_forced_aligner.acoustic_modeling.triphone.TreeStatsArguments`] Arguments for processing """ - feat_strings = self.worker.construct_feature_proc_strings() alignment_model_path = os.path.join(self.previous_aligner.working_directory, "final.mdl") - return [ - TreeStatsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"acc_tree.{j.name}.log"), - j.dictionary_ids, - self.worker.context_independent_csl, - alignment_model_path, - feat_strings[j.name], - j.construct_path_dictionary(self.previous_aligner.working_directory, "ali", "ark"), - j.construct_path_dictionary(self.working_directory, "tree", "acc"), + arguments = [] + for j in self.jobs: + feat_strings = {} + ali_paths = {} + treeacc_paths = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + ali_paths[d_id] = j.construct_path( + self.previous_aligner.working_directory, "ali", "ark", d_id + ) + treeacc_paths[d_id] = j.construct_path(self.working_directory, "tree", "acc", d_id) + arguments.append( + TreeStatsArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"acc_tree.{j.id}.log"), + j.dictionary_ids, + self.worker.context_independent_csl, + alignment_model_path, + feat_strings, + ali_paths, + treeacc_paths, + ) ) - for j in self.jobs - if j.has_data - ] + return arguments def convert_alignments_arguments(self) -> List[ConvertAlignmentsArguments]: """ @@ -240,9 +260,9 @@ def convert_alignments_arguments(self) -> List[ConvertAlignmentsArguments]: """ return [ ConvertAlignmentsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"convert_alignments.{j.name}.log"), + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"convert_alignments.{j.id}.log"), j.dictionary_ids, self.model_path, self.tree_path, @@ -251,7 +271,6 @@ def convert_alignments_arguments(self) -> List[ConvertAlignmentsArguments]: j.construct_path_dictionary(self.working_directory, "ali", "ark"), ) for j in self.jobs - if j.has_data ] def convert_alignments(self) -> None: @@ -272,12 +291,10 @@ def convert_alignments(self) -> None: Reference Kaldi script """ - self.log_info("Converting alignments...") + logger.info("Converting alignments...") arguments = self.convert_alignments_arguments() - with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -377,7 +394,7 @@ def tree_stats(self) -> None: """ jobs = self.tree_stats_arguments() - if self.use_mp: + if GLOBAL_CONFIG.use_mp: run_mp(tree_stats_func, jobs, self.working_log_directory) else: run_non_mp(tree_stats_func, jobs, self.working_log_directory) @@ -395,7 +412,7 @@ def tree_stats(self) -> None: + tree_accs, stderr=log_file, ) - if not self.debug: + if not GLOBAL_CONFIG.debug: for f in tree_accs: os.remove(f) diff --git a/montreal_forced_aligner/alignment/adapting.py b/montreal_forced_aligner/alignment/adapting.py index 4e7f1f3e..1f7b18b6 100644 --- a/montreal_forced_aligner/alignment/adapting.py +++ b/montreal_forced_aligner/alignment/adapting.py @@ -1,6 +1,7 @@ """Class definitions for adapting acoustic models""" from __future__ import annotations +import logging import multiprocessing as mp import os import shutil @@ -14,6 +15,9 @@ from montreal_forced_aligner.abc import AdapterMixin from montreal_forced_aligner.alignment.multiprocessing import AccStatsArguments, AccStatsFunction from montreal_forced_aligner.alignment.pretrained import PretrainedAligner +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.data import WorkflowType +from montreal_forced_aligner.db import CorpusWorkflow from montreal_forced_aligner.exceptions import KaldiProcessingError from montreal_forced_aligner.helper import mfa_open from montreal_forced_aligner.models import AcousticModel @@ -30,6 +34,8 @@ __all__ = ["AdaptingAligner"] +logger = logging.getLogger("mfa") + class AdaptingAligner(PretrainedAligner, AdapterMixin): """ @@ -70,25 +76,35 @@ def map_acc_stats_arguments(self, alignment=False) -> List[AccStatsArguments]: list[:class:`~montreal_forced_aligner.alignment.multiprocessing.AccStatsArguments`] Arguments for processing """ - feat_strings = self.construct_feature_proc_strings() if alignment: model_path = self.alignment_model_path else: model_path = self.model_path - return [ - AccStatsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"map_acc_stats.{j.name}.log"), - j.dictionary_ids, - feat_strings[j.name], - j.construct_path_dictionary(self.working_directory, "ali", "ark"), - j.construct_path_dictionary(self.working_directory, "map", "acc"), - model_path, + arguments = [] + for j in self.jobs: + feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + arguments.append( + AccStatsArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"map_acc_stats.{j.id}.log"), + j.dictionary_ids, + feat_strings, + j.construct_path_dictionary(self.working_directory, "ali", "ark"), + j.construct_path_dictionary(self.working_directory, "map", "acc"), + model_path, + ) ) - for j in self.jobs - if j.has_data - ] + return arguments def acc_stats(self, alignment: bool = False) -> None: """ @@ -106,13 +122,9 @@ def acc_stats(self, alignment: bool = False) -> None: else: initial_mdl_path = os.path.join(self.working_directory, "unadapted.mdl") final_mdl_path = os.path.join(self.working_directory, "final.mdl") - if not os.path.exists(initial_mdl_path): - return - self.log_info("Accumulating statistics...") - with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: + logger.info("Accumulating statistics...") + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -191,23 +203,11 @@ def acc_stats(self, alignment: bool = False) -> None: ) est_proc.communicate() - @property - def workflow_identifier(self) -> str: - """Adaptation identifier""" - return "adapt_acoustic_model" - @property def align_directory(self) -> str: """Align directory""" return os.path.join(self.output_directory, "adapted_align") - @property - def working_directory(self) -> str: - """Current working directory""" - if self.adaptation_done: - return self.align_directory - return self.workflow_directory - @property def working_log_directory(self) -> str: """Current log directory""" @@ -216,16 +216,16 @@ def working_log_directory(self) -> str: @property def model_path(self) -> str: """Current acoustic model path""" - if not self.adaptation_done: + if self.current_workflow.workflow_type == WorkflowType.acoustic_model_adaptation: return os.path.join(self.working_directory, "unadapted.mdl") return os.path.join(self.working_directory, "final.mdl") @property def alignment_model_path(self) -> str: """Current acoustic model path""" - if not self.adaptation_done: + if self.current_workflow.workflow_type == WorkflowType.acoustic_model_adaptation: path = os.path.join(self.working_directory, "unadapted.alimdl") - if os.path.exists(path) and getattr(self, "speaker_independent", True): + if os.path.exists(path) and not getattr(self, "uses_speaker_adaptation", False): return path return self.model_path return super().alignment_model_path @@ -264,26 +264,43 @@ def train_map(self) -> None: if self.uses_speaker_adaptation: self.acc_stats(alignment=True) - self.log_debug(f"Mapping models took {time.time() - begin}") + logger.debug(f"Mapping models took {time.time() - begin:.3f} seconds") def adapt(self) -> None: """Run the adaptation""" - self.setup() - dirty_path = os.path.join(self.working_directory, "dirty") - done_path = os.path.join(self.working_directory, "done") - if os.path.exists(done_path): - self.log_info("Adaptation already done, skipping.") - return - self.log_info("Generating initial alignments...") - for f in ["final.mdl", "final.alimdl"]: - p = os.path.join(self.working_directory, f) - if not os.path.exists(p): - continue - os.rename(p, os.path.join(self.working_directory, f.replace("final", "unadapted"))) + logger.info("Generating initial alignments...") self.align() + alignment_workflow = self.current_workflow + self.create_new_current_workflow(WorkflowType.acoustic_model_adaptation) + for f in ["final.mdl", "final.alimdl"]: + shutil.copyfile( + os.path.join(alignment_workflow.working_directory, f), + os.path.join(self.working_directory, f.replace("final", "unadapted")), + ) + shutil.copyfile( + os.path.join(alignment_workflow.working_directory, "tree"), + os.path.join(self.working_directory, "tree"), + ) + shutil.copyfile( + os.path.join(alignment_workflow.working_directory, "lda.mat"), + os.path.join(self.working_directory, "lda.mat"), + ) + for j in self.jobs: + old_paths = j.construct_path_dictionary( + alignment_workflow.working_directory, "ali", "ark" + ) + new_paths = j.construct_path_dictionary(self.working_directory, "ali", "ark") + for k, v in old_paths.items(): + shutil.copyfile(v, new_paths[k]) + old_paths = j.construct_path_dictionary( + alignment_workflow.working_directory, "trans", "ark" + ) + new_paths = j.construct_path_dictionary(self.working_directory, "trans", "ark") + for k, v in old_paths.items(): + shutil.copyfile(v, new_paths[k]) os.makedirs(self.align_directory, exist_ok=True) try: - self.log_info("Adapting pretrained model...") + logger.info("Adapting pretrained model...") self.train_map() self.export_model(os.path.join(self.working_log_directory, "acoustic_model.zip")) shutil.copyfile( @@ -308,19 +325,23 @@ def adapt(self) -> None: os.path.join(self.working_directory, "lda.mat"), os.path.join(self.align_directory, "lda.mat"), ) - self.adaptation_done = True + wf = self.current_workflow + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update( + {"done": True} + ) + session.commit() except Exception as e: - with mfa_open(dirty_path, "w"): - pass + wf = self.current_workflow + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update( + {"dirty": True} + ) + session.commit() if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise - with mfa_open(done_path, "w"): - pass @property def meta(self) -> MetaDict: diff --git a/montreal_forced_aligner/alignment/base.py b/montreal_forced_aligner/alignment/base.py index af4fb5ca..468e4073 100644 --- a/montreal_forced_aligner/alignment/base.py +++ b/montreal_forced_aligner/alignment/base.py @@ -4,15 +4,19 @@ import collections import csv import functools +import io +import logging import multiprocessing as mp import os +import subprocess import time +import typing from queue import Empty from typing import Dict, List, Optional import sqlalchemy import tqdm -from sqlalchemy.orm import joinedload, selectinload, subqueryload +from sqlalchemy.orm import joinedload, subqueryload from montreal_forced_aligner.abc import FileExporterMixin from montreal_forced_aligner.alignment.mixins import AlignMixin @@ -21,19 +25,30 @@ AlignmentExtractionFunction, ExportTextGridArguments, ExportTextGridProcessWorker, + FineTuneArguments, + FineTuneFunction, GeneratePronunciationsArguments, GeneratePronunciationsFunction, construct_output_path, + construct_output_tiers, ) +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpusPronunciationMixin -from montreal_forced_aligner.data import CtmInterval, PronunciationProbabilityCounter, TextFileType +from montreal_forced_aligner.data import ( + CtmInterval, + PronunciationProbabilityCounter, + TextFileType, + WorkflowType, +) from montreal_forced_aligner.db import ( Corpus, - DictBundle, + CorpusWorkflow, Dictionary, File, PhoneInterval, + PhonologicalRule, Pronunciation, + RuleApplication, SoundFile, Speaker, TextFile, @@ -41,15 +56,38 @@ Word, WordInterval, WordType, + bulk_update, +) +from montreal_forced_aligner.exceptions import ( + AlignerError, + AlignmentExportError, + KaldiProcessingError, +) +from montreal_forced_aligner.helper import ( + align_phones, + format_correction, + format_probability, + mfa_open, ) -from montreal_forced_aligner.exceptions import AlignmentExportError -from montreal_forced_aligner.helper import align_phones, mfa_open from montreal_forced_aligner.textgrid import export_textgrid, output_textgrid_writing_errors -from montreal_forced_aligner.utils import Counter, KaldiProcessWorker, Stopped +from montreal_forced_aligner.utils import ( + Counter, + KaldiProcessWorker, + Stopped, + log_kaldi_errors, + run_kaldi_function, + thirdparty_binary, +) + +if typing.TYPE_CHECKING: + from montreal_forced_aligner.abc import MetaDict __all__ = ["CorpusAligner"] +logger = logging.getLogger("mfa") + + class CorpusAligner(AcousticCorpusPronunciationMixin, AlignMixin, FileExporterMixin): """ Mixin class that aligns corpora with pronunciation dictionaries @@ -64,9 +102,49 @@ class CorpusAligner(AcousticCorpusPronunciationMixin, AlignMixin, FileExporterMi For file exporting parameters """ - def __init__(self, **kwargs): + def __init__(self, max_active: int = 2500, lattice_beam: int = 6, **kwargs): super().__init__(**kwargs) self.export_output_directory = None + self.max_active = max_active + self.lattice_beam = lattice_beam + self.phone_lm_order = 2 + self.phone_lm_method = "unsmoothed" + + @property + def hclg_options(self) -> MetaDict: + """Options for constructing HCLG FSTs""" + context_width, central_pos = self.get_tree_info() + return { + "context_width": context_width, + "central_pos": central_pos, + "self_loop_scale": self.self_loop_scale, + "transition_scale": self.transition_scale, + } + + @property + def decode_options(self) -> MetaDict: + """Options needed for decoding""" + return { + "first_beam": getattr(self, "first_beam", 10), + "beam": self.beam, + "first_max_active": getattr(self, "first_max_active", 2000), + "max_active": self.max_active, + "lattice_beam": self.lattice_beam, + "acoustic_scale": self.acoustic_scale, + "transition_scale": self.transition_scale, + "self_loop_scale": self.self_loop_scale, + "uses_speaker_adaptation": self.uses_speaker_adaptation, + } + + @property + def score_options(self) -> MetaDict: + """Options needed for scoring lattices""" + return { + "frame_shift": round(self.frame_shift / 1000, 3), + "acoustic_scale": self.acoustic_scale, + "language_model_weight": getattr(self, "language_model_weight", 10), + "word_insertion_penalty": getattr(self, "word_insertion_penalty", 0.5), + } def alignment_extraction_arguments(self) -> List[AlignmentExtractionArguments]: """ @@ -79,19 +157,26 @@ def alignment_extraction_arguments(self) -> List[AlignmentExtractionArguments]: Arguments for processing """ arguments = [] + workflow = self.current_workflow + from_transcription = False + if workflow.workflow_type in ( + WorkflowType.per_speaker_transcription, + WorkflowType.transcription, + WorkflowType.phone_transcription, + ): + from_transcription = True for j in self.jobs: - if not j.has_data: - continue arguments.append( AlignmentExtractionArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"get_phone_ctm.{j.name}.log"), + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"get_phone_ctm.{j.id}.log"), self.alignment_model_path, round(self.frame_shift / 1000, 4), - self.cleanup_textgrids, - j.construct_path_dictionary(self.working_directory, "ali", "ark"), - j.construct_path_dictionary(self.data_directory, "text", "int.scp"), + self.phone_symbol_table_path, + self.score_options, + self.phone_confidence, + from_transcription, ) ) @@ -115,16 +200,17 @@ def export_textgrid_arguments( """ return [ ExportTextGridArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"export_textgrids.{j.name}.log"), - self.frame_shift, + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"export_textgrids.{j.id}.log"), + self.export_frame_shift, + GLOBAL_CONFIG.cleanup_textgrids, + self.clitic_marker, self.export_output_directory, output_format, include_original_text, ) for j in self.jobs - if j.has_data ] def generate_pronunciations_arguments( @@ -141,19 +227,80 @@ def generate_pronunciations_arguments( return [ GeneratePronunciationsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"generate_pronunciations.{j.name}.log"), - j.construct_path_dictionary(self.data_directory, "text", "int.scp"), - j.construct_path_dictionary(self.working_directory, "ali", "ark"), + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"generate_pronunciations.{j.id}.log"), self.model_path, False, ) for j in self.jobs - if j.has_data ] - def compute_pronunciation_probabilities(self, compute_silence_probabilities=True): + def align(self, workflow_name=None) -> None: + """Run the aligner""" + self.initialize_database() + self.create_new_current_workflow(WorkflowType.alignment, workflow_name) + wf = self.current_workflow + if wf.done: + logger.info("Alignment already done, skipping.") + return + begin = time.time() + acoustic_model = getattr(self, "acoustic_model", None) + if acoustic_model is not None: + acoustic_model.export_model(self.working_directory) + try: + self.compile_train_graphs() + + logger.info("Performing first-pass alignment...") + self.uses_speaker_adaptation = False + for j in self.jobs: + paths = j.construct_dictionary_dependent_paths( + self.working_directory, "trans", "ark" + ) + for p in paths.values(): + if os.path.exists(p): + os.remove(p) + self.align_utterances() + if ( + acoustic_model is not None + and acoustic_model.meta["features"]["uses_speaker_adaptation"] + ): + if self.alignment_model_path.endswith(".mdl"): + if os.path.exists(self.alignment_model_path.replace(".mdl", ".alimdl")): + raise AlignerError( + "Not using speaker independent model when it is available" + ) + self.calc_fmllr() + + self.uses_speaker_adaptation = True + assert self.alignment_model_path.endswith(".mdl") + logger.info("Performing second-pass alignment...") + self.align_utterances() + self.collect_alignments() + if self.use_phone_model: + self.transcribe(WorkflowType.phone_transcription) + elif self.fine_tune: + self.fine_tune_alignments() + + self.compile_information() + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update( + {"done": True} + ) + session.commit() + except Exception as e: + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update( + {"dirty": True} + ) + session.commit() + if isinstance(e, KaldiProcessingError): + log_kaldi_errors(e.error_logs) + e.update_log_file() + raise + logger.debug(f"Generated alignments in {time.time() - begin:.3f} seconds") + + def compute_pronunciation_probabilities(self): """ Multiprocessing function that computes pronunciation probabilities from alignments @@ -161,6 +308,7 @@ def compute_pronunciation_probabilities(self, compute_silence_probabilities=True ---------- compute_silence_probabilities: bool Flag for whether to compute silence probabilities for pronunciations, defaults to True + See Also -------- :class:`~montreal_forced_aligner.alignment.multiprocessing.GeneratePronunciationsFunction` @@ -173,28 +321,15 @@ def compute_pronunciation_probabilities(self, compute_silence_probabilities=True Reference Kaldi script """ - def format_probability(probability_value: float) -> float: - """Format a probability to have two decimal places and be between 0.01 and 0.99""" - return min(max(round(probability_value, 2), 0.01), 0.99) - - def format_correction(correction_value: float) -> float: - """Format a probability correction value to have two decimal places and be greater than 0.01""" - correction_value = round(correction_value, 2) - if correction_value == 0: - correction_value = 0.01 - return correction_value - begin = time.time() dictionary_counters = { dict_id: PronunciationProbabilityCounter() for dict_id in self.dictionary_lookup.values() } - self.log_info("Generating pronunciations...") + logger.info("Generating pronunciations...") arguments = self.generate_pronunciations_arguments() - with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -228,7 +363,7 @@ def format_correction(correction_value: float) -> float: for v in error_dict.values(): raise v else: - self.log_debug("Not using multiprocessing...") + logger.debug("Not using multiprocessing...") for args in arguments: function = GeneratePronunciationsFunction(args) for dict_id, utterance_counter in function.run(): @@ -247,6 +382,8 @@ def format_correction(correction_value: float) -> float: "w", encoding="utf8", ) as log_file, self.session() as session: + session.query(Pronunciation).update({"count": 0}) + session.commit() dictionaries = session.query(Dictionary.id) dictionary_mappings = [] for (d_id,) in dictionaries: @@ -256,14 +393,26 @@ def format_correction(correction_value: float) -> float: session.query(Word.word) .filter(Word.dictionary_id == d_id) .filter(Word.word_type != WordType.silence) + .filter(Word.count > 0) ) pronunciations = ( - session.query(Word.word, Pronunciation.pronunciation, Pronunciation.id) + session.query( + Word.word, + Pronunciation.pronunciation, + Pronunciation.id, + Pronunciation.base_pronunciation_id, + ) .join(Pronunciation.word) .filter(Word.dictionary_id == d_id) .filter(Word.word_type != WordType.silence) + .filter(Word.count > 0) ) pron_mapping = {} + pronunciations = [ + (w, p, p_id) + for w, p, p_id, b_id in pronunciations + if p_id == b_id or p in counter.word_pronunciation_counts[w] + ] for w, p, p_id in pronunciations: pron_mapping[(w, p)] = {"id": p_id} if w in {initial_key[0], final_key[0], self.silence_word}: @@ -272,19 +421,21 @@ def format_correction(correction_value: float) -> float: for (w,) in words: if w in {initial_key[0], final_key[0], self.silence_word}: continue + if w not in counter.word_pronunciation_counts: + continue pron_counts = counter.word_pronunciation_counts[w] max_value = max(pron_counts.values()) for p, c in pron_counts.items(): + pron_mapping[(w, p)]["count"] = c pron_mapping[(w, p)]["probability"] = format_probability(c / max_value) - if not compute_silence_probabilities: - log_file.write("Skipping silence calculations") - continue silence_count = sum(counter.silence_before_counts.values()) non_silence_count = sum(counter.non_silence_before_counts.values()) log_file.write(f"Total silence count was {silence_count}\n") log_file.write(f"Total non silence count was {non_silence_count}\n") - silence_probability = silence_count / (silence_count + non_silence_count) + silence_probability = format_probability( + silence_count / (silence_count + non_silence_count) + ) silence_prob_sum += silence_probability silence_probabilities = {} for w, p, _ in pronunciations: @@ -293,6 +444,10 @@ def format_correction(correction_value: float) -> float: counter.silence_following_counts[(w, p)] + counter.non_silence_following_counts[(w, p)] ) + pron_mapping[(w, p)]["silence_following_count"] = count + pron_mapping[(w, p)][ + "non_silence_following_count" + ] = counter.non_silence_following_counts[(w, p)] w_p_silence_count = count + (silence_probability * lambda_2) prob = format_probability(w_p_silence_count / (total_count + lambda_2)) silence_probabilities[(w, p)] = prob @@ -355,20 +510,165 @@ def format_correction(correction_value: float) -> float: "final_non_silence_correction": final_non_silence_correction, } ) - if compute_silence_probabilities: - self.silence_probability = silence_prob_sum / self.num_dictionaries - self.initial_silence_probability = initial_silence_prob_sum / self.num_dictionaries - self.final_silence_correction = ( - final_silence_correction_sum / self.num_dictionaries - ) - self.final_non_silence_correction = ( - final_non_silence_correction_sum / self.num_dictionaries - ) - session.bulk_update_mappings(Dictionary, dictionary_mappings) + self.silence_probability = format_probability(silence_prob_sum / self.num_dictionaries) + self.initial_silence_probability = format_probability( + initial_silence_prob_sum / self.num_dictionaries + ) + self.final_silence_correction = format_probability( + final_silence_correction_sum / self.num_dictionaries + ) + self.final_non_silence_correction = ( + final_non_silence_correction_sum / self.num_dictionaries + ) + bulk_update(session, Dictionary, dictionary_mappings) session.commit() - self.log_debug(f"Calculating pronunciation probabilities took {time.time() - begin}") + rules: List[PhonologicalRule] = ( + session.query(PhonologicalRule) + .options( + subqueryload(PhonologicalRule.pronunciations).joinedload( + RuleApplication.pronunciation, innerjoin=True + ) + ) + .all() + ) + if rules: + rules_for_deletion = [] + for r in rules: + base_count = 0 + base_sil_after_count = 0 + base_nonsil_after_count = 0 + + rule_count = 0 + rule_sil_before_correction = 0 + base_sil_before_correction = 0 + rule_nonsil_before_correction = 0 + base_nonsil_before_correction = 0 + rule_sil_after_count = 0 + rule_nonsil_after_count = 0 + rule_correction_count = 0 + base_correction_count = 0 + non_application_query = session.query(Pronunciation).filter( + Pronunciation.pronunciation.regexp_match( + r.match_regex.pattern.replace("?P", "") + .replace("?P", "") + .replace("?P", "") + ), + Pronunciation.count > 1, + ) + for p in non_application_query: + base_count += p.count + + if p.silence_before_correction: + base_sil_before_correction += p.silence_before_correction + base_nonsil_before_correction += p.non_silence_before_correction + base_correction_count += 1 + + base_sil_after_count += ( + p.silence_following_count if p.silence_following_count else 0 + ) + base_nonsil_after_count += ( + p.non_silence_following_count if p.non_silence_following_count else 0 + ) + + for p in r.pronunciations: + p = p.pronunciation + rule_count += p.count - def _collect_alignments(self) -> None: + if p.silence_before_correction: + rule_sil_before_correction += p.silence_before_correction + rule_nonsil_before_correction += p.non_silence_before_correction + rule_correction_count += 1 + + rule_sil_after_count += ( + p.silence_following_count if p.silence_following_count else 0 + ) + rule_nonsil_after_count += ( + p.non_silence_following_count if p.non_silence_following_count else 0 + ) + if not rule_count: + rules_for_deletion.append(r) + continue + r.probability = format_probability(rule_count / (rule_count + base_count)) + if rule_correction_count: + rule_sil_before_correction = ( + rule_sil_before_correction / rule_correction_count + ) + rule_nonsil_before_correction = ( + rule_nonsil_before_correction / rule_correction_count + ) + if base_correction_count: + base_sil_before_correction = ( + base_sil_before_correction / base_correction_count + ) + base_nonsil_before_correction = ( + base_nonsil_before_correction / base_correction_count + ) + else: + base_sil_before_correction = 1.0 + base_nonsil_before_correction = 1.0 + r.silence_before_correction = format_correction( + rule_sil_before_correction - base_sil_before_correction, + positive_only=False, + ) + r.non_silence_before_correction = format_correction( + rule_nonsil_before_correction - base_nonsil_before_correction, + positive_only=False, + ) + + silence_after_probability = format_probability( + (rule_sil_after_count + lambda_2) + / (rule_sil_after_count + rule_nonsil_after_count + lambda_2) + ) + base_sil_after_probability = format_probability( + (base_sil_after_count + lambda_2) + / (base_sil_after_count + base_nonsil_after_count + lambda_2) + ) + r.silence_after_probability = format_correction( + silence_after_probability / base_sil_after_probability + ) + previous_pronunciation_counts = { + k: v + for k, v in session.query( + Dictionary.name, sqlalchemy.func.count(Pronunciation.id) + ) + .join(Pronunciation.word) + .join(Word.dictionary) + .group_by(Dictionary.name) + } + for r in rules_for_deletion: + logger.debug(f"Removing {r} for zero counts.") + session.query(RuleApplication).filter( + RuleApplication.rule_id.in_([r.id for r in rules_for_deletion]) + ).delete() + session.flush() + session.query(PhonologicalRule).filter( + PhonologicalRule.id.in_([r.id for r in rules_for_deletion]) + ).delete() + session.flush() + session.query(Pronunciation).filter( + Pronunciation.id != Pronunciation.base_pronunciation_id, + Pronunciation.count == 0, + ).delete() + session.commit() + pronunciation_counts = { + k: v + for k, v in session.query( + Dictionary.name, sqlalchemy.func.count(Pronunciation.id) + ) + .join(Pronunciation.word) + .join(Word.dictionary) + .group_by(Dictionary.name) + } + for d_name, c in pronunciation_counts.items(): + prev_c = previous_pronunciation_counts[d_name] + logger.debug( + f"{d_name}: Reduced number of pronunciations from {prev_c} to {c}" + ) + logger.debug( + f"Calculating pronunciation probabilities took {time.time() - begin:.3f} seconds" + ) + + def collect_alignments(self) -> None: """ Process alignment archives to extract word or phone alignments @@ -376,25 +676,240 @@ def _collect_alignments(self) -> None: -------- :class:`~montreal_forced_aligner.alignment.multiprocessing.AlignmentExtractionFunction` Multiprocessing function for extracting alignments - :meth:`.CorpusAligner.word_alignment_arguments` - Arguments for word CTMs - :meth:`.CorpusAligner.phone_alignment_arguments` - Arguments for phone alignment + :meth:`.CorpusAligner.alignment_extraction_arguments` + Arguments for extraction + """ + indices = [ + ("word_utterance_workflow_index", "word_interval", ["utterance_id", "workflow_id"]), + ("phone_utterance_workflow_index", "phone_interval", ["utterance_id", "workflow_id"]), + ("ix_word_interval_workflow_id", "word_interval", ["workflow_id"]), + ("ix_word_interval_word_id", "word_interval", ["word_id"]), + ("ix_word_interval_utterance_id", "word_interval", ["utterance_id"]), + ("ix_word_interval_pronunciation_id", "word_interval", ["pronunciation_id"]), + ("ix_word_interval_begin", "word_interval", ["begin"]), + ("ix_phone_interval_workflow_id", "phone_interval", ["workflow_id"]), + ("ix_phone_interval_word_interval_id", "phone_interval", ["word_interval_id"]), + ("ix_phone_interval_utterance_id", "phone_interval", ["utterance_id"]), + ("ix_phone_interval_phone_id", "phone_interval", ["phone_id"]), + ("ix_phone_interval_begin", "phone_interval", ["begin"]), + ] + with self.session() as session: + session.execute(sqlalchemy.text("ALTER TABLE word_interval DISABLE TRIGGER all")) + session.execute(sqlalchemy.text("ALTER TABLE phone_interval DISABLE TRIGGER all")) + session.commit() + for ix in indices: + try: + session.execute(sqlalchemy.text(f"DROP INDEX {ix[0]}")) + except Exception: + pass + session.commit() + with self.session() as session: + workflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + if workflow.alignments_collected: + return + max_phone_interval_id = session.query(sqlalchemy.func.max(PhoneInterval.id)).scalar() + if max_phone_interval_id is None: + max_phone_interval_id = 0 + max_word_interval_id = session.query(sqlalchemy.func.max(WordInterval.id)).scalar() + 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: + logger.info(f"Collecting phone and word alignments from {workflow.name} lattices...") + + arguments = self.alignment_extraction_arguments() + has_words = False + phone_interval_count = 0 + word_buf = io.StringIO() + word_writer = csv.DictWriter( + word_buf, + [ + "id", + "begin", + "end", + "utterance_id", + "word_id", + "pronunciation_id", + "workflow_id", + ], + ) + phone_buf = io.StringIO() + phone_writer = csv.DictWriter( + phone_buf, + [ + "id", + "begin", + "end", + "phone_goodness", + "phone_id", + "word_interval_id", + "utterance_id", + "workflow_id", + ], + ) + conn = self.db_engine.raw_connection() + cursor = conn.cursor() + new_words = [] + word_index = self.get_next_primary_key(Word) + mapping_id = session.query(sqlalchemy.func.max(Word.mapping_id)).scalar() + if mapping_id is None: + mapping_id = -1 + mapping_id += 1 + for ( + utterance, + word_intervals, + phone_intervals, + phone_word_mapping, + ) in run_kaldi_function(AlignmentExtractionFunction, arguments, pbar.update): + new_phone_interval_mappings = [] + new_word_interval_mappings = [] + for interval in phone_intervals: + max_phone_interval_id += 1 + new_phone_interval_mappings.append( + { + "id": max_phone_interval_id, + "begin": interval.begin, + "end": interval.end, + "phone_id": interval.label, + "utterance_id": utterance, + "workflow_id": workflow.id, + "phone_goodness": interval.confidence if interval.confidence else 0.0, + } + ) + for interval in word_intervals: + word_id = interval.word_id + if isinstance(word_id, str): + new_words.append( + { + "id": word_index, + "mapping_id": mapping_id, + "word": word_id, + "dictionary_id": 1, + "word_type": WordType.oov, + } + ) + word_id = word_index + word_index += 1 + mapping_id += 1 + max_word_interval_id += 1 + new_word_interval_mappings.append( + { + "id": max_word_interval_id, + "begin": interval.begin, + "end": interval.end, + "word_id": word_id, + "pronunciation_id": interval.pronunciation_id, + "utterance_id": utterance, + "workflow_id": workflow.id, + } + ) + for i, index in enumerate(phone_word_mapping): + new_phone_interval_mappings[i][ + "word_interval_id" + ] = new_word_interval_mappings[index]["id"] + phone_writer.writerows(new_phone_interval_mappings) + word_writer.writerows(new_word_interval_mappings) + if new_word_interval_mappings: + has_words = True + if phone_interval_count > 1000000: + if has_words: + word_buf.seek(0) + cursor.copy_from(word_buf, WordInterval.__tablename__, sep=",", null="") + word_buf.truncate(0) + word_buf.seek(0) + + phone_buf.seek(0) + cursor.copy_from(phone_buf, PhoneInterval.__tablename__, sep=",", null="") + phone_buf.truncate(0) + phone_buf.seek(0) + + if word_buf.tell() != 0: + word_buf.seek(0) + cursor.copy_from(word_buf, WordInterval.__tablename__, sep=",", null="") + word_buf.truncate(0) + word_buf.seek(0) + + if phone_buf.tell() != 0: + phone_buf.seek(0) + cursor.copy_from(phone_buf, PhoneInterval.__tablename__, sep=",", null="") + phone_buf.truncate(0) + phone_buf.seek(0) + conn.commit() + conn.close() + logger.info("Refreshing indices...") + with tqdm.tqdm( + total=len(indices), disable=GLOBAL_CONFIG.quiet + ) as pbar, self.session() as session: + if new_words: + session.execute(sqlalchemy.insert(Word).values(new_words)) + for ix in indices: + session.execute( + sqlalchemy.text(f'CREATE INDEX {ix[0]} ON {ix[1]} ({", ".join(ix[2])})') + ) + session.commit() + pbar.update(1) + session.execute(sqlalchemy.text("ALTER TABLE word_interval ENABLE TRIGGER all")) + session.execute(sqlalchemy.text("ALTER TABLE phone_interval ENABLE TRIGGER all")) + session.commit() + + with self.session() as session: + workflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + workflow.alignments_collected = True + if ( + workflow.workflow_type is WorkflowType.transcription + or workflow.workflow_type is WorkflowType.per_speaker_transcription + ): + query = ( + session.query(Utterance) + .options(subqueryload(Utterance.word_intervals).joinedload(WordInterval.word)) + .group_by(Utterance.id) + ) + mapping = [] + for u in query: + text = [ + x.word.word + for x in u.word_intervals + if x.word.word != self.silence_word and x.workflow_id == workflow.id + ] + mapping.append({"id": u.id, "transcription_text": " ".join(text)}) + bulk_update(session, Utterance, mapping) + session.query(CorpusWorkflow).filter(CorpusWorkflow.current == True).update( # noqa + {CorpusWorkflow.alignments_collected: True} + ) + session.commit() + + def fine_tune_alignments(self) -> None: """ - self.log_info("Collecting phone and word alignments from alignment lattices...") - jobs = self.alignment_extraction_arguments() # Phone CTM jobs + Fine tune aligned boundaries to millisecond precision + + Parameters + ---------- + workflow: :class:`~montreal_forced_aligner.data.WorkflowType` + Workflow type to fine tune + + """ + logger.info("Fine tuning alignments...") + begin = time.time() with self.session() as session, tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) + total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar: - phone_interval_mappings = [] - word_interval_mappings = [] - if self.use_mp: + update_mappings = [] + arguments = self.fine_tune_arguments() + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() procs = [] - for i, args in enumerate(jobs): - function = AlignmentExtractionFunction(args) + for i, args in enumerate(arguments): + function = FineTuneFunction(args) p = KaldiProcessWorker(i, return_queue, function, stopped) procs.append(p) p.start() @@ -413,79 +928,114 @@ def _collect_alignments(self) -> None: else: break continue - utterance, word_intervals, phone_intervals = result - for interval in phone_intervals: - phone_interval_mappings.append( - { - "begin": interval.begin, - "end": interval.end, - "label": interval.label, - "utterance_id": utterance, - } - ) - for interval in word_intervals: - word_interval_mappings.append( - { - "begin": interval.begin, - "end": interval.end, - "label": interval.label, - "utterance_id": utterance, - } - ) + update_mappings.extend(result[0]) + update_mappings.extend( + [{"id": x, "begin": 0, "end": 0, "label": ""} for x in result[1]] + ) pbar.update(1) for p in procs: p.join() + if error_dict: for v in error_dict.values(): raise v - else: - for args in jobs: - function = AlignmentExtractionFunction(args) - for utterance, word_intervals, phone_intervals in function.run(): - - for interval in phone_intervals: - phone_interval_mappings.append( - { - "begin": interval.begin, - "end": interval.end, - "label": interval.label, - "utterance_id": utterance, - } - ) - for interval in word_intervals: - word_interval_mappings.append( - { - "begin": interval.begin, - "end": interval.end, - "label": interval.label, - "utterance_id": utterance, - } - ) + else: + logger.debug("Not using multiprocessing...") + for args in arguments: + function = FineTuneFunction(args) + for result in function.run(): + update_mappings.extend(result[0]) + update_mappings.extend( + [{"id": x, "begin": 0, "end": 0, "label": ""} for x in result[1]] + ) pbar.update(1) - self.alignment_done = True - session.query(Corpus).update({"alignment_done": True}) - session.bulk_insert_mappings( - PhoneInterval, phone_interval_mappings, return_defaults=False, render_nulls=True - ) - session.bulk_insert_mappings( - WordInterval, word_interval_mappings, return_defaults=False, render_nulls=True + bulk_update(session, PhoneInterval, update_mappings) + session.flush() + session.execute(PhoneInterval.__table__.delete().where(PhoneInterval.end == 0)) + session.flush() + word_update_mappings = [] + word_intervals = ( + session.query( + WordInterval.id, + sqlalchemy.func.min(PhoneInterval.begin), + sqlalchemy.func.max(PhoneInterval.end), + ) + .join(PhoneInterval.word_interval) + .group_by(WordInterval.id) ) + for wi_id, begin, end in word_intervals: + word_update_mappings.append({"id": wi_id, "begin": begin, "end": end}) + bulk_update(session, WordInterval, word_update_mappings) session.commit() + self.export_frame_shift = round(self.export_frame_shift / 10, 4) + logger.debug(f"Fine tuning alignments took {time.time() - begin:.3f} seconds") - def collect_alignments(self) -> None: + def fine_tune_arguments(self) -> List[FineTuneArguments]: """ - Collect word and phone alignments from alignment archives + Generate Job arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.FineTuneFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.alignment.multiprocessing.FineTuneArguments`] + Arguments for processing """ - if self.alignment_done: - if self.export_output_directory is not None: - self.export_textgrids() - return - self._collect_alignments() + args = [] + for j in self.jobs: + log_path = os.path.join(self.working_log_directory, f"fine_tune.{j.id}.log") + args.append( + FineTuneArguments( + j.id, + getattr(self, "db_string", ""), + log_path, + self.phone_symbol_table_path, + self.disambiguation_symbols_int_path, + self.tree_path, + self.model_path, + self.frame_shift, + self.mfcc_options, + self.pitch_options, + self.lda_options, + self.align_options, + self.position_dependent_phones, + self.kaldi_grouped_phones, + ) + ) + return args + + def get_tree_info(self) -> typing.Tuple[int, int]: + """ + Get the context width and central position for the acoustic model + + Returns + ------- + int + Context width + int + Central position + """ + tree_proc = subprocess.Popen( + [thirdparty_binary("tree-info"), self.tree_path], + encoding="utf8", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, _ = tree_proc.communicate() + context_width = 1 + central_pos = 0 + for line in stdout.split("\n"): + text = line.strip().split(" ") + if text[0] == "context-width": + context_width = int(text[1]) + elif text[0] == "central-position": + central_pos = int(text[1]) + return context_width, central_pos def export_textgrids( - self, output_format: str = TextFileType.TEXTGRID.value, include_original_text: bool = False + self, + output_format: str = TextFileType.TEXTGRID.value, + include_original_text: bool = False, ) -> None: """ Exports alignments to TextGrid files @@ -502,13 +1052,13 @@ def export_textgrids( output_format: str, optional Format to save alignments, one of 'long_textgrids' (the default), 'short_textgrids', or 'json', passed to praatio """ - if not self.alignment_done: - self._collect_alignments() + workflow = self.current_workflow + if not workflow.alignments_collected: + self.collect_alignments() begin = time.time() error_dict = {} - with tqdm.tqdm(total=self.num_files, disable=getattr(self, "quiet", False)) as pbar: - + with tqdm.tqdm(total=self.num_files, disable=GLOBAL_CONFIG.quiet) as pbar: with self.session() as session: files = ( session.query( @@ -521,7 +1071,7 @@ def export_textgrids( .join(File.sound_file) .join(File.text_file) ) - if self.use_mp and self.num_jobs > 1: + if GLOBAL_CONFIG.use_mp and GLOBAL_CONFIG.num_jobs > 1: stopped = Stopped() finished_adding = Stopped() @@ -533,16 +1083,14 @@ def export_textgrids( exported_file_count = Counter() export_procs = [] self.db_engine.dispose() - for j in self.jobs: - if not j.has_data: - continue + for j in range(len(self.jobs)): export_proc = ExportTextGridProcessWorker( - self.db_path, + self.db_string, for_write_queue, return_queue, stopped, finished_adding, - export_args[j.name], + export_args[j], exported_file_count, ) export_proc.start() @@ -573,11 +1121,10 @@ def export_textgrids( stopped.stop() raise finally: - - for i in range(self.num_jobs): + for i in range(len(self.jobs)): export_procs[i].join() else: - self.log_debug("Not using multiprocessing for TextGrid export") + logger.debug("Not using multiprocessing for TextGrid export") for file_id, name, relative_path, duration, text_file_path in files: output_path = construct_output_path( @@ -587,48 +1134,33 @@ def export_textgrids( text_file_path, output_format, ) - utterances = ( - session.query(Utterance) - .options( - joinedload(Utterance.speaker, innerjoin=True).load_only( - Speaker.name - ), - selectinload(Utterance.phone_intervals), - selectinload(Utterance.word_intervals), - ) - .filter(Utterance.file_id == file_id) + + data = construct_output_tiers( + session, + file_id, + workflow, + GLOBAL_CONFIG.cleanup_textgrids, + self.clitic_marker, + include_original_text, ) - data = {} - for utt in utterances: - if utt.speaker.name not in data: - data[utt.speaker.name] = {"words": [], "phones": []} - for wi in utt.word_intervals: - data[utt.speaker.name]["words"].append( - CtmInterval(wi.begin, wi.end, wi.label, utt.id) - ) - - for pi in utt.phone_intervals: - data[utt.speaker.name]["phones"].append( - CtmInterval(pi.begin, pi.end, pi.label, utt.id) - ) export_textgrid( data, output_path, duration, - self.frame_shift, + self.export_frame_shift, output_format=output_format, ) pbar.update(1) if error_dict: - self.log_warning( + logger.warning( f"There were {len(error_dict)} errors encountered in generating TextGrids. " f"Check {os.path.join(self.export_output_directory, 'output_errors.txt')} " f"for more details" ) output_textgrid_writing_errors(self.export_output_directory, error_dict) - self.log_info(f"Finished exporting TextGrids to {self.export_output_directory}!") - self.log_debug(f"Exported TextGrids in a total of {time.time() - begin} seconds") + logger.info(f"Finished exporting TextGrids to {self.export_output_directory}!") + logger.debug(f"Exported TextGrids in a total of {time.time() - begin:.3f} seconds") def export_files( self, @@ -645,12 +1177,18 @@ def export_files( Directory to save to output_format: str, optional Format to save alignments, one of 'long_textgrids' (the default), 'short_textgrids', or 'json', passed to praatio + include_original_text: bool + Flag for including the original text of the corpus files as a tier + workflow: :class:`~montreal_forced_aligner.data.WorkflowType` + Workflow to use when exporting files """ if output_format is None: output_format = TextFileType.TEXTGRID.value self.export_output_directory = output_directory - self.log_info(f"Exporting TextGrids to {self.export_output_directory}...") + logger.info( + f"Exporting {self.current_workflow.name} TextGrids to {self.export_output_directory}..." + ) os.makedirs(self.export_output_directory, exist_ok=True) self.export_textgrids(output_format, include_original_text) @@ -658,6 +1196,8 @@ def evaluate_alignments( self, mapping: Optional[Dict[str, str]] = None, output_directory: Optional[str] = None, + comparison_source=WorkflowType.alignment, + reference_source=WorkflowType.reference, ) -> None: """ Evaluate alignments against a reference directory @@ -668,12 +1208,26 @@ def evaluate_alignments( Mapping between phones that should be considered equal across different phone set types output_directory: str, optional Directory to save results, if not specified, it will be saved in the log directory + comparison_source: :class:`~montreal_forced_aligner.data.WorkflowType` + Workflow to compare to the reference intervals, defaults to :attr:`~montreal_forced_aligner.data.WorkflowType.alignment` + comparison_source: :class:`~montreal_forced_aligner.data.WorkflowType` + Workflow to use as the reference intervals, defaults to :attr:`~montreal_forced_aligner.data.WorkflowType.reference` """ + from montreal_forced_aligner.config import GLOBAL_CONFIG + begin = time.time() if output_directory: - csv_path = os.path.join(output_directory, "alignment_evaluation.csv") + csv_path = os.path.join( + output_directory, + f"{comparison_source.name}_{reference_source.name}_evaluation.csv", + ) else: - csv_path = os.path.join(self.working_log_directory, "alignment_evaluation.csv") + self._current_workflow = "evaluation" + os.makedirs(self.working_log_directory, exist_ok=True) + csv_path = os.path.join( + self.working_log_directory, + f"{comparison_source.name}_{reference_source.name}_evaluation.csv", + ) csv_header = [ "file", "begin", @@ -694,151 +1248,144 @@ def evaluate_alignments( score_sum = 0 phone_edit_sum = 0 phone_length_sum = 0 - if self.alignment_evaluation_done: - self.log_info("Exporting saved evaluation...") - with self.session() as session, mfa_open(csv_path, "w") as f: - writer = csv.DictWriter(f, fieldnames=csv_header) - writer.writeheader() - bn = DictBundle( - "evaluation_data", - File.c.name.label("file"), - Utterance.begin, - Utterance.end, - Speaker.c.name.label("speaker"), - Utterance.duration, - Utterance.normalized_text, - Utterance.oovs, - sqlalchemy.func.count(Utterance.reference_phone_intervals).label( - "reference_phone_count" - ), - Utterance.alignment_score, - Utterance.phone_error_rate, - Utterance.alignment_log_likelihood, - ) - utterances = ( - session.query(bn) - .join(Utterance.speaker) - .join(Utterance.file) - .group_by(Utterance.id) - .join(Utterance.reference_phone_intervals) - ) - for line in utterances: - data = line["evaluation_data"] - data["word_count"] = len(data["normalized_text"].split()) - data["oov_count"] = len(data["oovs"].split()) - phone_error_rate = data["phone_error_rate"] - reference_phone_count = data["reference_phone_count"] - if data["alignment_score"] is not None: - score_count += 1 - score_sum += data["alignment_score"] - phone_edit_sum += int(phone_error_rate * reference_phone_count) - phone_length_sum += reference_phone_count - writer.writerow(data) - else: + with self.session() as session: # Set up - self.log_info("Evaluating alignments...") - self.log_debug(f"Mapping: {mapping}") + logger.info("Evaluating alignments...") + logger.debug(f"Mapping: {mapping}") + reference_workflow_id = self.get_latest_workflow_run(reference_source, session).id + comparison_workflow_id = self.get_latest_workflow_run(comparison_source, session).id update_mappings = [] indices = [] to_comp = [] score_func = functools.partial( align_phones, silence_phone=self.optional_silence_phone, - ignored_phones={self.oov_phone}, custom_mapping=mapping, ) - with self.session() as session: - unaligned_utts = [] - utterances = session.query(Utterance).options( - subqueryload(Utterance.reference_phone_intervals), - subqueryload(Utterance.phone_intervals), - joinedload(Utterance.file, innerjoin=True), - joinedload(Utterance.speaker, innerjoin=True), - ) - for u in utterances: - reference_phone_count = len(u.reference_phone_intervals) - if not reference_phone_count: - continue - if u.alignment_log_likelihood is None: # couldn't be aligned - phone_error_rate = reference_phone_count - unaligned_utts.append(u) - update_mappings.append( - { - "id": u.id, - "alignment_score": None, - "phone_error_rate": phone_error_rate, - } - ) - continue - reference_phone_labels = [x.as_ctm() for x in u.reference_phone_intervals] - phone_labels = [x.as_ctm() for x in u.phone_intervals] - indices.append(u) - to_comp.append((reference_phone_labels, phone_labels)) - - with mp.Pool(self.num_jobs) as pool, mfa_open(csv_path, "w") as f: - writer = csv.DictWriter(f, fieldnames=csv_header) - writer.writeheader() - gen = pool.starmap(score_func, to_comp) - for u in unaligned_utts: - word_count = len(u.normalized_text.split()) - oov_count = len(u.oovs.split()) - reference_phone_count = len(u.reference_phone_intervals) - writer.writerow( - { - "file": u.file_name, - "begin": u.begin, - "end": u.end, - "speaker": u.speaker_name, - "duration": u.duration, - "normalized_text": u.normalized_text, - "oovs": u.oovs, - "reference_phone_count": reference_phone_count, - "alignment_score": None, - "phone_error_rate": reference_phone_count, - "alignment_log_likelihood": None, - "word_count": word_count, - "oov_count": oov_count, - } - ) - for i, (score, phone_error_rate) in enumerate(gen): - if score is None: + unaligned_utts = [] + utterances: typing.List[Utterance] = session.query(Utterance).options( + joinedload(Utterance.file, innerjoin=True), + joinedload(Utterance.speaker, innerjoin=True), + subqueryload(Utterance.phone_intervals).options( + joinedload(PhoneInterval.phone, innerjoin=True), + joinedload(PhoneInterval.workflow, innerjoin=True), + ), + subqueryload(Utterance.word_intervals).options( + joinedload(WordInterval.word, innerjoin=True), + joinedload(WordInterval.workflow, innerjoin=True), + ), + ) + reference_phone_counts = {} + for u in utterances: + reference_phones = u.phone_intervals_for_workflow(reference_workflow_id) + comparison_phones = u.phone_intervals_for_workflow(comparison_workflow_id) + if self.use_cutoff_model: + for wi in u.word_intervals: + if wi.workflow_id != comparison_workflow_id: continue - u = indices[i] + if wi.word.word_type is WordType.cutoff: + comparison_phones = [ + x + for x in comparison_phones + if x.end <= wi.begin or x.begin >= wi.end + ] + comparison_phones.append( + CtmInterval(begin=wi.begin, end=wi.end, label=self.oov_word) + ) + comparison_phones = sorted(comparison_phones) - word_count = len(u.normalized_text.split()) - oov_count = len(u.oovs.split()) - reference_phone_count = len(u.reference_phone_intervals) - update_mappings.append( - { - "id": u.id, - "alignment_score": score, - "phone_error_rate": phone_error_rate, - } - ) - writer.writerow( - { - "file": u.file_name, - "begin": u.begin, - "end": u.end, - "speaker": u.speaker_name, - "duration": u.duration, - "normalized_text": u.normalized_text, - "oovs": u.oovs, - "reference_phone_count": reference_phone_count, - "alignment_score": score, - "phone_error_rate": phone_error_rate, - "alignment_log_likelihood": u.alignment_log_likelihood, - "word_count": word_count, - "oov_count": oov_count, - } - ) + reference_phone_counts[u.id] = len(reference_phones) + if not reference_phone_counts[u.id]: + continue + if not comparison_phones: # couldn't be aligned + phone_error_rate = reference_phone_counts[u.id] + unaligned_utts.append(u) + update_mappings.append( + { + "id": u.id, + "alignment_score": None, + "phone_error_rate": phone_error_rate, + } + ) + continue + indices.append(u) + to_comp.append((reference_phones, comparison_phones)) + with mp.Pool(GLOBAL_CONFIG.num_jobs) as pool: + gen = pool.starmap(score_func, to_comp) + for i, (score, phone_error_rate) in enumerate(gen): + if score is None: + continue + u = indices[i] + reference_phone_count = reference_phone_counts[u.id] + update_mappings.append( + { + "id": u.id, + "alignment_score": score, + "phone_error_rate": phone_error_rate, + } + ) + score_count += 1 + score_sum += score + phone_edit_sum += int(phone_error_rate * reference_phone_count) + phone_length_sum += reference_phone_count + bulk_update(session, Utterance, update_mappings) + self.alignment_evaluation_done = True + session.query(Corpus).update({Corpus.alignment_evaluation_done: True}) + session.commit() + logger.info("Exporting evaluation...") + with mfa_open(csv_path, "w") as f: + writer = csv.DictWriter(f, fieldnames=csv_header) + writer.writeheader() + utterances = ( + session.query( + Utterance.id, + File.name, + Utterance.begin, + Utterance.end, + Speaker.name, + Utterance.duration, + Utterance.normalized_text, + Utterance.oovs, + Utterance.alignment_score, + Utterance.phone_error_rate, + Utterance.alignment_log_likelihood, + ) + .join(Utterance.speaker) + .join(Utterance.file) + ) + for ( + u_id, + file_name, + begin, + end, + speaker_name, + duration, + normalized_text, + oovs, + alignment_score, + phone_error_rate, + alignment_log_likelihood, + ) in utterances: + data = { + "file": file_name, + "begin": begin, + "end": end, + "duration": duration, + "speaker": speaker_name, + "normalized_text": normalized_text, + "oovs": oovs, + "reference_phone_count": reference_phone_counts[u_id], + "alignment_score": alignment_score, + "phone_error_rate": phone_error_rate, + "alignment_log_likelihood": alignment_log_likelihood, + } + data["word_count"] = len(data["normalized_text"].split()) + data["oov_count"] = len(data["oovs"].split()) + if alignment_score is not None: score_count += 1 - score_sum += score - phone_edit_sum += int(phone_error_rate * reference_phone_count) - phone_length_sum += reference_phone_count - session.bulk_update_mappings(Utterance, update_mappings) - session.query(Corpus).update({"alignment_evaluation_done": True}) - session.commit() - self.log_info(f"Average overlap score: {score_sum/score_count}") - self.log_info(f"Average phone error rate: {phone_edit_sum/phone_length_sum}") - self.log_debug(f"Alignment evaluation took {time.time()-begin} seconds") + score_sum += alignment_score + writer.writerow(data) + + logger.info(f"Average overlap score: {score_sum/score_count}") + logger.info(f"Average phone error rate: {phone_edit_sum/phone_length_sum}") + logger.debug(f"Alignment evaluation took {time.time()-begin} seconds") diff --git a/montreal_forced_aligner/alignment/mixins.py b/montreal_forced_aligner/alignment/mixins.py index 9fdec543..71ff49be 100644 --- a/montreal_forced_aligner/alignment/mixins.py +++ b/montreal_forced_aligner/alignment/mixins.py @@ -2,6 +2,7 @@ from __future__ import annotations import csv +import datetime import logging import multiprocessing as mp import os @@ -18,9 +19,20 @@ CompileInformationArguments, CompileTrainGraphsArguments, CompileTrainGraphsFunction, + PhoneConfidenceArguments, + PhoneConfidenceFunction, compile_information_func, ) -from montreal_forced_aligner.db import File, Speaker, Utterance +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.db import ( + CorpusWorkflow, + File, + Job, + PhoneInterval, + Speaker, + Utterance, + bulk_update, +) from montreal_forced_aligner.dictionary.mixins import DictionaryMixin from montreal_forced_aligner.exceptions import NoAlignmentsError from montreal_forced_aligner.helper import mfa_open @@ -28,7 +40,9 @@ if TYPE_CHECKING: from montreal_forced_aligner.abc import MetaDict - from montreal_forced_aligner.corpus.multiprocessing import Job + + +logger = logging.getLogger("mfa") class AlignMixin(DictionaryMixin): @@ -58,17 +72,12 @@ class AlignMixin(DictionaryMixin): Attributes ---------- - logger: logging.Logger - Eventual top-level worker logger - jobs: list[Job] + jobs: list[:class:`~montreal_forced_aligner.corpus.multiprocessing.Job`] Jobs to process - use_mp: bool - Flag for using multiprocessing """ logger: logging.Logger jobs: List[Job] - use_mp: bool def __init__( self, @@ -78,6 +87,9 @@ def __init__( boost_silence: float = 1.0, beam: int = 10, retry_beam: int = 40, + fine_tune: bool = False, + phone_confidence: bool = False, + use_phone_model: bool = False, **kwargs, ): super().__init__(**kwargs) @@ -87,6 +99,9 @@ def __init__( self.boost_silence = boost_silence self.beam = beam self.retry_beam = retry_beam + self.fine_tune = fine_tune + self.phone_confidence = phone_confidence + self.use_phone_model = use_phone_model if self.retry_beam <= self.beam: self.retry_beam = self.beam * 4 self.unaligned_files = set() @@ -117,22 +132,18 @@ def compile_train_graphs_arguments(self) -> List[CompileTrainGraphsArguments]: Arguments for processing """ args = [] + model_path = self.model_path + if not os.path.exists(model_path): + model_path = self.alignment_model_path for j in self.jobs: - if not j.has_data: - continue - model_path = self.model_path - if not os.path.exists(model_path): - model_path = self.alignment_model_path args.append( CompileTrainGraphsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"compile_train_graphs.{j.name}.log"), - j.dictionary_ids, + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"compile_train_graphs.{j.id}.log"), os.path.join(self.working_directory, "tree"), model_path, - j.construct_path_dictionary(self.data_directory, "text", "int.scp"), - j.construct_path_dictionary(self.working_directory, "fsts", "ark"), + getattr(self, "use_g2p", False), ) ) return args @@ -147,30 +158,64 @@ def align_arguments(self) -> List[AlignArguments]: Arguments for processing """ args = [] - feat_strings = self.construct_feature_proc_strings() iteration = getattr(self, "iteration", None) for j in self.jobs: - if not j.has_data: - continue if iteration is not None: log_path = os.path.join( - self.working_log_directory, f"align.{iteration}.{j.name}.log" + self.working_log_directory, f"align.{iteration}.{j.id}.log" ) else: - log_path = os.path.join(self.working_log_directory, f"align.{j.name}.log") - if not getattr(self, "speaker_independent", True): + log_path = os.path.join(self.working_log_directory, f"align.{j.id}.log") + if getattr(self, "uses_speaker_adaptation", False): log_path = log_path.replace(".log", ".fmllr.log") args.append( AlignArguments( - j.name, - getattr(self, "db_path", ""), + j.id, + getattr(self, "db_string", ""), log_path, - j.dictionary_ids, - j.construct_path_dictionary(self.working_directory, "fsts", "ark"), - feat_strings[j.name], self.alignment_model_path, - j.construct_path_dictionary(self.working_directory, "ali", "ark"), - self.align_options, + self.decode_options + if self.phone_confidence + and getattr(self, "uses_speaker_adaptation", False) + and hasattr(self, "decode_options") + else self.align_options, + self.feature_options, + self.phone_confidence, + ) + ) + return args + + def phone_confidence_arguments(self) -> List[PhoneConfidenceArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.PhoneConfidenceFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.alignment.multiprocessing.PhoneConfidenceArguments`] + Arguments for processing + """ + args = [] + for j in self.jobs: + log_path = os.path.join(self.working_log_directory, f"phone_confidence.{j.id}.log") + + feat_strings = {} + for d in j.dictionaries: + feat_strings[d.id] = j.construct_feature_proc_string( + self.working_directory, + d.id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + args.append( + PhoneConfidenceArguments( + j.id, + getattr(self, "db_string", ""), + log_path, + self.model_path, + self.phone_pdf_counts_path, + feat_strings, ) ) return args @@ -187,19 +232,17 @@ def compile_information_arguments(self) -> List[CompileInformationArguments]: args = [] iteration = getattr(self, "iteration", None) for j in self.jobs: - if not j.has_data: - continue if iteration is not None: log_path = os.path.join( - self.working_log_directory, f"align.{iteration}.{j.name}.log" + self.working_log_directory, f"align.{iteration}.{j.id}.log" ) else: - log_path = os.path.join(self.working_log_directory, f"align.{j.name}.log") + log_path = os.path.join(self.working_log_directory, f"align.{j.id}.log") args.append( CompileInformationArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"compile_information.{j.name}.log"), + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"compile_information.{j.id}.log"), log_path, ) ) @@ -253,13 +296,11 @@ def compile_train_graphs(self) -> None: begin = time.time() log_directory = self.working_log_directory os.makedirs(log_directory, exist_ok=True) - self.log_info("Compiling training graphs...") + logger.info("Compiling training graphs...") error_sum = 0 arguments = self.compile_train_graphs_arguments() - with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -293,15 +334,71 @@ def compile_train_graphs(self) -> None: for v in error_dict.values(): raise v else: - self.log_debug("Not using multiprocessing...") + logger.debug("Not using multiprocessing...") for args in arguments: function = CompileTrainGraphsFunction(args) for done, errors in function.run(): pbar.update(done + errors) error_sum += errors if error_sum: - self.log_warning(f"Compilation of training graphs failed for {error_sum} utterances.") - self.log_debug(f"Compiling training graphs took {time.time() - begin}") + logger.warning(f"Compilation of training graphs failed for {error_sum} utterances.") + logger.debug(f"Compiling training graphs took {time.time() - begin:.3f} seconds") + + def get_phone_confidences(self): + if not os.path.exists(self.phone_pdf_counts_path): + logger.warning("Cannot calculate phone confidences with the current model.") + return + logger.info("Calculating phone confidences...") + begin = time.time() + + with self.session() as session: + with tqdm.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: + error_dict = {} + return_queue = mp.Queue() + stopped = Stopped() + procs = [] + for i, args in enumerate(arguments): + function = PhoneConfidenceFunction(args) + p = KaldiProcessWorker(i, return_queue, function, stopped) + procs.append(p) + p.start() + while True: + try: + result = return_queue.get(timeout=1) + if isinstance(result, Exception): + error_dict[getattr(result, "job_name", 0)] = result + continue + if stopped.stop_check(): + continue + except Empty: + for proc in procs: + if not proc.finished.stop_check(): + break + else: + break + continue + interval_update_mappings.extend(result) + pbar.update(1) + for p in procs: + p.join() + + if error_dict: + for v in error_dict.values(): + raise v + + else: + logger.debug("Not using multiprocessing...") + for args in arguments: + function = PhoneConfidenceFunction(args) + for result in function.run(): + interval_update_mappings.extend(result) + pbar.update(1) + bulk_update(session, PhoneInterval, interval_update_mappings) + session.commit() + logger.debug(f"Calculating phone confidences took {time.time() - begin:.3f} seconds") def align_utterances(self, training=False) -> None: """ @@ -319,9 +416,9 @@ def align_utterances(self, training=False) -> None: Reference Kaldi script """ begin = time.time() - self.log_info("Generating alignments...") + logger.info("Generating alignments...") with tqdm.tqdm( - total=self.num_current_utterances, disable=getattr(self, "quiet", False) + total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session() as session: if not training: utterances = session.query(Utterance) @@ -329,8 +426,10 @@ def align_utterances(self, training=False) -> None: utterances = utterances.filter(Utterance.in_subset == True) # noqa utterances.update({"alignment_log_likelihood": None}) session.commit() + log_like_sum = 0 + log_like_count = 0 update_mappings = [] - if self.use_mp: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -357,6 +456,8 @@ def align_utterances(self, training=False) -> None: continue if not training: utterance, log_likelihood = result + log_like_sum += log_likelihood + log_like_count += 1 update_mappings.append( {"id": utterance, "alignment_log_likelihood": log_likelihood} ) @@ -373,11 +474,13 @@ def align_utterances(self, training=False) -> None: raise v else: - self.log_debug("Not using multiprocessing...") + logger.debug("Not using multiprocessing...") for args in self.align_arguments(): function = AlignFunction(args) for utterance, log_likelihood in function.run(): if not training: + log_like_sum += log_likelihood + log_like_count += 1 update_mappings.append( {"id": utterance, "alignment_log_likelihood": log_likelihood} ) @@ -387,7 +490,7 @@ def align_utterances(self, training=False) -> None: self.num_current_utterances, self.beam, self.retry_beam ) if not training: - session.bulk_update_mappings(Utterance, update_mappings) + bulk_update(session, Utterance, update_mappings) session.query(Utterance).filter( Utterance.alignment_log_likelihood != None # noqa ).update( @@ -397,8 +500,21 @@ def align_utterances(self, training=False) -> None: }, synchronize_session="fetch", ) + if not training: + if not getattr(self, "uses_speaker_adaptation", False): + workflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + workflow.time_stamp = datetime.datetime.now() + workflow.score = log_like_sum / log_like_count session.commit() - self.log_debug(f"Alignment round took {time.time() - begin}") + if not GLOBAL_CONFIG.debug: + for file in os.listdir(self.working_directory): + if any(file.startswith(x) for x in ["fsts."]): + os.remove(os.path.join(self.working_directory, file)) + logger.debug(f"Alignment round took {time.time() - begin:.3f} seconds") def compile_information(self) -> None: """ @@ -416,7 +532,7 @@ def compile_information(self) -> None: jobs = self.compile_information_arguments() - if self.use_mp: + if GLOBAL_CONFIG.use_mp: alignment_info = run_mp( compile_information_func, jobs, self.working_log_directory, True ) @@ -465,24 +581,24 @@ def compile_information(self) -> None: ) if not avg_like_frames: - self.log_warning( + logger.warning( "No files were aligned, this likely indicates serious problems with the aligner." ) else: if too_short_count: - self.log_debug( + logger.debug( f"There were {too_short_count} utterances that were too short to be aligned." ) if beam_too_narrow_count: - self.log_debug( + logger.debug( f"There were {beam_too_narrow_count} utterances that could not be aligned with " f"the current beam settings." ) average_log_like = avg_like_sum / avg_like_frames if average_logdet_sum: average_log_like += average_logdet_sum / average_logdet_frames - self.log_debug(f"Average per frame likelihood for alignment: {average_log_like}") - self.log_debug(f"Compiling information took {time.time() - compile_info_begin}") + logger.debug(f"Average per frame likelihood for alignment: {average_log_like}") + logger.debug(f"Compiling information took {time.time() - compile_info_begin:.3f} seconds") @property @abstractmethod @@ -501,10 +617,15 @@ def model_path(self) -> str: """Acoustic model file path""" return os.path.join(self.working_directory, "final.mdl") + @property + def phone_pdf_counts_path(self) -> str: + """Acoustic model file path""" + return os.path.join(self.working_directory, "phone_pdf.counts") + @property def alignment_model_path(self) -> str: """Acoustic model file path for speaker-independent alignment""" path = os.path.join(self.working_directory, "final.alimdl") - if os.path.exists(path) and getattr(self, "speaker_independent", True): + if os.path.exists(path) and not getattr(self, "uses_speaker_adaptation", False): return path return self.model_path diff --git a/montreal_forced_aligner/alignment/multiprocessing.py b/montreal_forced_aligner/alignment/multiprocessing.py index 2a81dc36..5466a38f 100644 --- a/montreal_forced_aligner/alignment/multiprocessing.py +++ b/montreal_forced_aligner/alignment/multiprocessing.py @@ -5,9 +5,13 @@ """ from __future__ import annotations +import collections +import json +import logging import multiprocessing as mp import os import re +import statistics import subprocess import sys import traceback @@ -15,29 +19,50 @@ from queue import Empty from typing import TYPE_CHECKING, Dict, List, Union -import sqlalchemy.engine -from sqlalchemy.orm import Session, joinedload, load_only, selectinload +import numpy as np +import pynini +import pywrapfst +from sqlalchemy.orm import Session, joinedload, selectinload, subqueryload +from montreal_forced_aligner.corpus.features import ( + compute_mfcc_process, + compute_pitch_process, + compute_transform_process, +) from montreal_forced_aligner.data import ( CtmInterval, MfaArguments, PronunciationProbabilityCounter, TextgridFormats, + WordCtmInterval, WordType, + WorkflowType, ) from montreal_forced_aligner.db import ( - Dictionary, + CorpusWorkflow, + DictBundle, File, + Job, Phone, + PhoneInterval, Pronunciation, + SoundFile, Speaker, Utterance, Word, + WordInterval, ) -from montreal_forced_aligner.exceptions import AlignmentExportError +from montreal_forced_aligner.exceptions import AlignmentExportError, FeatureGenerationError from montreal_forced_aligner.helper import mfa_open, split_phone_position -from montreal_forced_aligner.textgrid import export_textgrid, process_ctm_line -from montreal_forced_aligner.utils import Counter, KaldiFunction, Stopped, thirdparty_binary +from montreal_forced_aligner.textgrid import export_textgrid +from montreal_forced_aligner.utils import ( + Counter, + KaldiFunction, + Stopped, + parse_ctm_output, + read_feats, + thirdparty_binary, +) if TYPE_CHECKING: from dataclasses import dataclass @@ -65,32 +90,208 @@ ] +def phones_to_prons( + text: str, + intervals: List[CtmInterval], + align_lexicon_fst: pynini.Fst, + word_symbol_table: pywrapfst.SymbolTableView, + phone_symbol_table: pywrapfst.SymbolTableView, + optional_silence_phone: str, + transcription: bool = False, + clitic_marker=None, +): + if "" in text: + words = [x.replace(" ", "") for x in text.split("")] + else: + words = text.split() + word_begin = "#1" + word_end = "#2" + word_begin_symbol = phone_symbol_table.find(word_begin) + word_end_symbol = phone_symbol_table.find(word_end) + acceptor = pynini.accep(text, token_type=word_symbol_table) + phone_to_word = pynini.compose(align_lexicon_fst, acceptor) + phone_fst = pynini.Fst() + current_state = phone_fst.add_state() + phone_fst.set_start(current_state) + for p in intervals: + next_state = phone_fst.add_state() + symbol = phone_symbol_table.find(p.label) + phone_fst.add_arc( + current_state, + pywrapfst.Arc( + symbol, symbol, pywrapfst.Weight.one(phone_fst.weight_type()), next_state + ), + ) + current_state = next_state + if transcription: + if intervals[-1].label == optional_silence_phone: + state = current_state - 1 + else: + state = current_state + phone_to_word_state = phone_to_word.num_states() - 1 + for i in range(phone_symbol_table.num_symbols()): + if phone_symbol_table.find(i) == "": + continue + if phone_symbol_table.find(i).startswith("#"): + continue + phone_fst.add_arc( + state, + pywrapfst.Arc( + phone_symbol_table.find(""), + i, + pywrapfst.Weight.one(phone_fst.weight_type()), + state, + ), + ) + + phone_to_word.add_arc( + phone_to_word_state, + pywrapfst.Arc( + i, + phone_symbol_table.find(""), + pywrapfst.Weight.one(phone_fst.weight_type()), + phone_to_word_state, + ), + ) + for s in range(current_state + 1): + phone_fst.add_arc( + s, + pywrapfst.Arc( + word_end_symbol, word_end_symbol, pywrapfst.Weight.one(phone_fst.weight_type()), s + ), + ) + phone_fst.add_arc( + s, + pywrapfst.Arc( + word_begin_symbol, + word_begin_symbol, + pywrapfst.Weight.one(phone_fst.weight_type()), + s, + ), + ) + + phone_fst.set_final(current_state, pywrapfst.Weight.one(phone_fst.weight_type())) + phone_fst.arcsort("olabel") + + lattice = pynini.compose(phone_fst, phone_to_word) + try: + path_string = pynini.shortestpath(lattice).project("input").string(phone_symbol_table) + except Exception: + logging.debug("For the text and intervals:") + logging.debug(text) + logging.debug([x.label for x in intervals]) + logging.debug("There was an issue composing word and phone FSTs") + logging.debug("PHONE FST:") + phone_fst.set_input_symbols(phone_symbol_table) + phone_fst.set_output_symbols(phone_symbol_table) + logging.debug(phone_fst) + logging.debug("PHONE_TO_WORD FST:") + phone_to_word.set_input_symbols(phone_symbol_table) + phone_to_word.set_output_symbols(word_symbol_table) + logging.debug(phone_to_word) + raise + path_string = path_string.replace(f"{word_end} {word_begin}", word_begin) + path_string = path_string.replace(f"{word_end}", word_begin) + word_splits = re.split(rf" ?{word_begin} ?", path_string) + word_splits = [x.split() for x in word_splits if x != optional_silence_phone and x] + + return list(zip(words, word_splits)) + + @dataclass class GeneratePronunciationsArguments(MfaArguments): - """Arguments for :func:`~montreal_forced_aligner.alignment.multiprocessing.GeneratePronunciationsFunction`""" + """ + Arguments for :func:`~montreal_forced_aligner.alignment.multiprocessing.GeneratePronunciationsFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + text_int_paths: dict[int, str] + Per dictionary text SCP paths + ali_paths: dict[int, str] + Per dictionary alignment paths + model_path: str + Acoustic model path + for_g2p: bool + Flag for training a G2P model with acoustic information + """ - text_int_paths: Dict[int, str] - ali_paths: Dict[int, str] model_path: str for_g2p: bool @dataclass class AlignmentExtractionArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.AlignmentExtractionFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.AlignmentExtractionFunction` + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run model_path: str + Acoustic model path frame_shift: float - cleanup_textgrids: bool - ali_paths: Dict[int, str] - text_int_paths: Dict[int, str] + Frame shift in seconds + ali_paths: dict[int, str] + Per dictionary alignment paths + text_int_paths: dict[int, str] + Per dictionary text SCP paths + phone_symbol_path: str + Path to phone symbols table + score_options: dict[str, Any] + Options for Kaldi functions + """ + + model_path: str + frame_shift: float + phone_symbol_path: str + score_options: MetaDict + confidence: bool + transcription: bool @dataclass class ExportTextGridArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.ExportTextGridProcessWorker`""" + """ + Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.ExportTextGridProcessWorker` - frame_shift: int + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + export_frame_shift: float + Frame shift in seconds + cleanup_textgrids: bool + Flag to cleanup silences and recombine words + clitic_marker: str + Marker indicating clitics + output_directory: str + Directory for exporting + output_format: str + Format to export + include_original_text: bool + Flag for including original unnormalized text as a tier + workflow_id: int + Integer id of workflow to export + """ + + export_frame_shift: float + cleanup_textgrids: bool + clitic_marker: str output_directory: str output_format: str include_original_text: bool @@ -98,38 +299,190 @@ class ExportTextGridArguments(MfaArguments): @dataclass class CompileInformationArguments(MfaArguments): - """Arguments for :func:`~montreal_forced_aligner.alignment.multiprocessing.compile_information_func`""" + """ + Arguments for :func:`~montreal_forced_aligner.alignment.multiprocessing.compile_information_func` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + align_log_path: str + Path to log file for parsing + """ align_log_path: str @dataclass class CompileTrainGraphsArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.CompileTrainGraphsFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.CompileTrainGraphsFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + tree_path: str + Path to tree file + model_path: str + Path to model file + text_int_paths: dict[int, str] + Mapping of dictionaries to text scp files + fst_ark_paths: dict[int, str] + Mapping of dictionaries to fst ark files + """ - dictionaries: List[int] tree_path: str model_path: str - text_int_paths: Dict[int, str] - fst_ark_paths: Dict[int, str] + use_g2p: bool @dataclass class AlignArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.AlignFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.AlignFunction` - dictionaries: List[int] - fst_ark_paths: Dict[int, str] - feature_strings: Dict[int, str] + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + fst_ark_paths: dict[int, str] + Mapping of dictionaries to fst ark files + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings model_path: str - ali_paths: Dict[int, str] + Path to model file + ali_paths: dict[int, str] + Per dictionary alignment paths + align_options: dict[str, Any] + Alignment options + """ + + model_path: str + align_options: MetaDict + feature_options: MetaDict + confidence: bool + + +@dataclass +class FineTuneArguments(MfaArguments): + """ + Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.AlignFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + working_directory: str + Current working directory + tree_path: str + Path to tree file + model_path: str + Path to model file + frame_shift: int + Frame shift in ms + cmvn_paths: dict[int, str] + Mapping of dictionaries to CMVN scp paths + fmllr_paths: dict[int, str] + Mapping of dictionaries to fMLLR ark paths + lda_mat_path: str, optional + Path to LDA matrix file + mfcc_options: dict[str, Any] + MFCC computation options + pitch_options: dict[str, Any] + Pitch computation options + align_options: dict[str, Any] + Alignment options + workflow_id: int + Integer ID for workflow to fine tune + position_dependent_phones: bool + Flag for whether to use position dependent phones + grouped_phones: dict[str, list[str]] + Grouped lists of phones + """ + + phone_symbol_table_path: str + disambiguation_symbols_int_path: str + tree_path: str + model_path: str + frame_shift: int + mfcc_options: MetaDict + pitch_options: MetaDict + lda_options: MetaDict align_options: MetaDict + position_dependent_phones: bool + grouped_phones: Dict[str, List[str]] + + +@dataclass +class PhoneConfidenceArguments(MfaArguments): + """ + Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.AlignFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + model_path: str + Path to model file + phone_pdf_counts_path: str + Path to output PDF counts + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings + """ + + model_path: str + phone_pdf_counts_path: str + feature_strings: Dict[int, str] @dataclass class AccStatsArguments(MfaArguments): """ Arguments for :class:`~montreal_forced_aligner.alignment.multiprocessing.AccStatsFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings + ali_paths: dict[int, str] + Per dictionary alignment paths + acc_paths: dict[int, str] + Per dictionary accumulated stats paths + model_path: str + Path to model file """ dictionaries: List[int] @@ -164,23 +517,24 @@ class CompileTrainGraphsFunction(KaldiFunction): def __init__(self, args: CompileTrainGraphsArguments): super().__init__(args) - self.dictionaries = args.dictionaries self.tree_path = args.tree_path self.model_path = args.model_path - self.text_int_paths = args.text_int_paths - self.fst_ark_paths = args.fst_ark_paths - self.working_dir = os.path.dirname(list(self.fst_ark_paths.values())[0]) + self.use_g2p = args.use_g2p def _run(self) -> typing.Generator[typing.Tuple[int, int]]: """Run the function""" - db_engine = sqlalchemy.create_engine(f"sqlite:///{self.db_path}?mode=ro&nolock=1") - - with mfa_open(self.log_path, "w") as log_file, Session(db_engine) as session: - dictionaries = ( - session.query(Dictionary) - .join(Dictionary.speakers) - .filter(Speaker.job_id == self.job_name) - .distinct() + + with mfa_open(self.log_path, "w") as log_file, Session(self.db_engine) as session: + job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + workflow: CorpusWorkflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() ) tree_proc = subprocess.Popen( @@ -198,21 +552,20 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: context_width = int(text[1]) elif text[0] == "central-position": central_pos = int(text[1]) - out_disambig = os.path.join(self.working_dir, f"{self.job_name}.disambig") - ilabels_temp = os.path.join(self.working_dir, f"{self.job_name}.ilabels") - clg_path = os.path.join(self.working_dir, f"{self.job_name}.clg.temp") + out_disambig = os.path.join(workflow.working_directory, f"{self.job_name}.disambig") + ilabels_temp = os.path.join(workflow.working_directory, f"{self.job_name}.ilabels") + clg_path = os.path.join(workflow.working_directory, f"{self.job_name}.clg.temp") ha_out_disambig = os.path.join( - self.working_dir, f"{self.job_name}.ha_out_disambig.temp" + workflow.working_directory, f"{self.job_name}.ha_out_disambig.temp" ) - for d in dictionaries: - fst_ark_path = self.fst_ark_paths[d.id] - text_path = self.text_int_paths[d.id] - if d.use_g2p: - import pynini - from pynini.lib import rewrite + text_int_paths = job.per_dictionary_text_int_scp_paths + if self.use_g2p: + import pynini + from pynini.lib import rewrite - from montreal_forced_aligner.g2p.generator import threshold_lattice_to_dfa + from montreal_forced_aligner.g2p.generator import threshold_lattice_to_dfa + for d in job.dictionaries: fst = pynini.Fst.read(d.lexicon_fst_path) token_type = pynini.SymbolTable.read_text(d.grapheme_symbol_table_path) utterances = ( @@ -220,16 +573,24 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: .join(Utterance.speaker) .filter(Utterance.ignored == False) # noqa .filter(Utterance.normalized_character_text != "") - .filter(Speaker.job_id == self.job_name) + .filter(Utterance.job_id == self.job_name) .filter(Speaker.dictionary_id == d.id) .order_by(Utterance.kaldi_id) ) + fst_ark_path = job.construct_path( + workflow.working_directory, "fsts", "ark", d.id + ) + with mfa_open(fst_ark_path, "wb") as fst_output_file: for utt_id, full_text in utterances: - full_text = f" {full_text} " - lattice = rewrite.rewrite_lattice(full_text, fst, token_type) - lattice = threshold_lattice_to_dfa(lattice, 2.0) - input = lattice.write_to_string() + try: + lattice = rewrite.rewrite_lattice(full_text, fst, token_type) + lattice = threshold_lattice_to_dfa(lattice, 2.0) + input = lattice.write_to_string() + except pynini.lib.rewrite.Error: + log_file.write(f'Error composing "{full_text}"\n') + log_file.flush() + continue clg_compose_proc = subprocess.Popen( [ thirdparty_binary("fstcomposecontext"), @@ -331,7 +692,12 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: fst_output_file.write(stdout) yield 1, 0 - else: + else: + for d in job.dictionaries: + fst_ark_path = job.construct_path( + workflow.working_directory, "fsts", "ark", d.id + ) + text_path = text_int_paths[d.id] proc = subprocess.Popen( [ thirdparty_binary("compile-train-graphs"), @@ -339,7 +705,7 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: self.tree_path, self.model_path, d.lexicon_fst_path, - f"ark:{text_path}", + f"ark,s,cs:{text_path}", f"ark:{fst_ark_path}", ], stderr=subprocess.PIPE, @@ -433,7 +799,7 @@ class AlignFunction(KaldiFunction): Main function that calls this function in parallel :meth:`.AlignMixin.align_arguments` Job method for generating arguments for this function - :kaldi_src:`align-equal-compiled` + :kaldi_src:`align-gmm-compiled` Relevant Kaldi binary :kaldi_src:`gmm-boost-silence` Relevant Kaldi binary @@ -444,63 +810,727 @@ class AlignFunction(KaldiFunction): Arguments for the function """ + progress_pattern = re.compile( + r"^LOG.*Log-like per frame for utterance (?P.*) is (?P[-\d.]+) over (?P\d+) frames." + ) + def __init__(self, args: AlignArguments): super().__init__(args) - self.dictionaries = args.dictionaries - self.fst_ark_paths = args.fst_ark_paths - self.feature_strings = args.feature_strings self.model_path = args.model_path - self.ali_paths = args.ali_paths self.align_options = args.align_options + self.feature_options = args.feature_options + self.confidence = args.confidence def _run(self) -> typing.Generator[typing.Tuple[int, float]]: """Run the function""" - with mfa_open(self.log_path, "w") as log_file: - for dict_id in self.dictionaries: - feature_string = self.feature_strings[dict_id] - fst_path = self.fst_ark_paths[dict_id] - ali_path = self.ali_paths[dict_id] - com = [ - thirdparty_binary("gmm-align-compiled"), - f"--transition-scale={self.align_options['transition_scale']}", - f"--acoustic-scale={self.align_options['acoustic_scale']}", - f"--self-loop-scale={self.align_options['self_loop_scale']}", - f"--beam={self.align_options['beam']}", - f"--retry-beam={self.align_options['retry_beam']}", - "--careful=false", - "-", - f"ark:{fst_path}", - feature_string, - f"ark:{ali_path}", - "ark,t:-", - ] - - boost_proc = subprocess.Popen( - [ - thirdparty_binary("gmm-boost-silence"), - f"--boost={self.align_options['boost_silence']}", - self.align_options["optional_silence_csl"], + + with mfa_open(self.log_path, "w") as log_file, Session(self.db_engine) as session: + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + workflow: CorpusWorkflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + + for d in job.dictionaries: + dict_id = d.id + word_symbols_path = d.words_symbol_path + feature_string = job.construct_feature_proc_string( + workflow.working_directory, + dict_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + fst_path = job.construct_path(workflow.working_directory, "fsts", "ark", dict_id) + fmllr_path = job.construct_path( + workflow.working_directory, "trans", "ark", dict_id + ) + ali_path = job.construct_path(workflow.working_directory, "ali", "ark", dict_id) + if ( + self.confidence + and self.feature_options["uses_speaker_adaptation"] + and os.path.exists(fmllr_path) + ): + ali_path = job.construct_path( + workflow.working_directory, "lat", "ark", dict_id + ) + com = [ + thirdparty_binary("gmm-latgen-faster"), + f"--acoustic-scale={self.align_options['acoustic_scale']}", + f"--beam={self.align_options['beam']}", + f"--max-active={self.align_options['max_active']}", + f"--lattice-beam={self.align_options['lattice_beam']}", + f"--word-symbol-table={word_symbols_path}", self.model_path, + f"ark,s,cs:{fst_path}", + feature_string, + f"ark:{ali_path}", + ] + align_proc = subprocess.Popen( + com, stderr=subprocess.PIPE, env=os.environ, encoding="utf8" + ) + process_stream = align_proc.stderr + else: + com = [ + thirdparty_binary("gmm-align-compiled"), + f"--transition-scale={self.align_options['transition_scale']}", + f"--acoustic-scale={self.align_options['acoustic_scale']}", + f"--self-loop-scale={self.align_options['self_loop_scale']}", + f"--beam={self.align_options['beam']}", + f"--retry-beam={self.align_options['retry_beam']}", + "--careful=false", "-", + f"ark,s,cs:{fst_path}", + feature_string, + f"ark:{ali_path}", + "ark,t:-", + ] + + boost_proc = subprocess.Popen( + [ + thirdparty_binary("gmm-boost-silence"), + f"--boost={self.align_options['boost_silence']}", + self.align_options["optional_silence_csl"], + self.model_path, + "-", + ], + stderr=log_file, + stdout=subprocess.PIPE, + env=os.environ, + ) + align_proc = subprocess.Popen( + com, + stdout=subprocess.PIPE, + stderr=log_file, + encoding="utf8", + stdin=boost_proc.stdout, + env=os.environ, + ) + process_stream = align_proc.stdout + no_feature_count = 0 + for line in process_stream: + if re.search("No features for utterance", line): + no_feature_count += 1 + line = line.strip() + if ( + self.confidence + and self.feature_options["uses_speaker_adaptation"] + and os.path.exists(fmllr_path) + ): + m = self.progress_pattern.match(line) + if m: + utterance = m.group("utterance") + u_id = int(utterance.split("-")[-1]) + yield u_id, float(m.group("loglike")) + else: + utterance, log_likelihood = line.split() + u_id = int(utterance.split("-")[-1]) + yield u_id, float(log_likelihood) + if no_feature_count: + align_proc.wait() + raise FeatureGenerationError( + f"There was an issue in feature generation for {no_feature_count} utterances. " + f"This can be caused by version incompatibilities between MFA and the model, " + f"in which case you should re-download or re-train your model, " + f"or downgrade MFA to the version that the model was trained on." + ) + self.check_call(align_proc) + + +class FineTuneFunction(KaldiFunction): + """ + Multiprocessing function for fine tuning alignment. + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.alignment.multiprocessing.FineTuneArguments` + Arguments for the function + """ + + def __init__(self, args: FineTuneArguments): + super().__init__(args) + self.frame_shift = args.frame_shift + self.scaling_factor = 10 + + self.frame_shift_seconds = round(self.frame_shift / 1000, 3) + self.new_frame_shift = int(self.frame_shift / self.scaling_factor) + self.new_frame_shift_seconds = round(self.new_frame_shift / 1000, 4) + self.feature_padding_factor = 4 + self.padding = round(self.frame_shift_seconds, 3) + self.tree_path = args.tree_path + self.model_path = args.model_path + self.mfcc_options = args.mfcc_options + self.mfcc_options["frame-shift"] = self.new_frame_shift + self.mfcc_options["snip-edges"] = False + self.pitch_options = args.pitch_options + self.pitch_options["frame-shift"] = self.new_frame_shift + self.pitch_options["snip-edges"] = False + self.lda_options = args.lda_options + self.align_options = args.align_options + self.grouped_phones = args.grouped_phones + self.position_dependent_phones = args.position_dependent_phones + self.disambiguation_symbols_int_path = args.disambiguation_symbols_int_path + self.segment_begins = {} + self.segment_ends = {} + self.original_intervals = {} + self.utterance_initial_intervals = {} + + def setup_files( + self, session: Session, job: Job, workflow: CorpusWorkflow, dictionary_id: int + ): + wav_path = job.construct_path( + workflow.working_directory, "fine_tune_wav", "scp", dictionary_id + ) + segment_path = job.construct_path( + workflow.working_directory, "fine_tune_segments", "scp", dictionary_id + ) + feature_segment_path = job.construct_path( + workflow.working_directory, "fine_tune_feature_segments", "scp", dictionary_id + ) + utt2spk_path = job.construct_path( + workflow.working_directory, "fine_tune_utt2spk", "scp", dictionary_id + ) + text_path = job.construct_path( + workflow.working_directory, "fine_tune_text", "scp", dictionary_id + ) + + columns = [ + PhoneInterval.utterance_id, + Phone.kaldi_label, + PhoneInterval.id, + PhoneInterval.begin, + PhoneInterval.end, + SoundFile.sox_string, + SoundFile.sound_file_path, + SoundFile.sample_rate, + Utterance.channel, + Utterance.speaker_id, + Utterance.file_id, + ] + utterance_ends = { + k: v + for k, v in session.query(Utterance.id, Utterance.end).filter( + Utterance.job_id == self.job_name + ) + } + bn = DictBundle("interval_data", *columns) + + interval_query = ( + session.query(bn) + .join(PhoneInterval.phone) + .join(PhoneInterval.utterance) + .join(Utterance.file) + .join(File.sound_file) + .filter(Utterance.job_id == self.job_name) + .filter(PhoneInterval.workflow_id == workflow.id) + .order_by(PhoneInterval.utterance_id, PhoneInterval.begin) + ) + wav_data = {} + utt2spk_data = {} + segment_data = {} + text_data = {} + prev_label = None + current_id = None + for row in interval_query: + data = row.interval_data + if current_id is None: + current_id = data["utterance_id"] + label = data["kaldi_label"] + if current_id != data["utterance_id"] or prev_label is None: + self.utterance_initial_intervals[data["utterance_id"]] = { + "id": data["id"], + "begin": data["begin"], + "end": data["end"], + } + prev_label = label + current_id = data["utterance_id"] + continue + boundary_id = f"{data['utterance_id']}-{data['id']}" + utt2spk_data[boundary_id] = data["speaker_id"] + sox_string = data["sox_string"] + if not sox_string: + sox_string = f'sox "{data["sound_file_path"]}" -t wav -b 16 -r 16000 - |' + wav_data[str(data["file_id"])] = sox_string + interval_begin = data["begin"] + self.original_intervals[data["id"]] = { + "begin": data["begin"], + "end": data["end"], + "utterance_id": data["utterance_id"], + } + segment_begin = round(interval_begin - self.padding, 4) + feature_segment_begin = round( + interval_begin - (self.padding * self.feature_padding_factor), 4 + ) + if segment_begin < 0: + segment_begin = 0 + if feature_segment_begin < 0: + feature_segment_begin = 0 + begin_offset = round(segment_begin - feature_segment_begin, 4) + segment_end = round(interval_begin + self.padding, 4) + feature_segment_end = round( + interval_begin + (self.padding * self.feature_padding_factor), 4 + ) + if segment_end > utterance_ends[data["utterance_id"]]: + segment_end = utterance_ends[data["utterance_id"]] + if feature_segment_end > utterance_ends[data["utterance_id"]]: + feature_segment_end = utterance_ends[data["utterance_id"]] + end_offset = round(segment_end - feature_segment_begin, 4) + self.segment_begins[data["id"]] = segment_begin + self.segment_ends[data["id"]] = data["end"] + segment_data[boundary_id] = ( + map( + str, + [ + data["file_id"], + f"{feature_segment_begin:.4f}", + f"{feature_segment_end:.4f}", + data["channel"], + ], + ), + map(str, [boundary_id, f"{begin_offset:.4f}", f"{end_offset:.4f}"]), + ) + + text_data[ + boundary_id + ] = f"{self.phone_to_group_mapping[prev_label]} {self.phone_to_group_mapping[label]}" + prev_label = label + + with mfa_open(utt2spk_path, "w") as f: + for k, v in sorted(utt2spk_data.items()): + f.write(f"{k} {v}\n") + with mfa_open(wav_path, "w") as f: + for k, v in sorted(wav_data.items()): + f.write(f"{k} {v}\n") + with mfa_open(segment_path, "w") as f, mfa_open(feature_segment_path, "w") as feature_f: + for k, v in sorted(segment_data.items()): + f.write(f"{k} {' '.join(v[0])}\n") + feature_f.write(f"{k} {' '.join(v[1])}\n") + with mfa_open(text_path, "w") as f: + for k, v in sorted(text_data.items()): + f.write(f"{k} {v}\n") + + def _run(self) -> typing.Generator[typing.Tuple[int, float]]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + workflow: CorpusWorkflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + + reversed_phone_mapping = {} + phone_mapping = {} + phone_query = session.query(Phone.mapping_id, Phone.id, Phone.kaldi_label) + for m_id, p_id, phone in phone_query: + reversed_phone_mapping[m_id] = p_id + phone_mapping[phone] = m_id + + lexicon_path = os.path.join(workflow.working_directory, "phone.fst") + group_mapping_path = os.path.join(workflow.working_directory, "groups.txt") + fst = pynini.Fst() + initial_state = fst.add_state() + fst.set_start(initial_state) + fst.set_final(initial_state, 0) + processed = set() + if self.position_dependent_phones: + self.grouped_phones["silence"] = ["sil", "sil_B", "sil_I", "sil_E", "sil_S"] + self.grouped_phones["unknown"] = ["spn", "spn_B", "spn_I", "spn_E", "spn_S"] + else: + self.grouped_phones["silence"] = ["sil"] + self.grouped_phones["unknown"] = ["spn"] + group_set = [""] + sorted(k for k in self.grouped_phones.keys()) + group_mapping = {k: i for i, k in enumerate(group_set)} + self.phone_to_group_mapping = {} + for k, group in self.grouped_phones.items(): + for p in group: + self.phone_to_group_mapping[p] = group_mapping[k] + fst.add_arc( + initial_state, + pywrapfst.Arc(phone_mapping[p], group_mapping[k], 0, initial_state), + ) + processed.update(group) + with mfa_open(group_mapping_path, "w") as f: + for i, k in group_mapping.items(): + f.write(f"{k} {i}\n") + for phone, i in phone_mapping.items(): + if phone in processed: + continue + fst.add_arc(initial_state, pywrapfst.Arc(i, i, 0, initial_state)) + fst.arcsort("olabel") + fst.write(lexicon_path) + min_length = round(self.frame_shift_seconds / 3, 4) + cmvn_paths = job.per_dictionary_cmvn_scp_paths + for d_id in job.dictionary_ids: + cmvn_path = cmvn_paths[d_id] + wav_path = job.construct_path( + workflow.working_directory, "fine_tune_wav", "scp", d_id + ) + segment_path = job.construct_path( + workflow.working_directory, "fine_tune_segments", "scp", d_id + ) + feature_segment_path = job.construct_path( + workflow.working_directory, "fine_tune_feature_segments", "scp", d_id + ) + utt2spk_path = job.construct_path( + workflow.working_directory, "fine_tune_utt2spk", "scp", d_id + ) + text_path = job.construct_path( + workflow.working_directory, "fine_tune_text", "scp", d_id + ) + pitch_ark_path = job.construct_path( + workflow.working_directory, "fine_tune_pitch", "ark", d_id + ) + mfcc_ark_path = job.construct_path( + workflow.working_directory, "fine_tune_mfcc", "ark", d_id + ) + feats_ark_path = job.construct_path( + workflow.working_directory, "fine_tune_feats", "ark", d_id + ) + + fmllr_path = job.construct_path(workflow.working_directory, "trans", "ark", d_id) + + self.setup_files(session, job, workflow, d_id) + fst_ark_path = job.construct_path( + workflow.working_directory, "fine_tune_fsts", "ark", d_id + ) + proc = subprocess.Popen( + [ + thirdparty_binary("compile-train-graphs"), + f"--read-disambig-syms={self.disambiguation_symbols_int_path}", + self.tree_path, + self.model_path, + lexicon_path, + f"ark,s,cs:{text_path}", + f"ark:{fst_ark_path}", ], stderr=log_file, + env=os.environ, + ) + proc.communicate() + + seg_proc = subprocess.Popen( + [ + thirdparty_binary("extract-segments"), + f"--min-segment-length={min_length}", + f"scp:{wav_path}", + segment_path, + "ark:-", + ], stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + mfcc_proc = compute_mfcc_process( + log_file, wav_path, subprocess.PIPE, self.mfcc_options + ) + cmvn_proc = subprocess.Popen( + [ + "apply-cmvn", + f"--utt2spk=ark:{utt2spk_path}", + f"scp:{cmvn_path}", + "ark:-", + f"ark:{mfcc_ark_path}", + ], env=os.environ, + stdin=mfcc_proc.stdout, + stderr=log_file, + ) + + use_pitch = self.pitch_options["use-pitch"] or self.pitch_options["use-voicing"] + if use_pitch: + pitch_proc = compute_pitch_process( + log_file, wav_path, subprocess.PIPE, self.pitch_options + ) + pitch_copy_proc = subprocess.Popen( + [ + thirdparty_binary("copy-feats"), + "--compress=true", + "ark:-", + f"ark:{pitch_ark_path}", + ], + stdin=pitch_proc.stdout, + stderr=log_file, + env=os.environ, + ) + for line in seg_proc.stdout: + mfcc_proc.stdin.write(line) + mfcc_proc.stdin.flush() + if use_pitch: + pitch_proc.stdin.write(line) + pitch_proc.stdin.flush() + mfcc_proc.stdin.close() + if use_pitch: + pitch_proc.stdin.close() + cmvn_proc.wait() + if use_pitch: + pitch_copy_proc.wait() + if use_pitch: + paste_proc = subprocess.Popen( + [ + thirdparty_binary("paste-feats"), + "--length-tolerance=2", + f"ark:{mfcc_ark_path}", + f"ark:{pitch_ark_path}", + f"ark:{feats_ark_path}", + ], + stderr=log_file, + env=os.environ, + ) + paste_proc.wait() + else: + feats_ark_path = mfcc_ark_path + + extract_proc = subprocess.Popen( + [ + thirdparty_binary("extract-feature-segments"), + f"--min-segment-length={min_length}", + f"--frame-shift={self.new_frame_shift}", + f'--snip-edges={self.mfcc_options["snip-edges"]}', + f"ark,s,cs:{feats_ark_path}", + feature_segment_path, + "ark:-", + ], + stdin=paste_proc.stdout, + stderr=log_file, + stdout=subprocess.PIPE, + env=os.environ, + ) + trans_proc = compute_transform_process( + log_file, + extract_proc, + utt2spk_path, + workflow.lda_mat_path, + fmllr_path, + self.lda_options, ) align_proc = subprocess.Popen( - com, + [ + thirdparty_binary("gmm-align-compiled"), + f"--transition-scale={self.align_options['transition_scale']}", + f"--acoustic-scale={self.align_options['acoustic_scale']}", + f"--self-loop-scale={self.align_options['self_loop_scale']}", + f"--beam={self.align_options['beam']}", + f"--retry-beam={self.align_options['retry_beam']}", + "--careful=false", + self.model_path, + f"ark,s,cs:{fst_ark_path}", + "ark,s,cs:-", + "ark:-", + ], stdout=subprocess.PIPE, stderr=log_file, + stdin=trans_proc.stdout, + env=os.environ, + ) + + ctm_proc = subprocess.Popen( + [ + thirdparty_binary("ali-to-phones"), + "--ctm-output", + f"--frame-shift={self.new_frame_shift_seconds}", + self.model_path, + "ark,s,cs:-", + "-", + ], + stderr=log_file, + stdin=align_proc.stdout, + stdout=subprocess.PIPE, + env=os.environ, encoding="utf8", - stdin=boost_proc.stdout, + ) + interval_mapping = [] + current_utterance = None + for boundary_id, ctm_intervals in parse_ctm_output( + ctm_proc, reversed_phone_mapping, raw_id=True + ): + utterance_id, interval_id = boundary_id.split("-") + interval_id = int(interval_id) + utterance_id = int(utterance_id) + + if current_utterance is None: + current_utterance = utterance_id + if current_utterance != utterance_id: + interval_mapping = sorted(interval_mapping, key=lambda x: x["id"]) + interval_mapping.insert( + 0, self.utterance_initial_intervals[current_utterance] + ) + + deletions = [] + while True: + for i in range(len(interval_mapping) - 1): + if interval_mapping[i]["end"] != interval_mapping[i + 1]["begin"]: + interval_mapping[i]["end"] = interval_mapping[i + 1]["begin"] + new_deletions = [ + x["id"] for x in interval_mapping if x["begin"] >= x["end"] + ] + interval_mapping = [ + x for x in interval_mapping if x["id"] not in new_deletions + ] + deletions.extend(new_deletions) + if not new_deletions and all( + interval_mapping[i]["end"] == interval_mapping[i + 1]["begin"] + for i in range(len(interval_mapping) - 1) + ): + break + yield interval_mapping, deletions + interval_mapping = [] + current_utterance = utterance_id + interval_mapping.append( + { + "id": interval_id, + "begin": round( + ctm_intervals[1].begin + self.segment_begins[interval_id], 4 + ), + "end": self.original_intervals[interval_id]["end"], + "label": ctm_intervals[1].label, + } + ) + if interval_mapping: + deletions = [] + while True: + for i in range(len(interval_mapping) - 1): + if interval_mapping[i]["end"] != interval_mapping[i + 1]["begin"]: + interval_mapping[i]["end"] = interval_mapping[i + 1]["begin"] + new_deletions = [ + x["id"] for x in interval_mapping if x["begin"] >= x["end"] + ] + interval_mapping = [ + x for x in interval_mapping if x["id"] not in new_deletions + ] + deletions.extend(new_deletions) + if not new_deletions and all( + interval_mapping[i]["end"] == interval_mapping[i + 1]["begin"] + for i in range(len(interval_mapping) - 1) + ): + break + yield interval_mapping, deletions + self.check_call(ctm_proc) + + +class PhoneConfidenceFunction(KaldiFunction): + """ + Multiprocessing function to calculate phone confidence metrics + + See Also + -------- + :kaldi_src:`gmm-compute-likes` + Relevant Kaldi binary + :kaldi_src:`transform-feats` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.alignment.multiprocessing.PhoneConfidenceArguments` + Arguments for the function + """ + + def __init__(self, args: PhoneConfidenceArguments): + super().__init__(args) + self.model_path = args.model_path + self.phone_pdf_counts_path = args.phone_pdf_counts_path + self.feature_strings = args.feature_strings + + def _run(self) -> typing.Generator[typing.Tuple[int, str]]: + """Run the function""" + with Session(self.db_engine) as session: + utterances = ( + session.query(Utterance) + .filter(Utterance.job_id == self.job_name) + .options( + selectinload(Utterance.phone_intervals).joinedload( + PhoneInterval.phone, innerjoin=True + ) + ) + ) + utterances = {u.id: (u.begin, u.phone_intervals) for u in utterances} + phone_mapping = {p.phone: p.id for p in session.query(Phone)} + + with mfa_open(self.phone_pdf_counts_path, "r") as f: + data = json.load(f) + phone_pdf_mapping = collections.defaultdict(collections.Counter) + for phone, pdf_counts in data.items(): + phone = split_phone_position(phone)[0] + for pdf, count in pdf_counts.items(): + phone_pdf_mapping[phone][int(pdf)] += count + phones = {p: i for i, p in enumerate(sorted(phone_pdf_mapping.keys()))} + reversed_phones = {k: v for v, k in phones.items()} + + for phone, pdf_counts in phone_pdf_mapping.items(): + phone_total = sum(pdf_counts.values()) + for pdf, count in pdf_counts.items(): + phone_pdf_mapping[phone][int(pdf)] = count / phone_total + + with mfa_open(self.log_path, "w") as log_file: + for dict_id in self.feature_strings.keys(): + feature_string = self.feature_strings[dict_id] + output_proc = subprocess.Popen( + [ + thirdparty_binary("gmm-compute-likes"), + self.model_path, + feature_string, + "ark,t:-", + ], + stderr=log_file, + stdout=subprocess.PIPE, env=os.environ, ) - for line in align_proc.stdout: - line = line.strip() - utterance, log_likelihood = line.split() - u_id = int(utterance.split("-")[-1]) - yield u_id, float(log_likelihood) - self.check_call(align_proc) + interval_mappings = [] + new_interval_mappings = [] + for utterance_id, likelihoods in read_feats(output_proc): + phone_likes = np.zeros((likelihoods.shape[0], len(phones))) + for i, p in reversed_phones.items(): + like = likelihoods[:, [x for x in phone_pdf_mapping[p].keys()]] + weight = np.array([x for x in phone_pdf_mapping[p].values()]) + phone_likes[:, i] = np.dot(like, weight) + top_phone_inds = np.argmax(phone_likes, axis=1) + utt_begin, intervals = utterances[utterance_id] + for pi in intervals: + if pi.phone.phone == "sil": + continue + frame_begin = int(((pi.begin - utt_begin) * 1000) / 10) + frame_end = int(((pi.end - utt_begin) * 1000) / 10) + if frame_begin == frame_end: + frame_end += 1 + alternate_labels = collections.Counter() + scores = [] + + for i in range(frame_begin, frame_end): + top_phone_ind = top_phone_inds[i] + alternate_label = reversed_phones[top_phone_ind] + alternate_label = split_phone_position(alternate_label)[0] + alternate_labels[alternate_label] += 1 + if alternate_label == pi.phone.phone: + scores.append(0) + else: + actual_score = phone_likes[i, phones[pi.phone.phone]] + scores.append(phone_likes[i, top_phone_ind] - actual_score) + average_score = statistics.mean(scores) + alternate_label = max(alternate_labels, key=lambda x: alternate_labels[x]) + interval_mappings.append({"id": pi.id, "phone_goodness": average_score}) + new_interval_mappings.append( + { + "begin": pi.begin, + "end": pi.end, + "utterance_id": pi.utterance_id, + "phone_id": phone_mapping[alternate_label], + } + ) + yield interval_mappings + interval_mappings = [] + self.check_call(output_proc) class GeneratePronunciationsFunction(KaldiFunction): @@ -524,13 +1554,9 @@ class GeneratePronunciationsFunction(KaldiFunction): def __init__(self, args: GeneratePronunciationsArguments): super().__init__(args) - self.text_int_paths = args.text_int_paths - self.ali_paths = args.ali_paths self.model_path = args.model_path self.for_g2p = args.for_g2p self.reversed_phone_mapping = {} - self.word_boundary_int_paths = {} - self.reversed_word_mapping = {} self.silence_words = set() def _process_pronunciations( @@ -571,112 +1597,105 @@ def _process_pronunciations( def _run(self) -> typing.Generator[typing.Tuple[int, int, str]]: """Run the function""" - db_engine = sqlalchemy.create_engine(f"sqlite:///{self.db_path}?mode=ro&nolock=1") - with mfa_open(self.log_path, "w") as log_file, Session(db_engine) as session: - phones = session.query(Phone.phone, Phone.mapping_id) + self.phone_symbol_table = None + with mfa_open(self.log_path, "w") as log_file, Session(self.db_engine) as session: + job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + workflow: CorpusWorkflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + phones = session.query(Phone.kaldi_label, Phone.mapping_id) for phone, mapping_id in phones: self.reversed_phone_mapping[mapping_id] = phone - for dict_id in self.text_int_paths.keys(): - d = session.query(Dictionary).get(dict_id) - self.position_dependent_phones = d.position_dependent_phones + for d in job.dictionaries: + utts = ( + session.query(Utterance.id, Utterance.normalized_text) + .join(Utterance.speaker) + .filter(Utterance.job_id == self.job_name) + .filter(Speaker.dictionary_id == d.id) + ) + self.utterance_texts = {} + for u_id, text in utts: + self.utterance_texts[u_id] = text + if self.phone_symbol_table is None: + self.phone_symbol_table = pywrapfst.SymbolTable.read_text( + d.phone_symbol_table_path + ) + self.word_symbol_table = pywrapfst.SymbolTable.read_text(d.words_symbol_path) + self.align_lexicon_fst = pynini.Fst.read(d.align_lexicon_path) self.clitic_marker = d.clitic_marker self.silence_words.add(d.silence_word) self.oov_word = d.oov_word self.optional_silence_phone = d.optional_silence_phone - self.word_boundary_int_paths[d.id] = d.word_boundary_int_path - self.reversed_word_mapping[d.id] = {} + self.oov_phone = d.oov_phone silence_words = ( session.query(Word.word) - .filter(Word.dictionary_id == dict_id) + .filter(Word.dictionary_id == d.id) .filter(Word.word_type == WordType.silence) ) self.silence_words.update(x for x, in silence_words) - words = session.query(Word.mapping_id, Word.word).filter( - Word.dictionary_id == dict_id - ) - for w_id, w in words: - self.reversed_word_mapping[d.id][w_id] = w - current_utterance = None - word_pronunciations = [] - text_int_path = self.text_int_paths[dict_id] - word_boundary_path = self.word_boundary_int_paths[dict_id] - ali_path = self.ali_paths[dict_id] + ali_path = job.construct_path(workflow.working_directory, "ali", "ark", d.id) if not os.path.exists(ali_path): continue - lin_proc = subprocess.Popen( - [ - thirdparty_binary("linear-to-nbest"), - f"ark:{ali_path}", - f"ark:{text_int_path}", - "", - "", - "ark:-", - ], - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - align_proc = subprocess.Popen( + + ctm_proc = subprocess.Popen( [ - thirdparty_binary("lattice-align-words"), - word_boundary_path, + thirdparty_binary("ali-to-phones"), + "--ctm-output", self.model_path, - "ark:-", - "ark:-", + f"ark,s,cs:{ali_path}", + "-", ], - stdin=lin_proc.stdout, - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - - prons_proc = subprocess.Popen( - [thirdparty_binary("nbest-to-prons"), self.model_path, "ark:-", "-"], - stdin=align_proc.stdout, stderr=log_file, - encoding="utf8", stdout=subprocess.PIPE, env=os.environ, + encoding="utf8", ) - for line in prons_proc.stdout: - line = line.strip().split() - utt = line[0] - if utt != current_utterance and current_utterance is not None: - log_file.write(f"{current_utterance}\t{word_pronunciations}\n") - if self.for_g2p: - phones = "" - for x in word_pronunciations: - phones += x[1] + " " - yield dict_id, current_utterance, phones.strip() - else: - yield dict_id, self._process_pronunciations(word_pronunciations) - word_pronunciations = [] - current_utterance = utt - pron = [int(x) for x in line[4:]] - word = self.reversed_word_mapping[dict_id][int(line[3])] - if self.for_g2p: - pron = " ".join(self.reversed_phone_mapping[x] for x in pron) - else: - if self.position_dependent_phones: - pron = " ".join( - split_phone_position(self.reversed_phone_mapping[x])[0] - for x in pron - ) - else: - pron = " ".join(self.reversed_phone_mapping[x] for x in pron) - word_pronunciations.append((word, pron)) - if word_pronunciations: + for utterance, intervals in parse_ctm_output( + ctm_proc, self.reversed_phone_mapping + ): + word_pronunciations = phones_to_prons( + self.utterance_texts[utterance], + intervals, + self.align_lexicon_fst, + self.word_symbol_table, + self.phone_symbol_table, + self.optional_silence_phone, + ) + if d.position_dependent_phones: + word_pronunciations = [ + (x[0], [split_phone_position(y)[0] for y in x[1]]) + for x in word_pronunciations + ] + word_pronunciations = [(x[0], " ".join(x[1])) for x in word_pronunciations] + word_pronunciations = [ + x if x[1] != self.oov_phone else (self.oov_word, self.oov_phone) + for x in word_pronunciations + ] if self.for_g2p: - phones = "" - for x in word_pronunciations: - phones += x[1] + " " - yield dict_id, current_utterance, phones.strip() + phones = [] + for i, x in enumerate(word_pronunciations): + if i > 0 and ( + x[0].startswith(self.clitic_marker) + or word_pronunciations[i - 1][0].endswith(self.clitic_marker) + ): + phones.pop(-1) + else: + phones.append("#1") + phones.extend(x[1].split()) + phones.append("#2") + yield d.id, utterance, " ".join(phones) else: - yield dict_id, self._process_pronunciations(word_pronunciations) - - self.check_call(prons_proc) + yield d.id, self._process_pronunciations(word_pronunciations) + self.check_call(ctm_proc) def compile_information_func( @@ -770,17 +1789,21 @@ def __init__(self, args: AlignmentExtractionArguments): super().__init__(args) self.model_path = args.model_path self.frame_shift = args.frame_shift - self.ali_paths = args.ali_paths - self.text_int_paths = args.text_int_paths - self.cleanup_textgrids = args.cleanup_textgrids - self.utterance_texts = {} self.utterance_begins = {} - self.word_boundary_int_paths = {} self.reversed_phone_mapping = {} - self.words = {} + self.reversed_word_mapping = {} + self.pronunciation_mapping = {} + self.phone_mapping = {} self.silence_words = set() + self.confidence = args.confidence + self.transcription = args.transcription + self.score_options = args.score_options - def cleanup_intervals(self, utterance_name: int, dict_id: int, intervals: List[CtmInterval]): + def cleanup_intervals( + self, + utterance_name, + intervals: List[CtmInterval], + ): """ Clean up phone intervals to remove silence @@ -794,216 +1817,399 @@ def cleanup_intervals(self, utterance_name: int, dict_id: int, intervals: List[C list[:class:`~montreal_forced_aligner.data.CtmInterval`] Cleaned up intervals """ + word_pronunciations = phones_to_prons( + self.utterance_texts[utterance_name], + intervals, + self.align_lexicon_fst, + self.word_symbol_table, + self.phone_symbol_table, + self.optional_silence_phone, + self.transcription, + ) actual_phone_intervals = [] actual_word_intervals = [] - utterance_name = utterance_name + phone_word_mapping = [] utterance_begin = self.utterance_begins[utterance_name] current_word_begin = None - words = self.utterance_texts[utterance_name] words_index = 0 current_phones = [] for interval in intervals: interval.begin += utterance_begin interval.end += utterance_begin - phone_label = self.reversed_phone_mapping[int(interval.label)] - if phone_label == self.optional_silence_phone: - if words_index < len(words) and words[words_index] in self.silence_words: - interval.label = phone_label - actual_phone_intervals.append(interval) - actual_word_intervals.append( - CtmInterval( - interval.begin, interval.end, words[words_index], utterance_name - ) + if interval.label == self.optional_silence_phone: + interval.label = self.phone_to_phone_id[interval.label] + actual_phone_intervals.append(interval) + actual_word_intervals.append( + WordCtmInterval( + interval.begin, + interval.end, + self.word_mapping[self.silence_word], + self.pronunciation_mapping[ + (self.silence_word, self.optional_silence_phone) + ], ) - current_word_begin = None - current_phones = [] - words_index += 1 - continue - elif self.cleanup_textgrids: - continue + ) + phone_word_mapping.append(len(actual_word_intervals) - 1) + current_word_begin = None + current_phones = [] + continue + if current_word_begin is None: + current_word_begin = interval.begin + current_phones.append(interval.label) + try: + cur_word = word_pronunciations[words_index] + except IndexError: + if self.transcription: + break else: - interval.label = phone_label - actual_phone_intervals.append(interval) - continue - if self.position_dependent_phones and "_" in phone_label: - phone, position = split_phone_position(phone_label) - if position in {"B", "S"}: - current_word_begin = interval.begin - if position in {"E", "S"}: + raise + pronunciation = " ".join(cur_word[1]) + if self.position_dependent_phones: + pronunciation = re.sub(r"_[BIES]\b", "", pronunciation) + if current_phones == cur_word[1]: + if ( + pronunciation == self.oov_phone + and (cur_word[0], pronunciation) not in self.pronunciation_mapping + ): + pron_id = self.pronunciation_mapping[(self.oov_word, pronunciation)] + else: + pron_id = self.pronunciation_mapping.get((cur_word[0], pronunciation), None) + actual_word_intervals.append( + WordCtmInterval( + current_word_begin, + interval.end, + self.word_mapping[cur_word[0]], + pron_id, + ) + ) + for _ in range(len(current_phones)): + phone_word_mapping.append(len(actual_word_intervals) - 1) + current_word_begin = None + current_phones = [] + words_index += 1 + interval.label = self.phone_to_phone_id[interval.label] + actual_phone_intervals.append(interval) + return actual_word_intervals, actual_phone_intervals, phone_word_mapping + + def cleanup_g2p_intervals( + self, + utterance_name, + intervals: List[CtmInterval], + ): + """ + Clean up phone intervals to remove silence + + Parameters + ---------- + intervals: list[:class:`~montreal_forced_aligner.data.CtmInterval`] + Intervals to process + + Returns + ------- + list[:class:`~montreal_forced_aligner.data.CtmInterval`] + Cleaned up intervals + """ + word_pronunciations = phones_to_prons( + self.utterance_texts[utterance_name], + intervals, + self.align_lexicon_fst, + self.word_symbol_table, + self.phone_symbol_table, + self.optional_silence_phone, + clitic_marker=self.clitic_marker, + ) + actual_phone_intervals = [] + actual_word_intervals = [] + phone_word_mapping = [] + utterance_begin = self.utterance_begins[utterance_name] + current_word_begin = None + words_index = 0 + current_phones = [] + for interval in intervals: + interval.begin += utterance_begin + interval.end += utterance_begin + if interval.label == self.optional_silence_phone: + interval.label = self.phone_to_phone_id[interval.label] + actual_phone_intervals.append(interval) + actual_word_intervals.append( + WordCtmInterval( + interval.begin, + interval.end, + self.word_mapping[self.silence_word], + None, + ) + ) + phone_word_mapping.append(len(actual_word_intervals) - 1) + current_word_begin = None + current_phones = [] + continue + if current_word_begin is None: + current_word_begin = interval.begin + current_phones.append(interval.label) + cur_word = word_pronunciations[words_index] + pronunciation = " ".join(cur_word[1]) + if self.position_dependent_phones: + pronunciation = re.sub(r"_[BIES]\b", "", pronunciation) + if current_phones == cur_word[1]: + try: if ( - self.cleanup_textgrids - and actual_word_intervals - and self.clitic_marker - and ( - actual_word_intervals[-1].label.endswith(self.clitic_marker) - or words[words_index].endswith(self.clitic_marker) - ) + pronunciation == self.oov_phone + and (cur_word[0], pronunciation) not in self.pronunciation_mapping ): - actual_word_intervals[-1].end = interval.end - actual_word_intervals[-1].label += words[words_index] + pron_id = self.pronunciation_mapping[(self.oov_word, pronunciation)] else: - actual_word_intervals.append( - CtmInterval( - current_word_begin, - interval.end, - words[words_index], - utterance_name, - ) - ) - words_index += 1 - current_word_begin = None - interval.label = phone - else: - if current_word_begin is None: - current_word_begin = interval.begin - current_phones.append(phone_label) - cur_word = words[words_index] - if cur_word not in self.words[dict_id]: - cur_word = self.oov_word - if tuple(current_phones) in self.words[dict_id][cur_word]: - actual_word_intervals.append( - CtmInterval( - current_word_begin, interval.end, words[words_index], utterance_name - ) + pron_id = self.pronunciation_mapping[(cur_word[0], pronunciation)] + except KeyError: + pron_id = None + try: + word_id = self.word_mapping[cur_word[0]] + except KeyError: + word_id = cur_word[0] + actual_word_intervals.append( + WordCtmInterval( + current_word_begin, + interval.end, + word_id, + pron_id, ) - current_word_begin = None - current_phones = [] - words_index += 1 + ) + for _ in range(len(current_phones)): + phone_word_mapping.append(len(actual_word_intervals) - 1) + current_word_begin = None + current_phones = [] + words_index += 1 + interval.label = self.phone_to_phone_id[interval.label] actual_phone_intervals.append(interval) - return actual_word_intervals, actual_phone_intervals + return actual_word_intervals, actual_phone_intervals, phone_word_mapping def _run(self) -> typing.Generator[typing.Tuple[int, List[CtmInterval], List[CtmInterval]]]: """Run the function""" - db_engine = sqlalchemy.create_engine(f"sqlite:///{self.db_path}?mode=ro&nolock=1") - with Session(db_engine) as session: - for dict_id in self.ali_paths.keys(): - d = session.query(Dictionary).get(dict_id) + align_lexicon_paths = {} + self.phone_symbol_table = None + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + workflow: CorpusWorkflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) - self.position_dependent_phones = d.position_dependent_phones + self.phone_to_phone_id = {} + ds = session.query(Phone.kaldi_label, Phone.id, Phone.mapping_id).all() + for phone, p_id, mapping_id in ds: + self.reversed_phone_mapping[mapping_id] = phone + self.phone_to_phone_id[phone] = p_id + self.phone_mapping[phone] = mapping_id + + for d in job.dictionaries: + columns = [Utterance.id, Utterance.begin] + if d.use_g2p: + columns.append(Utterance.normalized_character_text) + else: + columns.append(Utterance.normalized_text) + utts = ( + session.query(*columns) + .join(Utterance.speaker) + .filter(Utterance.job_id == self.job_name) + .filter(Speaker.dictionary_id == d.id) + ) + self.utterance_begins = {} + self.utterance_texts = {} + for u_id, begin, text in utts: + self.utterance_begins[u_id] = begin + self.utterance_texts[u_id] = text + if self.phone_symbol_table is None: + self.phone_symbol_table = pywrapfst.SymbolTable.read_text( + d.phone_symbol_table_path + ) + self.align_lexicon_fst = pynini.Fst.read(d.align_lexicon_path) + if d.use_g2p: + self.word_symbol_table = pywrapfst.SymbolTable.read_text( + d.grapheme_symbol_table_path + ) + self.align_lexicon_fst.invert() + else: + self.word_symbol_table = pywrapfst.SymbolTable.read_text(d.words_symbol_path) self.clitic_marker = d.clitic_marker self.silence_word = d.silence_word self.oov_word = d.oov_word + self.oov_phone = "spn" + self.position_dependent_phones = d.position_dependent_phones self.optional_silence_phone = d.optional_silence_phone - self.word_boundary_int_paths[dict_id] = d.word_boundary_int_path - + if self.transcription or self.confidence: + align_lexicon_paths[d.id] = d.align_lexicon_int_path + else: + align_lexicon_paths[d.id] = d.align_lexicon_path silence_words = ( - session.query(Word.word) - .filter(Word.dictionary_id == dict_id) + session.query(Word.id) + .filter(Word.dictionary_id == d.id) .filter(Word.word_type == WordType.silence) ) self.silence_words.update(x for x, in silence_words) - words = ( - session.query(Word.word, Pronunciation.pronunciation) - .join(Pronunciation.word) - .filter(Word.dictionary_id == dict_id) - .filter(Word.word_type != WordType.silence) - ) - self.words[dict_id] = {} - for w, pron in words: - if w not in self.words[dict_id]: - self.words[dict_id][w] = set() - self.words[dict_id][w].add(tuple(pron.split(" "))) - utts = ( - session.query(Utterance) - .join(Utterance.speaker) - .options(load_only(Utterance.id, Utterance.normalized_text, Utterance.begin)) - .filter(Speaker.job_id == self.job_name) - ) - for utt in utts: - self.utterance_texts[utt.id] = utt.normalized_text.split() - self.utterance_begins[utt.id] = utt.begin - ds = session.query(Phone.phone, Phone.mapping_id).all() - for phone, mapping_id in ds: - self.reversed_phone_mapping[mapping_id] = phone - with mfa_open(self.log_path, "w") as log_file: - for dict_id in self.ali_paths.keys(): - cur_utt = None - intervals = [] - ali_path = self.ali_paths[dict_id] - text_int_path = self.text_int_paths[dict_id] - word_boundary_int_path = self.word_boundary_int_paths[dict_id] - lin_proc = subprocess.Popen( - [ - thirdparty_binary("linear-to-nbest"), - f"ark:{ali_path}", - f"ark:{text_int_path}", - "", - "", - "ark:-", - ], - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - align_words_proc = subprocess.Popen( - [ - thirdparty_binary("lattice-align-words"), - word_boundary_int_path, - self.model_path, - "ark:-", - "ark:-", - ], - stdin=lin_proc.stdout, - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - phone_proc = subprocess.Popen( - [ - thirdparty_binary("lattice-to-phone-lattice"), - self.model_path, - "ark:-", - "ark:-", - ], - stdout=subprocess.PIPE, - stdin=align_words_proc.stdout, - stderr=log_file, - env=os.environ, + words = session.query(Word.word, Word.id, Word.mapping_id).filter( + Word.dictionary_id == d.id ) - nbest_proc = subprocess.Popen( - [ - thirdparty_binary("nbest-to-ctm"), - "--print-args=false", - f"--frame-shift={self.frame_shift}", - "ark:-", - "-", - ], - stdin=phone_proc.stdout, - stderr=log_file, - stdout=subprocess.PIPE, - env=os.environ, - encoding="utf8", + self.word_mapping = {} + self.reversed_word_mapping = {} + for w, w_id, m_id in words: + self.word_mapping[w] = w_id + self.reversed_word_mapping[m_id] = w + self.pronunciation_mapping = {} + pronunciations = ( + session.query(Word.word, Pronunciation.pronunciation, Pronunciation.id) + .join(Pronunciation.word) + .filter(Word.dictionary_id == d.id) ) - for line in nbest_proc.stdout: - line = line.strip() - if not line: - continue - - try: - interval = process_ctm_line(line) - except ValueError: - continue - if cur_utt is None: - cur_utt = interval.utterance - if cur_utt != interval.utterance: - word_intervals, phone_intervals = self.cleanup_intervals( - cur_utt, dict_id, intervals - ) - yield cur_utt, word_intervals, phone_intervals - intervals = [] - cur_utt = interval.utterance - intervals.append(interval) - self.check_call(nbest_proc) - if intervals: - word_intervals, phone_intervals = self.cleanup_intervals( - cur_utt, dict_id, intervals + for w, pron, p_id in pronunciations: + self.pronunciation_mapping[(w, pron)] = p_id + + lat_path = job.construct_path( + workflow.working_directory, "lat.carpa.rescored", "ark", d.id ) - yield cur_utt, word_intervals, phone_intervals + if not os.path.exists(lat_path): + lat_path = job.construct_path(workflow.working_directory, "lat", "ark", d.id) + ali_path = job.construct_path(workflow.working_directory, "ali", "ark", d.id) + if self.transcription: + self.utterance_texts = {} + lat_align_proc = subprocess.Popen( + [ + thirdparty_binary("lattice-align-words-lexicon"), + align_lexicon_paths[d.id], + self.model_path, + f"ark,s,cs:{lat_path}", + "ark:-", + ], + stderr=log_file, + stdout=subprocess.PIPE, + env=os.environ, + ) + one_best_proc = subprocess.Popen( + [ + thirdparty_binary("lattice-best-path"), + f"--acoustic-scale={self.score_options['acoustic_scale']}", + "ark,s,cs:-", + "ark,t:-", + f"ark:{ali_path}", + ], + stderr=log_file, + stdin=lat_align_proc.stdout, + stdout=subprocess.PIPE, + env=os.environ, + ) + for line in one_best_proc.stdout: + line = line.strip().decode("utf8").split() + utt_id = int(line.pop(0).split("-")[1]) + text = " ".join([self.reversed_word_mapping[int(x)] for x in line]) + self.utterance_texts[utt_id] = text + + if self.confidence and os.path.exists(lat_path): + lat_align_proc = subprocess.Popen( + [ + thirdparty_binary("lattice-align-words-lexicon"), + align_lexicon_paths[d.id], + self.model_path, + f"ark,s,cs:{lat_path}", + "ark:-", + ], + stderr=log_file, + stdout=subprocess.PIPE, + env=os.environ, + ) + phone_lat_proc = subprocess.Popen( + [ + thirdparty_binary("lattice-to-phone-lattice"), + "--replace-words=true", + self.model_path, + "ark,s,cs:-", + "ark:-", + ], + stderr=log_file, + stdin=lat_align_proc.stdout, + stdout=subprocess.PIPE, + env=os.environ, + ) + ctm_proc = subprocess.Popen( + [ + thirdparty_binary("lattice-to-ctm-conf"), + f"--acoustic-scale={self.score_options['acoustic_scale']}", + "ark,s,cs:-", + "-", + ], + stderr=log_file, + stdin=phone_lat_proc.stdout, + stdout=subprocess.PIPE, + env=os.environ, + encoding="utf8", + ) + for utterance, intervals in parse_ctm_output( + ctm_proc, self.reversed_phone_mapping + ): + try: + ( + word_intervals, + phone_intervals, + phone_word_mapping, + ) = self.cleanup_intervals(utterance, intervals) + except pywrapfst.FstOpError: + log_file.write(f"Error for {utterance}\n") + log_file.write(f"{self.utterance_texts[utterance]}\n") + log_file.write(f"{' '.join(x.label for x in intervals)}\n") + log_file.flush() + continue + yield utterance, word_intervals, phone_intervals, phone_word_mapping + + self.check_call(ctm_proc) + else: + ctm_proc = subprocess.Popen( + [ + thirdparty_binary("ali-to-phones"), + "--ctm-output", + f"--frame-shift={self.frame_shift}", + self.model_path, + f"ark,s,cs:{ali_path}", + "-", + ], + stderr=log_file, + stdout=subprocess.PIPE, + env=os.environ, + encoding="utf8", + ) + for utterance, intervals in parse_ctm_output( + ctm_proc, self.reversed_phone_mapping + ): + if not d.use_g2p: + + ( + word_intervals, + phone_intervals, + phone_word_mapping, + ) = self.cleanup_intervals(utterance, intervals) + else: + try: + ( + word_intervals, + phone_intervals, + phone_word_mapping, + ) = self.cleanup_g2p_intervals(utterance, intervals) + except pywrapfst.FstOpError: + continue + yield utterance, word_intervals, phone_intervals, phone_word_mapping + self.check_call(ctm_proc) def construct_output_tiers( - session: Session, file: File + session: Session, + file_id: int, + workflow: CorpusWorkflow, + cleanup_textgrids: bool, + clitic_marker: str, + include_original_text: bool, ) -> Dict[str, Dict[str, List[CtmInterval]]]: """ Construct aligned output tiers for a file @@ -1020,17 +2226,69 @@ def construct_output_tiers( Dict[str, Dict[str,List[CtmInterval]]] Aligned tiers """ + utterances = ( + session.query(Utterance) + .options( + joinedload(Utterance.speaker, innerjoin=True).load_only(Speaker.name), + ) + .filter(Utterance.file_id == file_id) + ) data = {} - for utt in file.utterances: + for utt in utterances: + word_intervals = ( + session.query(WordInterval, Word) + .join(WordInterval.word) + .filter(WordInterval.utterance_id == utt.id) + .filter(WordInterval.workflow_id == workflow.id) + .options( + selectinload(WordInterval.phone_intervals).joinedload( + PhoneInterval.phone, innerjoin=True + ) + ) + .order_by(WordInterval.begin) + ) + if cleanup_textgrids: + word_intervals = word_intervals.filter(Word.word_type != WordType.silence) if utt.speaker.name not in data: data[utt.speaker.name] = {"words": [], "phones": []} - for wi in utt.word_intervals: - data[utt.speaker.name]["words"].append(CtmInterval(wi.begin, wi.end, wi.label, utt.id)) + if include_original_text: + data[utt.speaker.name]["utterances"] = [] + actual_words = utt.normalized_text.split() + if include_original_text: + data[utt.speaker.name]["utterances"].append(CtmInterval(utt.begin, utt.end, utt.text)) + for i, (wi, w) in enumerate(word_intervals.all()): + if len(wi.phone_intervals) == 0: + continue + label = w.word + if cleanup_textgrids: + if ( + w.word_type is WordType.oov + and workflow.workflow_type is WorkflowType.alignment + ): + label = actual_words[i] + if ( + data[utt.speaker.name]["words"] + and clitic_marker + and ( + data[utt.speaker.name]["words"][-1].label.endswith(clitic_marker) + or label.startswith(clitic_marker) + ) + ): + data[utt.speaker.name]["words"][-1].end = wi.end + data[utt.speaker.name]["words"][-1].label += label - for pi in utt.phone_intervals: - data[utt.speaker.name]["phones"].append( - CtmInterval(pi.begin, pi.end, pi.label, utt.id) - ) + for pi in sorted(wi.phone_intervals, key=lambda x: x.begin): + data[utt.speaker.name]["phones"].append( + CtmInterval(pi.begin, pi.end, pi.phone.phone) + ) + continue + + data[utt.speaker.name]["words"].append(CtmInterval(wi.begin, wi.end, label)) + + for pi in wi.phone_intervals: + data[utt.speaker.name]["phones"].append( + CtmInterval(pi.begin, pi.end, pi.phone.phone) + ) return data @@ -1095,7 +2353,7 @@ class ExportTextGridProcessWorker(mp.Process): def __init__( self, - db_path: str, + db_string: str, for_write_queue: mp.Queue, return_queue: mp.Queue, stopped: Stopped, @@ -1104,7 +2362,7 @@ def __init__( exported_file_count: Counter, ): mp.Process.__init__(self) - self.db_path = db_path + self.db_string = db_string self.for_write_queue = for_write_queue self.return_queue = return_queue self.stopped = stopped @@ -1113,16 +2371,27 @@ def __init__( self.output_directory = arguments.output_directory self.output_format = arguments.output_format - self.frame_shift = arguments.frame_shift + self.export_frame_shift = arguments.export_frame_shift self.log_path = arguments.log_path self.include_original_text = arguments.include_original_text + self.cleanup_textgrids = arguments.cleanup_textgrids + self.clitic_marker = arguments.clitic_marker self.exported_file_count = exported_file_count def run(self) -> None: """Run the exporter function""" - db_engine = sqlalchemy.create_engine(f"sqlite:///{self.db_path}?mode=ro&nolock=1") - with mfa_open(self.log_path, "w") as log_file, Session(db_engine) as session: - + with mfa_open(self.log_path, "w") as log_file, Session(self.db_engine) as session: + workflow: CorpusWorkflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.current == True) # noqa + .first() + ) + log_file.write(f"Exporting TextGrids for Workflow ID: {workflow.id}\n") + log_file.write(f"Output directory: {self.output_directory}\n") + log_file.write(f"Output format: {self.output_format}\n") + log_file.write(f"Frame shift: {self.export_frame_shift}\n") + log_file.write(f"Include original text: {self.include_original_text}\n") + log_file.write(f"Clean up textgrids: {self.cleanup_textgrids}\n") while True: try: ( @@ -1148,37 +2417,16 @@ def run(self) -> None: text_file_path, self.output_format, ) - utterances = ( - session.query(Utterance) - .options( - joinedload(Utterance.speaker, innerjoin=True).load_only(Speaker.name), - selectinload(Utterance.phone_intervals), - selectinload(Utterance.word_intervals), - ) - .filter(Utterance.file_id == file_id) + data = construct_output_tiers( + session, + file_id, + workflow, + self.cleanup_textgrids, + self.clitic_marker, + self.include_original_text, ) - data = {} - for utt in utterances: - if utt.speaker.name not in data: - data[utt.speaker.name] = {"words": [], "phones": []} - if self.include_original_text: - data[utt.speaker.name]["utterances"] = [] - - if self.include_original_text: - data[utt.speaker.name]["utterances"].append( - CtmInterval(utt.begin, utt.end, utt.text, utt.id) - ) - for wi in utt.word_intervals: - data[utt.speaker.name]["words"].append( - CtmInterval(wi.begin, wi.end, wi.label, utt.id) - ) - - for pi in utt.phone_intervals: - data[utt.speaker.name]["phones"].append( - CtmInterval(pi.begin, pi.end, pi.label, utt.id) - ) export_textgrid( - data, output_path, duration, self.frame_shift, self.output_format + data, output_path, duration, self.export_frame_shift, self.output_format ) self.return_queue.put(1) except Exception: @@ -1190,5 +2438,4 @@ def run(self) -> None: ) ) self.stopped.stop() - raise log_file.write("Done!\n") diff --git a/montreal_forced_aligner/alignment/pretrained.py b/montreal_forced_aligner/alignment/pretrained.py index 50c76173..0f49c3c6 100644 --- a/montreal_forced_aligner/alignment/pretrained.py +++ b/montreal_forced_aligner/alignment/pretrained.py @@ -1,17 +1,21 @@ """Class definitions for aligning with pretrained acoustic models""" from __future__ import annotations +import datetime +import logging import os import shutil import time -from typing import TYPE_CHECKING, List, Optional +import typing +from typing import TYPE_CHECKING, Any, Dict, Optional +import sqlalchemy from sqlalchemy.orm import Session from montreal_forced_aligner.abc import TopLevelMfaWorker -from montreal_forced_aligner.alignment.base import CorpusAligner -from montreal_forced_aligner.data import PhoneType +from montreal_forced_aligner.data import PhoneType, WorkflowType from montreal_forced_aligner.db import ( + CorpusWorkflow, Dictionary, Grapheme, Phone, @@ -20,24 +24,31 @@ Utterance, WordInterval, ) -from montreal_forced_aligner.exceptions import AlignerError, KaldiProcessingError -from montreal_forced_aligner.helper import load_configuration, mfa_open, parse_old_features +from montreal_forced_aligner.exceptions import KaldiProcessingError +from montreal_forced_aligner.helper import ( + load_configuration, + mfa_open, + parse_old_features, + split_phone_position, +) from montreal_forced_aligner.models import AcousticModel from montreal_forced_aligner.online.alignment import ( OnlineAlignmentArguments, OnlineAlignmentFunction, ) +from montreal_forced_aligner.transcription.transcriber import TranscriberMixin from montreal_forced_aligner.utils import log_kaldi_errors if TYPE_CHECKING: - from argparse import Namespace from montreal_forced_aligner.abc import MetaDict __all__ = ["PretrainedAligner"] +logger = logging.getLogger("mfa") + -class PretrainedAligner(CorpusAligner, TopLevelMfaWorker): +class PretrainedAligner(TranscriberMixin, TopLevelMfaWorker): """ Class for aligning a dataset using a pretrained acoustic model @@ -56,7 +67,7 @@ class PretrainedAligner(CorpusAligner, TopLevelMfaWorker): def __init__( self, - acoustic_model_path: str, + acoustic_model_path: str = None, **kwargs, ): self.acoustic_model = AcousticModel(acoustic_model_path) @@ -64,18 +75,17 @@ def __init__( kw.update(kwargs) super().__init__(**kw) - @property - def working_directory(self) -> str: - """Working directory""" - return self.workflow_directory - def setup_acoustic_model(self) -> None: """Set up the acoustic model""" + if self.acoustic_model.meta["version"] < "2.1": + logger.warning( + "The acoustic model was trained in an earlier version of MFA. " + "There may be incompatibilities in feature generation that cause errors. " + "Please download the latest version of the model via `mfa model download`, " + "use a different acoustic model, or use version 2.0.6 of MFA." + ) self.acoustic_model.export_model(self.working_directory) os.makedirs(self.phones_dir, exist_ok=True) - exist_check = os.path.exists(self.db_path) - if not exist_check: - self.initialize_database() for f in ["phones.txt", "graphemes.txt"]: path = os.path.join(self.working_directory, f) if os.path.exists(path): @@ -91,7 +101,6 @@ def setup_acoustic_model(self) -> None: self.laughter_word = dict_info["laughter_word"] self.clitic_marker = dict_info["clitic_marker"] self.position_dependent_phones = dict_info["position_dependent_phones"] - self.compile_regexes() if not self.use_g2p: return dictionary_id_cache = {} @@ -122,9 +131,6 @@ def setup_acoustic_model(self) -> None: root_temp_directory=self.dictionary_output_directory, position_dependent_phones=self.position_dependent_phones, clitic_marker=self.clitic_marker, - bracket_regex=self.bracket_regex.pattern, - clitic_cleanup_regex=self.clitic_cleanup_regex.pattern, - laughter_regex=self.laughter_regex.pattern, default=dict_name == dict_info["default"], use_g2p=self.use_g2p, max_disambiguation_symbol=0, @@ -142,23 +148,30 @@ def setup_acoustic_model(self) -> None: fst_path = os.path.join(self.acoustic_model.dirname, dict_name + ".fst") if os.path.exists(fst_path): os.makedirs(dictionary.temp_directory, exist_ok=True) - shutil.copyfile(fst_path, os.path.join(dictionary.temp_directory, "L.fst")) + shutil.copyfile(fst_path, dictionary.lexicon_fst_path) + fst_path = os.path.join(self.acoustic_model.dirname, dict_name + "_align.fst") + if os.path.exists(fst_path): + os.makedirs(dictionary.temp_directory, exist_ok=True) + shutil.copyfile(fst_path, dictionary.align_lexicon_path) phone_objs = [] with mfa_open(self.phone_symbol_table_path, "r") as f: for line in f: line = line.strip() - phone, mapping_id = line.split() + phone_label, mapping_id = line.split() mapping_id = int(mapping_id) phone_type = PhoneType.non_silence - if phone.startswith("#"): + if phone_label.startswith("#"): phone_type = PhoneType.disambiguation - elif phone in self.kaldi_silence_phones: + elif phone_label in self.kaldi_silence_phones: phone_type = PhoneType.silence + phone, pos = split_phone_position(phone_label) phone_objs.append( { "id": mapping_id + 1, "mapping_id": mapping_id, "phone": phone, + "position": pos, + "kaldi_label": phone_label, "phone_type": phone_type, } ) @@ -171,12 +184,18 @@ def setup_acoustic_model(self) -> None: grapheme_objs.append( {"id": mapping_id + 1, "mapping_id": mapping_id, "grapheme": grapheme} ) - session.bulk_insert_mappings(Grapheme, grapheme_objs) - session.bulk_insert_mappings(Phone, phone_objs) + session.bulk_insert_mappings( + Grapheme, grapheme_objs, return_defaults=False, render_nulls=True + ) + session.bulk_insert_mappings( + Phone, phone_objs, return_defaults=False, render_nulls=True + ) session.commit() def setup(self) -> None: """Setup for alignment""" + self.ignore_empty_utterances = True + super(PretrainedAligner, self).setup() if self.initialized: return begin = time.time() @@ -184,41 +203,35 @@ def setup(self) -> None: os.makedirs(self.working_log_directory, exist_ok=True) check = self.check_previous_run() if check: - self.log_debug( + logger.debug( "There were some differences in the current run compared to the last one. " "This may cause issues, run with --clean, if you hit an error." ) self.setup_acoustic_model() self.load_corpus() if self.excluded_pronunciation_count: - self.log_warning( + logger.warning( f"There were {self.excluded_pronunciation_count} pronunciations in the dictionary that " - f"were ignored for containing one of {len(self.excluded_phones)} phones not present in the" + f"were ignored for containing one of {len(self.excluded_phones)} phones not present in the " f"trained acoustic model. Please run `mfa validate` to get more details." ) self.acoustic_model.validate(self) - import logging - - logger = logging.getLogger(self.identifier) - self.acoustic_model.log_details(logger) + self.acoustic_model.log_details() except Exception as e: if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise self.initialized = True - self.log_debug(f"Setup for alignment in {time.time() - begin} seconds") + logger.debug(f"Setup for alignment in {time.time() - begin:.3f} seconds") @classmethod def parse_parameters( cls, config_path: Optional[str] = None, - args: Optional[Namespace] = None, - unknown_args: Optional[List[str]] = None, + args: Optional[Dict[str, Any]] = None, + unknown_args: Optional[typing.Iterable[str]] = None, ) -> MetaDict: """ Parse parameters from a config path or command-line arguments @@ -227,8 +240,8 @@ def parse_parameters( ---------- config_path: str Config path - args: :class:`~argparse.Namespace` - Command-line arguments from argparse + args: dict[str, Any] + Parsed arguments unknown_args: list[str], optional Extra command-line arguments @@ -262,11 +275,6 @@ def configuration(self) -> MetaDict: ) return config - @property - def workflow_identifier(self) -> str: - """Aligner identifier""" - return "pretrained_aligner" - def align_one_utterance(self, utterance: Utterance, session: Session) -> None: """ Align a single utterance @@ -278,14 +286,30 @@ def align_one_utterance(self, utterance: Utterance, session: Session) -> None: session: :class:`~sqlalchemy.orm.session.Session` Session to use """ - dictionary = utterance.speaker.dictionary + dictionary_id = utterance.speaker.dictionary_id self.acoustic_model.export_model(self.working_directory) sox_string = utterance.file.sound_file.sox_string + workflow = self.get_latest_workflow_run(WorkflowType.online_alignment, session) + if workflow is None: + workflow = CorpusWorkflow( + name=f"{utterance.id}_ali", + workflow_type=WorkflowType.online_alignment, + time_stamp=datetime.datetime.now(), + working_directory=self.working_directory, + ) + session.add(workflow) + session.flush() if not sox_string: sox_string = utterance.file.sound_file.sound_file_path text_int_path = os.path.join(self.working_directory, "text.int") with mfa_open(text_int_path, "w") as f: - f.write(f"{utterance.kaldi_id} {utterance.normalized_text_int}\n") + normalized_text_int = " ".join( + [ + str(self.word_mapping(utterance.speaker.dictionary_id)[x]) + for x in utterance.normalized_text.split() + ] + ) + f.write(f"{utterance.kaldi_id} {normalized_text_int}\n") if utterance.features: feats_path = os.path.join(self.working_directory, "feats.scp") with mfa_open(feats_path, "w") as f: @@ -299,10 +323,6 @@ def align_one_utterance(self, utterance: Utterance, session: Session) -> None: f.write( f"{utterance.kaldi_id} {utterance.file_id} {utterance.begin} {utterance.end} {utterance.channel}\n" ) - if utterance.speaker.cmvn: - cmvn_path = os.path.join(self.working_directory, "cmvn.scp") - with mfa_open(cmvn_path, "w") as f: - f.write(f"{utterance.speaker.id} {utterance.speaker.cmvn}\n") spk2utt_path = os.path.join(self.working_directory, "spk2utt.scp") utt2spk_path = os.path.join(self.working_directory, "utt2spk.scp") with mfa_open(spk2utt_path, "w") as f: @@ -312,7 +332,7 @@ def align_one_utterance(self, utterance: Utterance, session: Session) -> None: args = OnlineAlignmentArguments( 0, - self.db_path, + self.db_string, os.path.join(self.working_directory, "align.log"), self.working_directory, sox_string, @@ -320,71 +340,82 @@ def align_one_utterance(self, utterance: Utterance, session: Session) -> None: self.mfcc_options, self.pitch_options, self.feature_options, + self.lda_options, self.align_options, self.alignment_model_path, self.tree_path, - self.disambiguation_symbols_int_path, - dictionary.lexicon_fst_path, - dictionary.word_boundary_int_path, - self.reversed_phone_mapping, - self.optional_silence_phone, - {self.silence_word}, + dictionary_id, ) + + max_phone_interval_id = session.query(sqlalchemy.func.max(PhoneInterval.id)).scalar() + if max_phone_interval_id is None: + max_phone_interval_id = 0 + max_word_interval_id = session.query(sqlalchemy.func.max(WordInterval.id)).scalar() + if max_word_interval_id is None: + max_word_interval_id = 0 + phone_interval_mappings = [] + word_interval_mappings = [] func = OnlineAlignmentFunction(args) - word_intervals, phone_intervals, log_likelihood = func.run() - session.query(PhoneInterval).filter(PhoneInterval.utterance_id == utterance.id).delete() - session.query(WordInterval).filter(WordInterval.utterance_id == utterance.id).delete() + for result in func.run(): + if isinstance(result, Exception): + raise result + _, word_intervals, phone_intervals, phone_word_mapping, log_likelihood = result + for interval in phone_intervals: + max_phone_interval_id += 1 + phone_interval_mappings.append( + { + "id": max_phone_interval_id, + "begin": interval.begin, + "end": interval.end, + "phone_id": interval.label, + "utterance_id": utterance.id, + "workflow_id": workflow.id, + "phone_goodness": interval.confidence, + } + ) + for interval in word_intervals: + max_word_interval_id += 1 + word_interval_mappings.append( + { + "id": max_word_interval_id, + "begin": interval.begin, + "end": interval.end, + "word_id": interval.word_id, + "pronunciation_id": interval.pronunciation_id, + "utterance_id": utterance.id, + "workflow_id": workflow.id, + } + ) + for i, index in enumerate(phone_word_mapping): + phone_interval_mappings[i]["word_interval_id"] = word_interval_mappings[index][ + "id" + ] + utterance.alignment_log_likelihood = log_likelihood + session.query(PhoneInterval).filter(PhoneInterval.utterance_id == utterance.id).filter( + PhoneInterval.workflow_id == workflow.id + ).delete() + session.query(WordInterval).filter(WordInterval.utterance_id == utterance.id).filter( + WordInterval.workflow_id == workflow.id + ).delete() session.flush() - for wi in word_intervals: - session.add(WordInterval.from_ctm(wi, utterance)) - for pi in phone_intervals: - session.add(PhoneInterval.from_ctm(pi, utterance)) - utterance.alignment_log_likelihood = log_likelihood + session.bulk_insert_mappings( + WordInterval, word_interval_mappings, return_defaults=False, render_nulls=True + ) + session.bulk_insert_mappings( + PhoneInterval, phone_interval_mappings, return_defaults=False, render_nulls=True + ) session.commit() - def align(self) -> None: + def align(self, workflow_name=None) -> None: """Run the aligner""" - self.setup() - done_path = os.path.join(self.working_directory, "done") - dirty_path = os.path.join(self.working_directory, "dirty") - if os.path.exists(done_path): - self.log_info("Alignment already done, skipping.") + self.initialize_database() + self.create_new_current_workflow(WorkflowType.alignment, workflow_name) + wf = self.current_workflow + if wf.done: + logger.info("Alignment already done, skipping.") return - try: - log_dir = os.path.join(self.working_directory, "log") - os.makedirs(log_dir, exist_ok=True) - self.compile_train_graphs() - - self.log_info("Performing first-pass alignment...") - self.speaker_independent = True - self.align_utterances() - self.compile_information() - if self.uses_speaker_adaptation: - if self.alignment_model_path.endswith(".mdl"): - if os.path.exists(self.alignment_model_path.replace(".mdl", ".alimdl")): - raise AlignerError( - "Not using speaker independent model when it is available" - ) - self.calc_fmllr() - - self.speaker_independent = False - assert self.alignment_model_path.endswith(".mdl") - self.log_info("Performing second-pass alignment...") - self.align_utterances() - - self.compile_information() - except Exception as e: - with mfa_open(dirty_path, "w"): - pass - if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) - raise - with mfa_open(done_path, "w"): - pass + self.setup() + super().align() class DictionaryTrainer(PretrainedAligner): @@ -415,9 +446,7 @@ def __init__( self.calculate_silence_probs = calculate_silence_probs self.min_count = min_count - def export_lexicons( - self, output_directory: str, silence_probabilities: Optional[bool] = False - ) -> None: + def export_lexicons(self, output_directory: str) -> None: """ Generate pronunciation probabilities for the dictionary @@ -444,5 +473,4 @@ def export_lexicons( dictionary.id, os.path.join(output_directory, dictionary.name + ".dict"), probability=True, - silence_probabilities=silence_probabilities, ) diff --git a/montreal_forced_aligner/command_line/__init__.py b/montreal_forced_aligner/command_line/__init__.py index 2b19da02..104ec960 100644 --- a/montreal_forced_aligner/command_line/__init__.py +++ b/montreal_forced_aligner/command_line/__init__.py @@ -4,34 +4,38 @@ """ -from montreal_forced_aligner.command_line.adapt import run_adapt_model -from montreal_forced_aligner.command_line.align import run_align_corpus -from montreal_forced_aligner.command_line.anchor import run_anchor -from montreal_forced_aligner.command_line.classify_speakers import run_classify_speakers -from montreal_forced_aligner.command_line.create_segments import run_create_segments -from montreal_forced_aligner.command_line.g2p import run_g2p -from montreal_forced_aligner.command_line.mfa import create_parser, main -from montreal_forced_aligner.command_line.model import inspect_model, run_model, save_model -from montreal_forced_aligner.command_line.train_acoustic_model import run_train_acoustic_model -from montreal_forced_aligner.command_line.train_dictionary import run_train_dictionary -from montreal_forced_aligner.command_line.train_g2p import run_train_g2p -from montreal_forced_aligner.command_line.train_ivector_extractor import ( - run_train_ivector_extractor, +from montreal_forced_aligner.command_line.adapt import adapt_model_cli +from montreal_forced_aligner.command_line.align import align_corpus_cli +from montreal_forced_aligner.command_line.anchor import anchor_cli +from montreal_forced_aligner.command_line.configure import configure_cli +from montreal_forced_aligner.command_line.create_segments import create_segments_cli +from montreal_forced_aligner.command_line.diarize_speakers import diarize_speakers_cli +from montreal_forced_aligner.command_line.g2p import g2p_cli +from montreal_forced_aligner.command_line.history import history_cli +from montreal_forced_aligner.command_line.mfa import mfa_cli +from montreal_forced_aligner.command_line.model import model_cli +from montreal_forced_aligner.command_line.train_acoustic_model import train_acoustic_model_cli +from montreal_forced_aligner.command_line.train_dictionary import train_dictionary_cli +from montreal_forced_aligner.command_line.train_g2p import train_g2p_cli +from montreal_forced_aligner.command_line.train_ivector_extractor import train_ivector_cli +from montreal_forced_aligner.command_line.train_lm import train_lm_cli +from montreal_forced_aligner.command_line.transcribe import transcribe_corpus_cli +from montreal_forced_aligner.command_line.validate import ( + validate_corpus_cli, + validate_dictionary_cli, ) -from montreal_forced_aligner.command_line.train_lm import run_train_lm -from montreal_forced_aligner.command_line.transcribe import run_transcribe_corpus -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.command_line.validate import run_validate_corpus __all__ = [ "adapt", "align", "anchor", - "classify_speakers", + "diarize_speakers.py", "create_segments", "g2p", "mfa", "model", + "configure", + "history", "train_acoustic_model", "train_dictionary", "train_g2p", @@ -40,23 +44,22 @@ "transcribe", "utils", "validate", - "run_transcribe_corpus", - "run_validate_corpus", - "run_train_lm", - "run_train_g2p", - "run_align_corpus", - "run_train_dictionary", - "run_anchor", - "run_model", - "run_adapt_model", - "run_train_acoustic_model", - "run_train_ivector_extractor", - "run_g2p", - "run_create_segments", - "run_classify_speakers", - "create_parser", - "validate_model_arg", - "main", - "save_model", - "inspect_model", + "adapt_model_cli", + "align_corpus_cli", + "diarize_speakers_cli", + "create_segments_cli", + "g2p_cli", + "mfa_cli", + "configure_cli", + "history_cli", + "anchor_cli", + "model_cli", + "train_acoustic_model_cli", + "train_dictionary_cli", + "train_g2p_cli", + "train_ivector_cli", + "train_lm_cli", + "transcribe_corpus_cli", + "validate_dictionary_cli", + "validate_corpus_cli", ] diff --git a/montreal_forced_aligner/command_line/adapt.py b/montreal_forced_aligner/command_line/adapt.py index 067659da..62e91624 100644 --- a/montreal_forced_aligner/command_line/adapt.py +++ b/montreal_forced_aligner/command_line/adapt.py @@ -2,123 +2,117 @@ from __future__ import annotations import os -import time -from typing import TYPE_CHECKING, List, Optional -from montreal_forced_aligner.alignment import AdaptingAligner -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.exceptions import ArgumentError - -if TYPE_CHECKING: - from argparse import Namespace - -__all__ = ["adapt_model", "validate_args", "run_adapt_model"] +import click - -def adapt_model(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +from montreal_forced_aligner.alignment import AdaptingAligner +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_acoustic_model, + validate_dictionary, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE + +__all__ = ["adapt_model_cli"] + + +@click.command( + name="adapt", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Adapt an acoustic model", +) +@click.argument("corpus_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.argument("dictionary_path", type=click.UNPROCESSED, callback=validate_dictionary) +@click.argument("acoustic_model_path", type=click.UNPROCESSED, callback=validate_acoustic_model) +@click.argument("output_model_path", type=click.Path(file_okay=True, dir_okay=False)) +@click.option( + "--output_directory", + help="Path to save alignments.", + type=click.Path(file_okay=False, dir_okay=True), +) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--speaker_characters", + "-s", + help="Number of characters of file names to use for determining speaker, " + "default is to use directory names.", + type=str, + default="0", +) +@click.option( + "--audio_directory", + "-a", + help="Audio directory root to use for finding audio files.", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.option( + "--output_format", + help="Format for aligned output files (default is long_textgrid).", + default="long_textgrid", + type=click.Choice(["long_textgrid", "short_textgrid", "json", "csv"]), +) +@click.option( + "--include_original_text", + is_flag=True, + help="Flag to include original utterance text in the output.", + default=False, +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def adapt_model_cli(context, **kwargs) -> None: """ - Run the acoustic model adaptation - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Adapt an acoustic model to a new corpus. """ + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + output_directory = kwargs.get("output_directory", None) + output_model_path = kwargs.get("output_model_path", None) + corpus_directory = kwargs["corpus_directory"] + dictionary_path = kwargs["dictionary_path"] + acoustic_model_path = kwargs["acoustic_model_path"] + output_format = kwargs["output_format"] + include_original_text = kwargs["include_original_text"] adapter = AdaptingAligner( - acoustic_model_path=args.acoustic_model_path, - corpus_directory=args.corpus_directory, - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **AdaptingAligner.parse_parameters(args.config_path, args, unknown_args), + corpus_directory=corpus_directory, + dictionary_path=dictionary_path, + acoustic_model_path=acoustic_model_path, + **AdaptingAligner.parse_parameters(config_path, context.params, context.args), ) + if kwargs.get("clean", False): + adapter.clean_working_directory() + adapter.remove_database() try: adapter.adapt() - generate_final_alignments = True - if args.output_directory is None: - generate_final_alignments = False - else: - os.makedirs(args.output_directory, exist_ok=True) - export_model = True - if args.output_model_path is None: - export_model = False - - if generate_final_alignments: - begin = time.time() + if output_directory is not None: + os.makedirs(output_directory, exist_ok=True) adapter.align() - adapter.log_debug( - f"Generated alignments with adapted model in {time.time() - begin} seconds" - ) - output_format = getattr(args, "output_format", None) adapter.export_files( - args.output_directory, + output_directory, output_format, - include_original_text=getattr(args, "include_original_text", False), + include_original_text=include_original_text, ) - if export_model: - adapter.export_model(args.output_model_path) + if output_model_path is not None: + adapter.export_model(output_model_path) except Exception: adapter.dirty = True raise finally: adapter.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - try: - args.speaker_characters = int(args.speaker_characters) - except ValueError: - pass - - args.output_directory = None - if not args.output_model_path: - args.output_model_path = None - output_paths = args.output_paths - if len(output_paths) > 2: - raise ArgumentError(f"Got more arguments for output_paths than 2: {output_paths}") - for path in output_paths: - if path.endswith(".zip"): - args.output_model_path = path - else: - args.output_directory = path.rstrip("/").rstrip("\\") - - args.corpus_directory = args.corpus_directory.rstrip("/").rstrip("\\") - if not os.path.exists(args.corpus_directory): - raise ArgumentError(f"Could not find the corpus directory {args.corpus_directory}.") - if not os.path.isdir(args.corpus_directory): - raise ArgumentError( - f"The specified corpus directory ({args.corpus_directory}) is not a directory." - ) - - args.dictionary_path = validate_model_arg(args.dictionary_path, "dictionary") - args.acoustic_model_path = validate_model_arg(args.acoustic_model_path, "acoustic") - - -def run_adapt_model(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running acoustic model adaptation - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - adapt_model(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/align.py b/montreal_forced_aligner/command_line/align.py index 3c3ce906..1ca96ff2 100644 --- a/montreal_forced_aligner/command_line/align.py +++ b/montreal_forced_aligner/command_line/align.py @@ -2,106 +2,155 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, List, Optional +import click import yaml from montreal_forced_aligner.alignment import PretrainedAligner -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.exceptions import ArgumentError +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_acoustic_model, + validate_dictionary, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE +from montreal_forced_aligner.data import WorkflowType from montreal_forced_aligner.helper import mfa_open -if TYPE_CHECKING: - from argparse import Namespace - - -__all__ = ["align_corpus", "validate_args", "run_align_corpus"] - - -def align_corpus(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +__all__ = ["align_corpus_cli"] + + +@click.command( + name="align", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Align a corpus", +) +@click.argument("corpus_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.argument("dictionary_path", type=click.UNPROCESSED, callback=validate_dictionary) +@click.argument("acoustic_model_path", type=click.UNPROCESSED, callback=validate_acoustic_model) +@click.argument("output_directory", type=click.Path(file_okay=False, dir_okay=True)) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--speaker_characters", + "-s", + help="Number of characters of file names to use for determining speaker, " + "default is to use directory names.", + type=str, + default="0", +) +@click.option( + "--audio_directory", + "-a", + help="Audio directory root to use for finding audio files.", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.option( + "--reference_directory", + help="Directory containing gold standard alignments to evaluate", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.option( + "--custom_mapping_path", + help="YAML file for mapping phones across phone sets in evaluations.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--output_format", + help="Format for aligned output files (default is long_textgrid).", + default="long_textgrid", + type=click.Choice(["long_textgrid", "short_textgrid", "json", "csv"]), +) +@click.option( + "--include_original_text", + is_flag=True, + help="Flag to include original utterance text in the output.", + default=False, +) +@click.option( + "--fine_tune", is_flag=True, help="Flag for running extra fine tuning stage.", default=False +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def align_corpus_cli(context, **kwargs) -> None: """ - Run the alignment - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Align a corpus with a pronunciation dictionary and a pretrained acoustic model. """ + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + reference_directory = kwargs.get("reference_directory", None) + custom_mapping_path = kwargs.get("custom_mapping_path", None) + corpus_directory = kwargs["corpus_directory"] + dictionary_path = kwargs["dictionary_path"] + acoustic_model_path = kwargs["acoustic_model_path"] + output_directory = kwargs["output_directory"] + output_format = kwargs["output_format"] + include_original_text = kwargs["include_original_text"] aligner = PretrainedAligner( - acoustic_model_path=args.acoustic_model_path, - corpus_directory=args.corpus_directory, - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **PretrainedAligner.parse_parameters(args.config_path, args, unknown_args), + corpus_directory=corpus_directory, + dictionary_path=dictionary_path, + acoustic_model_path=acoustic_model_path, + **PretrainedAligner.parse_parameters(config_path, context.params, context.args), ) + if kwargs.get("clean", False): + aligner.clean_working_directory() + aligner.remove_database() try: aligner.align() - output_format = getattr(args, "output_format", None) - aligner.export_files( - args.output_directory, - output_format=output_format, - include_original_text=getattr(args, "include_original_text", False), - ) - if getattr(args, "reference_directory", ""): + if aligner.use_phone_model: + aligner.export_files( + output_directory, + output_format=output_format, + include_original_text=include_original_text, + ) + else: + aligner.export_files( + output_directory, + output_format=output_format, + include_original_text=include_original_text, + ) + if reference_directory: mapping = None - if getattr(args, "custom_mapping_path", ""): - with mfa_open(args.custom_mapping_path, "r") as f: + if custom_mapping_path: + with mfa_open(custom_mapping_path, "r") as f: mapping = yaml.safe_load(f) - aligner.load_reference_alignments(args.reference_directory) - aligner.evaluate_alignments(mapping, output_directory=args.output_directory) + aligner.load_reference_alignments(reference_directory) + reference_alignments = WorkflowType.reference + else: + reference_alignments = WorkflowType.alignment + + if aligner.use_phone_model: + aligner.evaluate_alignments( + mapping, + output_directory=output_directory, + reference_source=reference_alignments, + comparison_source=WorkflowType.phone_transcription, + ) + else: + if reference_alignments is WorkflowType.reference: + aligner.evaluate_alignments( + mapping, + output_directory=output_directory, + reference_source=reference_alignments, + comparison_source=WorkflowType.alignment, + ) except Exception: aligner.dirty = True raise finally: aligner.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - try: - args.speaker_characters = int(args.speaker_characters) - except ValueError: - pass - args.output_directory = args.output_directory.rstrip("/").rstrip("\\") - args.corpus_directory = args.corpus_directory.rstrip("/").rstrip("\\") - if not os.path.exists(args.corpus_directory): - raise ArgumentError(f"Could not find the corpus directory {args.corpus_directory}.") - if not os.path.isdir(args.corpus_directory): - raise ArgumentError( - f"The specified corpus directory ({args.corpus_directory}) is not a directory." - ) - - if args.corpus_directory == args.output_directory: - raise ArgumentError("Corpus directory and output directory cannot be the same folder.") - - args.dictionary_path = validate_model_arg(args.dictionary_path, "dictionary") - args.acoustic_model_path = validate_model_arg(args.acoustic_model_path, "acoustic") - - -def run_align_corpus(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running alignment - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - align_corpus(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/anchor.py b/montreal_forced_aligner/command_line/anchor.py index 0fd6783d..5bb25ae7 100644 --- a/montreal_forced_aligner/command_line/anchor.py +++ b/montreal_forced_aligner/command_line/anchor.py @@ -2,26 +2,24 @@ from __future__ import annotations import sys -import warnings -__all__ = ["run_anchor"] +import click +__all__ = ["anchor_cli"] -def run_anchor() -> None: # pragma: no cover + +@click.command(name="anchor", short_help="Launch Anchor") +@click.help_option("-h", "--help") +def anchor_cli(*args, **kwargs) -> None: # pragma: no cover """ - Wrapper function for launching Anchor Annotator + Launch Anchor Annotator (if installed) """ try: - from anchor import Application, MainWindow + from anchor.command_line import main except ImportError: + raise print( "Anchor annotator utility is not installed, please install it via pip install anchor-annotator." ) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - app = Application(sys.argv) - main = MainWindow() - - app.setActiveWindow(main) - main.show() - sys.exit(app.exec_()) + sys.exit(1) + main() diff --git a/montreal_forced_aligner/command_line/classify_speakers.py b/montreal_forced_aligner/command_line/classify_speakers.py deleted file mode 100644 index 425ab040..00000000 --- a/montreal_forced_aligner/command_line/classify_speakers.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Command line functions for classifying speakers""" -from __future__ import annotations - -import os -from typing import TYPE_CHECKING, List, Optional - -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.exceptions import ArgumentError -from montreal_forced_aligner.speaker_classifier import SpeakerClassifier - -if TYPE_CHECKING: - from argparse import Namespace - -__all__ = ["classify_speakers", "validate_args", "run_classify_speakers"] - - -def classify_speakers( - args: Namespace, unknown_args: Optional[List[str]] = None -) -> None: # pragma: no cover - """ - Run the speaker classification - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects - """ - classifier = SpeakerClassifier( - ivector_extractor_path=args.ivector_extractor_path, - corpus_directory=args.corpus_directory, - temporary_directory=args.temporary_directory, - **SpeakerClassifier.parse_parameters(args.config_path, args, unknown_args), - ) - try: - - classifier.cluster_utterances() - - classifier.export_files(args.output_directory) - except Exception: - classifier.dirty = True - raise - finally: - classifier.cleanup() - - -def validate_args(args: Namespace) -> None: # pragma: no cover - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - args.output_directory = args.output_directory.rstrip("/").rstrip("\\") - args.corpus_directory = args.corpus_directory.rstrip("/").rstrip("\\") - if args.cluster and not args.num_speakers: - raise ArgumentError("If using clustering, num_speakers must be specified") - if not os.path.exists(args.corpus_directory): - raise ArgumentError(f"Could not find the corpus directory {args.corpus_directory}.") - if not os.path.isdir(args.corpus_directory): - raise ArgumentError( - f"The specified corpus directory ({args.corpus_directory}) is not a directory." - ) - - if args.corpus_directory == args.output_directory: - raise ArgumentError("Corpus directory and output directory cannot be the same folder.") - - args.ivector_extractor_path = validate_model_arg(args.ivector_extractor_path, "ivector") - - -def run_classify_speakers( - args: Namespace, unknown_args: Optional[List[str]] = None -) -> None: # pragma: no cover - """ - Wrapper function for running speaker classification - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - classify_speakers(args) diff --git a/montreal_forced_aligner/command_line/configure.py b/montreal_forced_aligner/command_line/configure.py new file mode 100644 index 00000000..a4279723 --- /dev/null +++ b/montreal_forced_aligner/command_line/configure.py @@ -0,0 +1,120 @@ +import os + +import click + +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE + +__all__ = ["configure_cli"] + + +@click.command( + "configure", + help="The configure command is used to set global defaults for MFA so " + "you don't have to set them every time you call an MFA command.", +) +@click.option( + "-p", + "--profile", + help='Configuration profile to use, defaults to "global"', + type=str, + default=None, +) +@click.option( + "--temporary_directory", + "-t", + help=f"Set the default temporary directory, default is {GLOBAL_CONFIG.temporary_directory}", + type=str, + default=None, +) +@click.option( + "--num_jobs", + "-j", + help=f"Set the number of processes to use by default, defaults to {GLOBAL_CONFIG.num_jobs}", + type=int, + default=None, +) +@click.option( + "--always_clean/--never_clean", + "clean", + help="Turn on/off clean mode where MFA will clean temporary files before each run.", + default=None, +) +@click.option( + "--always_verbose/--never_verbose", + "verbose", + help="Turn on/off verbose mode where MFA will print more output.", + default=None, +) +@click.option( + "--always_quiet/--never_quiet", + "quiet", + help="Turn on/off quiet mode where MFA will not print any output.", + default=None, +) +@click.option( + "--always_debug/--never_debug", + "debug", + help="Turn on/off extra debugging functionality.", + default=None, +) +@click.option( + "--always_overwrite/--never_overwrite", + "overwrite", + help="Turn on/off overwriting export files.", + default=None, +) +@click.option( + "--enable_mp/--disable_mp", + "use_mp", + help="Turn on/off multiprocessing. Multiprocessing is recommended will allow for faster executions.", + default=None, +) +@click.option( + "--enable_textgrid_cleanup/--disable_textgrid_cleanup", + "cleanup_textgrids", + help="Turn on/off post-processing of TextGrids that cleans up " + "silences and recombines compound words and clitics.", + default=None, +) +@click.option( + "--enable_detect_phone_set/--disable_detect_phone_set", + "detect_phone_set", + help="Turn on/off automatic detection of phone sets during training.", + default=None, +) +@click.option( + "--enable_terminal_colors/--disable_terminal_colors", + "terminal_colors", + help="Turn on/off colored text in command line output.", + default=None, +) +@click.option( + "--blas_num_threads", + help="Number of threads to use for BLAS libraries, 1 is recommended " + "due to how much MFA relies on multiprocessing. " + f"Currently set to {GLOBAL_CONFIG.blas_num_threads}.", + type=int, + default=None, +) +@click.option( + "--github_token", + default=None, + help="Github token to use for model downloading.", + type=str, +) +@click.option( + "--database_port", + default=None, + help="Port for postgresql database.", + type=int, +) +@click.help_option("-h", "--help") +def configure_cli(**kwargs) -> None: + """ + Configure Montreal Forced Aligner command lines to new defaults + + """ + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() diff --git a/montreal_forced_aligner/command_line/create_segments.py b/montreal_forced_aligner/command_line/create_segments.py index 60d06ffb..2ec2e5fa 100644 --- a/montreal_forced_aligner/command_line/create_segments.py +++ b/montreal_forced_aligner/command_line/create_segments.py @@ -2,83 +2,84 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, List, Optional -from montreal_forced_aligner.exceptions import ArgumentError -from montreal_forced_aligner.segmenter import Segmenter - -if TYPE_CHECKING: - from argparse import Namespace - - -__all__ = ["create_segments", "validate_args", "run_create_segments"] - - -def create_segments(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +import click + +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE +from montreal_forced_aligner.vad.segmenter import Segmenter + +__all__ = ["create_segments_cli"] + + +@click.command( + name="segment", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Split long audio files into shorter segments", +) +@click.argument("corpus_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.argument("output_directory", type=click.Path(file_okay=False, dir_okay=True)) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--output_format", + help="Format for aligned output files (default is long_textgrid).", + default="long_textgrid", + type=click.Choice(["long_textgrid", "short_textgrid", "json", "csv"]), +) +@click.option( + "--speechbrain/--no_speechbrain", + "speechbrain", + help="Flag for using SpeechBrain's pretrained VAD model", +) +@click.option( + "--cuda/--no_cuda", + "cuda", + help="Flag for using CUDA for SpeechBrain's model", +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def create_segments_cli(context, **kwargs) -> None: """ - Run the sound file segmentation - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Create segments based on SpeechBrain's voice activity detection (VAD) model or a basic energy-based algorithm """ + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + + config_path = kwargs.get("config_path", None) + corpus_directory = kwargs["corpus_directory"] + output_directory = kwargs["output_directory"] + output_format = kwargs["output_format"] segmenter = Segmenter( - corpus_directory=args.corpus_directory, - temporary_directory=args.temporary_directory, - **Segmenter.parse_parameters(args.config_path, args, unknown_args), + corpus_directory=corpus_directory, + **Segmenter.parse_parameters(config_path, context.params, context.args), ) + if kwargs.get("clean", False): + segmenter.clean_working_directory() + segmenter.remove_database() try: segmenter.segment() - output_format = getattr(args, "output_format", None) - segmenter.export_files(args.output_directory, output_format) + segmenter.export_files(output_directory, output_format) except Exception: segmenter.dirty = True raise finally: segmenter.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - args.output_directory = args.output_directory.rstrip("/").rstrip("\\") - args.corpus_directory = args.corpus_directory.rstrip("/").rstrip("\\") - if not os.path.exists(args.corpus_directory): - raise ArgumentError(f"Could not find the corpus directory {args.corpus_directory}.") - if not os.path.isdir(args.corpus_directory): - raise ArgumentError( - f"The specified corpus directory ({args.corpus_directory}) is not a directory." - ) - - if args.corpus_directory == args.output_directory: - raise ArgumentError("Corpus directory and output directory cannot be the same folder.") - - -def run_create_segments(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running sound file segmentation - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - create_segments(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/diarize_speakers.py b/montreal_forced_aligner/command_line/diarize_speakers.py new file mode 100644 index 00000000..9794da58 --- /dev/null +++ b/montreal_forced_aligner/command_line/diarize_speakers.py @@ -0,0 +1,126 @@ +"""Command line functions for classifying speakers""" +from __future__ import annotations + +import os + +import click + +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_ivector_extractor, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE +from montreal_forced_aligner.data import ClusterType +from montreal_forced_aligner.diarization.speaker_diarizer import SpeakerDiarizer + +__all__ = ["diarize_speakers_cli"] + + +@click.command( + name="diarize", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Diarize a corpus", +) +@click.argument("corpus_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.argument( + "ivector_extractor_path", type=click.UNPROCESSED, callback=validate_ivector_extractor +) +@click.argument("output_directory", type=click.Path(file_okay=False, dir_okay=True)) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--expected_num_speakers", "-s", help="Number of speakers if known.", type=int, default=0 +) +@click.option( + "--output_format", + help="Format for aligned output files (default is long_textgrid).", + default="long_textgrid", + type=click.Choice(["long_textgrid", "short_textgrid", "json", "csv"]), +) +@click.option( + "--classify/--cluster", + "classify", + is_flag=True, + default=False, + help="Specify whether to classify speakers into pretrained IDs or cluster speakers without a classification model, default is cluster", +) +@click.option( + "--cluster_type", + help="Type of clustering algorithm to use", + default=ClusterType.mfa.name, + type=click.Choice([x.name for x in ClusterType]), +) +@click.option( + "--cuda/--no_cuda", + "cuda", + is_flag=True, + default=False, + help="Flag for using CUDA for SpeechBrain's model", +) +@click.option( + "--use_pca/--no_use_pca", + "use_pca", + is_flag=True, + default=True, + help="Flag for using PCA representations of ivectors", +) +@click.option( + "--evaluate", + "--validate", + "evaluation_mode", + is_flag=True, + help="Flag for whether to evaluate clustering/classification against existing speakers.", + default=False, +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def diarize_speakers_cli(context, **kwargs) -> None: + """ + Use an ivector extractor to cluster utterances into speakers + + If you would like to use SpeechBrain's speaker recognition model, specify ``speechbrain`` as the ``ivector_extractor_path``. + When using SpeechBrain's speaker recognition model, the ``--cuda`` flag is available to perform computations on GPU, and + the ``--num_jobs`` parameter will be used as a the batch size for any parallel computation. + """ + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + corpus_directory = kwargs["corpus_directory"] + ivector_extractor_path = kwargs["ivector_extractor_path"] + output_directory = kwargs["output_directory"] + classify = kwargs.get("classify", False) + classifier = SpeakerDiarizer( + corpus_directory=corpus_directory, + ivector_extractor_path=ivector_extractor_path, + **SpeakerDiarizer.parse_parameters(config_path, context.params, context.args), + ) + if kwargs.get("clean", False): + classifier.clean_working_directory() + classifier.remove_database() + try: + if classify: + classifier.classify_speakers() + else: + classifier.cluster_utterances() + + classifier.export_files(output_directory) + except Exception: + classifier.dirty = True + raise + finally: + classifier.cleanup() + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/g2p.py b/montreal_forced_aligner/command_line/g2p.py index 7cf6cf38..54a0169e 100644 --- a/montreal_forced_aligner/command_line/g2p.py +++ b/montreal_forced_aligner/command_line/g2p.py @@ -2,109 +2,85 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, List, Optional -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.g2p.generator import ( - OrthographicCorpusGenerator, - OrthographicWordListGenerator, - PyniniCorpusGenerator, - PyniniWordListGenerator, -) - -if TYPE_CHECKING: - from argparse import Namespace +import click +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_g2p_model, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE +from montreal_forced_aligner.g2p.generator import PyniniCorpusGenerator, PyniniWordListGenerator -__all__ = ["generate_dictionary", "validate_args", "run_g2p"] +__all__ = ["g2p_cli"] -def generate_dictionary(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +@click.command( + name="g2p", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Generate pronunciations", +) +@click.argument("input_path", type=click.Path(exists=True, file_okay=True, dir_okay=True)) +@click.argument("g2p_model_path", type=click.UNPROCESSED, callback=validate_g2p_model) +@click.argument("output_path", type=click.Path(file_okay=True, dir_okay=False)) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--include_bracketed", + is_flag=True, + help="Included words enclosed by brackets, job_name.e. [...], (...), <...>.", + default=False, +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def g2p_cli(context, **kwargs) -> None: """ - Run the G2P command - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Generate a pronunciation dictionary using a G2P model. """ - - if args.g2p_model_path is None: - if os.path.isdir(args.input_path): - g2p = OrthographicCorpusGenerator( - corpus_directory=args.input_path, - temporary_directory=args.temporary_directory, - **OrthographicCorpusGenerator.parse_parameters( - args.config_path, args, unknown_args - ) - ) - else: - g2p = OrthographicWordListGenerator( - word_list_path=args.input_path, - temporary_directory=args.temporary_directory, - **OrthographicWordListGenerator.parse_parameters( - args.config_path, args, unknown_args - ) - ) - + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + + config_path = kwargs.get("config_path", None) + input_path = kwargs["input_path"] + g2p_model_path = kwargs["g2p_model_path"] + output_path = kwargs["output_path"] + + if os.path.isdir(input_path): + g2p = PyniniCorpusGenerator( + corpus_directory=input_path, + g2p_model_path=g2p_model_path, + **PyniniCorpusGenerator.parse_parameters(config_path, context.params, context.args), + ) else: - if os.path.isdir(args.input_path): - g2p = PyniniCorpusGenerator( - g2p_model_path=args.g2p_model_path, - corpus_directory=args.input_path, - temporary_directory=args.temporary_directory, - **PyniniCorpusGenerator.parse_parameters(args.config_path, args, unknown_args) - ) - else: - g2p = PyniniWordListGenerator( - g2p_model_path=args.g2p_model_path, - word_list_path=args.input_path, - temporary_directory=args.temporary_directory, - **PyniniWordListGenerator.parse_parameters(args.config_path, args, unknown_args) - ) + g2p = PyniniWordListGenerator( + word_list_path=input_path, + g2p_model_path=g2p_model_path, + **PyniniWordListGenerator.parse_parameters(config_path, context.params, context.args), + ) + if kwargs.get("clean", False): + g2p.clean_working_directory() + g2p.remove_database() try: g2p.setup() - g2p.export_pronunciations(args.output_path) + g2p.export_pronunciations(output_path) except Exception: g2p.dirty = True raise finally: g2p.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - if not args.g2p_model_path: - args.g2p_model_path = None - else: - args.g2p_model_path = validate_model_arg(args.g2p_model_path, "g2p") - - -def run_g2p(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running G2P - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - generate_dictionary(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/history.py b/montreal_forced_aligner/command_line/history.py new file mode 100644 index 00000000..0141474c --- /dev/null +++ b/montreal_forced_aligner/command_line/history.py @@ -0,0 +1,39 @@ +import time + +import click + +from montreal_forced_aligner.config import GLOBAL_CONFIG, load_command_history + +__all__ = ["history_cli"] + + +@click.command( + "history", + help="Show previously run mfa commands", +) +@click.option("--depth", help="Number of commands to list, defaults to 10", type=int, default=10) +@click.option( + "--verbose/--no_verbose", + "-v/-nv", + "verbose", + help=f"Output debug messages, default is {GLOBAL_CONFIG.verbose}", + default=GLOBAL_CONFIG.verbose, +) +@click.help_option("-h", "--help") +def history_cli(depth: int, verbose: bool) -> None: + """ + List previous MFA commands + """ + history = load_command_history()[-depth:] + if verbose: + print("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( + 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"]) diff --git a/montreal_forced_aligner/command_line/mfa.py b/montreal_forced_aligner/command_line/mfa.py index 7b26cbdd..74064fbe 100644 --- a/montreal_forced_aligner/command_line/mfa.py +++ b/montreal_forced_aligner/command_line/mfa.py @@ -1,52 +1,42 @@ """Command line functions for calling the root mfa command""" from __future__ import annotations -import argparse import atexit import multiprocessing as mp import sys import time +import warnings from datetime import datetime -from typing import TYPE_CHECKING -from montreal_forced_aligner.command_line.adapt import run_adapt_model -from montreal_forced_aligner.command_line.align import run_align_corpus -from montreal_forced_aligner.command_line.anchor import run_anchor -from montreal_forced_aligner.command_line.classify_speakers import run_classify_speakers -from montreal_forced_aligner.command_line.create_segments import run_create_segments -from montreal_forced_aligner.command_line.g2p import run_g2p -from montreal_forced_aligner.command_line.model import run_model -from montreal_forced_aligner.command_line.train_acoustic_model import run_train_acoustic_model -from montreal_forced_aligner.command_line.train_dictionary import run_train_dictionary -from montreal_forced_aligner.command_line.train_g2p import run_train_g2p -from montreal_forced_aligner.command_line.train_ivector_extractor import ( - run_train_ivector_extractor, -) -from montreal_forced_aligner.command_line.train_lm import run_train_lm -from montreal_forced_aligner.command_line.transcribe import run_transcribe_corpus +import click + +from montreal_forced_aligner.command_line.adapt import adapt_model_cli +from montreal_forced_aligner.command_line.align import align_corpus_cli +from montreal_forced_aligner.command_line.anchor import anchor_cli +from montreal_forced_aligner.command_line.configure import configure_cli +from montreal_forced_aligner.command_line.create_segments import create_segments_cli +from montreal_forced_aligner.command_line.diarize_speakers import diarize_speakers_cli +from montreal_forced_aligner.command_line.g2p import g2p_cli +from montreal_forced_aligner.command_line.history import history_cli +from montreal_forced_aligner.command_line.model import model_cli +from montreal_forced_aligner.command_line.train_acoustic_model import train_acoustic_model_cli +from montreal_forced_aligner.command_line.train_dictionary import train_dictionary_cli +from montreal_forced_aligner.command_line.train_g2p import train_g2p_cli +from montreal_forced_aligner.command_line.train_ivector_extractor import train_ivector_cli +from montreal_forced_aligner.command_line.train_lm import train_lm_cli +from montreal_forced_aligner.command_line.transcribe import transcribe_corpus_cli from montreal_forced_aligner.command_line.validate import ( - run_validate_corpus, - run_validate_dictionary, -) -from montreal_forced_aligner.config import ( - load_command_history, - load_global_config, - update_command_history, - update_global_config, + validate_corpus_cli, + validate_dictionary_cli, ) -from montreal_forced_aligner.exceptions import MFAError -from montreal_forced_aligner.models import MODEL_TYPES +from montreal_forced_aligner.config import GLOBAL_CONFIG, update_command_history from montreal_forced_aligner.utils import check_third_party -if TYPE_CHECKING: - from argparse import ArgumentParser - - BEGIN = time.time() BEGIN_DATE = datetime.now() -__all__ = ["ExitHooks", "create_parser", "main"] +__all__ = ["ExitHooks", "mfa_cli"] class ExitHooks(object): @@ -103,931 +93,20 @@ def history_save_handler(self) -> None: raise self.exception -def create_parser() -> ArgumentParser: - """ - Constructs the MFA argument parser - - Returns - ------- - :class:`~argparse.ArgumentParser` - MFA argument parser - """ - GLOBAL_CONFIG = load_global_config() - - def add_global_options(subparser: argparse.ArgumentParser, textgrid_output: bool = False): - """ - Add a set of global options to a subparser - - Parameters - ---------- - subparser: :class:`~argparse.ArgumentParser` - Subparser to augment - textgrid_output: bool - Flag for whether the subparser is used for a command that generates TextGrids - """ - subparser.add_argument( - "-t", - "--temp_directory", - "--temporary_directory", - dest="temporary_directory", - type=str, - default=GLOBAL_CONFIG["temporary_directory"], - help=f"Temporary directory root to store MFA created files, default is {GLOBAL_CONFIG['temporary_directory']}", - ) - subparser.add_argument( - "--disable_mp", - help=f"Disable any multiprocessing during alignment (not recommended), default is {not GLOBAL_CONFIG['use_mp']}", - action="store_true", - default=not GLOBAL_CONFIG["use_mp"], - ) - subparser.add_argument( - "-j", - "--num_jobs", - type=int, - default=GLOBAL_CONFIG["num_jobs"], - help=f"Number of data splits (and cores to use if multiprocessing is enabled), defaults " - f"is {GLOBAL_CONFIG['num_jobs']}", - ) - subparser.add_argument( - "-v", - "--verbose", - help=f"Output debug messages, default is {GLOBAL_CONFIG['verbose']}", - action="store_true", - default=GLOBAL_CONFIG["verbose"], - ) - subparser.add_argument( - "-q", - "--quiet", - help=f"Suppress all output messages (overrides verbose), default is {GLOBAL_CONFIG['quiet']}", - action="store_true", - default=GLOBAL_CONFIG["quiet"], - ) - subparser.add_argument( - "--clean", - help=f"Remove files from previous runs, default is {GLOBAL_CONFIG['clean']}", - action="store_true", - default=GLOBAL_CONFIG["clean"], - ) - subparser.add_argument( - "--overwrite", - help=f"Overwrite output files when they exist, default is {GLOBAL_CONFIG['overwrite']}", - action="store_true", - default=GLOBAL_CONFIG["overwrite"], - ) - subparser.add_argument( - "--debug", - help=f"Run extra steps for debugging issues, default is {GLOBAL_CONFIG['debug']}", - action="store_true", - default=GLOBAL_CONFIG["debug"], - ) - if textgrid_output: - subparser.add_argument( - "--disable_textgrid_cleanup", - help=f"Disable extra clean up steps on TextGrid output, default is {not GLOBAL_CONFIG['cleanup_textgrids']}", - action="store_true", - default=not GLOBAL_CONFIG["cleanup_textgrids"], - ) - - pretrained_acoustic = ", ".join(MODEL_TYPES["acoustic"].get_available_models()) - if not pretrained_acoustic: - pretrained_acoustic = ( - "you can use ``mfa model download acoustic`` to get pretrained MFA models" - ) - - pretrained_ivector = ", ".join(MODEL_TYPES["ivector"].get_available_models()) - if not pretrained_ivector: - pretrained_ivector = ( - "you can use ``mfa model download ivector`` to get pretrained MFA models" - ) - - pretrained_g2p = ", ".join(MODEL_TYPES["g2p"].get_available_models()) - if not pretrained_g2p: - pretrained_g2p = "you can use ``mfa model download g2p`` to get pretrained MFA models" - - pretrained_lm = ", ".join(MODEL_TYPES["language_model"].get_available_models()) - if not pretrained_lm: - pretrained_lm = ( - "you can use ``mfa model download language_model`` to get pretrained MFA models" - ) - - pretrained_dictionary = ", ".join(MODEL_TYPES["dictionary"].get_available_models()) - if not pretrained_dictionary: - pretrained_dictionary = ( - "you can use ``mfa model download dictionary`` to get MFA dictionaries" - ) - - dictionary_path_help = f"Full path to pronunciation dictionary, or saved dictionary name ({pretrained_dictionary})" - - acoustic_model_path_help = ( - f"Full path to pre-trained acoustic model, or saved model name ({pretrained_acoustic})" - ) - language_model_path_help = ( - f"Full path to pre-trained language model, or saved model name ({pretrained_lm})" - ) - ivector_model_path_help = f"Full path to pre-trained ivector extractor model, or saved model name ({pretrained_ivector})" - g2p_model_path_help = ( - f"Full path to pre-trained G2P model, or saved model name ({pretrained_g2p}). " - "If not specified, then orthographic transcription is split into pronunciations." - ) - - parser = argparse.ArgumentParser() - - subparsers = parser.add_subparsers(dest="subcommand") - subparsers.required = True - - _ = subparsers.add_parser("version") - - align_parser = subparsers.add_parser( - "align", help="Align a corpus with a pretrained acoustic model" - ) - align_parser.add_argument("corpus_directory", help="Full path to the directory to align") - align_parser.add_argument( - "dictionary_path", - help=dictionary_path_help, - type=str, - ) - align_parser.add_argument( - "acoustic_model_path", - type=str, - help=acoustic_model_path_help, - ) - align_parser.add_argument( - "output_directory", - type=str, - help="Full path to output directory, will be created if it doesn't exist", - ) - align_parser.add_argument( - "--config_path", type=str, default="", help="Path to config file to use for alignment" - ) - align_parser.add_argument( - "-s", - "--speaker_characters", - type=str, - default="0", - help="Number of characters of file names to use for determining speaker, " - "default is to use directory names", - ) - align_parser.add_argument( - "-a", - "--audio_directory", - type=str, - default="", - help="Audio directory root to use for finding audio files", - ) - align_parser.add_argument( - "--reference_directory", - type=str, - default="", - help="Directory containing gold standard alignments to evaluate", - ) - align_parser.add_argument( - "--custom_mapping_path", - type=str, - default="", - help="YAML file for mapping phones across phone sets in evaluations", - ) - align_parser.add_argument( - "--output_format", - type=str, - default="long_textgrid", - choices=["long_textgrid", "short_textgrid", "json", "csv"], - help="Format for aligned output files (default is long_textgrid)", - ) - align_parser.add_argument( - "--include_original_text", - help="Flag to include original utterance text in the output", - action="store_true", - ) - add_global_options(align_parser, textgrid_output=True) - - adapt_parser = subparsers.add_parser("adapt", help="Adapt an acoustic model to a new corpus") - adapt_parser.add_argument("corpus_directory", help="Full path to the directory to align") - adapt_parser.add_argument("dictionary_path", type=str, help=dictionary_path_help) - adapt_parser.add_argument( - "acoustic_model_path", - type=str, - help=acoustic_model_path_help, - ) - adapt_parser.add_argument( - "output_paths", - type=str, - nargs="+", - help="Path to save the new acoustic model, path to export aligned TextGrids, or both", - ) - adapt_parser.add_argument( - "-o", - "--output_model_path", - type=str, - default="", - help="Full path to save adapted acoustic model", - ) - adapt_parser.add_argument( - "--config_path", type=str, default="", help="Path to config file to use for alignment" - ) - adapt_parser.add_argument( - "-s", - "--speaker_characters", - type=str, - default="0", - help="Number of characters of file names to use for determining speaker, " - "default is to use directory names", - ) - adapt_parser.add_argument( - "-a", - "--audio_directory", - type=str, - default="", - help="Audio directory root to use for finding audio files", - ) - adapt_parser.add_argument( - "--output_format", - type=str, - default="long_textgrid", - choices=["short_textgrid", "long_textgrid", "json"], - help="Format for aligned output files", - ) - adapt_parser.add_argument( - "--include_original_text", - help="Flag to include original utterance text in the output", - action="store_true", - ) - add_global_options(adapt_parser, textgrid_output=True) - - train_parser = subparsers.add_parser( - "train", help="Train a new acoustic model on a corpus and optionally export alignments" - ) - train_parser.add_argument( - "corpus_directory", type=str, help="Full path to the source directory to align" - ) - train_parser.add_argument("dictionary_path", type=str, help=dictionary_path_help, default="") - train_parser.add_argument( - "output_paths", - type=str, - nargs="+", - help="Path to save the new acoustic model, path to export aligned TextGrids, or both", - ) - train_parser.add_argument( - "--config_path", - type=str, - default="", - help="Path to config file to use for training and alignment", - ) - train_parser.add_argument( - "-o", - "--output_model_path", - type=str, - default="", - help="Full path to save resulting acoustic model", - ) - train_parser.add_argument( - "-s", - "--speaker_characters", - type=str, - default="0", - help="Number of characters of filenames to use for determining speaker, " - "default is to use directory names", - ) - train_parser.add_argument( - "-a", - "--audio_directory", - type=str, - default="", - help="Audio directory root to use for finding audio files", - ) - train_parser.add_argument( - "--phone_set", - dest="phone_set_type", - type=str, - help="Enable extra decision tree modeling based on the phone set", - default="UNKNOWN", - choices=["AUTO", "IPA", "ARPA", "PINYIN"], - ) - train_parser.add_argument( - "--output_format", - type=str, - default="long_textgrid", - choices=["short_textgrid", "long_textgrid", "json"], - help="Format for aligned output files", - ) - train_parser.add_argument( - "--include_original_text", - help="Flag to include original utterance text in the output", - action="store_true", - ) - add_global_options(train_parser, textgrid_output=True) - - validate_parser = subparsers.add_parser("validate", help="Validate a corpus for use in MFA") - validate_parser.add_argument( - "corpus_directory", type=str, help="Full path to the source directory to align" - ) - validate_parser.add_argument( - "dictionary_path", type=str, help=dictionary_path_help, default="" - ) - validate_parser.add_argument( - "acoustic_model_path", - type=str, - nargs="?", - default="", - help=acoustic_model_path_help, - ) - validate_parser.add_argument( - "-s", - "--speaker_characters", - type=str, - default="0", - help="Number of characters of file names to use for determining speaker, " - "default is to use directory names", - ) - validate_parser.add_argument( - "--config_path", - type=str, - default="", - help="Path to config file to use for training and alignment", - ) - validate_parser.add_argument( - "--test_transcriptions", help="Test accuracy of transcriptions", action="store_true" - ) - validate_parser.add_argument( - "--ignore_acoustics", - "--skip_acoustics", - dest="ignore_acoustics", - help="Skip acoustic feature generation and associated validation", - action="store_true", - ) - validate_parser.add_argument( - "-a", - "--audio_directory", - type=str, - default="", - help="Audio directory root to use for finding audio files", - ) - validate_parser.add_argument( - "--phone_set", - dest="phone_set_type", - type=str, - help="Enable extra decision tree modeling based on the phone set", - default="UNKNOWN", - choices=["AUTO", "IPA", "ARPA", "PINYIN"], - ) - add_global_options(validate_parser) - - validate_dictionary_parser = subparsers.add_parser( - "validate_dictionary", - help="Validate a dictionary using a G2P model to detect unlikely pronunciations", - ) - - validate_dictionary_parser.add_argument( - "dictionary_path", type=str, help=dictionary_path_help, default="" - ) - validate_dictionary_parser.add_argument( - "output_path", - type=str, - nargs="?", - help="Path to save the CSV file with the scored pronunciations", - ) - validate_dictionary_parser.add_argument( - "--g2p_model_path", - type=str, - help="Pretrained G2P model path", - ) - validate_dictionary_parser.add_argument( - "--g2p_threshold", - type=float, - default=1.5, - help="Threshold to use when running G2P. Paths with costs less than the best path times the threshold value will be included.", - ) - validate_dictionary_parser.add_argument( - "--config_path", - type=str, - default="", - help="Path to config file to use for validation", - ) - add_global_options(validate_dictionary_parser) - - g2p_parser = subparsers.add_parser( - "g2p", help="Generate a pronunciation dictionary using a G2P model" - ) - g2p_parser.add_argument( - "g2p_model_path", - help=g2p_model_path_help, - type=str, - nargs="?", - ) - - g2p_parser.add_argument( - "input_path", - type=str, - help="Corpus to base word list on or a text file of words to generate pronunciations", - ) - g2p_parser.add_argument("output_path", type=str, help="Path to save output dictionary") - g2p_parser.add_argument( - "--include_bracketed", - help="Included words enclosed by brackets, job_name.e. [...], (...), <...>", - action="store_true", - ) - g2p_parser.add_argument( - "--config_path", type=str, default="", help="Path to config file to use for G2P" - ) - add_global_options(g2p_parser) - - train_g2p_parser = subparsers.add_parser( - "train_g2p", help="Train a G2P model from a pronunciation dictionary" - ) - train_g2p_parser.add_argument("dictionary_path", type=str, help=dictionary_path_help) - - train_g2p_parser.add_argument( - "output_model_path", type=str, help="Desired location of generated model" - ) - train_g2p_parser.add_argument( - "--config_path", type=str, default="", help="Path to config file to use for G2P" - ) - train_g2p_parser.add_argument( - "--phonetisaurus", action="store_true", help="Flag for using Phonetisaurus-style models" - ) - train_g2p_parser.add_argument( - "--evaluate", - "--validate", - dest="evaluation_mode", - action="store_true", - help="Perform an analysis of accuracy training on " - "most of the data and validating on an unseen subset", - ) - add_global_options(train_g2p_parser) - help_message = "Inspect, download, and save pretrained MFA models" - model_parser = subparsers.add_parser( - "model", aliases=["models"], description=help_message, help=help_message - ) - - model_subparsers = model_parser.add_subparsers(dest="action") - model_subparsers.required = True - help_message = "Download a pretrained model from the MFA repository" - model_download_parser = model_subparsers.add_parser( - "download", description=help_message, help=help_message - ) - model_download_parser.add_argument( - "model_type", choices=sorted(MODEL_TYPES), help="Type of model to download" - ) - model_download_parser.add_argument( - "name", - help="Name of language code to download, if not specified, " - "will list all available languages", - type=str, - nargs="?", - ) - model_download_parser.add_argument( - "--github_token", - type=str, - default="", - help="Personal access token to use for requests to GitHub to increase rate limit", - ) - model_download_parser.add_argument( - "--ignore_cache", - action="store_true", - help="Flag to ignore existing downloaded models and force a re-download", - ) - help_message = "List of saved models" - model_list_parser = model_subparsers.add_parser( - "list", description=help_message, help=help_message - ) - model_list_parser.add_argument( - "model_type", - choices=sorted(MODEL_TYPES), - type=str, - nargs="?", - help="Type of model to list", - ) - model_list_parser.add_argument( - "--github_token", - type=str, - default="", - help="Personal access token to use for requests to GitHub to increase rate limit", - ) - - help_message = "Inspect a model and output its metadata" - model_inspect_parser = model_subparsers.add_parser( - "inspect", description=help_message, help=help_message - ) - model_inspect_parser.add_argument( - "model_type", - choices=sorted(MODEL_TYPES), - type=str, - nargs="?", - help="Type of model to inspect", - ) - model_inspect_parser.add_argument( - "name", type=str, help="Name of pretrained model or path to MFA model to inspect" - ) - - help_message = "Save a MFA model to the pretrained directory for name-based referencing" - model_save_parser = model_subparsers.add_parser( - "save", description=help_message, help=help_message - ) - model_save_parser.add_argument( - "model_type", type=str, choices=sorted(MODEL_TYPES), help="Type of MFA model" - ) - model_save_parser.add_argument( - "path", help="Path to MFA model to save for invoking with just its name" - ) - model_save_parser.add_argument( - "--name", - help="Name to use as reference (defaults to the name of the zip file", - type=str, - default="", - ) - model_save_parser.add_argument( - "--overwrite", - help="Flag to overwrite existing pretrained models with the same name (and model type)", - action="store_true", - ) - - train_lm_parser = subparsers.add_parser( - "train_lm", help="Train a language model from a corpus" - ) - train_lm_parser.add_argument( - "source_path", - type=str, - help="Full path to the source directory to train from, alternatively " - "an ARPA format language model to convert for MFA use", - ) - train_lm_parser.add_argument( - "output_model_path", type=str, help="Full path to save resulting language model" - ) - train_lm_parser.add_argument( - "-m", - "--model_path", - type=str, - help="Full path to existing language model to merge probabilities", - ) - train_lm_parser.add_argument( - "-w", - "--model_weight", - type=float, - default=1.0, - help="Weight factor for supplemental language model, defaults to 1.0", - ) - train_lm_parser.add_argument( - "--dictionary_path", type=str, help=dictionary_path_help, default="" - ) - train_lm_parser.add_argument( - "--config_path", - type=str, - default="", - help="Path to config file to use for training and alignment", - ) - add_global_options(train_lm_parser) - - train_dictionary_parser = subparsers.add_parser( - "train_dictionary", - help="Calculate pronunciation probabilities for a dictionary based on alignment results in a corpus", - ) - train_dictionary_parser.add_argument( - "corpus_directory", help="Full path to the directory to align" - ) - train_dictionary_parser.add_argument("dictionary_path", type=str, help=dictionary_path_help) - train_dictionary_parser.add_argument( - "acoustic_model_path", - type=str, - help=acoustic_model_path_help, - ) - train_dictionary_parser.add_argument( - "output_directory", - type=str, - help="Full path to output directory, will be created if it doesn't exist", - ) - train_dictionary_parser.add_argument( - "--config_path", type=str, default="", help="Path to config file to use for alignment" - ) - train_dictionary_parser.add_argument( - "--silence_probabilities", - action="store_true", - help="Flag for saving silence information for pronunciations", - ) - train_dictionary_parser.add_argument( - "-s", - "--speaker_characters", - type=str, - default="0", - help="Number of characters of file names to use for determining speaker, " - "default is to use directory names", - ) - add_global_options(train_dictionary_parser) - - train_ivector_parser = subparsers.add_parser( - "train_ivector", - help="Train an ivector extractor from a corpus and pretrained acoustic model", - ) - train_ivector_parser.add_argument( - "corpus_directory", - type=str, - help="Full path to the source directory to train the ivector extractor", - ) - train_ivector_parser.add_argument( - "output_model_path", - type=str, - help="Full path to save resulting ivector extractor", - ) - train_ivector_parser.add_argument( - "-s", - "--speaker_characters", - type=str, - default="0", - help="Number of characters of filenames to use for determining speaker, " - "default is to use directory names", - ) - train_ivector_parser.add_argument( - "--config_path", type=str, default="", help="Path to config file to use for training" - ) - add_global_options(train_ivector_parser) - - classify_speakers_parser = subparsers.add_parser( - "classify_speakers", help="Use an ivector extractor to cluster utterances into speakers" - ) - classify_speakers_parser.add_argument( - "corpus_directory", - type=str, - help="Full path to the source directory to run speaker classification", - ) - classify_speakers_parser.add_argument( - "ivector_extractor_path", type=str, default="", help=ivector_model_path_help - ) - classify_speakers_parser.add_argument( - "output_directory", - type=str, - help="Full path to output directory, will be created if it doesn't exist", - ) - - classify_speakers_parser.add_argument( - "-s", "--num_speakers", type=int, default=0, help="Number of speakers if known" - ) - classify_speakers_parser.add_argument( - "--cluster", help="Using clustering instead of classification", action="store_true" - ) - classify_speakers_parser.add_argument( - "--config_path", - type=str, - default="", - help="Path to config file to use for ivector extraction", - ) - add_global_options(classify_speakers_parser) - - create_segments_parser = subparsers.add_parser( - "create_segments", help="Create segments based on voice activity dectection (VAD)" - ) - create_segments_parser.add_argument( - "corpus_directory", help="Full path to the source directory to run VAD segmentation" - ) - create_segments_parser.add_argument( - "output_directory", - type=str, - help="Full path to output directory, will be created if it doesn't exist", - ) - create_segments_parser.add_argument( - "--config_path", type=str, default="", help="Path to config file to use for segmentation" - ) - add_global_options(create_segments_parser) - - transcribe_parser = subparsers.add_parser( - "transcribe", - help="Transcribe utterances using an acoustic model, language model, and pronunciation dictionary", - ) - transcribe_parser.add_argument( - "corpus_directory", type=str, help="Full path to the directory to transcribe" - ) - transcribe_parser.add_argument("dictionary_path", type=str, help=dictionary_path_help) - transcribe_parser.add_argument( - "acoustic_model_path", - type=str, - help=acoustic_model_path_help, - ) - transcribe_parser.add_argument( - "language_model_path", - type=str, - help=language_model_path_help, - ) - transcribe_parser.add_argument( - "output_directory", - type=str, - help="Full path to output directory, will be created if it doesn't exist", - ) - transcribe_parser.add_argument( - "--config_path", type=str, default="", help="Path to config file to use for transcription" - ) - transcribe_parser.add_argument( - "--output_type", - type=str, - default="transcription", - choices=["transcription", "alignment"], - help="Whether to output transcription or alignment of transcribed files", - ) - transcribe_parser.add_argument( - "-s", - "--speaker_characters", - type=str, - default="0", - help="Number of characters of file names to use for determining speaker, " - "default is to use directory names", - ) - transcribe_parser.add_argument( - "-a", - "--audio_directory", - type=str, - default="", - help="Audio directory root to use for finding audio files", - ) - transcribe_parser.add_argument( - "-e", - "--evaluate", - dest="evaluation_mode", - help="Evaluate the transcription against golden texts", - action="store_true", - ) - transcribe_parser.add_argument( - "--include_original_text", - help="Flag to include original utterance text in the output", - action="store_true", - ) - transcribe_parser.add_argument( - "--language_model_weight", - dest="language_model_weight", - help="Specific language model weight to use in evaluating transcriptions, if not specified, " - "metrics will be calculated over a range from 7 to 16 (inclusive)", - type=int, - ) - transcribe_parser.add_argument( - "--word_insertion_penalty", - dest="word_insertion_penalty", - help="Specific word insertion penalty to use in evaluating transcriptions, if not specified, " - "metrics will be calculated over values of [0, 0.5, 1.0]", - type=float, - ) - add_global_options(transcribe_parser) - - config_parser = subparsers.add_parser( - "configure", - help="The configure command is used to set global defaults for MFA so " - "you don't have to set them every time you call an MFA command.", - ) - config_parser.add_argument( - "-t", - "--temp_directory", - "--temporary_directory", - dest="temporary_directory", - type=str, - default="", - help=f"Set the default temporary directory, default is {GLOBAL_CONFIG['temporary_directory']}", - ) - config_parser.add_argument( - "-j", - "--num_jobs", - type=int, - help=f"Set the number of processes to use by default, defaults to {GLOBAL_CONFIG['num_jobs']}", - ) - config_parser.add_argument( - "--always_clean", - help="Always remove files from previous runs by default", - action="store_true", - ) - config_parser.add_argument( - "--never_clean", - help="Don't remove files from previous runs by default", - action="store_true", - ) - config_parser.add_argument( - "--always_verbose", help="Default to verbose output", action="store_true" - ) - config_parser.add_argument( - "--never_verbose", help="Default to non-verbose output", action="store_true" - ) - config_parser.add_argument("--always_quiet", help="Default to no output", action="store_true") - config_parser.add_argument( - "--never_quiet", help="Default to printing output", action="store_true" - ) - config_parser.add_argument( - "--always_debug", help="Default to running debugging steps", action="store_true" - ) - config_parser.add_argument( - "--never_debug", help="Default to not running debugging steps", action="store_true" - ) - config_parser.add_argument( - "--always_overwrite", help="Always overwrite output files", action="store_true" - ) - config_parser.add_argument( - "--never_overwrite", - help="Never overwrite output files (if file already exists, " - "the output will be saved in the temp directory)", - action="store_true", - ) - config_parser.add_argument( - "--disable_mp", - help="Disable all multiprocessing (not recommended as it will usually " - "increase processing times)", - action="store_true", - ) - config_parser.add_argument( - "--enable_mp", - help="Enable multiprocessing (recommended and enabled by default)", - action="store_true", - ) - config_parser.add_argument( - "--disable_textgrid_cleanup", - help="Disable postprocessing of TextGrids that cleans up " - "silences and recombines compound words and clitics", - action="store_true", - ) - config_parser.add_argument( - "--enable_textgrid_cleanup", - help="Enable postprocessing of TextGrids that cleans up " - "silences and recombines compound words and clitics", - action="store_true", - ) - config_parser.add_argument( - "--disable_detect_phone_set", - help="Disable auto-detecting phone sets from the dictionary during training", - action="store_true", - ) - config_parser.add_argument( - "--enable_detect_phone_set", - help="Enable auto-detecting phone sets from the dictionary during training", - action="store_true", - ) - config_parser.add_argument( - "--disable_terminal_colors", help="Turn off colored text in output", action="store_true" - ) - config_parser.add_argument( - "--enable_terminal_colors", help="Turn on colored text in output", action="store_true" - ) - config_parser.add_argument( - "--terminal_width", - help=f"Set width of terminal output, " - f"currently set to {GLOBAL_CONFIG['terminal_width']}", - default=GLOBAL_CONFIG["terminal_width"], - type=int, - ) - config_parser.add_argument( - "--blas_num_threads", - help=f"Number of threads to use for BLAS libraries, 1 is recommended " - f"due to how much MFA relies on multiprocessing. " - f"Currently set to {GLOBAL_CONFIG['blas_num_threads']}", - default=GLOBAL_CONFIG["blas_num_threads"], - type=int, - ) - - history_parser = subparsers.add_parser("history", help="Show previously run mfa commands") - _ = subparsers.add_parser("thirdparty", help="DEPRECATED: Please install Kaldi via conda.") - _ = subparsers.add_parser( - "download", help="DEPRECATED: Please use mfa model download instead." - ) - - history_parser.add_argument( - "depth", type=int, help="Number of commands to list", nargs="?", default=10 - ) - history_parser.add_argument( - "-v", - "--verbose", - help=f"Output debug messages, default is {GLOBAL_CONFIG['verbose']}", - action="store_true", - ) - - _ = subparsers.add_parser( - "anchor", aliases=["annotator"], help="Launch Anchor Annotator (if installed)" - ) - - return parser - - -parser = create_parser() - - -def print_history(args: argparse.Namespace) -> None: - """ - Print the history of MFA commands - - Parameters - ---------- - args: argparse.Namespace - Parsed args - """ - depth = args.depth - history = load_command_history()[-depth:] - if args.verbose: - print("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( - f"{h['command']}\t{d}\t{execution_time}\t{h['version']}\t{h['exit_code']}\t{h['exception']}" - ) - pass - else: - for h in history: - print(h["command"]) - - -def main() -> None: +@click.group( + name="mfa", + help="Montreal Forced Aligner is a command line utility for aligning speech and text.", +) +def mfa_cli() -> None: """ Main function for the MFA command line interface """ + GLOBAL_CONFIG.load() + from montreal_forced_aligner.helper import configure_logger + if not GLOBAL_CONFIG.current_profile.debug: + warnings.simplefilter("ignore") + configure_logger("mfa") check_third_party() hooks = ExitHooks() @@ -1036,88 +115,27 @@ def main() -> None: from colorama import init init() - parser = create_parser() mp.freeze_support() - args, unknown = parser.parse_known_args() - for short in ["-c", "-d"]: - if short in unknown: - print( - f"Due to the number of options that `{short}` could refer to, it is not accepted. " - "Please specify the full argument", - file=sys.stderr, - ) - sys.exit(1) - try: - if args.subcommand in ["g2p", "train_g2p"]: - try: - import pynini # noqa - except ImportError: - print( - "There was an issue importing Pynini, please ensure that it is installed. If you are on Windows, " - "please use the Windows Subsystem for Linux to use g2p functionality.", - file=sys.stderr, - ) - sys.exit(1) - if args.subcommand == "align": - run_align_corpus(args, unknown) - elif args.subcommand == "adapt": - run_adapt_model(args, unknown) - elif args.subcommand == "train": - run_train_acoustic_model(args, unknown) - elif args.subcommand == "g2p": - run_g2p(args, unknown) - elif args.subcommand == "train_g2p": - run_train_g2p(args, unknown) - elif args.subcommand == "validate": - run_validate_corpus(args, unknown) - elif args.subcommand == "validate_dictionary": - run_validate_dictionary(args, unknown) - elif args.subcommand in ["model", "models"]: - run_model(args) - elif args.subcommand == "train_lm": - run_train_lm(args, unknown) - elif args.subcommand == "train_dictionary": - run_train_dictionary(args, unknown) - elif args.subcommand == "train_ivector": - run_train_ivector_extractor(args, unknown) - elif args.subcommand == "classify_speakers": # pragma: no cover - run_classify_speakers(args, unknown) - elif args.subcommand in ["annotator", "anchor"]: - run_anchor() - elif args.subcommand == "transcribe": - run_transcribe_corpus(args, unknown) - elif args.subcommand == "create_segments": - run_create_segments(args, unknown) - elif args.subcommand == "configure": - update_global_config(args) - global GLOBAL_CONFIG - GLOBAL_CONFIG = load_global_config() - elif args.subcommand == "history": - print_history(args) - elif args.subcommand == "version": - from montreal_forced_aligner.utils import get_mfa_version - print(get_mfa_version()) - elif args.subcommand == "thirdparty": # Deprecated command - raise DeprecationWarning( - "Necessary thirdparty executables are now installed via conda. Please refer to the installation docs for the updated commands." - ) - elif args.subcommand == "download": # Deprecated command - raise DeprecationWarning( - "Downloading models is now run through the `mfa model download` command, please use that instead." - ) - except MFAError as e: - if getattr(args, "debug", False): - raise - print(e, file=sys.stderr) - sys.exit(1) +mfa_cli.add_command(adapt_model_cli) +mfa_cli.add_command(align_corpus_cli) +mfa_cli.add_command(anchor_cli) +mfa_cli.add_command(diarize_speakers_cli) +mfa_cli.add_command(create_segments_cli) +mfa_cli.add_command(configure_cli) +mfa_cli.add_command(history_cli) +mfa_cli.add_command(g2p_cli) +mfa_cli.add_command(model_cli, name="model") +mfa_cli.add_command(model_cli, name="models") +mfa_cli.add_command(train_acoustic_model_cli) +mfa_cli.add_command(train_dictionary_cli) +mfa_cli.add_command(train_g2p_cli) +mfa_cli.add_command(train_ivector_cli) +mfa_cli.add_command(train_lm_cli) +mfa_cli.add_command(transcribe_corpus_cli) +mfa_cli.add_command(validate_corpus_cli) +mfa_cli.add_command(validate_dictionary_cli) if __name__ == "__main__": - import warnings - - warnings.warn( - "Use 'python -m montreal_forced_aligner', not 'python -m montreal_forced_aligner.command_line.mfa'", - DeprecationWarning, - ) - main() + mfa_cli() diff --git a/montreal_forced_aligner/command_line/model.py b/montreal_forced_aligner/command_line/model.py index 495f3bba..135123ab 100644 --- a/montreal_forced_aligner/command_line/model.py +++ b/montreal_forced_aligner/command_line/model.py @@ -1,63 +1,156 @@ """Command line functions for interacting with MFA models""" from __future__ import annotations +import logging import os import shutil -from typing import TYPE_CHECKING, Optional +import typing -from montreal_forced_aligner.config import get_temporary_directory +import click + +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.data import PhoneSetType from montreal_forced_aligner.exceptions import ( - FileArgumentNotFoundError, ModelLoadError, + ModelSaveError, ModelTypeNotSupportedError, MultipleModelTypesFoundError, PretrainedModelNotFoundError, - RemoteModelNotFoundError, ) from montreal_forced_aligner.models import MODEL_TYPES, Archive, ModelManager, guess_model_type -from montreal_forced_aligner.utils import configure_logger - -if TYPE_CHECKING: - from argparse import Namespace - __all__ = [ - "inspect_model", - "validate_args", - "save_model", - "run_model", + "model_cli", + "save_model_cli", + "download_model_cli", + "list_model_cli", + "inspect_model_cli", ] -def inspect_model(path: str) -> None: +@click.group(name="model", short_help="Download, inspect, and save models") +@click.help_option("-h", "--help") +def model_cli() -> None: """ - Inspect a model and print out metadata information about it + Inspect, download, and save pretrained MFA models + """ + pass - Parameters - ---------- - path: str - Path to model + +@model_cli.command(name="download", short_help="Download pretrained models") +@click.argument("model_type", type=click.Choice(sorted(MODEL_TYPES))) +@click.argument("model_name", nargs=-1, type=str) +@click.option( + "--github_token", + help="Personal access token to use for requests to GitHub to increase rate limit.", + type=str, + default=None, +) +@click.option( + "--ignore_cache", + is_flag=True, + help="Flag to ignore existing downloaded models and force a re-download.", + default=False, +) +@click.help_option("-h", "--help") +def download_model_cli( + model_type: str, model_name: typing.List[str], github_token: str, ignore_cache: bool +) -> None: """ + Download pretrained models from the MFA repository. If no model names are specified, the list of all downloadable models + of the given model type will be printed. + """ + manager = ModelManager(token=github_token) + if model_name: + for name in model_name: + manager.download_model(model_type, name, ignore_cache) + else: + manager.print_remote_models(model_type) + + +@model_cli.command(name="list", short_help="List available models") +@click.argument("model_type", type=click.Choice(sorted(MODEL_TYPES))) +@click.help_option("-h", "--help") +def list_model_cli(model_type: str) -> None: + """ + List of locally saved models. + """ + manager = ModelManager(token=GLOBAL_CONFIG.github_token) + manager.print_local_models(model_type) + + +@model_cli.command(name="inspect", short_help="Inspect a model") +@click.argument("model_type", type=click.Choice(sorted(MODEL_TYPES))) +@click.argument("model", type=str) +@click.help_option("-h", "--help") +def inspect_model_cli(model_type: str, model: str) -> None: + """ + Inspect a model and print out its metadata. + """ + 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" + ) + shutil.rmtree(GLOBAL_CONFIG.current_profile.temporary_directory, ignore_errors=True) + if model_type and model_type not in MODEL_TYPES: + raise ModelTypeNotSupportedError(model_type, MODEL_TYPES) + elif model_type: + model_type = model_type.lower() + possible_model_types = guess_model_type(model) + if not possible_model_types: + if model_type: + model_class = MODEL_TYPES[model_type] + path = model_class.get_pretrained_path(model) + if path is None: + raise PretrainedModelNotFoundError( + model, model_type, model_class.get_available_models() + ) + else: + found_model_types = [] + path = None + for model_type, model_class in MODEL_TYPES.items(): + p = model_class.get_pretrained_path(model) + if p is not None: + path = p + found_model_types.append(model_type) + if len(found_model_types) > 1: + raise MultipleModelTypesFoundError(model, found_model_types) + if path is None: + raise PretrainedModelNotFoundError(model) + model = path working_dir = os.path.join(get_temporary_directory(), "models", "inspect") - ext = os.path.splitext(path)[1] - model = None - if ext == Archive.extensions[0]: # Figure out what kind of model it is - a = Archive(path, working_dir) - model = a.get_subclass_object() + ext = os.path.splitext(model)[1] + if model_type: + if model_type == MODEL_TYPES["dictionary"]: + m = MODEL_TYPES[model_type](model, working_dir, phone_set_type=PhoneSetType.AUTO) + else: + m = MODEL_TYPES[model_type](model, working_dir) else: - for model_class in MODEL_TYPES.values(): - if model_class.valid_extension(path): - if model_class == MODEL_TYPES["dictionary"]: - model = model_class(path, working_dir, phone_set_type=PhoneSetType.AUTO) - else: - model = model_class(path, working_dir) - if not model: - raise ModelLoadError(path) - model.pretty_print() - - -def save_model(path: str, model_type: str, output_name: Optional[str]) -> None: + m = None + if ext == Archive.extensions[0]: # Figure out what kind of model it is + a = Archive(model, working_dir) + m = a.get_subclass_object() + if not m: + raise ModelLoadError(path) + m.pretty_print() + + +@model_cli.command(name="save", short_help="Save a model") +@click.argument("model_type", type=click.Choice(sorted(MODEL_TYPES))) +@click.argument("path", type=click.Path(exists=True, file_okay=True, dir_okay=False)) +@click.option( + "--name", help="Name to use as reference (defaults to the name of the zip file).", type=str +) +@click.option( + "--overwrite/--no_overwrite", + "overwrite", + help=f"Overwrite output files when they exist, default is {GLOBAL_CONFIG.overwrite}", + default=GLOBAL_CONFIG.overwrite, +) +@click.help_option("-h", "--help") +def save_model_cli(path: str, model_type: str, name: str, overwrite: bool) -> None: """ Save a model to pretrained folder for later use @@ -68,114 +161,16 @@ def save_model(path: str, model_type: str, output_name: Optional[str]) -> None: model_type: str Type of model """ - logger = configure_logger("save_model") + logger = logging.getLogger("mfa") model_name = os.path.splitext(os.path.basename(path))[0] model_class = MODEL_TYPES[model_type] - if output_name: - out_path = model_class.get_pretrained_path(output_name, enforce_existence=False) + 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): + raise ModelSaveError(out_path) shutil.copyfile(path, out_path) logger.info( - f"Saved model to {output_name}, you can now use {output_name} in place of paths in mfa commands." + f"Saved model to {name}, you can now use {name} in place of paths in mfa commands." ) - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - :class:`~montreal_forced_aligner.exceptions.ModelTypeNotSupportedError` - If the type of model is not supported - :class:`~montreal_forced_aligner.exceptions.FileArgumentNotFoundError` - If the file specified is not found - :class:`~montreal_forced_aligner.exceptions.PretrainedModelNotFoundError` - If the pretrained model specified is not found - :class:`~montreal_forced_aligner.exceptions.ModelExtensionError` - If the extension is not valid for the specified model type - :class:`~montreal_forced_aligner.exceptions.MultipleModelTypesFoundError` - If multiple model types match the name - """ - if args.action == "download": - if args.model_type not in MODEL_TYPES: - raise ModelTypeNotSupportedError(args.model_type, MODEL_TYPES) - elif args.model_type: - args.model_type = args.model_type.lower() - if args.name: - manager = ModelManager() - manager.refresh_remote() - available_languages = manager.remote_models[args.model_type] - if args.name not in available_languages: - raise RemoteModelNotFoundError( - args.name, args.model_type, list(available_languages.keys()) - ) - elif args.action == "list": - if args.model_type and args.model_type.lower() not in MODEL_TYPES: - raise ModelTypeNotSupportedError(args.model_type, MODEL_TYPES) - elif args.model_type: - args.model_type = args.model_type.lower() - elif args.action == "inspect": - if args.model_type and args.model_type not in MODEL_TYPES: - raise ModelTypeNotSupportedError(args.model_type, MODEL_TYPES) - elif args.model_type: - args.model_type = args.model_type.lower() - possible_model_types = guess_model_type(args.name) - if not possible_model_types: - if args.model_type: - model_class = MODEL_TYPES[args.model_type] - path = model_class.get_pretrained_path(args.name) - if path is None: - raise PretrainedModelNotFoundError( - args.name, args.model_type, model_class.get_available_models() - ) - else: - found_model_types = [] - path = None - for model_type, model_class in MODEL_TYPES.items(): - p = model_class.get_pretrained_path(args.name) - if p is not None: - path = p - found_model_types.append(model_type) - if len(found_model_types) > 1: - raise MultipleModelTypesFoundError(args.name, found_model_types) - if path is None: - raise PretrainedModelNotFoundError(args.name) - args.name = path - else: - if not os.path.exists(args.name): - raise FileArgumentNotFoundError(args.name) - elif args.action == "save": - if not os.path.exists(args.path): - raise FileArgumentNotFoundError(args.path) - - -def run_model(args: Namespace) -> None: - """ - Wrapper function for running model utility commands - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - """ - validate_args(args) - manager = ModelManager(token=getattr(args, "github_token", None)) - if args.action == "download" and args.name: - manager.download_model(args.model_type, args.name, args.ignore_cache) - elif args.action == "download": - manager.print_remote_models(args.model_type) - elif args.action == "list": - manager.print_local_models(args.model_type) - elif args.action == "inspect": - inspect_model(args.name) - elif args.action == "save": - save_model(args.path, args.model_type, args.name) diff --git a/montreal_forced_aligner/command_line/train_acoustic_model.py b/montreal_forced_aligner/command_line/train_acoustic_model.py index aa71b281..63ff5ec8 100644 --- a/montreal_forced_aligner/command_line/train_acoustic_model.py +++ b/montreal_forced_aligner/command_line/train_acoustic_model.py @@ -2,111 +2,116 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, List, Optional - -from montreal_forced_aligner.acoustic_modeling import TrainableAligner -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.exceptions import ArgumentError - -if TYPE_CHECKING: - from argparse import Namespace +import click -__all__ = ["train_acoustic_model", "validate_args", "run_train_acoustic_model"] - - -def train_acoustic_model(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +from montreal_forced_aligner.acoustic_modeling import TrainableAligner +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_dictionary, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE + +__all__ = ["train_acoustic_model_cli"] + + +@click.command( + name="train", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Train a new acoustic model", +) +@click.argument("corpus_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.argument("dictionary_path", type=click.UNPROCESSED, callback=validate_dictionary) +@click.argument("output_model_path", type=click.Path(file_okay=True, dir_okay=False)) +@click.option( + "--output_directory", + help="Path to save alignments.", + type=click.Path(file_okay=False, dir_okay=True), +) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--speaker_characters", + "-s", + help="Number of characters of file names to use for determining speaker, " + "default is to use directory names.", + type=str, + default="0", +) +@click.option( + "--audio_directory", + "-a", + help="Audio directory root to use for finding audio files.", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.option( + "--phone_set", + "phone_set_type", + help="Enable extra decision tree modeling based on the phone set.", + default="UNKNOWN", + type=click.Choice(["UNKNOWN", "AUTO", "MFA", "IPA", "ARPA", "PINYIN"]), +) +@click.option( + "--output_format", + help="Format for aligned output files (default is long_textgrid).", + default="long_textgrid", + type=click.Choice(["long_textgrid", "short_textgrid", "json", "csv"]), +) +@click.option( + "--include_original_text", + is_flag=True, + help="Flag to include original utterance text in the output.", + default=False, +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def train_acoustic_model_cli(context, **kwargs) -> None: """ - Run the acoustic model training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Train a new acoustic model on a corpus and optionally export alignments """ + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + output_model_path = kwargs.get("output_model_path", None) + output_directory = kwargs.get("output_directory", None) + corpus_directory = kwargs["corpus_directory"] + dictionary_path = kwargs["dictionary_path"] trainer = TrainableAligner( - corpus_directory=args.corpus_directory, - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **TrainableAligner.parse_parameters(args.config_path, args, unknown_args), + corpus_directory=corpus_directory, + dictionary_path=dictionary_path, + **TrainableAligner.parse_parameters(config_path, context.params, context.args), ) + if kwargs.get("clean", False): + trainer.clean_working_directory() + trainer.remove_database() try: trainer.train() - if args.output_model_path is not None: - trainer.export_model(args.output_model_path) + if output_model_path is not None: + trainer.export_model(output_model_path) - if args.output_directory is not None: - output_format = getattr(args, "output_format", None) + if output_directory is not None: trainer.export_files( - args.output_directory, - output_format, - include_original_text=getattr(args, "include_original_text", False), + output_directory, + kwargs["output_format"], + include_original_text=kwargs["include_original_text"], ) except Exception: trainer.dirty = True raise finally: trainer.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - try: - args.speaker_characters = int(args.speaker_characters) - except ValueError: - pass - - args.output_directory = None - if not args.output_model_path: - args.output_model_path = None - output_paths = args.output_paths - if len(output_paths) > 2: - raise ArgumentError(f"Got more arguments for output_paths than 2: {output_paths}") - for path in output_paths: - if path.endswith(".zip"): - args.output_model_path = path - else: - args.output_directory = path.rstrip("/").rstrip("\\") - - args.corpus_directory = args.corpus_directory.rstrip("/").rstrip("\\") - if args.corpus_directory == args.output_directory: - raise ArgumentError("Corpus directory and output directory cannot be the same folder.") - if not os.path.exists(args.corpus_directory): - raise (ArgumentError(f'Could not find the corpus directory "{args.corpus_directory}".')) - if not os.path.isdir(args.corpus_directory): - raise ( - ArgumentError( - f'The specified corpus directory "{args.corpus_directory}" is not a directory.' - ) - ) - - args.dictionary_path = validate_model_arg(args.dictionary_path, "dictionary") - - -def run_train_acoustic_model(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running acoustic model training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - train_acoustic_model(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/train_dictionary.py b/montreal_forced_aligner/command_line/train_dictionary.py index 3a1966e5..ff579e41 100644 --- a/montreal_forced_aligner/command_line/train_dictionary.py +++ b/montreal_forced_aligner/command_line/train_dictionary.py @@ -2,91 +2,94 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, List, Optional - -from montreal_forced_aligner.alignment.pretrained import DictionaryTrainer -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.exceptions import ArgumentError - -if TYPE_CHECKING: - from argparse import Namespace - - -__all__ = ["train_dictionary", "validate_args", "run_train_dictionary"] +import click -def train_dictionary(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +from montreal_forced_aligner.alignment.pretrained import DictionaryTrainer +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_acoustic_model, + validate_dictionary, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE + +__all__ = ["train_dictionary_cli"] + + +@click.command( + name="train_dictionary", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Calculate pronunciation probabilities", +) +@click.argument("corpus_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.argument("dictionary_path", type=click.UNPROCESSED, callback=validate_dictionary) +@click.argument("acoustic_model_path", type=click.UNPROCESSED, callback=validate_acoustic_model) +@click.argument("output_directory", type=click.Path(file_okay=False, dir_okay=True)) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--silence_probabilities", + is_flag=True, + help="Flag for saving silence information for pronunciations.", + default=False, +) +@click.option( + "--speaker_characters", + "-s", + help="Number of characters of file names to use for determining speaker, " + "default is to use directory names.", + type=str, + default="0", +) +@click.option( + "--audio_directory", + "-a", + help="Audio directory root to use for finding audio files.", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def train_dictionary_cli(context, **kwargs) -> None: """ - Run the pronunciation probability training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Calculate pronunciation probabilities for a dictionary based on alignment results in a corpus. """ - aligner = DictionaryTrainer( - acoustic_model_path=args.acoustic_model_path, - corpus_directory=args.corpus_directory, - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **DictionaryTrainer.parse_parameters(args.config_path, args, unknown_args), + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + acoustic_model_path = kwargs["acoustic_model_path"] + corpus_directory = kwargs["corpus_directory"] + dictionary_path = kwargs["dictionary_path"] + output_directory = kwargs["output_directory"] + trainer = DictionaryTrainer( + acoustic_model_path=acoustic_model_path, + corpus_directory=corpus_directory, + dictionary_path=dictionary_path, + **DictionaryTrainer.parse_parameters(config_path, context.params, context.args), ) + if kwargs.get("clean", False): + trainer.clean_working_directory() + trainer.remove_database() try: - aligner.align() - aligner.export_lexicons( - args.output_directory, getattr(args, "silence_probabilities", False) - ) + trainer.align() + trainer.export_lexicons(output_directory) except Exception: - aligner.dirty = True + trainer.dirty = True raise finally: - aligner.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - try: - args.speaker_characters = int(args.speaker_characters) - except ValueError: - pass - args.output_directory = args.output_directory.rstrip("/").rstrip("\\") - args.corpus_directory = args.corpus_directory.rstrip("/").rstrip("\\") - if not os.path.exists(args.corpus_directory): - raise ArgumentError(f"Could not find the corpus directory {args.corpus_directory}.") - if not os.path.isdir(args.corpus_directory): - raise ArgumentError( - f"The specified corpus directory ({args.corpus_directory}) is not a directory." - ) - - args.dictionary_path = validate_model_arg(args.dictionary_path, "dictionary") - args.acoustic_model_path = validate_model_arg(args.acoustic_model_path, "acoustic") - - -def run_train_dictionary(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running pronunciation probability training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - train_dictionary(args, unknown_args) + trainer.cleanup() + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/train_g2p.py b/montreal_forced_aligner/command_line/train_g2p.py index 34048704..e82cc510 100644 --- a/montreal_forced_aligner/command_line/train_g2p.py +++ b/montreal_forced_aligner/command_line/train_g2p.py @@ -1,83 +1,94 @@ """Command line functions for training G2P models""" from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional +import os -from montreal_forced_aligner.command_line.utils import validate_model_arg +import click + +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_dictionary, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE from montreal_forced_aligner.g2p.phonetisaurus_trainer import PhonetisaurusTrainer from montreal_forced_aligner.g2p.trainer import PyniniTrainer -if TYPE_CHECKING: - from argparse import Namespace - - -__all__ = ["train_g2p", "validate_args", "run_train_g2p"] - - -def train_g2p(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +__all__ = ["train_g2p_cli"] + + +@click.command( + name="train_g2p", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Train a G2P model", +) +@click.argument("dictionary_path", type=click.UNPROCESSED, callback=validate_dictionary) +@click.argument("output_model_path", type=click.Path(file_okay=True, dir_okay=False)) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--phonetisaurus", + is_flag=True, + help="Flag for using Phonetisaurus-style models.", + default=False, +) +@click.option( + "--evaluate", + "--validate", + "evaluation_mode", + is_flag=True, + help="Perform an analysis of accuracy training on " + "most of the data and validating on an unseen subset.", + default=False, +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def train_g2p_cli(context, **kwargs) -> None: """ - Run the G2P model training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Train a G2P model from a pronunciation dictionary. """ - if getattr(args, "phonetisaurus", True): + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + dictionary_path = kwargs["dictionary_path"] + phonetisaurus = kwargs["phonetisaurus"] + output_model_path = kwargs["output_model_path"] + if phonetisaurus: trainer = PhonetisaurusTrainer( - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **PhonetisaurusTrainer.parse_parameters(args.config_path, args, unknown_args) + dictionary_path=dictionary_path, + **PhonetisaurusTrainer.parse_parameters(config_path, context.params, context.args), ) else: trainer = PyniniTrainer( - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **PyniniTrainer.parse_parameters(args.config_path, args, unknown_args) + dictionary_path=dictionary_path, + **PyniniTrainer.parse_parameters(config_path, context.params, context.args), ) + if kwargs.get("clean", False): + trainer.clean_working_directory() + trainer.remove_database() try: trainer.setup() trainer.train() - trainer.export_model(args.output_model_path) + trainer.export_model(output_model_path) except Exception: trainer.dirty = True raise finally: trainer.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - args.dictionary_path = validate_model_arg(args.dictionary_path, "dictionary") - - -def run_train_g2p(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running G2P model training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - train_g2p(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/train_ivector_extractor.py b/montreal_forced_aligner/command_line/train_ivector_extractor.py index 8812452d..255ecbc8 100644 --- a/montreal_forced_aligner/command_line/train_ivector_extractor.py +++ b/montreal_forced_aligner/command_line/train_ivector_extractor.py @@ -2,89 +2,83 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, List, Optional -from montreal_forced_aligner.exceptions import ArgumentError -from montreal_forced_aligner.ivector.trainer import TrainableIvectorExtractor - -if TYPE_CHECKING: - from argparse import Namespace - -__all__ = ["train_ivector", "validate_args", "run_train_ivector_extractor"] +import click +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE +from montreal_forced_aligner.ivector.trainer import TrainableIvectorExtractor -def train_ivector(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +__all__ = ["train_ivector_cli"] + + +@click.command( + name="train_ivector", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Train an ivector extractor", +) +@click.argument("corpus_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.argument("output_model_path", type=click.Path(file_okay=True, dir_okay=False)) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--speaker_characters", + "-s", + help="Number of characters of file names to use for determining speaker, " + "default is to use directory names.", + type=str, + default="0", +) +@click.option( + "--audio_directory", + "-a", + help="Audio directory root to use for finding audio files.", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def train_ivector_cli(context, **kwargs) -> None: """ - Run the ivector extractor training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Train an ivector extractor from a corpus and pretrained acoustic model. """ + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + corpus_directory = kwargs["corpus_directory"] + output_model_path = kwargs["output_model_path"] trainer = TrainableIvectorExtractor( - corpus_directory=args.corpus_directory, - temporary_directory=args.temporary_directory, - **TrainableIvectorExtractor.parse_parameters(args.config_path, args, unknown_args), + corpus_directory=corpus_directory, + **TrainableIvectorExtractor.parse_parameters(config_path, context.params, context.args), ) + if kwargs.get("clean", False): + trainer.clean_working_directory() + trainer.remove_database() try: trainer.train() - trainer.export_model(args.output_model_path) + trainer.export_model(output_model_path) except Exception: trainer.dirty = True raise finally: trainer.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - try: - args.speaker_characters = int(args.speaker_characters) - except ValueError: - pass - args.corpus_directory = args.corpus_directory.rstrip("/").rstrip("\\") - if args.config_path and not os.path.exists(args.config_path): - raise (ArgumentError(f"Could not find the config file {args.config_path}.")) - - if not os.path.exists(args.corpus_directory): - raise (ArgumentError(f"Could not find the corpus directory {args.corpus_directory}.")) - if not os.path.isdir(args.corpus_directory): - raise ( - ArgumentError( - f"The specified corpus directory ({args.corpus_directory}) is not a directory." - ) - ) - - -def run_train_ivector_extractor(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running ivector extraction training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - train_ivector(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/train_lm.py b/montreal_forced_aligner/command_line/train_lm.py index 2d6d7eaa..b58afabb 100644 --- a/montreal_forced_aligner/command_line/train_lm.py +++ b/montreal_forced_aligner/command_line/train_lm.py @@ -2,114 +2,96 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, List, Optional -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.exceptions import ArgumentError +import click + +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_dictionary, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE from montreal_forced_aligner.language_modeling.trainer import ( MfaLmArpaTrainer, MfaLmCorpusTrainer, MfaLmDictionaryCorpusTrainer, ) -if TYPE_CHECKING: - from argparse import Namespace - -__all__ = ["train_lm", "validate_args", "run_train_lm"] +__all__ = ["train_lm_cli"] -def train_lm(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +@click.command( + name="train_lm", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Train a language model", +) +@click.argument("source_path", type=click.Path(exists=True, file_okay=True, dir_okay=True)) +@click.argument("output_model_path", type=click.Path(file_okay=True, dir_okay=False)) +@click.option( + "--dictionary_path", + help="Full path to pronunciation dictionary, or saved dictionary name.", + type=click.UNPROCESSED, + callback=validate_dictionary, +) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def train_lm_cli(context, **kwargs) -> None: """ - Run the language model training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Train a language model from a corpus or convert an existing ARPA-format language model to an MFA language model. """ - - if not args.source_path.lower().endswith(".arpa"): - if not args.dictionary_path: + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + dictionary_path = kwargs.get("dictionary_path", None) + source_path = kwargs["source_path"] + output_model_path = kwargs["output_model_path"] + + if not source_path.lower().endswith(".arpa"): + if not dictionary_path: trainer = MfaLmCorpusTrainer( - corpus_directory=args.source_path, - temporary_directory=args.temporary_directory, - **MfaLmCorpusTrainer.parse_parameters(args.config_path, args, unknown_args), + corpus_directory=source_path, + **MfaLmCorpusTrainer.parse_parameters(config_path, context.params, context.args), ) else: trainer = MfaLmDictionaryCorpusTrainer( - corpus_directory=args.source_path, - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, + corpus_directory=source_path, + dictionary_path=dictionary_path, **MfaLmDictionaryCorpusTrainer.parse_parameters( - args.config_path, args, unknown_args + config_path, context.params, context.args ), ) else: trainer = MfaLmArpaTrainer( - arpa_path=args.source_path, - temporary_directory=args.temporary_directory, - **MfaLmArpaTrainer.parse_parameters(args.config_path, args, unknown_args), + arpa_path=source_path, + **MfaLmArpaTrainer.parse_parameters(config_path, context.params, context.args), ) + if kwargs.get("clean", False): + trainer.clean_working_directory() + trainer.remove_database() try: trainer.setup() trainer.train() - trainer.export_model(args.output_model_path) + trainer.export_model(output_model_path) except Exception: trainer.dirty = True raise finally: trainer.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - args.source_path = args.source_path.rstrip("/").rstrip("\\") - if args.dictionary_path: - args.dictionary_path = validate_model_arg(args.dictionary_path, "dictionary") - if not args.source_path.endswith(".arpa"): - if not os.path.exists(args.source_path): - raise (ArgumentError(f"Could not find the corpus directory {args.source_path}.")) - if not os.path.isdir(args.source_path): - raise ( - ArgumentError( - f"The specified corpus directory ({args.source_path}) is not a directory." - ) - ) - else: - if not os.path.exists(args.source_path): - raise (ArgumentError(f"Could not find the source file {args.source_path}.")) - if args.config_path and not os.path.exists(args.config_path): - raise (ArgumentError(f"Could not find the config file {args.config_path}.")) - if args.model_path and not os.path.exists(args.model_path): - raise (ArgumentError(f"Could not find the model file {args.model_path}.")) - - -def run_train_lm(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running language model training - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - train_lm(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/transcribe.py b/montreal_forced_aligner/command_line/transcribe.py index d90740db..83e5c242 100644 --- a/montreal_forced_aligner/command_line/transcribe.py +++ b/montreal_forced_aligner/command_line/transcribe.py @@ -2,109 +2,135 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, List, Optional -import yaml - -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.exceptions import ArgumentError -from montreal_forced_aligner.helper import mfa_open +import click + +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_acoustic_model, + validate_dictionary, + validate_language_model, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE from montreal_forced_aligner.transcription import Transcriber -if TYPE_CHECKING: - from argparse import Namespace - - -__all__ = ["transcribe_corpus", "validate_args", "run_transcribe_corpus"] - - -def transcribe_corpus(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +__all__ = ["transcribe_corpus_cli"] + + +@click.command( + name="transcribe", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Transcribe audio files", +) +@click.argument("corpus_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.argument("dictionary_path", type=click.UNPROCESSED, callback=validate_dictionary) +@click.argument("acoustic_model_path", type=click.UNPROCESSED, callback=validate_acoustic_model) +@click.argument("language_model_path", type=click.UNPROCESSED, callback=validate_language_model) +@click.argument("output_directory", type=click.Path(file_okay=False, dir_okay=True)) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--speaker_characters", + "-s", + help="Number of characters of file names to use for determining speaker, " + "default is to use directory names.", + type=str, + default="0", +) +@click.option( + "--audio_directory", + "-a", + help="Audio directory root to use for finding audio files.", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.option( + "--output_type", + help="Flag for outputting transcription text or alignments.", + default="transcription", + type=click.Choice(["transcription", "alignment"]), +) +@click.option( + "--output_format", + help="Format for aligned output files (default is long_textgrid).", + default="long_textgrid", + type=click.Choice(["long_textgrid", "short_textgrid", "json", "csv"]), +) +@click.option( + "--evaluate", + "evaluation_mode", + is_flag=True, + help="Evaluate the transcription against golden texts.", + default=False, +) +@click.option( + "--include_original_text", + is_flag=True, + help="Flag to include original utterance text in the output.", + default=False, +) +@click.option( + "--language_model_weight", + help="Specific language model weight to use in evaluating transcriptions, defaults to 16.", + type=int, + default=16, +) +@click.option( + "--word_insertion_penalty", + help="Specific word insertion penalty between 0.0 and 1.0 to use in evaluating transcription, defaults to 1.0.", + type=float, + default=1.0, +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def transcribe_corpus_cli(context, **kwargs) -> None: """ - Run the transcription command - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Transcribe utterances using an acoustic model, language model, and pronunciation dictionary. """ + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + corpus_directory = kwargs["corpus_directory"] + acoustic_model_path = kwargs["acoustic_model_path"] + language_model_path = kwargs["language_model_path"] + dictionary_path = kwargs["dictionary_path"] + output_directory = kwargs["output_directory"] + output_format = kwargs["output_format"] + include_original_text = kwargs["include_original_text"] transcriber = Transcriber( - acoustic_model_path=args.acoustic_model_path, - language_model_path=args.language_model_path, - corpus_directory=args.corpus_directory, - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **Transcriber.parse_parameters(args.config_path, args, unknown_args), + corpus_directory=corpus_directory, + dictionary_path=dictionary_path, + acoustic_model_path=acoustic_model_path, + language_model_path=language_model_path, + **Transcriber.parse_parameters(config_path, context.params, context.args), ) + if kwargs.get("clean", False): + transcriber.clean_working_directory() + transcriber.remove_database() try: transcriber.setup() transcriber.transcribe() transcriber.export_files( - args.output_directory, - output_format=getattr(args, "output_format", None), - include_original_text=getattr(args, "include_original_text", False), + output_directory, + output_format=output_format, + include_original_text=include_original_text, ) - if getattr(args, "reference_directory", ""): - mapping = None - if getattr(args, "custom_mapping_path", ""): - with mfa_open(args.custom_mapping_path, "r") as f: - mapping = yaml.safe_load(f) - transcriber.load_reference_alignments(args.reference_directory) - transcriber.evaluate_alignments(mapping, output_directory=args.output_directory) except Exception: transcriber.dirty = True raise finally: transcriber.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - try: - args.speaker_characters = int(args.speaker_characters) - except ValueError: - pass - args.output_directory = args.output_directory.rstrip("/").rstrip("\\") - args.corpus_directory = args.corpus_directory.rstrip("/").rstrip("\\") - - args.dictionary_path = validate_model_arg(args.dictionary_path, "dictionary") - args.acoustic_model_path = validate_model_arg(args.acoustic_model_path, "acoustic") - args.language_model_path = validate_model_arg(args.language_model_path, "language_model") - - if not os.path.exists(args.corpus_directory): - raise ArgumentError(f"Could not find the corpus directory {args.corpus_directory}.") - if not os.path.isdir(args.corpus_directory): - raise ArgumentError( - f"The specified corpus directory ({args.corpus_directory}) is not a directory." - ) - - if args.corpus_directory == args.output_directory: - raise ArgumentError("Corpus directory and output directory cannot be the same folder.") - - -def run_transcribe_corpus(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running corpus transcription - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - transcribe_corpus(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/command_line/utils.py b/montreal_forced_aligner/command_line/utils.py index 8745b5e2..df62d2a3 100644 --- a/montreal_forced_aligner/command_line/utils.py +++ b/montreal_forced_aligner/command_line/utils.py @@ -1,10 +1,18 @@ """Utility functions for command line commands""" from __future__ import annotations +import functools import os +import shutil +import subprocess +import time +import typing +import click +import sqlalchemy.engine import yaml +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.exceptions import ( FileArgumentNotFoundError, ModelExtensionError, @@ -15,7 +23,100 @@ from montreal_forced_aligner.helper import mfa_open from montreal_forced_aligner.models import MODEL_TYPES -__all__ = ["validate_model_arg"] +__all__ = [ + "validate_acoustic_model", + "validate_g2p_model", + "validate_ivector_extractor", + "validate_language_model", + "validate_dictionary", + "check_databases", +] + + +def common_options(f: typing.Callable) -> typing.Callable: + """ + Add common MFA cli options to a given command + """ + options = [ + click.option( + "-p", + "--profile", + help='Configuration profile to use, defaults to "global"', + type=str, + default=None, + ), + click.option( + "--temporary_directory", + "-t", + "temporary_directory", + help=f"Set the default temporary directory, default is {GLOBAL_CONFIG.temporary_directory}", + type=str, + default=GLOBAL_CONFIG.temporary_directory, + ), + click.option( + "-j", + "--num_jobs", + "num_jobs", + help=f"Set the number of processes to use by default, defaults to {GLOBAL_CONFIG.num_jobs}", + type=int, + default=None, + ), + click.option( + "--clean/--no_clean", + "clean", + help=f"Remove files from previous runs, default is {GLOBAL_CONFIG.clean}", + default=None, + ), + click.option( + "--verbose/--no_verbose", + "-v/-nv", + "verbose", + help=f"Output debug messages, default is {GLOBAL_CONFIG.verbose}", + default=None, + ), + click.option( + "--quiet/--no_quiet", + "-q/-nq", + "quiet", + help=f"Suppress all output messages (overrides verbose), default is {GLOBAL_CONFIG.quiet}", + default=None, + ), + click.option( + "--overwrite/--no_overwrite", + "overwrite", + help=f"Overwrite output files when they exist, default is {GLOBAL_CONFIG.overwrite}", + default=None, + ), + click.option( + "--use_mp/--no_use_mp", + "use_mp", + help="Turn on/off multiprocessing. Multiprocessing is recommended will allow for faster executions.", + default=None, + ), + click.option( + "--debug/--no_debug", + "-d/-nd", + "debug", + help=f"Run extra steps for debugging issues, default is {GLOBAL_CONFIG.debug}", + default=None, + ), + click.option( + "--single_speaker", + "single_speaker", + is_flag=True, + help="Single speaker mode creates multiprocessing splits based on utterances rather than speakers.", + default=False, + ), + click.option( + "--textgrid_cleanup/--no_textgrid_cleanup", + "cleanup_textgrids", + help="Turn on/off post-processing of TextGrids that cleans up " + "silences and recombines compound words and clitics.", + default=None, + ), + ] + options.reverse() + return functools.reduce(lambda x, opt: opt(x), options, f) def validate_model_arg(name: str, model_type: str) -> str: @@ -48,7 +149,7 @@ def validate_model_arg(name: str, model_type: str) -> str: If a multispeaker dictionary does not have a default dictionary """ if model_type not in MODEL_TYPES: - raise ModelTypeNotSupportedError(model_type, MODEL_TYPES) + raise click.BadParameter(str(ModelTypeNotSupportedError(model_type, MODEL_TYPES))) model_class = MODEL_TYPES[model_type] available_models = model_class.get_available_models() model_class = MODEL_TYPES[model_type] @@ -56,21 +157,182 @@ def validate_model_arg(name: str, model_type: str) -> str: name = model_class.get_pretrained_path(name) elif model_class.valid_extension(name): if not os.path.exists(name): - raise FileArgumentNotFoundError(name) + raise click.BadParameter(str(FileArgumentNotFoundError(name))) if model_type == "dictionary" and os.path.splitext(name)[1].lower() == ".yaml": with mfa_open(name, "r") as f: data = yaml.safe_load(f) - found_default = False - for speaker, path in data.items(): - if speaker == "default": - found_default = True - path = validate_model_arg(path, "dictionary") - if not found_default: - raise NoDefaultSpeakerDictionaryError() + paths = sorted(set(data.values())) + for path in paths: + validate_model_arg(path, "dictionary") + if "default" not in data: + raise click.BadParameter(str(NoDefaultSpeakerDictionaryError())) else: if os.path.exists(name): if os.path.splitext(name)[1]: - raise ModelExtensionError(name, model_type, model_class.extensions) + raise click.BadParameter( + str(ModelExtensionError(name, model_type, model_class.extensions)) + ) else: - raise PretrainedModelNotFoundError(name, model_type, available_models) + raise click.BadParameter( + str(PretrainedModelNotFoundError(name, model_type, available_models)) + ) return name + + +def validate_acoustic_model(ctx, param, value): + """Validation callback for acoustic model paths""" + if value: + return validate_model_arg(value, "acoustic") + + +def validate_dictionary(ctx, param, value): + """Validation callback for dictionary paths""" + if value: + return validate_model_arg(value, "dictionary") + + +def validate_language_model(ctx, param, value): + """Validation callback for language model paths""" + if value: + return validate_model_arg(value, "language_model") + + +def validate_g2p_model(ctx, param, value): + """Validation callback for G2O model paths""" + return validate_model_arg(value, "g2p") + + +def validate_ivector_extractor(ctx, param, value): + """Validation callback for ivector extractor paths""" + if value == "speechbrain": + return value + return validate_model_arg(value, "ivector") + + +def configure_pg(directory): + configuration_updates = { + "#log_min_duration_statement = -1": "log_min_duration_statement = 5000", + "#enable_partitionwise_join = off": "enable_partitionwise_join = on", + "#enable_partitionwise_aggregate = off": "enable_partitionwise_aggregate = on", + "#maintenance_work_mem = 64MB": "maintenance_work_mem = 500MB", + "#work_mem = 4MB": "work_mem = 128MB", + "shared_buffers = 128MB": "shared_buffers = 256MB", + } + with mfa_open(os.path.join(directory, "postgresql.conf"), "r") as f: + config = f.read() + for query, rep in configuration_updates.items(): + config = config.replace(query, rep) + with mfa_open(os.path.join(directory, "postgresql.conf"), "w") as f: + f.write(config) + + +def check_databases(db_name=None) -> None: + """Check for existence of necessary databases""" + GLOBAL_CONFIG.load() + + db_directory = os.path.join( + GLOBAL_CONFIG["temporary_directory"], f"pg_mfa_{GLOBAL_CONFIG.current_profile_name}" + ) + init_log_path = os.path.join( + GLOBAL_CONFIG["temporary_directory"], + f"pg_init_log_{GLOBAL_CONFIG.current_profile_name}.txt", + ) + log_path = os.path.join( + GLOBAL_CONFIG["temporary_directory"], f"pg_log_{GLOBAL_CONFIG.current_profile_name}.txt" + ) + os.makedirs(GLOBAL_CONFIG["temporary_directory"], exist_ok=True) + create = not os.path.exists(db_directory) + if not create: + try: + engine = sqlalchemy.create_engine( + f"postgresql+psycopg2://localhost:{GLOBAL_CONFIG.current_profile.database_port}/{db_name}" + ) + conn = engine.connect() + conn.close() + engine.dispose() + return + except Exception: + pass + with open(init_log_path, "w") as log_file: + if create: + subprocess.check_call( + ["initdb", "-D", db_directory, "--encoding=UTF8"], + stdout=log_file, + stderr=log_file, + ) + configure_pg(db_directory) + try: + subprocess.check_call( + [ + "pg_ctl", + "-D", + db_directory, + "-l", + log_path, + "-o", + f"-F -p {GLOBAL_CONFIG.current_profile.database_port}", + "start", + ], + stdout=log_file, + stderr=log_file, + ) + subprocess.check_call( + [ + "createuser", + "-p", + str(GLOBAL_CONFIG.current_profile.database_port), + "-s", + "postgres", + ], + stdout=log_file, + stderr=log_file, + ) + except Exception: + pass + else: + try: + subprocess.check_call( + [ + "pg_ctl", + "-D", + db_directory, + "-l", + log_path, + "-o", + f"-F -p {GLOBAL_CONFIG.current_profile.database_port}", + "start", + ], + stdout=log_file, + stderr=log_file, + ) + except Exception: + pass + + +def cleanup_databases() -> None: + """Stop current database""" + GLOBAL_CONFIG.load() + + db_directory = os.path.join( + GLOBAL_CONFIG["temporary_directory"], f"pg_mfa_{GLOBAL_CONFIG.current_profile_name}" + ) + time.sleep(1) + try: + subprocess.check_call( + ["pg_ctl", "-D", db_directory, "stop"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass + + +def remove_databases() -> None: + """Remove database""" + time.sleep(1) + GLOBAL_CONFIG.load() + + db_directory = os.path.join( + GLOBAL_CONFIG["temporary_directory"], f"pg_mfa_{GLOBAL_CONFIG.current_profile_name}" + ) + shutil.rmtree(db_directory) diff --git a/montreal_forced_aligner/command_line/validate.py b/montreal_forced_aligner/command_line/validate.py index ce11966c..fa031d8c 100644 --- a/montreal_forced_aligner/command_line/validate.py +++ b/montreal_forced_aligner/command_line/validate.py @@ -2,49 +2,114 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, List, Optional -from montreal_forced_aligner.command_line.utils import validate_model_arg -from montreal_forced_aligner.exceptions import ArgumentError +import click + +from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + common_options, + validate_acoustic_model, + validate_dictionary, +) +from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE from montreal_forced_aligner.validation.corpus_validator import ( PretrainedValidator, TrainingValidator, ) from montreal_forced_aligner.validation.dictionary_validator import DictionaryValidator -if TYPE_CHECKING: - from argparse import Namespace - +__all__ = ["validate_corpus_cli", "validate_dictionary_cli"] -__all__ = ["validate_corpus", "validate_args", "run_validate_corpus"] - -def validate_corpus(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +@click.command( + name="validate", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Validate corpus", +) +@click.argument("corpus_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.argument("dictionary_path", type=click.UNPROCESSED, callback=validate_dictionary) +@click.option( + "--acoustic_model_path", + help="Acoustic model to use in testing alignments.", + type=click.UNPROCESSED, + callback=validate_acoustic_model, +) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--speaker_characters", + "-s", + help="Number of characters of file names to use for determining speaker, " + "default is to use directory names.", + type=str, + default="0", +) +@click.option( + "--audio_directory", + "-a", + help="Audio directory root to use for finding audio files.", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.option( + "--phone_set", + help="Enable extra decision tree modeling based on the phone set.", + default="UNKNOWN", + type=click.Choice(["UNKNOWN", "AUTO", "IPA", "ARPA", "PINYIN"]), +) +@click.option( + "--ignore_acoustics", + "--skip_acoustics", + "ignore_acoustics", + is_flag=True, + help="Skip acoustic feature generation and associated validation.", + default=False, +) +@click.option( + "--test_transcriptions", + is_flag=True, + help="Use per-speaker language models to test accuracy of transcriptions.", + default=False, +) +@common_options +@click.help_option("-h", "--help") +@click.pass_context +def validate_corpus_cli(context, **kwargs) -> None: """ - Run the validation command - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Validate a corpus for use in MFA. """ - if args.acoustic_model_path: + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + corpus_directory = kwargs["corpus_directory"] + dictionary_path = kwargs["dictionary_path"] + acoustic_model_path = kwargs.get("acoustic_model_path", None) + if acoustic_model_path: validator = PretrainedValidator( - acoustic_model_path=args.acoustic_model_path, - corpus_directory=args.corpus_directory, - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **PretrainedValidator.parse_parameters(args.config_path, args, unknown_args), + corpus_directory=corpus_directory, + dictionary_path=dictionary_path, + acoustic_model_path=acoustic_model_path, + **PretrainedValidator.parse_parameters(config_path, context.params, context.args), ) else: validator = TrainingValidator( - corpus_directory=args.corpus_directory, - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **TrainingValidator.parse_parameters(args.config_path, args, unknown_args), + corpus_directory=corpus_directory, + dictionary_path=dictionary_path, + **TrainingValidator.parse_parameters(config_path, context.params, context.args), ) + validator.clean_working_directory() + validator.remove_database() try: validator.validate() except Exception: @@ -52,113 +117,65 @@ def validate_corpus(args: Namespace, unknown_args: Optional[List[str]] = None) - raise finally: validator.cleanup() + cleanup_databases() -def validate_dictionary(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: +@click.command( + name="validate_dictionary", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=True, + ), + short_help="Validate dictionary", +) +@click.argument("dictionary_path", type=str) +@click.option( + "--output_path", + help="Path to save the CSV file with the scored pronunciations.", + type=click.Path(file_okay=False, dir_okay=True), +) +@click.option("--g2p_model_path", help="Pretrained G2P model path.", type=str) +@click.option( + "--config_path", + "-c", + help="Path to config file to use for training.", + type=click.Path(exists=True, file_okay=True, dir_okay=False), +) +@click.option( + "--g2p_threshold", + help="Threshold to use when running G2P. " + "Paths with costs less than the best path times the threshold value will be included.", + type=float, + default=1.5, +) +@common_options +@click.help_option("-h", "--help") +def validate_dictionary_cli(*args, **kwargs) -> None: """ - Run the validation command - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Command line arguments - unknown_args: list[str] - Optional arguments that will be passed to configuration objects + Validate a dictionary using a G2P model to detect unlikely pronunciations. """ + if kwargs.get("profile", None) is not None: + os.putenv(MFA_PROFILE_VARIABLE, kwargs["profile"]) + GLOBAL_CONFIG.current_profile.update(kwargs) + GLOBAL_CONFIG.save() + check_databases() + config_path = kwargs.get("config_path", None) + g2p_model_path = kwargs["g2p_model_path"] + dictionary_path = kwargs["dictionary_path"] + output_path = kwargs["output_path"] validator = DictionaryValidator( - g2p_model_path=args.g2p_model_path, - dictionary_path=args.dictionary_path, - temporary_directory=args.temporary_directory, - **DictionaryValidator.parse_parameters(args.config_path, args, unknown_args), + g2p_model_path=g2p_model_path, + dictionary_path=dictionary_path, + **DictionaryValidator.parse_parameters(config_path, kwargs, args), ) + validator.clean_working_directory() + validator.remove_database() try: - validator.validate(output_path=args.output_path) + validator.validate(output_path=output_path) except Exception: validator.dirty = True raise finally: validator.cleanup() - - -def validate_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - try: - args.speaker_characters = int(args.speaker_characters) - except ValueError: - pass - if args.test_transcriptions and args.ignore_acoustics: - raise ArgumentError("Cannot test transcriptions without acoustic feature generation.") - if not os.path.exists(args.corpus_directory): - raise (ArgumentError(f"Could not find the corpus directory {args.corpus_directory}.")) - if not os.path.isdir(args.corpus_directory): - raise ( - ArgumentError( - f"The specified corpus directory ({args.corpus_directory}) is not a directory." - ) - ) - - args.dictionary_path = validate_model_arg(args.dictionary_path, "dictionary") - if args.acoustic_model_path: - args.acoustic_model_path = validate_model_arg(args.acoustic_model_path, "acoustic") - - -def validate_dictionary_args(args: Namespace) -> None: - """ - Validate the command line arguments - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.ArgumentError` - If there is a problem with any arguments - """ - - args.dictionary_path = validate_model_arg(args.dictionary_path, "dictionary") - if args.g2p_model_path: - args.g2p_model_path = validate_model_arg(args.g2p_model_path, "g2p") - - -def run_validate_corpus(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running corpus validation - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_args(args) - validate_corpus(args, unknown_args) - - -def run_validate_dictionary(args: Namespace, unknown_args: Optional[List[str]] = None) -> None: - """ - Wrapper function for running dictionary validation - - Parameters - ---------- - args: :class:`~argparse.Namespace` - Parsed command line arguments - unknown_args: list[str] - Parsed command line arguments to be passed to the configuration objects - """ - validate_dictionary_args(args) - validate_dictionary(args, unknown_args) + cleanup_databases() diff --git a/montreal_forced_aligner/config.py b/montreal_forced_aligner/config.py index cfc667f1..a0abcc01 100644 --- a/montreal_forced_aligner/config.py +++ b/montreal_forced_aligner/config.py @@ -5,32 +5,36 @@ """ from __future__ import annotations +import os import re -from typing import TYPE_CHECKING, Any, Dict, List +import typing +from typing import Any, Dict, List, Union + +import click +import dataclassy +import joblib +import yaml +from dataclassy import dataclass from montreal_forced_aligner.exceptions import RootDirectoryError from montreal_forced_aligner.helper import mfa_open -if TYPE_CHECKING: - from argparse import Namespace - -import os - -import yaml - __all__ = [ "generate_config_path", "generate_command_history_path", "load_command_history", "get_temporary_directory", "update_command_history", - "update_global_config", - "load_global_config", - "USE_COLORS", - "BLAS_THREADS", + "MfaConfiguration", + "GLOBAL_CONFIG", ] MFA_ROOT_ENVIRONMENT_VARIABLE = "MFA_ROOT_DIR" +MFA_PROFILE_VARIABLE = "MFA_PROFILE" + +IVECTOR_DIMENSION = 192 +XVECTOR_DIMENSION = 192 +PLDA_DIMENSION = 192 def get_temporary_directory(): @@ -92,6 +96,8 @@ def load_command_history() -> List[Dict[str, Any]]: if os.path.exists(path): with mfa_open(path, "r") as f: history = yaml.safe_load(f) + if not history: + history = [] for h in history: h["command"] = re.sub(r"^\S+.py ", "mfa ", h["command"]) return history @@ -116,114 +122,120 @@ def update_command_history(command_data: Dict[str, Any]) -> None: history.append(command_data) history = history[-50:] with mfa_open(path, "w") as f: - yaml.safe_dump(history, f) + yaml.safe_dump(history, f, allow_unicode=True) -def update_global_config(args: Namespace) -> None: +@dataclass(slots=True) +class MfaProfile: + """ + Configuration class for a profile used from the command line """ - Update global configuration of MFA - Parameters - ---------- - args: :class:`~argparse.Namespace` - Arguments to set + clean: bool = False + verbose: bool = False + debug: bool = False + quiet: bool = False + overwrite: bool = False + terminal_colors: bool = True + cleanup_textgrids: bool = True + detect_phone_set: bool = False + database_backend: str = "psycopg2" + database_port: int = 5432 + bytes_limit: int = 100e6 + seed: int = 0 + num_jobs: int = 3 + blas_num_threads: int = 1 + use_mp: bool = True + single_speaker: bool = False + temporary_directory: str = get_temporary_directory() + github_token: typing.Optional[str] = None + + def __getitem__(self, item): + """Get key from profile""" + return getattr(self, item) + + def update(self, data: Union[Dict[str, Any], click.Context]) -> None: + """ + Update configuration from new data + + Parameters + ---------- + data: typing.Union[dict[str, typing.Any], :class:`click.Context`] + Parameters to update + """ + for k, v in data.items(): + if k == "temp_directory": + k = "temporary_directory" + if v is None: + continue + if hasattr(self, k): + setattr(self, k, v) + + +class MfaConfiguration: """ - global_configuration_file = generate_config_path() - default_config = { - "clean": False, - "verbose": False, - "debug": False, - "overwrite": False, - "terminal_colors": True, - "terminal_width": 120, - "cleanup_textgrids": True, - "detect_phone_set": False, - "num_jobs": 3, - "blas_num_threads": 1, - "use_mp": True, - "temporary_directory": get_temporary_directory(), - } - if os.path.exists(global_configuration_file): - with mfa_open(global_configuration_file, "r") as f: - data = yaml.safe_load(f) - default_config.update(data) - if args.always_clean: - default_config["clean"] = True - if args.never_clean: - default_config["clean"] = False - if args.always_verbose: - default_config["verbose"] = True - if args.never_verbose: - default_config["verbose"] = False - if args.always_debug: - default_config["debug"] = True - if args.never_debug: - default_config["debug"] = False - if args.always_overwrite: - default_config["overwrite"] = True - if args.never_overwrite: - default_config["overwrite"] = False - if args.disable_mp: - default_config["use_mp"] = False - if args.enable_mp: - default_config["use_mp"] = True - if args.disable_textgrid_cleanup: - default_config["cleanup_textgrids"] = False - if args.enable_textgrid_cleanup: - default_config["cleanup_textgrids"] = True - if args.disable_detect_phone_set: - default_config["detect_phone_set"] = False - if args.enable_detect_phone_set: - default_config["detect_phone_set"] = True - if args.disable_terminal_colors: - default_config["terminal_colors"] = False - if args.enable_terminal_colors: - default_config["terminal_colors"] = True - if args.num_jobs and args.num_jobs > 0: - default_config["num_jobs"] = args.num_jobs - if args.terminal_width and args.terminal_width > 0: - default_config["terminal_width"] = args.terminal_width - if args.blas_num_threads and args.blas_num_threads > 0: - default_config["blas_num_threads"] = args.blas_num_threads - if args.temporary_directory: - default_config["temporary_directory"] = args.temporary_directory - with mfa_open(global_configuration_file, "w") as f: - yaml.dump(default_config, f) - - -def load_global_config() -> Dict[str, Any]: + Global MFA configuration class """ - Load the global MFA configuration - Returns - ------- - dict[str, Any] - Global configuration - """ - global_configuration_file = generate_config_path() - default_config = { - "clean": False, - "verbose": False, - "quiet": False, - "debug": False, - "overwrite": False, - "terminal_colors": True, - "terminal_width": 120, - "cleanup_textgrids": True, - "detect_phone_set": False, - "num_jobs": 3, - "blas_num_threads": 1, - "use_mp": True, - "temporary_directory": get_temporary_directory(), - } - if os.path.exists(global_configuration_file): - with mfa_open(global_configuration_file, "r") as f: + def __init__(self): + self.current_profile_name = os.getenv(MFA_PROFILE_VARIABLE, "global") + self.config_path = generate_config_path() + self.global_profile = MfaProfile() + self.profiles: Dict[str, MfaProfile] = {} + self.profiles["global"] = self.global_profile + if not os.path.exists(self.config_path): + self.save() + else: + self.load() + + def __getattr__(self, item): + """Get key from current profile""" + if hasattr(self.current_profile, item): + return getattr(self.current_profile, item) + + def __getitem__(self, item): + """Get key from current profile""" + if hasattr(self.current_profile, item): + return getattr(self.current_profile, item) + + @property + def current_profile(self) -> MfaProfile: + """Name of the current :class:`~montreal_forced_aligner.config.MfaProfile`""" + self.current_profile_name = os.getenv(MFA_PROFILE_VARIABLE, "global") + if self.current_profile_name not in self.profiles: + self.profiles[self.current_profile_name] = MfaProfile() + self.profiles[self.current_profile_name].update(dataclassy.asdict(self.global_profile)) + return self.profiles[self.current_profile_name] + + def save(self) -> None: + """Save MFA configuration""" + global_configuration_file = generate_config_path() + data = dataclassy.asdict(self.global_profile) + data["profiles"] = { + k: dataclassy.asdict(v) for k, v in self.profiles.items() if k != "global" + } + with mfa_open(global_configuration_file, "w") as f: + yaml.dump(data, f) + + def load(self) -> None: + """Load MFA configuration""" + with mfa_open(self.config_path, "r") as f: data = yaml.safe_load(f) - default_config.update(data) - if "temp_directory" in default_config: - default_config["temporary_directory"] = default_config["temp_directory"] - return default_config - - -USE_COLORS = load_global_config().get("terminal_colors", True) -BLAS_THREADS = load_global_config().get("blas_num_threads", 1) + for name, p in data.pop("profiles", {}).items(): + self.profiles[name] = MfaProfile() + self.profiles[name].update(p) + self.global_profile.update(data) + if ( + self.current_profile_name not in self.profiles + and self.current_profile_name != "global" + ): + self.profiles[self.current_profile_name] = MfaProfile() + self.profiles[self.current_profile_name].update(data) + + +GLOBAL_CONFIG = MfaConfiguration() +MEMORY = joblib.Memory( + location=os.path.join(get_temporary_directory(), "joblib_cache"), + verbose=4 if GLOBAL_CONFIG.current_profile.verbose else 0, + bytes_limit=GLOBAL_CONFIG.current_profile.bytes_limit, +) diff --git a/montreal_forced_aligner/corpus/acoustic_corpus.py b/montreal_forced_aligner/corpus/acoustic_corpus.py index a2d62466..28c4b257 100644 --- a/montreal_forced_aligner/corpus/acoustic_corpus.py +++ b/montreal_forced_aligner/corpus/acoustic_corpus.py @@ -1,6 +1,7 @@ """Class definitions for corpora""" from __future__ import annotations +import logging import multiprocessing as mp import os import subprocess @@ -9,13 +10,13 @@ import typing from abc import ABCMeta from queue import Empty -from typing import Dict, List, Optional +from typing import List, Optional import sqlalchemy import tqdm -from sqlalchemy.orm import Session -from montreal_forced_aligner.abc import MfaWorker, TemporaryDirectoryMixin +from montreal_forced_aligner.abc import MfaWorker +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.corpus.base import CorpusMixin from montreal_forced_aligner.corpus.classes import FileData from montreal_forced_aligner.corpus.features import ( @@ -23,27 +24,39 @@ CalcFmllrFunction, ComputeVadFunction, FeatureConfigMixin, + FinalFeatureArguments, + FinalFeatureFunction, MfccArguments, MfccFunction, + PitchRangeArguments, + PitchRangeFunction, VadArguments, ) from montreal_forced_aligner.corpus.helper import find_exts from montreal_forced_aligner.corpus.multiprocessing import ( AcousticDirectoryParser, CorpusProcessWorker, + ExportKaldiFilesArguments, + ExportKaldiFilesFunction, ) -from montreal_forced_aligner.data import DatabaseImportData +from montreal_forced_aligner.data import DatabaseImportData, PhoneType, WorkflowType from montreal_forced_aligner.db import ( Corpus, + CorpusWorkflow, File, - ReferencePhoneInterval, + Job, + Phone, + PhoneInterval, SoundFile, Speaker, + TextFile, Utterance, + bulk_update, ) -from montreal_forced_aligner.dictionary.mixins import DictionaryMixin, SanitizeFunction +from montreal_forced_aligner.dictionary.mixins import DictionaryMixin from montreal_forced_aligner.dictionary.multispeaker import MultispeakerDictionaryMixin from montreal_forced_aligner.exceptions import ( + FeatureGenerationError, KaldiProcessingError, SoundFileError, TextGridParseError, @@ -51,7 +64,13 @@ ) from montreal_forced_aligner.helper import load_scp, mfa_open from montreal_forced_aligner.textgrid import parse_aligned_textgrid -from montreal_forced_aligner.utils import Counter, KaldiProcessWorker, Stopped, thirdparty_binary +from montreal_forced_aligner.utils import ( + Counter, + KaldiProcessWorker, + Stopped, + run_kaldi_function, + thirdparty_binary, +) __all__ = [ "AcousticCorpusMixin", @@ -60,6 +79,8 @@ "AcousticCorpusPronunciationMixin", ] +logger = logging.getLogger("mfa") + class AcousticCorpusMixin(CorpusMixin, FeatureConfigMixin, metaclass=ABCMeta): """ @@ -81,10 +102,6 @@ class AcousticCorpusMixin(CorpusMixin, FeatureConfigMixin, metaclass=ABCMeta): ---------- sound_file_errors: list[str] List of sound files with errors in loading - transcriptions_without_wavs: list[str] - List of text files without sound files - no_transcription_files: list[str] - List of sound files without transcription files stopped: Stopped Stop check for loading the corpus """ @@ -93,31 +110,103 @@ def __init__(self, audio_directory: Optional[str] = None, **kwargs): super().__init__(**kwargs) self.audio_directory = audio_directory self.sound_file_errors = [] - self.transcriptions_without_wavs = [] - self.no_transcription_files = [] self.stopped = Stopped() self.features_generated = False - self.alignment_done = False self.transcription_done = False - self.has_reference_alignments = False self.alignment_evaluation_done = False + def has_alignments(self, workflow_id: typing.Optional[int] = None): + with self.session() as session: + if workflow_id is None: + check = session.query(PhoneInterval).limit(1).first() is not None + else: + if isinstance(workflow_id, int): + check = ( + session.query(CorpusWorkflow.alignments_collected) + .filter(CorpusWorkflow.id == workflow_id) + .scalar() + ) + else: + check = ( + session.query(CorpusWorkflow.alignments_collected) + .filter(CorpusWorkflow.workflow_type == workflow_id) + .scalar() + ) + return check + + def has_ivectors(self, speaker=False): + with self.session() as session: + if speaker: + check = ( + session.query(Speaker).filter(Speaker.ivector != None).limit(1).first() # noqa + is not None + ) + else: + check = ( + session.query(Utterance) + .filter(Utterance.ivector != None) # noqa + .limit(1) + .first() + is not None + ) + return check + + def has_xvectors(self): + with self.session() as session: + check = ( + session.query(Utterance).filter(Utterance.xvector != None).limit(1).first() # noqa + is not None + ) + return check + + def has_any_ivectors(self): + with self.session() as session: + check = ( + session.query(Utterance) + .filter( + sqlalchemy.or_(Utterance.xvector != None, Utterance.ivector != None) # noqa + ) + .limit(1) + .first() + is not None + ) + return check + + @property + def no_transcription_files(self) -> List[str]: + """List of sound files without text files""" + with self.session() as session: + files = session.query(SoundFile.sound_file_path).filter( + ~sqlalchemy.exists().where(SoundFile.file_id == TextFile.file_id) + ) + return [x[0] for x in files] + + @property + def transcriptions_without_wavs(self) -> List[str]: + """List of text files without sound files""" + with self.session() as session: + files = session.query(TextFile.text_file_path).filter( + ~sqlalchemy.exists().where(SoundFile.file_id == TextFile.file_id) + ) + return [x[0] for x in files] + def inspect_database(self) -> None: """Check if a database file exists and create the necessary metadata""" - exist_check = os.path.exists(self.db_path) - if not exist_check: - self.initialize_database() + self.initialize_database() with self.session() as session: corpus = session.query(Corpus).first() if corpus: self.imported = corpus.imported self.features_generated = corpus.features_generated - self.alignment_done = corpus.alignment_done - self.transcription_done = corpus.transcription_done - self.has_reference_alignments = corpus.has_reference_alignments - self.alignment_evaluation_done = corpus.alignment_evaluation_done + self.text_normalized = corpus.text_normalized else: - session.add(Corpus(name=self.data_source_identifier)) + session.add( + Corpus( + name=self.data_source_identifier, + path=self.corpus_directory, + data_directory=self.corpus_output_directory, + ) + ) session.commit() def load_reference_alignments(self, reference_directory: str) -> None: @@ -130,16 +219,29 @@ def load_reference_alignments(self, reference_directory: str) -> None: Directory containing reference alignments """ - if self.has_reference_alignments: - self.log_info("Reference alignments already loaded!") + self.create_new_current_workflow(WorkflowType.reference) + workflow = self.current_workflow + if workflow.alignments_collected: + logger.info("Reference alignments already loaded!") return - self.log_info("Loading reference files...") + logger.info("Loading reference files...") indices = [] jobs = [] reference_intervals = [] with tqdm.tqdm( - total=self.num_files, disable=getattr(self, "quiet", False) - ) as pbar, Session(self.db_engine, autoflush=False) as session: + total=self.num_files, disable=GLOBAL_CONFIG.quiet + ) as pbar, self.session() as session: + phone_mapping = {} + max_id = 0 + interval_id = session.query(sqlalchemy.func.max(PhoneInterval.id)).scalar() + if not interval_id: + interval_id = 0 + interval_id += 1 + for p, p_id in session.query(Phone.phone, Phone.id): + phone_mapping[p] = p_id + if p_id > max_id: + max_id = p_id + new_phones = [] for root, _, files in os.walk(reference_directory, followlinks=True): root_speaker = os.path.basename(root) for f in files: @@ -148,67 +250,111 @@ def load_reference_alignments(self, reference_directory: str) -> None: file_id = session.query(File.id).filter_by(name=file_name).scalar() if not file_id: continue - if self.use_mp: + if GLOBAL_CONFIG.use_mp: indices.append(file_id) jobs.append((os.path.join(root, f), root_speaker)) else: intervals = parse_aligned_textgrid(os.path.join(root, f), root_speaker) utterances = ( - session.query(Utterance.id, Speaker.name, Utterance.end) + session.query( + Utterance.id, Speaker.name, Utterance.begin, Utterance.end + ) .join(Utterance.speaker) - .join(Utterance.file) - .filter(File.id == file_id) + .filter(Utterance.file_id == file_id) + .order_by(Utterance.begin) ) - for u_id, speaker_name, end in utterances: + for u_id, speaker_name, begin, end in utterances: if speaker_name not in intervals: continue - while ( - intervals[speaker_name] - and intervals[speaker_name][0].end <= end - ): + while intervals[speaker_name]: interval = intervals[speaker_name].pop(0) - reference_intervals.append( - { - "begin": interval.begin, - "end": interval.end, - "label": interval.label, - "utterance_id": u_id, - } - ) + dur = interval.end - interval.begin + mid_point = interval.begin + (dur / 2) + if begin <= mid_point <= end: + if interval.label not in phone_mapping: + max_id += 1 + phone_mapping[interval.label] = max_id + new_phones.append( + { + "id": max_id, + "mapping_id": max_id - 1, + "phone": interval.label, + "kaldi_label": interval.label, + "phone_type": PhoneType.extra, + } + ) + reference_intervals.append( + { + "id": interval_id, + "begin": interval.begin, + "end": interval.end, + "phone_id": phone_mapping[interval.label], + "utterance_id": u_id, + "workflow_id": workflow.id, + } + ) + interval_id += 1 + if mid_point > end: + intervals[speaker_name].insert(0, interval) + break pbar.update(1) - if self.use_mp: - with mp.Pool(self.num_jobs) as pool: + + if GLOBAL_CONFIG.use_mp: + with mp.Pool(GLOBAL_CONFIG.num_jobs) as pool: gen = pool.starmap(parse_aligned_textgrid, jobs) for i, intervals in enumerate(gen): pbar.update(1) file_id = indices[i] utterances = ( - session.query(Utterance.id, Speaker.name, Utterance.end) + session.query( + Utterance.id, Speaker.name, Utterance.begin, Utterance.end + ) .join(Utterance.speaker) .filter(Utterance.file_id == file_id) + .order_by(Utterance.begin) ) - for u_id, speaker_name, end in utterances: + for u_id, speaker_name, begin, end in utterances: if speaker_name not in intervals: continue - while ( - intervals[speaker_name] and intervals[speaker_name][0].end <= end - ): + while intervals[speaker_name]: interval = intervals[speaker_name].pop(0) - reference_intervals.append( - { - "begin": interval.begin, - "end": interval.end, - "label": interval.label, - "utterance_id": u_id, - } - ) - with session.bind.begin() as conn: - conn.execute( - sqlalchemy.insert(ReferencePhoneInterval.__table__), reference_intervals - ) + dur = interval.end - interval.begin + mid_point = interval.begin + (dur / 2) + if begin <= mid_point <= end: + if interval.label not in phone_mapping: + max_id += 1 + phone_mapping[interval.label] = max_id + new_phones.append( + { + "id": max_id, + "mapping_id": max_id - 1, + "phone": interval.label, + "kaldi_label": interval.label, + "phone_type": PhoneType.extra, + } + ) + reference_intervals.append( + { + "id": interval_id, + "begin": interval.begin, + "end": interval.end, + "phone_id": phone_mapping[interval.label], + "utterance_id": u_id, + "workflow_id": workflow.id, + } + ) + interval_id += 1 + if mid_point > end: + intervals[speaker_name].insert(0, interval) + break + if new_phones: + session.execute(sqlalchemy.insert(Phone.__table__), new_phones) session.commit() - session.query(Corpus).update({"has_reference_alignments": True}) + session.execute(sqlalchemy.insert(PhoneInterval.__table__), reference_intervals) + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == workflow.id).update( + {CorpusWorkflow.done: True, CorpusWorkflow.alignments_collected: True} + ) session.commit() def load_corpus(self) -> None: @@ -217,12 +363,13 @@ def load_corpus(self) -> None: """ self.initialize_database() self._load_corpus() - + self._create_dummy_dictionary() self.initialize_jobs() + self.normalize_text() self.create_corpus_split() self.generate_features() - def generate_features(self, compute_cmvn: bool = True) -> None: + def generate_final_features(self) -> None: """ Generate features for the corpus @@ -230,16 +377,99 @@ def generate_features(self, compute_cmvn: bool = True) -> None: ---------- compute_cmvn: bool Flag for whether to compute CMVN, defaults to True + voiced_only: bool + Flag for whether to select only voiced frames, defaults to False """ - if self.features_generated: - return - self.log_info(f"Generating base features ({self.feature_type})...") - if self.feature_type == "mfcc": + logger.info("Generating final features...") + time_begin = time.time() + log_directory = os.path.join(self.split_directory, "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: + for _ in run_kaldi_function(FinalFeatureFunction, arguments, pbar.update): + pass + with self.session() as session: + update_mapping = {} + session.query(Utterance).update({"ignored": True}) + session.commit() + for j in self.jobs: + with mfa_open(j.feats_scp_path, "r") as f: + for line in f: + line = line.strip() + if line == "": + continue + f = line.split(maxsplit=1) + utt_id = int(f[0].split("-")[-1]) + feats = f[1] + update_mapping[utt_id] = { + "id": utt_id, + "features": feats, + "ignored": False, + } + + bulk_update(session, Utterance, list(update_mapping.values())) + session.commit() + + with self.session() as session: + ignored_utterances = ( + session.query( + SoundFile.sound_file_path, + Speaker.name, + Utterance.begin, + Utterance.end, + Utterance.text, + ) + .join(Utterance.speaker) + .join(Utterance.file) + .join(File.sound_file) + .filter(Utterance.ignored == True) # noqa + ) + ignored_count = 0 + for sound_file_path, speaker_name, begin, end, text in ignored_utterances: + logger.debug(f" - Ignored File: {sound_file_path}") + logger.debug(f" - Speaker: {speaker_name}") + logger.debug(f" - Begin: {begin}") + logger.debug(f" - End: {end}") + logger.debug(f" - Text: {text}") + ignored_count += 1 + if ignored_count: + logger.warning( + f"There were {ignored_count} utterances ignored due to an issue in feature generation, see the log file for full " + "details or run `mfa validate` on the corpus." + ) + logger.debug(f"Generating final features took {time.time() - time_begin:.3f} seconds") + + def generate_features(self) -> None: + """ + Generate features for the corpus + + Parameters + ---------- + compute_cmvn: bool + Flag for whether to compute CMVN, defaults to True + voiced_only: bool + Flag for whether to select only voiced frames, defaults to False + """ + with self.session() as session: + final_features_check = session.query(Corpus).first().features_generated + if final_features_check: + self.features_generated = True + logger.info("Features already generated.") + return + feature_check = ( + session.query(Utterance).filter(Utterance.features != None).first() # noqa + is not None + ) + if self.feature_type == "mfcc" and not feature_check: self.mfcc() self.combine_feats() - if compute_cmvn: - self.log_info("Calculating CMVN...") + if self.uses_cmvn: + logger.info("Calculating CMVN...") self.calc_cmvn() + if self.uses_voiced: + self.compute_vad() + self.generate_final_features() + self._write_feats() self.features_generated = True with self.session() as session: session.query(Corpus).update({"features_generated": True}) @@ -248,145 +478,29 @@ def generate_features(self, compute_cmvn: bool = True) -> None: def create_corpus_split(self) -> None: """Create the split directory for the corpus""" + with self.session() as session: + c = session.query(Corpus).first() + c.current_subset = 0 + session.commit() if self.features_generated: - self.log_info("Creating corpus split with features...") + logger.info("Creating corpus split with features...") super().create_corpus_split() else: - self.log_info("Creating corpus split for feature generation...") - split_dir = self.split_directory - os.makedirs(os.path.join(split_dir, "log"), exist_ok=True) - with self.session() as session: - for job in self.jobs: - job.output_for_features(split_dir, session) - - def construct_base_feature_string(self, all_feats: bool = False) -> str: - """ - Construct the base feature string independent of job name - - Used in initialization of MonophoneTrainer (to get dimension size) and IvectorTrainer (uses all feats) - - Parameters - ---------- - all_feats: bool - Flag for whether all features across all jobs should be taken into account - - Returns - ------- - str - Base feature string - """ - j = self.jobs[0] - if all_feats: - feat_path = os.path.join(self.base_data_directory, "feats.scp") - utt2spk_path = os.path.join(self.base_data_directory, "utt2spk.scp") - cmvn_path = os.path.join(self.base_data_directory, "cmvn.scp") - feats = f'ark,s,cs:apply-cmvn --utt2spk=ark:"{utt2spk_path}" scp:"{cmvn_path}" scp:"{feat_path}" ark:- |' - feats += " add-deltas ark:- ark:- |" - return feats - utt2spks = j.construct_path_dictionary(self.data_directory, "utt2spk", "scp") - cmvns = j.construct_path_dictionary(self.data_directory, "cmvn", "scp") - features = j.construct_path_dictionary(self.data_directory, "feats", "scp") - for dict_id in j.dictionary_ids: - feat_path = features[dict_id] - cmvn_path = cmvns[dict_id] - utt2spk_path = utt2spks[dict_id] - feats = f'ark,s,cs:apply-cmvn --utt2spk=ark:"{utt2spk_path}" scp:"{cmvn_path}" scp:"{feat_path}" ark:- |' - if self.uses_deltas: - feats += " add-deltas ark:- ark:- |" - - return feats - else: - utt2spk_path = j.construct_path(self.data_directory, "utt2spk", "scp") - cmvn_path = j.construct_path(self.data_directory, "cmvn", "scp") - feat_path = j.construct_path(self.data_directory, "feats", "scp") - feats = f'ark,s,cs:apply-cmvn --utt2spk=ark:"{utt2spk_path}" scp:"{cmvn_path}" scp:"{feat_path}" ark:- |' - if self.uses_deltas: - feats += " add-deltas ark:- ark:- |" - return feats - - def construct_feature_proc_strings( - self, - speaker_independent: bool = False, - ) -> typing.Union[List[Dict[str, str]], List[str]]: - """ - Constructs a feature processing string to supply to Kaldi binaries, taking into account corpus features and the - current working directory of the aligner (whether fMLLR or LDA transforms should be used, etc). - - Parameters - ---------- - speaker_independent: bool - Flag for whether features should be speaker-independent regardless of the presence of fMLLR transforms - - Returns - ------- - list[dict[str, str]] - Feature strings per job - """ - strings = [] - for j in self.jobs: - lda_mat_path = None - fmllrs = {} - if self.working_directory is not None: - lda_mat_path = os.path.join(self.working_directory, "lda.mat") - if not os.path.exists(lda_mat_path): - lda_mat_path = None - - fmllrs = j.construct_path_dictionary(self.working_directory, "trans", "ark") - utt2spks = j.construct_path_dictionary(self.data_directory, "utt2spk", "scp") - cmvns = j.construct_path_dictionary(self.data_directory, "cmvn", "scp") - features = j.construct_path_dictionary(self.data_directory, "feats", "scp") - vads = j.construct_path_dictionary(self.data_directory, "vad", "scp") - feat_strings = {} - if not j.dictionary_ids: - utt2spk_path = j.construct_path(self.data_directory, "utt2spk", "scp") - cmvn_path = j.construct_path(self.data_directory, "cmvn", "scp") - feat_path = j.construct_path(self.data_directory, "feats", "scp") - feats = f'ark,s,cs:apply-cmvn --utt2spk=ark:"{utt2spk_path}" scp:"{cmvn_path}" scp:"{feat_path}" ark:- |' - if self.uses_deltas: - feats += " add-deltas ark:- ark:- |" - - strings.append(feats) - continue - - for dict_id in j.dictionary_ids: - feat_path = features[dict_id] - cmvn_path = cmvns[dict_id] - utt2spk_path = utt2spks[dict_id] - fmllr_trans_path = None - try: - fmllr_trans_path = fmllrs[dict_id] - if not os.path.exists(fmllr_trans_path): - fmllr_trans_path = None - except KeyError: + logger.info("Creating corpus split for feature generation...") + os.makedirs(os.path.join(self.split_directory, "log"), exist_ok=True) + with self.session() as session, tqdm.tqdm( + total=self.num_utterances + self.num_files, disable=GLOBAL_CONFIG.quiet + ) as pbar: + jobs = session.query(Job) + arguments = [ + ExportKaldiFilesArguments( + j.id, self.db_string, None, self.split_directory, True + ) + for j in jobs + ] + + for _ in run_kaldi_function(ExportKaldiFilesFunction, arguments, pbar.update): pass - vad_path = vads[dict_id] - if self.uses_voiced: - feats = f'ark,s,cs:add-deltas scp:"{feat_path}" ark:- |' - if self.uses_cmvn: - feats += " apply-cmvn-sliding --norm-vars=false --center=true --cmn-window=300 ark:- ark:- |" - feats += f' select-voiced-frames ark:- scp,s,cs:"{vad_path}" ark:- |' - elif not os.path.exists(cmvn_path) and self.uses_cmvn: - feats = f'ark,s,cs:add-deltas scp:"{feat_path}" ark:- |' - if self.uses_cmvn: - feats += " apply-cmvn-sliding --norm-vars=false --center=true --cmn-window=300 ark:- ark:- |" - else: - feats = f'ark,s,cs:apply-cmvn --utt2spk=ark:"{utt2spk_path}" scp:"{cmvn_path}" scp:"{feat_path}" ark:- |' - if lda_mat_path is not None: - feats += f" splice-feats --left-context={self.splice_left_context} --right-context={self.splice_right_context} ark:- ark:- |" - feats += f' transform-feats "{lda_mat_path}" ark:- ark:- |' - elif self.uses_splices: - feats += f" splice-feats --left-context={self.splice_left_context} --right-context={self.splice_right_context} ark:- ark:- |" - elif self.uses_deltas: - feats += " add-deltas ark:- ark:- |" - if fmllr_trans_path is not None and not ( - self.speaker_independent or speaker_independent - ): - if not os.path.exists(fmllr_trans_path): - raise Exception(f"Could not find {fmllr_trans_path}") - feats += f' transform-feats --utt2spk=ark:"{utt2spk_path}" ark:"{fmllr_trans_path}" ark:- ark:- |' - feat_strings[dict_id] = feats - strings.append(feat_strings) - return strings def compute_vad_arguments(self) -> List[VadArguments]: """ @@ -399,15 +513,14 @@ def compute_vad_arguments(self) -> List[VadArguments]: """ return [ VadArguments( - j.name, - getattr(self, "db_engine", ""), - os.path.join(self.split_directory, "log", f"compute_vad.{j.name}.log"), + j.id, + getattr(self, "db_string", ""), + os.path.join(self.split_directory, "log", f"compute_vad.{j.id}.log"), j.construct_path(self.split_directory, "feats", "scp"), j.construct_path(self.split_directory, "vad", "scp"), self.vad_options, ) for j in self.jobs - if j.has_data ] def calc_fmllr_arguments(self, iteration: Optional[int] = None) -> List[CalcFmllrArguments]: @@ -419,27 +532,37 @@ def calc_fmllr_arguments(self, iteration: Optional[int] = None) -> List[CalcFmll list[:class:`~montreal_forced_aligner.corpus.features.CalcFmllrArguments`] Arguments for processing """ - feature_strings = self.construct_feature_proc_strings() base_log = "calc_fmllr" if iteration is not None: base_log += f".{iteration}" - return [ - CalcFmllrArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"{base_log}.{j.name}.log"), - j.dictionary_ids, - feature_strings[j.name], - j.construct_path_dictionary(self.working_directory, "ali", "ark"), - self.alignment_model_path, - self.model_path, - j.construct_path_dictionary(self.data_directory, "spk2utt", "scp"), - j.construct_path_dictionary(self.working_directory, "trans", "ark"), - self.fmllr_options, + arguments = [] + for j in self.jobs: + feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + arguments.append( + CalcFmllrArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"{base_log}.{j.id}.log"), + j.dictionary_ids, + feat_strings, + j.construct_path_dictionary(self.working_directory, "ali", "ark"), + self.alignment_model_path, + self.model_path, + j.construct_path_dictionary(self.data_directory, "spk2utt", "scp"), + j.construct_path_dictionary(self.working_directory, "trans", "ark"), + self.fmllr_options, + ) ) - for j in self.jobs - if j.has_data - ] + return arguments def mfcc_arguments(self) -> List[MfccArguments]: """ @@ -452,19 +575,73 @@ def mfcc_arguments(self) -> List[MfccArguments]: """ return [ MfccArguments( - j.name, - self.db_path, - os.path.join(self.split_directory, "log", f"make_mfcc.{j.name}.log"), - j.construct_path(self.split_directory, "wav", "scp"), - j.construct_path(self.split_directory, "segments", "scp"), - j.construct_path(self.split_directory, "feats", "scp"), + j.id, + self.db_string, + os.path.join(self.split_directory, "log", f"make_mfcc.{j.id}.log"), + self.split_directory, self.mfcc_options, self.pitch_options, ) for j in self.jobs - if j.has_data ] + def final_feature_arguments(self) -> List[FinalFeatureArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.corpus.features.MfccFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.corpus.features.MfccArguments`] + Arguments for processing + """ + return [ + FinalFeatureArguments( + j.id, + self.db_string, + os.path.join(self.split_directory, "log", f"generate_final_features.{j.id}.log"), + self.split_directory, + self.uses_cmvn, + self.uses_voiced, + getattr(self, "subsample", None), + ) + for j in self.jobs + ] + + def pitch_range_arguments(self) -> List[PitchRangeArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.corpus.features.MfccFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.corpus.features.MfccArguments`] + Arguments for processing + """ + return [ + PitchRangeArguments( + j.id, + self.db_string, + os.path.join(self.split_directory, "log", f"compute_pitch_range.{j.id}.log"), + self.split_directory, + self.pitch_options, + ) + for j in self.jobs + ] + + def compute_speaker_pitch_ranges(self): + logger.info("Calculating per-speaker f0 ranges...") + log_directory = os.path.join(self.split_directory, "log") + 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: + for speaker_id, min_f0, max_f0 in run_kaldi_function( + PitchRangeFunction, arguments, pbar.update + ): + update_mapping.append({"id": speaker_id, "min_f0": min_f0, "max_f0": max_f0}) + with self.session() as session: + bulk_update(session, Speaker, update_mapping) + session.commit() + def mfcc(self) -> None: """ Multiprocessing function that converts sound files into MFCCs. @@ -480,64 +657,15 @@ def mfcc(self) -> None: :kaldi_steps:`make_mfcc` Reference Kaldi script """ - self.log_info("Generating MFCCs...") + logger.info("Generating MFCCs...") + begin = time.time() log_directory = os.path.join(self.split_directory, "log") os.makedirs(log_directory, exist_ok=True) arguments = self.mfcc_arguments() - with tqdm.tqdm(total=self.num_utterances, disable=getattr(self, "quiet", False)) as pbar: - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(arguments): - function = MfccFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - pbar.update(result) - for p in procs: - p.join() - if error_dict: - for e in error_dict.values(): - print(e) - self.dirty = True - sys.exit(1) - else: - for args in arguments: - function = MfccFunction(args) - for num_utterances in function.run(): - pbar.update(num_utterances) - with self.session() as session: - update_mapping = [] - session.query(Utterance).update({"ignored": True}) - for j in arguments: - with mfa_open(j.feats_scp_path, "r") as f: - for line in f: - line = line.strip() - if line == "": - continue - f = line.split(maxsplit=1) - utt_id = int(f[0].split("-")[-1]) - feats = f[1] - update_mapping.append({"id": utt_id, "features": feats, "ignored": False}) - session.bulk_update_mappings(Utterance, update_mapping) - session.commit() + with tqdm.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") def calc_cmvn(self) -> None: """ @@ -548,7 +676,6 @@ def calc_cmvn(self) -> None: :kaldi_src:`compute-cmvn-stats` Relevant Kaldi binary """ - self._write_feats() self._write_spk2utt() spk2utt = os.path.join(self.corpus_output_directory, "spk2utt.scp") feats = os.path.join(self.corpus_output_directory, "feats.scp") @@ -572,9 +699,20 @@ def calc_cmvn(self) -> None: if isinstance(cmvn, list): cmvn = " ".join(cmvn) update_mapping.append({"id": int(s), "cmvn": cmvn}) - session.bulk_update_mappings(Speaker, update_mapping) + bulk_update(session, Speaker, update_mapping) session.commit() + for j in self.jobs: + query = ( + session.query(Speaker.id, Speaker.cmvn) + .join(Speaker.utterances) + .filter(Speaker.cmvn != None, Utterance.job_id == j.id) # noqa + .distinct() + ) + with mfa_open(j.construct_path(self.split_directory, "cmvn", ".scp"), "w") as f: + for s_id, cmvn in query: + f.write(f"{s_id} {cmvn}\n") + def calc_fmllr(self, iteration: Optional[int] = None) -> None: """ Multiprocessing function that computes speaker adaptation transforms via @@ -592,11 +730,11 @@ def calc_fmllr(self, iteration: Optional[int] = None) -> None: Reference Kaldi script """ begin = time.time() - self.log_info("Calculating fMLLR for speaker adaptation...") + logger.info("Calculating fMLLR for speaker adaptation...") arguments = self.calc_fmllr_arguments(iteration=iteration) - with tqdm.tqdm(total=self.num_speakers, disable=getattr(self, "quiet", False)) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -633,8 +771,8 @@ def calc_fmllr(self, iteration: Optional[int] = None) -> None: for _ in function.run(): pbar.update(1) - self.speaker_independent = False - self.log_debug(f"Fmllr calculation took {time.time() - begin}") + self.uses_speaker_adaptation = True + logger.debug(f"Fmllr calculation took {time.time() - begin:.3f} seconds") def compute_vad(self) -> None: """ @@ -647,15 +785,17 @@ def compute_vad(self) -> None: :meth:`.AcousticCorpusMixin.compute_vad_arguments` Job method for generating arguments for helper function """ - if os.path.exists(os.path.join(self.split_directory, "vad.0.scp")): - self.log_info("VAD already computed, skipping!") - return + with self.session() as session: + c = session.query(Corpus).first() + if c.vad_calculated: + logger.info("VAD already computed, skipping!") + return begin = time.time() - self.log_info("Computing VAD...") + logger.info("Computing VAD...") arguments = self.compute_vad_arguments() - with tqdm.tqdm(total=self.num_speakers, disable=getattr(self, "quiet", False)) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -692,51 +832,51 @@ def compute_vad(self) -> None: function = ComputeVadFunction(args) for done, no_feats, unvoiced in function.run(): pbar.update(done + no_feats + unvoiced) - self.log_debug(f"VAD computation took {time.time() - begin}") + vad_lines = [] + utterance_mapping = [] + for args in arguments: + with mfa_open(args.vad_scp_path) as inf: + for line in inf: + vad_lines.append(line) + utt_id, ark = line.strip().split(maxsplit=1) + utt_id = int(utt_id.split("-")[-1]) + utterance_mapping.append({"id": utt_id, "vad_ark": ark}) + with self.session() as session: + bulk_update(session, Utterance, utterance_mapping) + session.query(Corpus).update({Corpus.vad_calculated: True}) + session.commit() + with mfa_open(os.path.join(self.corpus_output_directory, "vad.scp"), "w") as outf: + for line in sorted(vad_lines, key=lambda x: x.split(maxsplit=1)[0]): + outf.write(line) + logger.debug(f"VAD computation took {time.time() - begin:.3f} seconds") def combine_feats(self) -> None: """ Combine feature generation results and store relevant information """ - - with self.session() as session: - ignored_utterances = ( - session.query( - SoundFile.sound_file_path, - Speaker.name, - Utterance.begin, - Utterance.end, - Utterance.text, - ) - .join(Utterance.speaker) - .join(Utterance.file) - .join(File.sound_file) - .filter(Utterance.ignored == True) # noqa - ) - ignored_count = 0 - for sound_file_path, speaker_name, begin, end, text in ignored_utterances: - self.log_debug(f" - Ignored File: {sound_file_path}") - self.log_debug(f" - Speaker: {speaker_name}") - self.log_debug(f" - Begin: {begin}") - self.log_debug(f" - End: {end}") - self.log_debug(f" - Text: {text}") - ignored_count += 1 - if ignored_count: - self.log_warning( - f"There were {ignored_count} utterances ignored due to an issue in feature generation, see the log file for full " - "details or run `mfa validate` on the corpus." - ) + lines = [] + for j in self.jobs: + with mfa_open(j.feats_scp_path) as f: + for line in f: + lines.append(line) + with open( + os.path.join(self.corpus_output_directory, "feats.scp"), "w", encoding="utf8" + ) as f: + for line in sorted(lines): + f.write(line) def _write_feats(self) -> None: """Write feats scp file for Kaldi""" - feats_path = os.path.join(self.corpus_output_directory, "feats.scp") - with self.session() as session, mfa_open(feats_path, "w") as f: + with self.session() as session, open( + os.path.join(self.corpus_output_directory, "feats.scp"), "w", encoding="utf8" + ) as f: utterances = ( session.query(Utterance.kaldi_id, Utterance.features) .filter_by(ignored=False) .order_by(Utterance.kaldi_id) ) for u_id, features in utterances: + f.write(f"{u_id} {features}\n") def get_feat_dim(self) -> int: @@ -748,29 +888,48 @@ def get_feat_dim(self) -> int: int Dimension of feature vectors """ - feature_string = self.construct_base_feature_string() - with mfa_open( - os.path.join(self.features_log_directory, "feat-to-dim.log"), "w" - ) as log_file: + job = self.jobs[0] + dict_id = None + log_path = os.path.join(self.features_log_directory, "feat-to-dim.log") + if job.dictionary_ids: + dict_id = self.jobs[0].dictionary_ids[0] + feature_string = job.construct_feature_proc_string( + self.working_directory, + dict_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + with mfa_open(log_path, "w") as log_file: + subset_ark_path = os.path.join(self.split_directory, "temp.ark") subset_proc = subprocess.Popen( [ thirdparty_binary("subset-feats"), "--n=1", feature_string, - "ark:-", + f"ark:{subset_ark_path}", ], stderr=log_file, - stdout=subprocess.PIPE, + env=os.environ, ) + subset_proc.wait() dim_proc = subprocess.Popen( - [thirdparty_binary("feat-to-dim"), "ark:-", "-"], - stdin=subset_proc.stdout, + [thirdparty_binary("feat-to-dim"), f"ark:{subset_ark_path}", "-"], stdout=subprocess.PIPE, stderr=log_file, + env=os.environ, + encoding="utf8", ) - stdout, stderr = dim_proc.communicate() - feats = stdout.decode("utf8").strip() - return int(feats) + feats = dim_proc.stdout.readline().strip() + dim_proc.wait() + if not feats: + with mfa_open(log_path) as f: + logged = f.read() + raise FeatureGenerationError(logged) + feats = int(feats) + os.remove(subset_ark_path) + return feats def _load_corpus_from_source_mp(self) -> None: """ @@ -782,7 +941,6 @@ def _load_corpus_from_source_mp(self) -> None: finished_adding = Stopped() stopped = Stopped() file_counts = Counter() - sanitize_function = getattr(self, "sanitize_function", None) error_dict = {} procs = [] self.db_engine.dispose() @@ -795,7 +953,7 @@ def _load_corpus_from_source_mp(self) -> None: file_counts, ) parser.start() - for i in range(self.num_jobs): + for i in range(GLOBAL_CONFIG.num_jobs): p = CorpusProcessWorker( i, job_queue, @@ -803,69 +961,69 @@ def _load_corpus_from_source_mp(self) -> None: stopped, finished_adding, self.speaker_characters, - sanitize_function, self.sample_frequency, ) procs.append(p) p.start() last_poll = time.time() - 30 - import_data = DatabaseImportData() try: - with self.session() as session: - with tqdm.tqdm(total=100, disable=getattr(self, "quiet", False)) as pbar: - while True: - try: - file = return_queue.get(timeout=1) - if isinstance(file, tuple): - error_type = file[0] - error = file[1] - if error_type == "error": - error_dict[error_type] = error - else: - if error_type not in error_dict: - error_dict[error_type] = [] - error_dict[error_type].append(error) - continue - if self.stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished_processing.stop_check(): - break + with self.session() as session, tqdm.tqdm( + total=100, disable=GLOBAL_CONFIG.quiet + ) as pbar: + import_data = DatabaseImportData() + while True: + try: + file = return_queue.get(timeout=1) + if isinstance(file, tuple): + error_type = file[0] + error = file[1] + if error_type == "error": + error_dict[error_type] = error else: - break + if error_type not in error_dict: + error_dict[error_type] = [] + error_dict[error_type].append(error) continue - if time.time() - last_poll > 5: - pbar.total = file_counts.value() - last_poll = time.time() - pbar.update(1) - import_data.add_objects(self.generate_import_objects(file)) + if self.stopped.stop_check(): + continue + except Empty: + for proc in procs: + if not proc.finished_processing.stop_check(): + break + else: + break + continue + if time.time() - last_poll > 5: + pbar.total = file_counts.value() + last_poll = time.time() + pbar.update(1) + import_data.add_objects(self.generate_import_objects(file)) - self.log_debug(f"Processing queue: {time.process_time() - begin_time}") + logger.debug(f"Processing queue: {time.process_time() - begin_time}") - if "error" in error_dict: - session.rollback() - raise error_dict["error"][1] - self._finalize_load(session, import_data) + if "error" in error_dict: + session.rollback() + raise error_dict["error"][1] + self._finalize_load(session, import_data) for k in ["sound_file_errors", "decode_error_files", "textgrid_read_errors"]: if hasattr(self, k): if k in error_dict: - self.log_info( + logger.info( "There were some issues with files in the corpus. " "Please look at the log file or run the validator for more information." ) - self.log_debug(f"{k} showed {len(error_dict[k])} errors:") + logger.debug(f"{k} showed {len(error_dict[k])} errors:") if k in {"textgrid_read_errors", "sound_file_errors"}: - getattr(self, k).update(error_dict[k]) + getattr(self, k).extend(error_dict[k]) for e in error_dict[k]: - self.log_debug(f"{e.file_name}: {e.error}") + logger.debug(f"{e.file_name}: {e.error}") else: - self.log_debug(", ".join(error_dict[k])) + logger.debug(", ".join(error_dict[k])) setattr(self, k, error_dict[k]) except Exception as e: if isinstance(e, KeyboardInterrupt): - self.log_info( + logger.info( "Detected ctrl-c, please wait a moment while we clean everything up..." ) self.stopped.set_sigint_source() @@ -893,19 +1051,18 @@ def _load_corpus_from_source_mp(self) -> None: break else: break + raise finally: parser.join() for p in procs: p.join() if self.stopped.stop_check(): - self.log_info( - f"Stopped parsing early ({time.process_time() - begin_time} seconds)" - ) + logger.info(f"Stopped parsing early ({time.process_time() - begin_time} seconds)") if self.stopped.source(): sys.exit(0) else: - self.log_debug( - f"Parsed corpus directory with {self.num_jobs} jobs in {time.process_time() - begin_time} seconds" + logger.debug( + f"Parsed corpus directory with {GLOBAL_CONFIG.num_jobs} jobs in {time.process_time() - begin_time} seconds" ) def _load_corpus_from_source(self) -> None: @@ -913,9 +1070,6 @@ def _load_corpus_from_source(self) -> None: Load a corpus without using multiprocessing """ begin_time = time.time() - sanitize_function = None - if hasattr(self, "sanitize_function"): - sanitize_function = self.sanitize_function all_sound_files = {} use_audio_directory = False @@ -931,9 +1085,9 @@ def _load_corpus_from_source(self) -> None: } all_sound_files.update(exts.other_audio_files) all_sound_files.update(exts.wav_files) - self.log_debug(f"Walking through {self.corpus_directory}...") - import_data = DatabaseImportData() + logger.debug(f"Walking through {self.corpus_directory}...") with self.session() as session: + import_data = DatabaseImportData() for root, _, files in os.walk(self.corpus_directory, followlinks=True): exts = find_exts(files) relative_path = root.replace(self.corpus_directory, "").lstrip("/").lstrip("\\") @@ -959,13 +1113,8 @@ def _load_corpus_from_source(self) -> None: elif file_name in exts.textgrid_files: tg_name = exts.textgrid_files[file_name] transcription_path = os.path.join(root, tg_name) - if wav_path is None and transcription_path is None: # Not a file for MFA + if wav_path is None: # Not a file for MFA continue - if wav_path is None: - self.transcriptions_without_wavs.append(transcription_path) - continue - if transcription_path is None: - self.no_transcription_files.append(wav_path) try: file = FileData.parse_file( file_name, @@ -973,7 +1122,6 @@ def _load_corpus_from_source(self) -> None: transcription_path, relative_path, self.speaker_characters, - sanitize_function, self.sample_frequency, ) import_data.add_objects(self.generate_import_objects(file)) @@ -985,23 +1133,23 @@ def _load_corpus_from_source(self) -> None: self.sound_file_errors.append(e) self._finalize_load(session, import_data) if self.decode_error_files or self.textgrid_read_errors: - self.log_info( + logger.info( "There were some issues with files in the corpus. " "Please look at the log file or run the validator for more information." ) if self.decode_error_files: - self.log_debug( + logger.debug( f"There were {len(self.decode_error_files)} errors decoding text files:" ) - self.log_debug(", ".join(self.decode_error_files)) + logger.debug(", ".join(self.decode_error_files)) if self.textgrid_read_errors: - self.log_debug( + logger.debug( f"There were {len(self.textgrid_read_errors)} errors decoding reading TextGrid files:" ) for e in self.textgrid_read_errors: - self.log_debug(f"{e.file_name}: {e.error}") + logger.debug(f"{e.file_name}: {e.error}") - self.log_debug(f"Parsed corpus directory in {time.time() - begin_time} seconds") + logger.debug(f"Parsed corpus directory in {time.time() - begin_time:.3f} seconds") class AcousticCorpusPronunciationMixin( @@ -1026,49 +1174,43 @@ def load_corpus(self) -> None: Load the corpus """ all_begin = time.time() - self.initialize_database() - self.log_debug(f"Using {self.phone_set_type}") - self.dictionary_setup() - self.log_debug(f"Loaded dictionary in {time.time() - all_begin}") - - begin = time.time() - self.write_lexicon_information() - self.log_debug(f"Wrote lexicon information in {time.time() - begin}") + if self.dictionary_model is not None: + logger.debug(f"Using {self.phone_set_type}") + self.dictionary_setup() + logger.debug(f"Loaded dictionary in {time.time() - all_begin:.3f} seconds") begin = time.time() self._load_corpus() - self.log_debug(f"Loaded corpus in {time.time() - begin}") + logger.debug(f"Loaded corpus in {time.time() - begin:.3f} seconds") begin = time.time() self.initialize_jobs() - self.log_debug(f"Initialized jobs in {time.time() - begin}") + logger.debug(f"Initialized jobs in {time.time() - begin:.3f} seconds") + + self.normalize_text() + + begin = time.time() + self.write_lexicon_information() + logger.debug(f"Wrote lexicon information in {time.time() - begin:.3f} seconds") begin = time.time() self.create_corpus_split() - self.log_debug(f"Created corpus split directory in {time.time() - begin}") + logger.debug(f"Created corpus split directory in {time.time() - begin:.3f} seconds") begin = time.time() self.generate_features() - self.log_debug(f"Generated features in {time.time() - begin}") + logger.debug(f"Generated features in {time.time() - begin:.3f} seconds") - begin = time.time() - self.calculate_oovs_found() - self.log_debug(f"Calculated oovs found in {time.time() - begin}") - self.log_debug(f"Setting up corpus took {time.time() - all_begin} seconds") + logger.debug(f"Setting up corpus took {time.time() - all_begin:.3f} seconds") -class AcousticCorpus(AcousticCorpusMixin, DictionaryMixin, MfaWorker, TemporaryDirectoryMixin): +class AcousticCorpus(AcousticCorpusMixin, DictionaryMixin, MfaWorker): """ Standalone class for working with acoustic corpora and pronunciation dictionaries Most functionality in MFA will use the :class:`~montreal_forced_aligner.corpus.acoustic_corpus.AcousticCorpusPronunciationMixin` class instead of this class. - Parameters - ---------- - num_jobs: int - Number of jobs to use in processing the corpus - See Also -------- :class:`~montreal_forced_aligner.corpus.acoustic_corpus.AcousticCorpusPronunciationMixin` @@ -1079,14 +1221,8 @@ class AcousticCorpus(AcousticCorpusMixin, DictionaryMixin, MfaWorker, TemporaryD For temporary directory parameters """ - def __init__(self, num_jobs=3, **kwargs): + def __init__(self, **kwargs): super(AcousticCorpus, self).__init__(**kwargs) - self.num_jobs = num_jobs - - @property - def sanitize_function(self) -> SanitizeFunction: - """Text sanitization function""" - return self.construct_sanitize_function() @property def identifier(self) -> str: @@ -1096,7 +1232,7 @@ def identifier(self) -> str: @property def output_directory(self) -> str: """Root temporary directory to store corpus and dictionary files""" - return os.path.join(self.temporary_directory, self.identifier) + return os.path.join(GLOBAL_CONFIG.temporary_directory, self.identifier) @property def working_directory(self) -> str: @@ -1104,21 +1240,13 @@ def working_directory(self) -> str: return self.corpus_output_directory -class AcousticCorpusWithPronunciations( - AcousticCorpusPronunciationMixin, MfaWorker, TemporaryDirectoryMixin -): +class AcousticCorpusWithPronunciations(AcousticCorpusPronunciationMixin, MfaWorker): """ Standalone class for parsing an acoustic corpus with a pronunciation dictionary - - Parameters - ---------- - num_jobs: int - Number of jobs to use in parsing """ - def __init__(self, num_jobs=3, **kwargs): + def __init__(self, **kwargs): super().__init__(**kwargs) - self.num_jobs = num_jobs @property def identifier(self) -> str: @@ -1128,7 +1256,7 @@ def identifier(self) -> str: @property def output_directory(self) -> str: """Root temporary directory to store corpus and dictionary files""" - return os.path.join(self.temporary_directory, self.identifier) + return os.path.join(GLOBAL_CONFIG.temporary_directory, self.identifier) @property def working_directory(self) -> str: diff --git a/montreal_forced_aligner/corpus/base.py b/montreal_forced_aligner/corpus/base.py index d60b7002..1de1d42d 100644 --- a/montreal_forced_aligner/corpus/base.py +++ b/montreal_forced_aligner/corpus/base.py @@ -1,35 +1,51 @@ """Class definitions for corpora""" from __future__ import annotations +import logging import os import time import typing from abc import ABCMeta, abstractmethod -from collections import Counter import sqlalchemy.engine -from sqlalchemy.orm import Session, joinedload, selectinload +import tqdm +from sqlalchemy.orm import Session, joinedload, selectinload, subqueryload from montreal_forced_aligner.abc import DatabaseMixin, MfaWorker +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.corpus.classes import FileData, UtteranceData -from montreal_forced_aligner.corpus.multiprocessing import Job -from montreal_forced_aligner.data import DatabaseImportData, TextFileType +from montreal_forced_aligner.corpus.multiprocessing import ( + ExportKaldiFilesArguments, + ExportKaldiFilesFunction, + NormalizeTextFunction, + dictionary_ids_for_job, +) +from montreal_forced_aligner.data import DatabaseImportData, TextFileType, WordType, WorkflowType from montreal_forced_aligner.db import ( Corpus, + CorpusWorkflow, + Dialect, Dictionary, + Dictionary2Job, File, + Job, + Pronunciation, SoundFile, Speaker, SpeakerOrdering, TextFile, Utterance, + Word, + bulk_update, ) from montreal_forced_aligner.exceptions import CorpusError from montreal_forced_aligner.helper import output_mapping -from montreal_forced_aligner.utils import Stopped +from montreal_forced_aligner.utils import Stopped, run_kaldi_function __all__ = ["CorpusMixin"] +logger = logging.getLogger("mfa") + class CorpusMixin(MfaWorker, DatabaseMixin, metaclass=ABCMeta): """ @@ -48,6 +64,8 @@ class CorpusMixin(MfaWorker, DatabaseMixin, metaclass=ABCMeta): Number of characters in the file name to specify the speaker ignore_speakers: bool Flag for whether to discard any parsed speaker information during top-level worker's processing + oov_count_threshold: int + Words in the corpus with counts less than or equal to the threshold will be treated as OOV items, defaults to 0 See Also -------- @@ -58,10 +76,8 @@ class CorpusMixin(MfaWorker, DatabaseMixin, metaclass=ABCMeta): Attributes ---------- - jobs: list[Job] + jobs: list[:class:`~montreal_forced_aligner.corpus.multiprocessing.Job`] List of jobs for processing the corpus and splitting speakers - word_counts: Counter - Counts of words in the corpus stopped: Stopped Stop check for loading the corpus decode_error_files: list[str] @@ -75,6 +91,7 @@ def __init__( corpus_directory: str, speaker_characters: typing.Union[int, str] = 0, ignore_speakers: bool = False, + oov_count_threshold: int = 0, **kwargs, ): if not os.path.exists(corpus_directory): @@ -87,32 +104,59 @@ def __init__( self.corpus_directory = corpus_directory self.speaker_characters = speaker_characters self.ignore_speakers = ignore_speakers - self.word_counts = Counter() + self.oov_count_threshold = oov_count_threshold self.stopped = Stopped() self.decode_error_files = [] self.textgrid_read_errors = [] - self.jobs: typing.List[Job] = [] self._num_speakers = None self._num_utterances = None self._num_files = None super().__init__(**kwargs) os.makedirs(self.corpus_output_directory, exist_ok=True) self.imported = False + self.text_normalized = False self._current_speaker_index = 1 self._current_file_index = 1 + self._current_utterance_index = 1 self._speaker_ids = {} + self._word_set = [] + self._jobs = [] + self.ignore_empty_utterances = False + + @property + def jobs(self) -> typing.List[Job]: + if not self._jobs: + with self.session() as session: + c: Corpus = session.query(Corpus).first() + jobs = session.query(Job).options( + joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries) + ) + if c.current_subset: + jobs = jobs.filter(Job.utterances.any(Utterance.in_subset == True)) # noqa + jobs = jobs.filter(Job.utterances.any(Utterance.ignored == False)) # noqa + self._jobs = jobs.all() + return self._jobs + + def dictionary_ids_for_job(self, job_id): + with self.session() as session: + return dictionary_ids_for_job(session, job_id) def inspect_database(self) -> None: """Check if a database file exists and create the necessary metadata""" - exist_check = os.path.exists(self.db_path) - if not exist_check: - self.initialize_database() + self.initialize_database() with self.session() as session: corpus = session.query(Corpus).first() if corpus: self.imported = corpus.imported + self.text_normalized = corpus.text_normalized else: - session.add(Corpus(name=self.data_source_identifier)) + session.add( + Corpus( + name=self.data_source_identifier, + path=self.corpus_directory, + data_directory=self.corpus_output_directory, + ) + ) session.commit() def get_utterances( @@ -148,7 +192,7 @@ def get_utterances( if session is None: session = self.session() if id is not None: - utterance = session.query(Utterance).get(id) + utterance = session.get(Utterance, id) if not utterance: raise Exception(f"Could not find utterance with id of {id}") return utterance @@ -197,7 +241,7 @@ def get_file( selectinload(File.utterances).joinedload(Utterance.speaker, innerjoin=True), joinedload(File.sound_file, innerjoin=True), joinedload(File.text_file, innerjoin=True), - selectinload(File.speakers).joinedload(SpeakerOrdering.speaker, innerjoin=True), + selectinload(File.speakers), ) if id is not None: file = file.get(id) @@ -223,15 +267,20 @@ def features_log_directory(self) -> str: @property def split_directory(self) -> str: """Directory used to store information split by job""" - return os.path.join(self.corpus_output_directory, f"split{self.num_jobs}") + return os.path.join( + self.corpus_output_directory, f"split{GLOBAL_CONFIG.current_profile.num_jobs}" + ) def _write_spk2utt(self) -> None: """Write spk2utt scp file for Kaldi""" data = {} utt2spk_data = {} with self.session() as session: - utterances = session.query(Utterance.kaldi_id, Utterance.speaker_id).order_by( - Utterance.kaldi_id + utterances = ( + session.query(Utterance.kaldi_id, Utterance.speaker_id) + .join(Utterance.speaker) + .filter(Speaker.name != "MFA_UNKNOWN") + .order_by(Utterance.kaldi_id) ) for utt_id, speaker_id in utterances: @@ -245,16 +294,31 @@ def _write_spk2utt(self) -> None: def create_corpus_split(self) -> None: """Create split directory and output information from Jobs""" - split_dir = self.split_directory - os.makedirs(os.path.join(split_dir, "log"), exist_ok=True) - with self.session() as session: - for job in self.jobs: - job.output_to_directory(split_dir, session) + os.makedirs(os.path.join(self.split_directory, "log"), exist_ok=True) + with self.session() as session, tqdm.tqdm( + total=self.num_utterances, disable=GLOBAL_CONFIG.quiet + ) as pbar: + jobs = session.query(Job) + arguments = [ + ExportKaldiFilesArguments( + j.id, self.db_string, None, self.split_directory, for_features=False + ) + for j in jobs + ] + + for _ in run_kaldi_function(ExportKaldiFilesFunction, arguments, pbar.update): + pass @property def corpus_word_set(self) -> typing.List[str]: """Set of words used in the corpus""" - return sorted(self.word_counts) + if not self._word_set: + with self.session() as session: + self._word_set = [ + x[0] + for x in session.query(Word.word).filter(Word.count > 0).order_by(Word.word) + ] + return self._word_set def add_utterance(self, utterance: UtteranceData, session: Session = None) -> Utterance: """ @@ -288,6 +352,7 @@ def add_utterance(self, utterance: UtteranceData, session: Session = None) -> Ut u = Utterance.from_data( utterance, file_obj, speaker_obj, frame_shift=getattr(self, "frame_shift", None) ) + u.id = self.get_next_primary_key(Utterance) session.add(u) if close: session.commit() @@ -333,7 +398,7 @@ def speakers(self, session: Session = None) -> sqlalchemy.orm.Query: close = True speakers = session.query(Speaker).options( selectinload(Speaker.utterances), - selectinload(Speaker.files).joinedload(SpeakerOrdering.file, innerjoin=True), + selectinload(Speaker.files), joinedload(Speaker.dictionary), ) if close: @@ -360,7 +425,7 @@ def files(self, session: Session = None) -> sqlalchemy.orm.Query: close = True files = session.query(File).options( selectinload(File.utterances), - selectinload(File.speakers).joinedload(SpeakerOrdering.speaker, innerjoin=True), + selectinload(File.speakers), joinedload(File.sound_file), joinedload(File.text_file), ) @@ -379,7 +444,7 @@ def utterances(self, session: Session = None) -> sqlalchemy.orm.Query: Returns ------- - sqlalchemy.orm.Query + :class:`sqlalchemy.orm.Query` Utterance query """ close = False @@ -391,7 +456,6 @@ def utterances(self, session: Session = None) -> sqlalchemy.orm.Query: joinedload(Utterance.speaker, innerjoin=True), selectinload(Utterance.phone_intervals), selectinload(Utterance.word_intervals), - selectinload(Utterance.reference_phone_intervals), ) if close: session.close() @@ -401,60 +465,122 @@ def initialize_jobs(self) -> None: """ Initialize the corpus's Jobs """ - self.log_info("Initializing multiprocessing jobs...") with self.session() as session: - if self.num_speakers < self.num_jobs: - self.log_warning( - f"Number of jobs was specified as {self.num_jobs}, " + if session.query(sqlalchemy.sql.exists().where(Utterance.job_id > 1)).scalar(): + logger.info("Jobs already initialized.") + return + logger.info("Initializing multiprocessing jobs...") + if ( + self.num_speakers < GLOBAL_CONFIG.current_profile.num_jobs + and not GLOBAL_CONFIG.current_profile.single_speaker + ): + logger.warning( + f"Number of jobs was specified as {GLOBAL_CONFIG.current_profile.num_jobs}, " f"but due to only having {self.num_speakers} speakers, MFA " - f"will only use {self.num_speakers} jobs." + f"will only use {self.num_speakers} jobs. Use the --single_speaker flag if you would like to split " + f"utterances across jobs regardless of their speaker." + ) + session.query(Job).filter(Job.id > GLOBAL_CONFIG.current_profile.num_jobs).delete() + session.query(Corpus).update( + {Corpus.num_jobs: GLOBAL_CONFIG.current_profile.num_jobs} + ) + session.commit() + elif ( + GLOBAL_CONFIG.current_profile.single_speaker + and self.num_utterances < GLOBAL_CONFIG.current_profile.num_jobs + ): + logger.warning( + f"Number of jobs was specified as {GLOBAL_CONFIG.current_profile.num_jobs}, " + f"but due to only having {self.num_utterances} utterances, MFA " + f"will only use {self.num_utterances} jobs." ) - self.num_jobs = self.num_speakers - self.jobs = [Job(i, self.db_engine) for i in range(self.num_jobs)] - utt_counts = {i: 0 for i in range(self.num_jobs)} + session.query(Job).filter(Job.id > GLOBAL_CONFIG.current_profile.num_jobs).delete() + session.query(Corpus).update( + {Corpus.num_jobs: GLOBAL_CONFIG.current_profile.num_jobs} + ) + session.commit() + + jobs = session.query(Job).all() update_mappings = [] - speakers = ( - session.query(Speaker.id, sqlalchemy.func.count(Utterance.id)) - .outerjoin(Speaker.utterances) - .group_by(Speaker.id) - .filter(Speaker.job_id == None) # noqa - .order_by(sqlalchemy.func.count(Utterance.id).desc()) - ) - if speakers: + if GLOBAL_CONFIG.current_profile.single_speaker: + utts_per_job = int(self.num_utterances / GLOBAL_CONFIG.current_profile.num_jobs) + if utts_per_job == 0: + utts_per_job = 1 + for i, j in enumerate(jobs): + update_mappings.extend( + {"id": u, "job_id": j.id} + for u in range((utts_per_job * i) + 1, (utts_per_job * (i + 1)) + 1) + ) + last_ind = update_mappings[-1]["id"] + 1 + for u in range(last_ind, self.num_utterances): + update_mappings.append({"id": u, "job_id": jobs[-1].id}) + bulk_update(session, Utterance, update_mappings) + else: + utt_counts = {j.id: 0 for j in jobs} + speakers = ( + session.query(Speaker.id, sqlalchemy.func.count(Utterance.id)) + .outerjoin(Speaker.utterances) + .group_by(Speaker.id) + .order_by(sqlalchemy.func.count(Utterance.id).desc()) + ) for s_id, speaker_utt_count in speakers: if not speaker_utt_count: continue job_id = min(utt_counts.keys(), key=lambda x: utt_counts[x]) - update_mappings.append({"id": s_id, "job_id": job_id}) + update_mappings.append({"speaker_id": s_id, "job_id": job_id}) utt_counts[job_id] += speaker_utt_count - session.bulk_update_mappings(Speaker, update_mappings) + bulk_update(session, Utterance, update_mappings, id_field="speaker_id") + session.commit() + if session.query(Dictionary2Job).count() == 0: + + dict_job_mappings = [] + for job_id, dict_id in ( + session.query(Utterance.job_id, Dictionary.id) + .join(Utterance.speaker) + .join(Speaker.dictionary) + .distinct() + ): + if not dict_id: + continue + dict_job_mappings.append({"job_id": job_id, "dictionary_id": dict_id}) + if dict_job_mappings: + session.execute(Dictionary2Job.insert().values(dict_job_mappings)) session.commit() - for j in self.jobs: - j.refresh_dictionaries(session) def _finalize_load(self, session: Session, import_data: DatabaseImportData): """Finalize the import of database objects after parsing""" - with session.bind.begin() as conn: - + with session.begin_nested(): + c = session.query(Corpus).first() + job_objs = [ + {"id": j, "corpus_id": c.id} + for j in range(1, GLOBAL_CONFIG.current_profile.num_jobs + 1) + ] + session.execute(sqlalchemy.insert(Job.__table__), job_objs) + c.num_jobs = GLOBAL_CONFIG.current_profile.num_jobs if import_data.speaker_objects: - conn.execute(sqlalchemy.insert(Speaker.__table__), import_data.speaker_objects) + session.execute(sqlalchemy.insert(Speaker.__table__), import_data.speaker_objects) if import_data.file_objects: - conn.execute(sqlalchemy.insert(File.__table__), import_data.file_objects) + session.execute(sqlalchemy.insert(File.__table__), import_data.file_objects) if import_data.text_file_objects: - conn.execute(sqlalchemy.insert(TextFile.__table__), import_data.text_file_objects) + session.execute( + sqlalchemy.insert(TextFile.__table__), import_data.text_file_objects + ) if import_data.sound_file_objects: - conn.execute( + session.execute( sqlalchemy.insert(SoundFile.__table__), import_data.sound_file_objects ) if import_data.speaker_ordering_objects: - conn.execute( - sqlalchemy.insert(SpeakerOrdering.__table__), + session.execute( + sqlalchemy.insert(SpeakerOrdering), import_data.speaker_ordering_objects, ) if import_data.utterance_objects: - conn.execute(sqlalchemy.insert(Utterance.__table__), import_data.utterance_objects) - session.commit() + session.execute( + sqlalchemy.insert(Utterance.__table__), import_data.utterance_objects + ) + session.flush() + self.imported = True speakers = ( session.query(Speaker.id) @@ -472,10 +598,185 @@ def _finalize_load(self, session: Session, import_data: DatabaseImportData): } ) if speaker_ids: + session.query(SpeakerOrdering).filter( + SpeakerOrdering.c.speaker_id.in_(speaker_ids) + ).delete() session.query(Speaker).filter(Speaker.id.in_(speaker_ids)).delete() self._num_speakers = None + self._num_utterances = None # Recalculate if already cached + self._num_files = None session.commit() + def normalize_text_arguments(self): + from montreal_forced_aligner.dictionary.mixins import DictionaryMixin + + if not isinstance(self, DictionaryMixin): + return None + from montreal_forced_aligner.corpus.multiprocessing import NormalizeTextArguments + + with self.session() as session: + jobs = session.query(Job).filter(Job.utterances.any()) + return [ + NormalizeTextArguments( + j.id, + self.db_string, + None, + self.word_break_markers, + self.punctuation, + self.clitic_markers, + self.compound_markers, + self.brackets, + self.laughter_word, + self.oov_word, + self.bracketed_word, + self.ignore_case, + getattr(self, "use_g2p", False), + ) + for j in jobs + ] + + def normalize_text(self) -> None: + """Normalize the text of the corpus using a dictionary's sanitization functions and word mappings""" + if self.text_normalized: + logger.info("Text already normalized.") + return + args = self.normalize_text_arguments() + if args is None: + return + logger.info("Normalizing text...") + log_directory = os.path.join(self.split_directory, "log") + word_update_mappings = {} + word_insert_mappings = {} + pronunciation_insert_mappings = [] + word_indexes = {} + word_mapping_ids = {} + max_mapping_ids = {} + os.makedirs(log_directory, exist_ok=True) + update_mapping = [] + word_key = self.get_next_primary_key(Word) + pronunciation_key = self.get_next_primary_key(Pronunciation) + with tqdm.tqdm( + total=self.num_utterances, disable=GLOBAL_CONFIG.quiet + ) as pbar, self.session() as session: + dictionaries: typing.Dict[int, Dictionary] = { + d.id: d for d in session.query(Dictionary) + } + has_words = session.query(Dictionary).filter(Dictionary.name == "unknown") is not None + words = session.query( + Word.id, Word.mapping_id, Word.dictionary_id, Word.word + ).order_by(Word.mapping_id) + if not has_words or getattr(self, "use_g2p", False): + + word_insert_mappings[""] = { + "id": word_key, + "word": "", + "word_type": WordType.silence, + "mapping_id": word_key - 1, + "count": 0, + "dictionary_id": 1, + } + word_key += 1 + for w_id, m_id, d_id, w in words: + word_indexes[(d_id, w)] = w_id + word_mapping_ids[(d_id, w)] = m_id + max_mapping_ids[d_id] = m_id + for result in run_kaldi_function(NormalizeTextFunction, args, pbar.update): + result, dict_id = result + if dict_id is not None and not getattr(self, "use_g2p", False): + oovs = set(result["oovs"].split()) + for w in oovs: + if w in dictionaries[dict_id].special_set: + continue + if (w, dict_id) not in word_insert_mappings: + max_mapping_ids[dict_id] += 1 + word_insert_mappings[(w, dict_id)] = { + "id": word_key, + "mapping_id": max_mapping_ids[d_id], + "word": w, + "count": 0, + "dictionary_id": dict_id, + "word_type": WordType.oov, + } + pronunciation_insert_mappings.append( + { + "id": pronunciation_key, + "word_id": word_key, + "pronunciation": getattr(self, "oov_phone", "spn"), + "base_pronunciation_id": pronunciation_key, + } + ) + word_key += 1 + pronunciation_key += 1 + word_insert_mappings[(w, dict_id)]["count"] += 1 + for w in result["normalized_text"].split(): + if w in oovs: + continue + if (dict_id, w) not in word_update_mappings: + word_update_mappings[(dict_id, w)] = { + "id": word_indexes[(dict_id, w)], + "count": 0, + } + word_update_mappings[(dict_id, w)]["count"] += 1 + else: + for word in result["normalized_text"].split(): + if word not in word_insert_mappings: + word_insert_mappings[word] = { + "id": word_key, + "word": word, + "word_type": WordType.speech, + "mapping_id": word_key - 1, + "count": 0, + "dictionary_id": 1, + } + pronunciation_insert_mappings.append( + { + "id": pronunciation_key, + "word_id": word_key, + "pronunciation": getattr(self, "oov_phone", "spn"), + "base_pronunciation_id": pronunciation_key, + } + ) + word_key += 1 + pronunciation_key += 1 + word_insert_mappings[word]["count"] += 1 + + update_mapping.append(result) + bulk_update(session, Utterance, update_mapping) + if word_update_mappings: + if has_words: + session.query(Word).update({"count": 0}) + session.commit() + bulk_update(session, Word, list(word_update_mappings.values())) + if word_insert_mappings: + if not has_words: + word_insert_mappings[""] = { + "id": word_key, + "word": "", + "word_type": WordType.oov, + "mapping_id": word_key - 1, + "count": 0, + "dictionary_id": 1, + } + session.bulk_insert_mappings( + Word, + list(word_insert_mappings.values()), + return_defaults=False, + render_nulls=True, + ) + session.bulk_insert_mappings( + Pronunciation, + pronunciation_insert_mappings, + return_defaults=False, + render_nulls=True, + ) + self.text_normalized = True + session.query(Corpus).update({"text_normalized": True}) + if self.oov_count_threshold > 0: + session.query(Word).filter(Word.word_type == WordType.speech).filter( + Word.count <= self.oov_count_threshold + ).update({Word.word_type: WordType.oov}) + session.commit() + def add_speaker(self, name: str, session: Session = None): """ Add a speaker to the corpus @@ -499,8 +800,10 @@ def add_speaker(self, name: str, session: Session = None): if not speaker_obj: dictionary = None if hasattr(self, "get_dictionary_id"): - dictionary = session.query(Dictionary).get(self.get_dictionary_id(name)) - speaker_obj = Speaker(name=name, dictionary=dictionary) + dictionary = session.get(Dictionary, self.get_dictionary_id(name)) + speaker_obj = Speaker( + id=self.get_next_primary_key(Speaker), name=name, dictionary=dictionary + ) session.add(speaker_obj) session.flush() self._speaker_ids[name] = speaker_obj.id @@ -511,6 +814,17 @@ def add_speaker(self, name: str, session: Session = None): session.commit() session.close() + def _create_dummy_dictionary(self): + with self.session() as session: + if session.query(Dictionary).first() is None: + dialect = Dialect(id=1, name="unspecified") + d = Dictionary(id=1, name="unknown", path="unknown", dialect=dialect) + session.add(dialect) + session.add(d) + session.flush() + session.query(Speaker).update({Speaker.dictionary_id: 1}) + session.commit() + def add_file(self, file: FileData, session: Session = None): """ Add a file to the corpus @@ -580,6 +894,7 @@ def add_file(self, file: FileData, session: Session = None): if frame_shift is not None: num_frames = int(duration / frame_shift) utterance = Utterance( + id=self._current_utterance_index, begin=u.begin, end=u.end, duration=duration, @@ -588,14 +903,13 @@ def add_file(self, file: FileData, session: Session = None): normalized_text=u.normalized_text, normalized_character_text=u.normalized_character_text, text=u.text, - normalized_text_int=u.normalized_text_int, - normalized_character_text_int=u.normalized_character_text_int, num_frames=num_frames, in_subset=False, ignored=False, file_id=self._current_file_index, speaker_id=self._speaker_ids[u.speaker_name], ) + self._current_utterance_index += 1 session.add(utterance) if close: @@ -672,26 +986,28 @@ def generate_import_objects(self, file: FileData) -> DatabaseImportData: num_frames = None if frame_shift is not None: num_frames = int(duration / frame_shift) + ignored = False + if self.ignore_empty_utterances and not u.text: + ignored = True data.utterance_objects.append( { + "id": self._current_utterance_index, "begin": u.begin, "end": u.end, - "duration": duration, "channel": u.channel, "oovs": u.oovs, "normalized_text": u.normalized_text, "normalized_character_text": u.normalized_character_text, "text": u.text, - "normalized_text_int": u.normalized_text_int, - "normalized_character_text_int": u.normalized_character_text_int, "num_frames": num_frames, "in_subset": False, - "ignored": False, + "ignored": ignored, "file_id": self._current_file_index, + "job_id": 1, "speaker_id": self._speaker_ids[u.speaker_name], } ) - + self._current_utterance_index += 1 self._current_file_index += 1 return data @@ -709,8 +1025,10 @@ def create_subset(self, subset: int) -> None: subset: int Number of utterances to include in subset """ - self.log_info(f"Creating subset directory with {subset} utterances...") + logger.info(f"Creating subset directory with {subset} utterances...") subset_directory = os.path.join(self.corpus_output_directory, f"subset_{subset}") + log_dir = os.path.join(subset_directory, "log") + os.makedirs(log_dir, exist_ok=True) num_dictionaries = getattr(self, "num_dictionaries", 1) with self.session() as session: begin = time.time() @@ -739,7 +1057,7 @@ def create_subset(self, subset: int) -> None: subset_per_dictionary = subsets_per_dictionary[dict_id] else: subset_per_dictionary = remaining_subset_per_dictionary - self.log_debug(f"For {dict_id}, total number of utterances is {num_utts}") + logger.debug(f"For {dict_id}, total number of utterances is {num_utts}") larger_subset_num = int(subset_per_dictionary * 10) if num_utts > larger_subset_num: @@ -767,7 +1085,7 @@ def create_subset(self, subset: int) -> None: .where(Utterance.id.in_(subset_utts)) ) session.execute(query) - self.log_debug(f"For {dict_id}, subset is {subset_per_dictionary}") + logger.debug(f"For {dict_id}, subset is {subset_per_dictionary}") elif num_utts > subset_per_dictionary: larger_subset_query = ( @@ -791,7 +1109,7 @@ def create_subset(self, subset: int) -> None: ) session.execute(query) - self.log_debug(f"For {dict_id}, subset is {subset_per_dictionary}") + logger.debug(f"For {dict_id}, subset is {subset_per_dictionary}") else: larger_subset_query = ( session.query(Utterance.id) @@ -839,11 +1157,25 @@ def create_subset(self, subset: int) -> None: session.commit() - self.log_debug(f"Setting subset flags took {time.time()-begin} seconds") - log_dir = os.path.join(subset_directory, "log") - os.makedirs(log_dir, exist_ok=True) - for j in self.jobs: - j.output_to_directory(subset_directory, session, subset=True) + logger.debug(f"Setting subset flags took {time.time()-begin} seconds") + with self.session() as session, tqdm.tqdm( + total=subset, disable=GLOBAL_CONFIG.quiet + ) as pbar: + jobs = ( + session.query(Job) + .options( + joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries) + ) + .filter(Job.utterances.any(Utterance.in_subset == True)) # noqa + ) + self._jobs = jobs.all() + arguments = [ + ExportKaldiFilesArguments(j.id, self.db_string, None, subset_directory, False) + for j in self._jobs + ] + + for _ in run_kaldi_function(ExportKaldiFilesFunction, arguments, pbar.update): + pass @property def num_files(self) -> int: @@ -884,38 +1216,44 @@ def subset_directory(self, subset: typing.Optional[int]) -> str: str Path to subset directory """ + self._jobs = [] + with self.session() as session: + c = session.query(Corpus).first() + if subset is None or subset >= self.num_utterances or subset <= 0: + c.current_subset = 0 + else: + c.current_subset = subset + session.commit() if subset is None or subset >= self.num_utterances or subset <= 0: return self.split_directory directory = os.path.join(self.corpus_output_directory, f"subset_{subset}") if not os.path.exists(directory): self.create_subset(subset) - for j in self.jobs: - j.has_data = False - with self.session() as session: - for j in self.jobs: - q = ( - session.query(Utterance) - .join(Utterance.speaker) - .filter(Utterance.in_subset == True) # noqa - .filter(Speaker.job_id == j.name) - ) - if session.query(q.exists()).scalar(): - j.has_data = True return directory - def calculate_word_counts(self) -> None: + def get_latest_workflow_run(self, workflow: WorkflowType, session: Session) -> CorpusWorkflow: """ - Calculates word frequencies of normalized texts, falling back to use the un-normalized text if an utterance - does not have normalized text + Get the latest version of a workflow type + + Parameters + ---------- + workflow: :class:`~montreal_forced_aligner.data.WorkflowType` + Workflow type + session: :class:`sqlalchemy.orm.Session` + Database session + + Returns + ------- + :class:`~montreal_forced_aligner.db.CorpusWorkflow` or None + Latest run of workflow type """ - self.word_counts = Counter() - with self.session() as session: - utterances = session.query(Utterance.normalized_text, Utterance.text) - for normalized, text in utterances: - if normalized: - self.word_counts.update(normalized.split()) - elif text: - self.word_counts.update(text.split()) + workflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.workflow_type == workflow) + .order_by(CorpusWorkflow.time_stamp.desc()) + .first() + ) + return workflow def _load_corpus(self) -> None: """ @@ -923,16 +1261,16 @@ def _load_corpus(self) -> None: """ self.inspect_database() - self.log_info("Setting up corpus information...") + logger.info("Setting up corpus information...") if not self.imported: - self.log_debug("Could not load from temp") - self.log_info("Loading corpus from source files...") - if self.use_mp: + logger.debug("Could not load from temp") + logger.info("Loading corpus from source files...") + if GLOBAL_CONFIG.use_mp: self._load_corpus_from_source_mp() else: self._load_corpus_from_source() else: - self.log_debug("Successfully loaded from temporary files") + logger.debug("Successfully loaded from temporary files") if not self.num_files: raise CorpusError( "There were no files found for this corpus. Please validate the corpus." @@ -943,7 +1281,7 @@ def _load_corpus(self) -> None: "and/or run the validation utility (mfa validate)." ) average_utterances = self.num_utterances / self.num_speakers - self.log_info( + logger.info( f"Found {self.num_speakers} speaker{'s' if self.num_speakers > 1 else ''} across {self.num_files} file{'s' if self.num_files > 1 else ''}, " f"average number of utterances per speaker: {average_utterances}" ) diff --git a/montreal_forced_aligner/corpus/classes.py b/montreal_forced_aligner/corpus/classes.py index 8ceabade..4ab32b1d 100644 --- a/montreal_forced_aligner/corpus/classes.py +++ b/montreal_forced_aligner/corpus/classes.py @@ -11,7 +11,6 @@ from montreal_forced_aligner.corpus.helper import get_wav_info, load_text from montreal_forced_aligner.data import SoundFileInformation, TextFileType -from montreal_forced_aligner.dictionary.multispeaker import MultispeakerSanitizationFunction from montreal_forced_aligner.exceptions import TextGridParseError, TextParseError if TYPE_CHECKING: @@ -62,7 +61,6 @@ def parse_file( text_path: Optional[str], relative_path: str, speaker_characters: Union[int, str], - sanitize_function: Optional[MultispeakerSanitizationFunction] = None, enforce_sample_rate: Optional[int] = None, ): """ @@ -119,14 +117,12 @@ def parse_file( root_speaker = speaker_name file.load_text( root_speaker=root_speaker, - sanitize_function=sanitize_function, ) return file def load_text( self, root_speaker: Optional[str] = None, - sanitize_function: Optional[MultispeakerSanitizationFunction] = None, ) -> None: """ Load the transcription text from the text_file of the object @@ -135,8 +131,6 @@ def load_text( ---------- root_speaker: str, optional Speaker derived from the root directory, ignored for TextGrids - sanitize_function: :class:`~montreal_forced_aligner.dictionary.mixins.SanitizeFunction`, optional - Function to sanitize words and strip punctuation """ if self.text_type == TextFileType.LAB: try: @@ -155,7 +149,6 @@ def load_text( channel=0, end=end, ) - utterance.parse_transcription(sanitize_function) self.utterances.append(utterance) self.speaker_ordering.append(root_speaker) elif self.text_type == TextFileType.TEXTGRID: @@ -205,7 +198,6 @@ def load_text( text=text, channel=channel, ) - utt.parse_transcription(sanitize_function) if not utt.text: continue self.utterances.append(utt) @@ -244,8 +236,6 @@ class UtteranceData: Sound file channel text: str, optional Utterance text - normalized_text: list[str] - Normalized utterance text, with compounds and clitics split up oovs: set[str] Set of words not found in a look up """ @@ -258,56 +248,4 @@ class UtteranceData: text: str = "" normalized_text: str = "" normalized_character_text: str = "" - normalized_text_int: str = "" - normalized_character_text_int: str = "" oovs: str = "" - - def parse_transcription(self, sanitize_function=Optional[MultispeakerSanitizationFunction]): - """ - Parse an orthographic transcription given punctuation and clitic markers - - Parameters - ---------- - sanitize_function: :class:`~montreal_forced_aligner.dictionary.multispeaker.MultispeakerSanitizationFunction`, optional - Function to sanitize words and strip punctuation - - """ - oovs = set() - normalized_text = [] - normalized_character_text = [] - normalized_text_int = [] - normalized_character_text_int = [] - if not self.text: - return - if sanitize_function is not None: - try: - sanitize, split = sanitize_function.get_functions_for_speaker(self.speaker_name) - except AttributeError: - sanitize = sanitize_function - split = None - words = sanitize(self.text) - if split is not None: - text = "" - for w in words: - for new_w in split(w): - if new_w in split.specials_set or ( - split.word_mapping is not None and new_w not in split.word_mapping - ): - oovs.add(new_w) - normalized_text.append(new_w) - if split.word_mapping is not None: - normalized_text_int.append(str(split.to_int(new_w))) - if normalized_character_text: - normalized_character_text.append("") - normalized_character_text_int.append(str(split.grapheme_to_int(""))) - for c in split.parse_graphemes(w): - normalized_character_text.append(c) - normalized_character_text_int.append(str(split.grapheme_to_int(c))) - if text: - text += " " - text += w - self.oovs = " ".join(sorted(oovs)) - self.normalized_text = " ".join(normalized_text) - self.normalized_character_text = " ".join(normalized_character_text) - self.normalized_text_int = " ".join(normalized_text_int) - self.normalized_character_text_int = " ".join(normalized_character_text_int) diff --git a/montreal_forced_aligner/corpus/features.py b/montreal_forced_aligner/corpus/features.py index 9146dcdf..35c09fab 100644 --- a/montreal_forced_aligner/corpus/features.py +++ b/montreal_forced_aligner/corpus/features.py @@ -1,6 +1,9 @@ """Classes for configuring feature generation""" from __future__ import annotations +import io +import logging +import math import os import re import subprocess @@ -9,28 +12,47 @@ from typing import TYPE_CHECKING, Any, Dict, List, Union import dataclassy +import numba +import numpy as np +import sqlalchemy +from numba import njit +from scipy.sparse import csr_matrix +from sqlalchemy.orm import Session, joinedload from montreal_forced_aligner.abc import KaldiFunction -from montreal_forced_aligner.data import MfaArguments +from montreal_forced_aligner.config import IVECTOR_DIMENSION, PLDA_DIMENSION +from montreal_forced_aligner.data import M_LOG_2PI, MfaArguments +from montreal_forced_aligner.db import Job, Utterance from montreal_forced_aligner.exceptions import KaldiProcessingError from montreal_forced_aligner.helper import mfa_open -from montreal_forced_aligner.utils import thirdparty_binary +from montreal_forced_aligner.utils import read_feats, thirdparty_binary if TYPE_CHECKING: SpeakerCharacterType = Union[str, int] from montreal_forced_aligner.abc import MetaDict + __all__ = [ "FeatureConfigMixin", + "VadConfigMixin", + "IvectorConfigMixin", "CalcFmllrFunction", "ComputeVadFunction", "VadArguments", + "MfccFunction", "MfccArguments", "CalcFmllrArguments", "ExtractIvectorsFunction", "ExtractIvectorsArguments", + "PldaModel", + "plda_distance", + "plda_log_likelihood", + "score_plda", + "compute_transform_process", ] +logger = logging.getLogger("mfa") + # noinspection PyUnresolvedReferences @dataclassy.dataclass(slots=True) @@ -49,13 +71,46 @@ class MfccArguments(MfaArguments): Arguments for :class:`~montreal_forced_aligner.corpus.features.MfccFunction` """ - wav_path: str - segment_path: str - feats_scp_path: str + data_directory: str mfcc_options: MetaDict pitch_options: MetaDict +# noinspection PyUnresolvedReferences +@dataclassy.dataclass(slots=True) +class FinalFeatureArguments(MfaArguments): + """ + Arguments for :class:`~montreal_forced_aligner.corpus.features.FinalFeatureFunction` + """ + + data_directory: str + uses_cmvn: bool + voiced_only: bool + subsample_feats: int + + +# noinspection PyUnresolvedReferences +@dataclassy.dataclass(slots=True) +class PitchArguments(MfaArguments): + """ + Arguments for :class:`~montreal_forced_aligner.corpus.features.MfccFunction` + """ + + data_directory: str + pitch_options: MetaDict + + +# noinspection PyUnresolvedReferences +@dataclassy.dataclass(slots=True) +class PitchRangeArguments(MfaArguments): + """ + Arguments for :class:`~montreal_forced_aligner.corpus.features.MfccFunction` + """ + + data_directory: str + pitch_options: MetaDict + + # noinspection PyUnresolvedReferences @dataclassy.dataclass(slots=True) class CalcFmllrArguments(MfaArguments): @@ -76,15 +131,21 @@ class CalcFmllrArguments(MfaArguments): class ExtractIvectorsArguments(MfaArguments): """Arguments for :class:`~montreal_forced_aligner.corpus.features.ExtractIvectorsFunction`""" - feature_string: str ivector_options: MetaDict ie_path: str - ivectors_path: str - model_path: str + ivectors_scp_path: str dubm_path: str -def make_safe(value: Any) -> str: +# noinspection PyUnresolvedReferences +@dataclassy.dataclass(slots=True) +class ExportIvectorsArguments(MfaArguments): + """Arguments for :class:`~montreal_forced_aligner.corpus.features.ExportIvectorsFunction`""" + + use_xvector: bool + + +def feature_make_safe(value: Any) -> str: """ Transform an arbitrary value into a string @@ -103,6 +164,288 @@ def make_safe(value: Any) -> str: return str(value) +def compute_mfcc_process( + log_file: io.FileIO, + wav_path: str, + segments: typing.Union[str, subprocess.Popen, subprocess.PIPE], + mfcc_options: MetaDict, + min_length=0.1, +) -> subprocess.Popen: + """ + Construct processes for computing features + + Parameters + ---------- + log_file: io.FileIO + File for logging stderr + wav_path: str + Wav scp to use + segments: str + Segments scp to use + mfcc_options: dict[str, Any] + Options for computing MFCC features + min_length: float + Minimum length of segments in seconds + no_logging: bool + Flag for logging progress information to log_file rather than a subprocess pipe + + Returns + ------- + subprocess.Popen + MFCC process + """ + mfcc_base_command = [thirdparty_binary("compute-mfcc-feats")] + for k, v in mfcc_options.items(): + mfcc_base_command.append(f"--{k.replace('_', '-')}={feature_make_safe(v)}") + if isinstance(segments, str) and os.path.exists(segments): + mfcc_base_command += ["ark:-", "ark,t:-"] + seg_proc = subprocess.Popen( + [ + thirdparty_binary("extract-segments"), + f"--min-segment-length={min_length}", + f"scp:{wav_path}", + segments, + "ark:-", + ], + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + mfcc_proc = subprocess.Popen( + mfcc_base_command, + stdout=subprocess.PIPE, + stderr=log_file, + stdin=seg_proc.stdout, + env=os.environ, + ) + elif isinstance(segments, subprocess.Popen): + mfcc_base_command += ["ark,s,cs:-", "ark,t:-"] + mfcc_proc = subprocess.Popen( + mfcc_base_command, + stdout=subprocess.PIPE, + stderr=log_file, + stdin=segments.stdout, + env=os.environ, + ) + elif segments == subprocess.PIPE: + mfcc_base_command += ["ark,s,cs:-", "ark,t:-"] + mfcc_proc = subprocess.Popen( + mfcc_base_command, + stdout=subprocess.PIPE, + stderr=log_file, + stdin=segments, + env=os.environ, + ) + else: + mfcc_base_command += [f"scp,p:{wav_path}", "ark:-"] + mfcc_proc = subprocess.Popen( + mfcc_base_command, + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + + return mfcc_proc + + +def compute_pitch_process( + log_file: io.FileIO, + wav_path: str, + segments: typing.Union[str, subprocess.Popen, subprocess.PIPE], + pitch_options: MetaDict, + min_length=0.1, +) -> subprocess.Popen: + """ + Construct processes for computing features + + Parameters + ---------- + log_file: io.FileIO + File for logging stderr + wav_path: str + Wav scp to use + segments: str + Segments scp to use + mfcc_options: dict[str, Any] + Options for computing MFCC features + pitch_options: dict[str, Any] + Options for computing pitch features + min_length: float + Minimum length of segments in seconds + no_logging: bool + Flag for logging progress information to log_file rather than a subprocess pipe + + Returns + ------- + subprocess.Popen + Pitch process + """ + use_pitch = pitch_options.pop("use-pitch") + use_voicing = pitch_options.pop("use-voicing") + use_delta_pitch = pitch_options.pop("use-delta-pitch") + normalize = pitch_options.pop("normalize", True) + pitch_command = [ + thirdparty_binary("compute-and-process-kaldi-pitch-feats"), + ] + for k, v in pitch_options.items(): + pitch_command.append(f"--{k.replace('_', '-')}={feature_make_safe(v)}") + if k == "delta-pitch": + pitch_command.append(f"--delta-pitch-noise-stddev={feature_make_safe(v)}") + if use_pitch: + if normalize: + pitch_command.append("--add-normalized-log-pitch=true") + else: + pitch_command.append("--add-raw-log-pitch=true") + else: + pitch_command.append("--add-normalized-log-pitch=false") + pitch_command.append("--add-raw-log-pitch=false") + if use_delta_pitch: + pitch_command.append("--add-delta-pitch=true") + pitch_command.append("--add-pov-feature=true") + else: + pitch_command.append("--add-delta-pitch=false") + if use_voicing: + pitch_command.append("--add-pov-feature=true") + else: + pitch_command.append("--add-pov-feature=false") + + if isinstance(segments, str) and os.path.exists(segments): + pitch_command += ["ark:-", "ark,t:-"] + seg_proc = subprocess.Popen( + [ + thirdparty_binary("extract-segments"), + f"--min-segment-length={min_length}", + f"scp:{wav_path}", + segments, + "ark:-", + ], + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + pitch_proc = subprocess.Popen( + pitch_command, + stdout=subprocess.PIPE, + stderr=log_file, + stdin=seg_proc.stdout, + env=os.environ, + ) + elif isinstance(segments, subprocess.Popen): + pitch_command += ["ark:-", "ark,t:-"] + pitch_proc = subprocess.Popen( + pitch_command, + stdout=subprocess.PIPE, + stderr=log_file, + stdin=segments.stdout, + env=os.environ, + ) + elif segments == subprocess.PIPE: + pitch_command += ["ark:-", "ark,t:-"] + pitch_proc = subprocess.Popen( + pitch_command, + stdout=subprocess.PIPE, + stderr=log_file, + stdin=segments, + env=os.environ, + ) + else: + pitch_command += [f"scp,p:{wav_path}", "ark,t:-"] + pitch_proc = subprocess.Popen( + pitch_command, + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + return pitch_proc + + +def compute_transform_process( + log_file: io.FileIO, + feat_proc: typing.Union[subprocess.Popen, str], + utt2spk_path: str, + lda_mat_path: typing.Optional[str], + fmllr_path: typing.Optional[str], + lda_options: MetaDict, +) -> subprocess.Popen: + """ + Construct feature transformation process + + Parameters + ---------- + log_file: io.FileIO + File for logging stderr + feat_proc: subprocess.Popen + Feature generation process + utt2spk_path: str + Utterance to speaker SCP file path + cmvn_path: str + CMVN SCP file path + lda_mat_path: str + LDA matrix file path + fmllr_path: str + fMLLR transform file path + lda_options: dict[str, Any] + Options for LDA + + Returns + ------- + subprocess.Popen + Processing for transforming features + """ + if isinstance(feat_proc, str): + feat_input = f"ark,s,cs:{feat_proc}" + use_stdin = False + else: + feat_input = "ark,s,cs:-" + use_stdin = True + if lda_mat_path is not None: + splice_proc = subprocess.Popen( + [ + "splice-feats", + f'--left-context={lda_options["splice_left_context"]}', + f'--right-context={lda_options["splice_right_context"]}', + feat_input, + "ark:-", + ], + env=os.environ, + stdin=feat_proc.stdout if use_stdin else None, + stdout=subprocess.PIPE, + stderr=log_file, + ) + delta_proc = subprocess.Popen( + ["transform-feats", lda_mat_path, "ark,s,cs:-", "ark:-"], + env=os.environ, + stdin=splice_proc.stdout, + stdout=subprocess.PIPE, + stderr=log_file, + ) + else: + delta_proc = subprocess.Popen( + ["add-deltas", feat_input, "ark:-"], + env=os.environ, + stdin=feat_proc.stdout if use_stdin else None, + stdout=subprocess.PIPE, + stderr=log_file, + ) + if fmllr_path is None: + return delta_proc + + fmllr_proc = subprocess.Popen( + [ + "transform-feats", + f"--utt2spk=ark:{utt2spk_path}", + f"ark:{fmllr_path}", + "ark,s,cs:-", + "ark,t:-", + ], + env=os.environ, + stdin=delta_proc.stdout, + stdout=subprocess.PIPE, + stderr=log_file, + ) + return fmllr_proc + + class MfccFunction(KaldiFunction): """ Multiprocessing function for generating MFCC features @@ -132,123 +475,383 @@ class MfccFunction(KaldiFunction): def __init__(self, args: MfccArguments): super().__init__(args) - self.wav_path = args.wav_path - self.segment_path = args.segment_path - self.feats_scp_path = args.feats_scp_path - self.mfcc_options = args.mfcc_options + self.data_directory = args.data_directory self.pitch_options = args.pitch_options + self.mfcc_options = args.mfcc_options def _run(self) -> typing.Generator[int]: """Run the function""" - processed = 0 - with mfa_open(self.log_path, "w") as log_file: - use_pitch = self.pitch_options.pop("use-pitch") - mfcc_base_command = [thirdparty_binary("compute-mfcc-feats"), "--verbose=2"] - raw_ark_path = self.feats_scp_path.replace(".scp", ".ark") + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = session.get(Job, self.job_name) + feats_scp_path = job.construct_path(self.data_directory, "feats", "scp") + pitch_scp_path = job.construct_path(self.data_directory, "pitch", "scp") + segments_scp_path = job.construct_path(self.data_directory, "segments", "scp") + wav_path = job.construct_path(self.data_directory, "wav", "scp") + raw_ark_path = job.construct_path(self.data_directory, "feats", "ark") + raw_pitch_ark_path = job.construct_path(self.data_directory, "pitch", "ark") if os.path.exists(raw_ark_path): return - for k, v in self.mfcc_options.items(): - mfcc_base_command.append(f"--{k.replace('_', '-')}={make_safe(v)}") - if os.path.exists(self.segment_path): - mfcc_base_command += ["ark:-", "ark:-"] - seg_proc = subprocess.Popen( + min_length = 0.1 + seg_proc = subprocess.Popen( + [ + thirdparty_binary("extract-segments"), + f"--min-segment-length={min_length}", + f"scp:{wav_path}", + segments_scp_path, + "ark:-", + ], + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + mfcc_proc = compute_mfcc_process( + log_file, wav_path, subprocess.PIPE, self.mfcc_options + ) + mfcc_copy_proc = subprocess.Popen( + [ + thirdparty_binary("copy-feats"), + "--compress=true", + "ark:-", + f"ark,scp:{raw_ark_path},{feats_scp_path}", + ], + stdin=mfcc_proc.stdout, + stderr=log_file, + env=os.environ, + ) + use_pitch = self.pitch_options["use-pitch"] or self.pitch_options["use-voicing"] + if use_pitch: + pitch_proc = compute_pitch_process( + log_file, wav_path, subprocess.PIPE, self.pitch_options + ) + pitch_copy_proc = subprocess.Popen( [ - thirdparty_binary("extract-segments"), - f"scp:{self.wav_path}", - self.segment_path, + thirdparty_binary("copy-feats"), + "--compress=true", "ark:-", + f"ark,scp:{raw_pitch_ark_path},{pitch_scp_path}", ], - stdout=subprocess.PIPE, + stdin=pitch_proc.stdout, stderr=log_file, env=os.environ, ) - comp_proc = subprocess.Popen( - mfcc_base_command, + for line in seg_proc.stdout: + mfcc_proc.stdin.write(line) + mfcc_proc.stdin.flush() + if use_pitch: + pitch_proc.stdin.write(line) + pitch_proc.stdin.flush() + if re.search(rb"\d+-\d+ ", line): + yield 1 + mfcc_proc.stdin.close() + if use_pitch: + pitch_proc.stdin.close() + mfcc_proc.wait() + if use_pitch: + pitch_proc.wait() + self.check_call(mfcc_copy_proc) + if use_pitch: + self.check_call(pitch_copy_proc) + + +class FinalFeatureFunction(KaldiFunction): + """ + Multiprocessing function for generating MFCC features + + See Also + -------- + :meth:`.AcousticCorpusMixin.mfcc` + Main function that calls this function in parallel + :meth:`.AcousticCorpusMixin.mfcc_arguments` + Job method for generating arguments for this function + :kaldi_src:`compute-mfcc-feats` + Relevant Kaldi binary + :kaldi_src:`extract-segments` + Relevant Kaldi binary + :kaldi_src:`copy-feats` + Relevant Kaldi binary + :kaldi_src:`feat-to-len` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.corpus.features.MfccArguments` + Arguments for the function + """ + + progress_pattern = re.compile(r"^LOG.* Processed (?P\d+) utterances") + + def __init__(self, args: FinalFeatureArguments): + super().__init__(args) + self.data_directory = args.data_directory + self.voiced_only = args.voiced_only + self.uses_cmvn = args.uses_cmvn + self.subsample_feats = args.subsample_feats + + def _run(self) -> typing.Generator[int]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = session.get(Job, self.job_name) + feats_scp_path = job.construct_path(self.data_directory, "feats", "scp") + temp_scp_path = job.construct_path(self.data_directory, "final_features", "scp") + utt2spk_path = job.construct_path(self.data_directory, "utt2spk", "scp") + cmvn_scp_path = job.construct_path(self.data_directory, "cmvn", "scp") + pitch_scp_path = job.construct_path(self.data_directory, "pitch", "scp") + pitch_ark_path = job.construct_path(self.data_directory, "pitch", "ark") + vad_scp_path = job.construct_path(self.data_directory, "vad", "scp") + raw_ark_path = job.construct_path(self.data_directory, "feats", "ark") + temp_ark_path = job.construct_path(self.data_directory, "final_features", "ark") + if os.path.exists(cmvn_scp_path): + cmvn_proc = subprocess.Popen( + [ + thirdparty_binary("apply-cmvn"), + f"--utt2spk=ark:{utt2spk_path}", + f"scp:{cmvn_scp_path}", + f"scp:{feats_scp_path}", + "ark:-", + ], stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=seg_proc.stdout, + stderr=log_file, env=os.environ, ) else: - mfcc_base_command += [f"scp,p:{self.wav_path}", "ark:-"] - comp_proc = subprocess.Popen( - mfcc_base_command, + cmvn_proc = subprocess.Popen( + [ + thirdparty_binary("apply-cmvn-sliding"), + "--norm-vars=false", + "--center=true", + "--cmn-window=300", + f"scp:{feats_scp_path}", + "ark:-", + ], stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stderr=log_file, env=os.environ, ) - if use_pitch: - pitch_base_command = [ - thirdparty_binary("compute-and-process-kaldi-pitch-feats"), - "--verbose=2", - ] - for k, v in self.pitch_options.items(): - pitch_base_command.append(f"--{k.replace('_', '-')}={make_safe(v)}") - if k == "delta-pitch": - pitch_base_command.append(f"--delta-pitch-noise-stddev={make_safe(v)}") - pitch_command = " ".join(pitch_base_command) - if os.path.exists(self.segment_path): - segment_command = ( - f'extract-segments scp:"{self.wav_path}" "{self.segment_path}" ark:- | ' - ) - pitch_input = "ark:-" - else: - segment_command = "" - pitch_input = f'scp:"{self.wav_path}"' - pitch_feat_string = ( - f"ark,s,cs:{segment_command}{pitch_command} {pitch_input} ark:- |" - ) - length_tolerance = 2 + if os.path.exists(pitch_scp_path): paste_proc = subprocess.Popen( [ thirdparty_binary("paste-feats"), - f"--length-tolerance={length_tolerance}", + "--length-tolerance=2", "ark:-", - pitch_feat_string, + f"scp:{pitch_scp_path}", "ark:-", ], - stdin=comp_proc.stdout, - env=os.environ, + stdin=cmvn_proc.stdout, stdout=subprocess.PIPE, stderr=log_file, - ) - copy_proc = subprocess.Popen( - [ - thirdparty_binary("copy-feats"), - "--verbose=2", - "--compress=true", - "ark:-", - f"ark,scp:{raw_ark_path},{self.feats_scp_path}", - ], - stdin=paste_proc.stdout, - stderr=subprocess.PIPE, env=os.environ, - encoding="utf8", ) else: - copy_proc = subprocess.Popen( + paste_proc = cmvn_proc + if self.voiced_only and os.path.exists(vad_scp_path): + voiced_proc = subprocess.Popen( [ - thirdparty_binary("copy-feats"), - "--verbose=2", - "--compress=true", + thirdparty_binary("select-voiced-frames"), + "ark:-", + f"scp:{vad_scp_path}", "ark:-", - f"ark,scp:{raw_ark_path},{self.feats_scp_path}", ], - stdin=comp_proc.stdout, + stdin=paste_proc.stdout, + stdout=subprocess.PIPE, stderr=log_file, env=os.environ, - encoding="utf8", ) - for line in comp_proc.stderr: - line = line.strip().decode("utf8") - log_file.write(line + "\n") - m = self.progress_pattern.match(line) - if m: - cur = int(m.group("num_utterances")) - increment = cur - processed - processed = cur - yield increment + if self.subsample_feats: + final_proc = subprocess.Popen( + [ + thirdparty_binary("subsample-feats"), + f"--n={self.subsample_feats}", + "ark:-", + "ark:-", + ], + stdin=voiced_proc.stdout, + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + else: + final_proc = voiced_proc + else: + final_proc = paste_proc + copy_proc = subprocess.Popen( + [ + thirdparty_binary("copy-feats"), + "--compress=true", + "ark:-", + f"ark,scp:{temp_ark_path},{temp_scp_path}", + ], + stdin=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + + for line in final_proc.stdout: + copy_proc.stdin.write(line) + copy_proc.stdin.flush() + if re.search(rb"\d+-\d+ ", line): + yield 1 + copy_proc.stdin.close() self.check_call(copy_proc) + os.remove(raw_ark_path) + os.remove(feats_scp_path) + os.rename(temp_scp_path, feats_scp_path) + if os.path.exists(pitch_scp_path): + os.remove(pitch_scp_path) + os.remove(pitch_ark_path) + + +class PitchFunction(KaldiFunction): + """ + Multiprocessing function for generating MFCC features + + See Also + -------- + :meth:`.AcousticCorpusMixin.mfcc` + Main function that calls this function in parallel + :meth:`.AcousticCorpusMixin.mfcc_arguments` + Job method for generating arguments for this function + :kaldi_src:`compute-mfcc-feats` + Relevant Kaldi binary + :kaldi_src:`extract-segments` + Relevant Kaldi binary + :kaldi_src:`copy-feats` + Relevant Kaldi binary + :kaldi_src:`feat-to-len` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.corpus.features.MfccArguments` + Arguments for the function + """ + + progress_pattern = re.compile(r"^LOG.* Processed (?P\d+) utterances") + + def __init__(self, args: PitchArguments): + super().__init__(args) + self.data_directory = args.data_directory + self.pitch_options = args.pitch_options + + def _run(self) -> typing.Generator[int]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = session.get(Job, self.job_name) + + feats_scp_path = job.construct_path(self.data_directory, "pitch", "scp") + raw_ark_path = job.construct_path(self.data_directory, "pitch", "ark") + wav_path = job.construct_path(self.data_directory, "wav", "scp") + segments_path = job.construct_path(self.data_directory, "segments", "scp") + if os.path.exists(raw_ark_path): + return + copy_proc = subprocess.Popen( + [ + thirdparty_binary("copy-feats"), + "--compress=true", + "ark,t:-", + f"ark,scp:{raw_ark_path},{feats_scp_path}", + ], + stdin=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + + pitch_proc = compute_pitch_process( + log_file, wav_path, segments_path, self.pitch_options + ) + for line in pitch_proc.stdout: + copy_proc.stdin.write(line) + copy_proc.stdin.flush() + if re.match(rb"^\d+-", line): + yield 1 + pitch_proc.wait() + copy_proc.stdin.close() + self.check_call(copy_proc) + + +class PitchRangeFunction(KaldiFunction): + """ + Multiprocessing function for generating MFCC features + + See Also + -------- + :meth:`.AcousticCorpusMixin.mfcc` + Main function that calls this function in parallel + :meth:`.AcousticCorpusMixin.mfcc_arguments` + Job method for generating arguments for this function + :kaldi_src:`compute-mfcc-feats` + Relevant Kaldi binary + :kaldi_src:`extract-segments` + Relevant Kaldi binary + :kaldi_src:`copy-feats` + Relevant Kaldi binary + :kaldi_src:`feat-to-len` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.corpus.features.MfccArguments` + Arguments for the function + """ + + progress_pattern = re.compile(r"^LOG.* Processed (?P\d+) utterances") + + def __init__(self, args: PitchRangeArguments): + super().__init__(args) + self.data_directory = args.data_directory + self.pitch_options = args.pitch_options + + def _run(self) -> typing.Generator[int]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = session.get(Job, self.job_name) + wav_path = job.construct_path(self.data_directory, "wav", "scp") + segment_path = job.construct_path(self.data_directory, "segments", "scp") + min_length = 0.1 + seg_proc = subprocess.Popen( + [ + thirdparty_binary("extract-segments"), + f"--min-segment-length={min_length}", + f"scp:{wav_path}", + segment_path, + "ark:-", + ], + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + pitch_command = [ + thirdparty_binary("compute-kaldi-pitch-feats"), + ] + for k, v in self.pitch_options.items(): + if k in {"use-pitch", "use-voicing", "normalize"}: + continue + pitch_command.append(f"--{k.replace('_', '-')}={feature_make_safe(v)}") + pitch_command += ["ark:-", "ark,t:-"] + pitch_proc = subprocess.Popen( + pitch_command, + stdout=subprocess.PIPE, + stdin=seg_proc.stdout, + stderr=log_file, + env=os.environ, + ) + current_speaker = None + pitch_points = [] + for ids, pitch_features in read_feats(pitch_proc, raw_id=True): + speaker_id, utt_id = ids.split("-") + speaker_id = int(speaker_id) + if current_speaker is None: + current_speaker = speaker_id + if current_speaker != speaker_id: + pitch_points = np.array(pitch_points) + mean_f0 = np.mean(pitch_points) + min_f0 = mean_f0 / 2 + max_f0 = mean_f0 * 2 + yield current_speaker, max(min_f0, 50), min(max_f0, 1500) + pitch_points = [] + current_speaker = speaker_id + indices = np.where(pitch_features[:, 0] > 0.5) + pitch_points.extend(pitch_features[indices[0], 1]) + self.check_call(pitch_proc) class ComputeVadFunction(KaldiFunction): @@ -285,13 +888,14 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int, int]]: with mfa_open(self.log_path, "w") as log_file: feats_scp_path = self.feats_scp_path vad_scp_path = self.vad_scp_path + vad_ark_path = self.vad_scp_path.replace(".scp", ".ark") vad_proc = subprocess.Popen( [ thirdparty_binary("compute-vad"), f"--vad-energy-mean-scale={self.vad_options['energy_mean_scale']}", f"--vad-energy-threshold={self.vad_options['energy_threshold']}", f"scp:{feats_scp_path}", - f"ark,t:{vad_scp_path}", + f"ark,scp:{vad_ark_path},{vad_scp_path}", ], stderr=subprocess.PIPE, encoding="utf8", @@ -427,7 +1031,7 @@ def _run(self) -> typing.Generator[str]: thirdparty_binary("gmm-est-fmllr"), "--verbose=4", f"--fmllr-update-type={self.fmllr_options['fmllr_update_type']}", - f"--spk2utt=ark:{spk2utt_path}", + f"--spk2utt=ark,s,cs:{spk2utt_path}", self.model_path, feature_string, "ark,s,cs:-", @@ -445,7 +1049,7 @@ def _run(self) -> typing.Generator[str]: thirdparty_binary("gmm-est-fmllr"), "--verbose=4", f"--fmllr-update-type={self.fmllr_options['fmllr_update_type']}", - f"--spk2utt=ark:{spk2utt_path}", + f"--spk2utt=ark,s,cs:{spk2utt_path}", self.model_path, feature_string, "ark,s,cs:-", @@ -519,8 +1123,6 @@ class FeatureConfigMixin: Flag for whether to allow downsampling, default is True allow_upsample : bool Flag for whether to allow upsampling, default is True - speaker_independent : bool - Flag for whether features are speaker independent, default is True uses_cmvn : bool Flag for whether to use CMVN, default is True uses_deltas : bool @@ -551,51 +1153,82 @@ def __init__( sample_frequency: int = 16000, allow_downsample: bool = True, allow_upsample: bool = True, - speaker_independent: bool = True, + dither: int = 1, + energy_floor: float = 0, + num_coefficients: int = 13, + num_mel_bins: int = 23, + cepstral_lifter: float = 22, + preemphasis_coefficient: float = 0.97, uses_cmvn: bool = True, uses_deltas: bool = True, uses_splices: bool = False, uses_voiced: bool = False, + adaptive_pitch_range: bool = False, uses_speaker_adaptation: bool = False, fmllr_update_type: str = "full", silence_weight: float = 0.0, splice_left_context: int = 3, splice_right_context: int = 3, use_pitch: bool = False, + use_voicing: bool = False, + use_delta_pitch: bool = False, min_f0: float = 50, - max_f0: float = 500, + max_f0: float = 800, delta_pitch: float = 0.005, penalty_factor: float = 0.1, **kwargs, ): super().__init__(**kwargs) self.feature_type = feature_type - self.use_energy = use_energy + + self.uses_cmvn = uses_cmvn + self.uses_deltas = uses_deltas + self.uses_splices = uses_splices + self.uses_voiced = uses_voiced + self.uses_speaker_adaptation = uses_speaker_adaptation + self.frame_shift = frame_shift + self.export_frame_shift = round(frame_shift / 1000, 4) self.frame_length = frame_length self.snip_edges = snip_edges + + # MFCC options + + self.use_energy = use_energy self.low_frequency = low_frequency self.high_frequency = high_frequency self.sample_frequency = sample_frequency self.allow_downsample = allow_downsample self.allow_upsample = allow_upsample - self.speaker_independent = speaker_independent - self.uses_cmvn = uses_cmvn - self.uses_deltas = uses_deltas - self.uses_splices = uses_splices - self.uses_voiced = uses_voiced - self.uses_speaker_adaptation = uses_speaker_adaptation + self.dither = dither + self.energy_floor = energy_floor + self.num_coefficients = num_coefficients + self.num_mel_bins = num_mel_bins + self.cepstral_lifter = cepstral_lifter + self.preemphasis_coefficient = preemphasis_coefficient + + # fMLLR options self.fmllr_update_type = fmllr_update_type self.silence_weight = silence_weight + + # Splicing options + self.splice_left_context = splice_left_context self.splice_right_context = splice_right_context # Pitch features + self.adaptive_pitch_range = adaptive_pitch_range self.use_pitch = use_pitch + self.use_voicing = use_voicing + self.use_delta_pitch = use_delta_pitch self.min_f0 = min_f0 self.max_f0 = max_f0 self.delta_pitch = delta_pitch self.penalty_factor = penalty_factor + self.normalize_pitch = True + if self.adaptive_pitch_range: + self.min_f0 = 50 + self.max_f0 = 1200 @property def vad_options(self) -> MetaDict: @@ -644,24 +1277,27 @@ def feature_options(self) -> MetaDict: "sample_frequency": self.sample_frequency, "allow_downsample": self.allow_downsample, "allow_upsample": self.allow_upsample, + "dither": self.dither, + "energy_floor": self.energy_floor, + "num_coefficients": self.num_coefficients, + "num_mel_bins": self.num_mel_bins, + "cepstral_lifter": self.cepstral_lifter, + "preemphasis_coefficient": self.preemphasis_coefficient, "uses_cmvn": self.uses_cmvn, "uses_deltas": self.uses_deltas, "uses_voiced": self.uses_voiced, "uses_splices": self.uses_splices, "uses_speaker_adaptation": self.uses_speaker_adaptation, "use_pitch": self.use_pitch, + "use_voicing": self.use_voicing, "min_f0": self.min_f0, "max_f0": self.max_f0, "delta_pitch": self.delta_pitch, "penalty_factor": self.penalty_factor, + "silence_weight": self.silence_weight, + "splice_left_context": self.splice_left_context, + "splice_right_context": self.splice_right_context, } - if self.uses_splices: - options.update( - { - "splice_left_context": self.splice_left_context, - "splice_right_context": self.splice_right_context, - } - ) return options @abstractmethod @@ -680,11 +1316,25 @@ def fmllr_options(self) -> MetaDict: ), # If we have silence phones from a dictionary, use them } + @property + def lda_options(self) -> MetaDict: + """Options for computing LDA""" + return { + "splice_left_context": self.splice_left_context, + "splice_right_context": self.splice_right_context, + } + @property def mfcc_options(self) -> MetaDict: """Parameters to use in computing MFCC features.""" return { "use-energy": self.use_energy, + "dither": self.dither, + "energy-floor": self.energy_floor, + "num-ceps": self.num_coefficients, + "num-mel-bins": self.num_mel_bins, + "cepstral-lifter": self.cepstral_lifter, + "preemphasis-coefficient": self.preemphasis_coefficient, "frame-shift": self.frame_shift, "frame-length": self.frame_length, "low-freq": self.low_frequency, @@ -700,6 +1350,8 @@ def pitch_options(self) -> MetaDict: """Parameters to use in computing MFCC features.""" return { "use-pitch": self.use_pitch, + "use-voicing": self.use_voicing, + "use-delta-pitch": self.use_delta_pitch, "frame-shift": self.frame_shift, "frame-length": self.frame_length, "min-f0": self.min_f0, @@ -708,10 +1360,44 @@ def pitch_options(self) -> MetaDict: "penalty-factor": self.penalty_factor, "delta-pitch": self.delta_pitch, "snip-edges": self.snip_edges, + "normalize": self.normalize_pitch, } -class IvectorConfigMixin(FeatureConfigMixin): +class VadConfigMixin(FeatureConfigMixin): + """ + Abstract mixin class for performing voice activity detection + + Parameters + ---------- + use_energy: bool + Flag for using the first coefficient of MFCCs + energy_threshold: float + Energy threshold above which a frame will be counted as voiced + energy_mean_scale: float + Proportion of the mean energy of the file that should be added to the energy_threshold + + See Also + -------- + :class:`~montreal_forced_aligner.corpus.features.FeatureConfigMixin` + For feature generation parameters + """ + + def __init__(self, energy_threshold=5.5, energy_mean_scale=0.5, **kwargs): + super().__init__(**kwargs) + self.energy_threshold = energy_threshold + self.energy_mean_scale = energy_mean_scale + + @property + def vad_options(self) -> MetaDict: + """Options for performing VAD""" + return { + "energy_threshold": self.energy_threshold, + "energy_mean_scale": self.energy_mean_scale, + } + + +class IvectorConfigMixin(VadConfigMixin): """ Mixin class for ivector features @@ -739,19 +1425,19 @@ class IvectorConfigMixin(FeatureConfigMixin): def __init__( self, - ivector_dimension=128, - num_gselect=20, - posterior_scale=1.0, - min_post=0.025, - max_count=100, + num_gselect: int = 20, + posterior_scale: float = 1.0, + min_post: float = 0.025, + max_count: int = 100, **kwargs, ): super().__init__(**kwargs) - self.ivector_dimension = ivector_dimension + self.ivector_dimension = IVECTOR_DIMENSION self.num_gselect = num_gselect self.posterior_scale = posterior_scale self.min_post = min_post self.max_count = max_count + self.normalize_pitch = False @abstractmethod def extract_ivectors(self) -> None: @@ -774,40 +1460,6 @@ def ivector_options(self) -> MetaDict: } -class VadConfigMixin(FeatureConfigMixin): - """ - Abstract mixin class for performing voice activity detection - - Parameters - ---------- - use_energy: bool - Flag for using the first coefficient of MFCCs - energy_threshold: float - Energy threshold above which a frame will be counted as voiced - energy_mean_scale: float - Proportion of the mean energy of the file that should be added to the energy_threshold - - See Also - -------- - :class:`~montreal_forced_aligner.corpus.features.FeatureConfigMixin` - For feature generation parameters - """ - - def __init__(self, use_energy=True, energy_threshold=5.5, energy_mean_scale=0.5, **kwargs): - super().__init__(**kwargs) - self.use_energy = use_energy - self.energy_threshold = energy_threshold - self.energy_mean_scale = energy_mean_scale - - @property - def vad_options(self) -> MetaDict: - """Options for performing VAD""" - return { - "energy_threshold": self.energy_threshold, - "energy_mean_scale": self.energy_mean_scale, - } - - class ExtractIvectorsFunction(KaldiFunction): """ Multiprocessing function for extracting ivectors. @@ -835,20 +1487,27 @@ class ExtractIvectorsFunction(KaldiFunction): Arguments for the function """ - progress_pattern = re.compile(r"^LOG.*Ivector norm for speaker (?P.+) was.*") + progress_pattern = re.compile(r"^VLOG.*Ivector norm for utterance (?P.+) was.*") def __init__(self, args: ExtractIvectorsArguments): super().__init__(args) - self.feature_string = args.feature_string self.ivector_options = args.ivector_options self.ie_path = args.ie_path - self.ivectors_path = args.ivectors_path - self.model_path = args.model_path + self.ivectors_scp_path = args.ivectors_scp_path self.dubm_path = args.dubm_path def _run(self) -> typing.Generator[str]: """Run the function""" - with mfa_open(self.log_path, "w") as log_file: + if os.path.exists(self.ivectors_scp_path): + return + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True)) + .filter(Job.id == self.job_name) + .first() + ) + feature_string = job.construct_online_feature_proc_string() gmm_global_get_post_proc = subprocess.Popen( [ @@ -856,23 +1515,25 @@ def _run(self) -> typing.Generator[str]: f"--n={self.ivector_options['num_gselect']}", f"--min-post={self.ivector_options['min_post']}", self.dubm_path, - self.feature_string, + feature_string, "ark:-", ], stdout=subprocess.PIPE, stderr=log_file, env=os.environ, ) + ivector_ark_path = self.ivectors_scp_path.replace(".scp", ".ark") extract_proc = subprocess.Popen( [ thirdparty_binary("ivector-extract"), + "--verbose=2", f"--acoustic-weight={self.ivector_options['posterior_scale']}", "--compute-objf-change=true", f"--max-count={self.ivector_options['max_count']}", self.ie_path, - self.feature_string, + feature_string, "ark,s,cs:-", - f"ark,t:{self.ivectors_path}", + f"ark,scp:{ivector_ark_path},{self.ivectors_scp_path}", ], stderr=subprocess.PIPE, encoding="utf8", @@ -881,6 +1542,670 @@ def _run(self) -> typing.Generator[str]: ) for line in extract_proc.stderr: log_file.write(line) + log_file.flush() m = self.progress_pattern.match(line.strip()) if m: - yield m.group("speaker") + yield m.group("utterance") + + +@njit +def plda_distance(train_ivector: np.ndarray, test_ivector: np.ndarray, psi: np.ndarray): + """ + Distance formulation of PLDA log likelihoods. Positive log likelihood ratios are transformed + into 1 / log likelihood ratio and negative log likelihood ratios are made positive. + + Parameters + ---------- + train_ivector: numpy.ndarray + Utterance ivector to use as reference + test_ivector: numpy.ndarray + Utterance ivector to compare + psi: numpy.ndarray + Input psi from :class:`~montreal_forced_aligner.corpus.features.PldaModel` + + Returns + ------- + float + PLDA distance + """ + max_log_likelihood = 40.0 + loglike = plda_log_likelihood(train_ivector, test_ivector, psi) + if loglike >= max_log_likelihood: + return 0.0 + return max_log_likelihood - loglike + + +@njit(cache=True) +def plda_variance_given(psi: np.ndarray, train_count: int = None): + if train_count is not None: + variance_given = 1.0 + psi / (train_count * psi + 1.0) + else: + variance_given = 1.0 + psi / (psi + 1.0) + logdet_given = np.sum(np.log(variance_given)) + variance_given = 1.0 / variance_given + return logdet_given, variance_given + + +@njit(cache=True) +def plda_variance_without(psi: np.ndarray): + variance_without = 1.0 + psi + logdet_without = np.sum(np.log(variance_without)) + variance_without = 1.0 / variance_without + return logdet_without, variance_without + + +@njit +def plda_log_likelihood( + train_ivector: np.ndarray, test_ivector: np.ndarray, psi: np.ndarray, train_count: int = None +): + """ + Calculate log likelihood of two ivectors belonging to the same class + + Parameters + ---------- + train_ivector: numpy.ndarray + Speaker or utterance ivector to use as reference + test_ivector: numpy.ndarray + Utterance ivector to compare + psi: numpy.ndarray + Input psi from :class:`~montreal_forced_aligner.corpus.features.PldaModel` + train_count: int, optional + Count of training ivector, if it represents a speaker + + Returns + ------- + float + Log likelihood ratio of same class hypothesis compared to difference class hypothesis + """ + train_ivector = train_ivector.astype("float64") + test_ivector = test_ivector.astype("float64") + psi = psi.astype("float64") + if train_count is not None: + mean = (train_count * psi) / (train_count * psi + 1.0) + mean *= train_ivector # N X D , X[0]- Train ivectors + else: + mean = (psi) / (psi + 1.0) + mean *= train_ivector # N X D , X[0]- Train ivectors + logdet_given, variance_given = plda_variance_given(psi, train_count) + # without class computation + logdet_without, variance_without = plda_variance_without(psi) + sqdiff_given = test_ivector - mean + sqdiff_given = sqdiff_given**2 + loglikes = -0.5 * ( + logdet_given + M_LOG_2PI * PLDA_DIMENSION + np.dot(sqdiff_given, variance_given) + ) + sqdiff_without = test_ivector**2 + loglike_without_class = -0.5 * ( + logdet_without + M_LOG_2PI * PLDA_DIMENSION + np.dot(sqdiff_without, variance_without) + ) + return loglikes - loglike_without_class + + +@njit(parallel=True) +def plda_distance_matrix( + train_ivectors: np.ndarray, + test_ivectors: np.ndarray, + psi: np.ndarray, +) -> np.ndarray: + """ + Adapted from https://github.com/prachiisc/PLDA_scoring/blob/master/PLDA_scoring.py#L177 + Computes plda affinity matrix using Loglikelihood function + + Parameters + ---------- + train_ivectors : numpy.ndarray + Ivectors to compare test ivectors against against 1 X N X D + test_ivectors : numpy.ndarray + Ivectors to compare against training examples 1 X M X D + normalize: bool + Flag for normalizing matrix by the maximum value + distance: bool + Flag for converting PLDA log likelihood ratios into a distance metric + + Returns + ------- + np.ndarray + Affinity matrix, shape is number of train ivectors by the number of test ivectors (M X N) + """ + num_train = train_ivectors.shape[0] + num_test = test_ivectors.shape[0] + distance_matrix = np.zeros((num_test, num_train)) + for i in numba.prange(num_train): + for j in numba.prange(num_test): + distance_matrix[i, j] = plda_log_likelihood(train_ivectors[i], test_ivectors[j], psi) + return distance_matrix + + +def pairwise_plda_distance_matrix( + ivectors: np.ndarray, + psi: np.ndarray, +) -> csr_matrix: + """ + Adapted from https://github.com/prachiisc/PLDA_scoring/blob/master/PLDA_scoring.py#L177 + Computes plda affinity matrix using Loglikelihood function + + Parameters + ---------- + train_ivectors : numpy.ndarray + Ivectors to compare test ivectors against against 1 X N X D + test_ivectors : numpy.ndarray + Ivectors to compare against training examples 1 X M X D + normalize: bool + Flag for normalizing matrix by the maximum value + distance: bool + Flag for converting PLDA log likelihood ratios into a distance metric + + Returns + ------- + np.ndarray + Affinity matrix, shape is number of train ivectors by the number of test ivectors (M X N) + """ + full = plda_distance_matrix(ivectors, ivectors, psi) + return csr_matrix(full[np.where(full > 5)]) + + +@njit(parallel=True) +def score_plda( + train_ivectors: np.ndarray, + test_ivectors: np.ndarray, + psi: np.ndarray, + normalize=False, + distance=False, +) -> np.ndarray: + """ + Adapted from https://github.com/prachiisc/PLDA_scoring/blob/master/PLDA_scoring.py#L177 + Computes plda affinity matrix using Loglikelihood function + + Parameters + ---------- + train_ivectors : numpy.ndarray + Ivectors to compare test ivectors against against 1 X N X D + test_ivectors : numpy.ndarray + Ivectors to compare against training examples 1 X M X D + normalize: bool + Flag for normalizing matrix by the maximum value + distance: bool + Flag for converting PLDA log likelihood ratios into a distance metric + + Returns + ------- + np.ndarray + Affinity matrix, shape is number of train ivectors by the number of test ivectors (M X N) + """ + mean = (psi) / (psi + 1.0) + mean = mean.reshape(1, -1) * train_ivectors + + # given class computation + variance_given = 1.0 + psi / (psi + 1.0) + logdet_given = np.sum(np.log(variance_given)) + variance_given = 1.0 / variance_given + + # without class computation + variance_without = 1.0 + psi + logdet_without = np.sum(np.log(variance_without)) + variance_without = 1.0 / variance_without + + sqdiff = test_ivectors # ---- Test x-vectors + num_train = train_ivectors.shape[0] + num_test = test_ivectors.shape[0] + dim = test_ivectors.shape[1] + loglikes = np.zeros((num_test, num_train)) + sqdiff_without = sqdiff**2 + loglike_without_class = -0.5 * ( + logdet_without + M_LOG_2PI * dim + (sqdiff_without @ variance_without) + ) + for i in numba.prange(num_train): + sqdiff_given = sqdiff - mean[i] + sqdiff_given = sqdiff_given**2 + loglikes[:, i] = ( + -0.5 * (logdet_given + M_LOG_2PI * dim + (sqdiff_given @ variance_given)) + ) - loglike_without_class + + if distance: + threshold = np.max(loglikes) + loglikes -= threshold + loglikes *= -1 + if normalize: + # loglike_ratio -= np.min(loglike_ratio) + loglikes /= threshold + return loglikes + + +@njit +def compute_classification_stats( + speaker_ivectors: np.ndarray, psi: np.ndarray, counts: np.ndarray +): + mean = (counts.reshape(-1, 1) * psi.reshape(1, -1)) / ( + counts.reshape(-1, 1) * psi.reshape(1, -1) + 1.0 + ) + mean = mean * speaker_ivectors # N X D , X[0]- Train ivectors + # given class computation + variance_given = 1.0 + psi / (counts.reshape(-1, 1) * psi.reshape(1, -1) + 1.0) + logdet_given = np.sum(np.log(variance_given), axis=1) + variance_given = 1.0 / variance_given + + # without class computation + variance_without = 1.0 + psi + logdet_without = np.sum(np.log(variance_without)) + variance_without = 1.0 / variance_without + return mean, variance_given, logdet_given, variance_without, logdet_without + + +@njit(parallel=True) +def classify_plda( + utterance_ivector: np.ndarray, + mean, + variance_given, + logdet_given, + variance_without, + logdet_without, +) -> typing.Tuple[int, float]: + """ + Adapted from https://github.com/prachiisc/PLDA_scoring/blob/master/PLDA_scoring.py#L177 + Computes plda affinity matrix using Loglikelihood function + + Parameters + ---------- + utterance_ivector : numpy.ndarray + Utterance ivector to compare against + + Returns + ------- + int + Best speaker index + float + Best speaker PLDA score + """ + + num_speakers = mean.shape[0] + + sqdiff_without = utterance_ivector**2 + loglike_without_class = -0.5 * ( + logdet_without + M_LOG_2PI * PLDA_DIMENSION + (sqdiff_without @ variance_without) + ) + loglikes = np.zeros((num_speakers,)) + for i in numba.prange(num_speakers): + sqdiff_given = utterance_ivector - mean[i] + sqdiff_given = sqdiff_given**2 + logdet = logdet_given[i] + variance = variance_given[i] + + loglikes[i] = ( + -0.5 * (logdet + M_LOG_2PI * PLDA_DIMENSION + (sqdiff_given @ variance)) + ) - loglike_without_class + + ind = loglikes.argmax() + return ind, loglikes[ind] + + +@njit(parallel=True) +def score_plda_train_counts( + train_ivectors: np.ndarray, test_ivectors: np.ndarray, psi: np.ndarray, counts: np.ndarray +) -> np.ndarray: + """ + Adapted from https://github.com/prachiisc/PLDA_scoring/blob/master/PLDA_scoring.py#L177 + Computes plda affinity matrix using Loglikelihood function + + Parameters + ---------- + train_ivectors : numpy.ndarray + Ivectors to compare test ivectors against against 1 X N X D + test_ivectors : numpy.ndarray + Ivectors to compare against training examples 1 X M X D + normalize: bool + Flag for normalizing matrix by the maximum value + distance: bool + Flag for converting PLDA log likelihood ratios into a distance metric + + Returns + ------- + np.ndarray + Affinity matrix, shape is number of train ivectors by the number of test ivectors (M X N) + """ + num_train = train_ivectors.shape[0] + num_test = test_ivectors.shape[0] + loglikes = np.zeros((num_test, num_train)) + for i in numba.prange(num_train): + for j in numba.prange(num_test): + loglikes[j, i] = plda_log_likelihood( + train_ivectors[i], test_ivectors[j], psi, counts[i] + ) + return loglikes + + +@dataclassy.dataclass(slots=True) +class PldaModel: + """PLDA model for transforming and scoring ivectors based on log likelihood ratios""" + + mean: np.ndarray + diagonalizing_transform: np.ndarray + psi: np.ndarray + offset: typing.Optional[np.ndarray] = None + pca_transform: typing.Optional[np.ndarray] = None + transformed_mean: typing.Optional[np.ndarray] = None + transformed_diagonalizing_transform: typing.Optional[np.ndarray] = None + + @classmethod + def load(cls, plda_path): + """ + Instantiate a PLDA model from a trained model file + + Parameters + ---------- + plda_path: str + Path to trained PLDA model + + Returns + ------- + :class:`~montreal_forced_aligner.corpus.features.PldaModel` + Instantiated object + """ + mean = None + diagonalizing_transform = None + diagonalizing_transform_lines = [] + psi = None + copy_proc = subprocess.Popen( + [thirdparty_binary("ivector-copy-plda"), "--binary=false", plda_path, "-"], + stderr=subprocess.DEVNULL, + stdout=subprocess.PIPE, + env=os.environ, + encoding="utf8", + ) + for line in copy_proc.stdout: + if mean is None: + line = line.replace("", "").strip()[2:-2] + mean = np.fromstring(line, sep=" ") + elif diagonalizing_transform is None: + if "[" in line: + continue + end_mat = "]" in line + line = line.replace("[", "").replace("]", "").strip() + row = np.fromstring(line, sep=" ") + diagonalizing_transform_lines.append(row) + if end_mat: + diagonalizing_transform = np.array(diagonalizing_transform_lines) + elif psi is None: + line = line.strip()[2:-2] + psi = np.fromstring(line, sep=" ") + copy_proc.wait() + offset = -diagonalizing_transform @ mean.reshape(-1, 1) + return PldaModel(mean, diagonalizing_transform, psi, offset) + + def distance(self, train_ivector: np.ndarray, test_ivector: np.ndarray): + """ + Distance formulation of PLDA log likelihoods. Positive log likelihood ratios are transformed + into 1 / log likelihood ratio and negative log likelihood ratios are made positive. + + Parameters + ---------- + train_ivector: numpy.ndarray + Utterance ivector to use as reference + test_ivector: numpy.ndarray + Utterance ivector to compare + + Returns + ------- + float + PLDA distance + """ + return plda_distance(train_ivector, test_ivector, self.psi) + + def log_likelihood(self, train_ivector: np.ndarray, test_ivector: np.ndarray, count: int = 1): + """ + Calculate log likelihood of two ivectors belonging to the same class + + Parameters + ---------- + train_ivector: numpy.ndarray + Speaker or utterance ivector to use as reference + test_ivector: numpy.ndarray + Utterance ivector to compare + count: int, optional + Count of training ivector, if it represents a speaker + + Returns + ------- + float + Log likelihood ratio of same class hypothesis compared to difference class hypothesis + """ + return plda_log_likelihood(train_ivector, test_ivector, self.psi, count) + + def process_ivectors(self, ivectors: np.ndarray, counts: np.ndarray = None) -> np.ndarray: + """ + Transform ivectors to PLDA space + + Parameters + ---------- + ivectors: numpy.ndarray + Ivectors to process + counts: numpy.ndarray, optional + Number of utterances if ivectors are per-speaker + + Returns + ------- + numpy.ndarray + Transformed ivectors + """ + # ivectors = self.preprocess_ivectors(ivectors) + # ivectors = self.compute_pca_transform(ivectors) + ivectors = self.transform_ivectors(ivectors, counts=counts) + return ivectors + + def preprocess_ivectors(self, ivectors: np.ndarray) -> np.ndarray: + """ + Adapted from https://github.com/prachiisc/PLDA_scoring/blob/master/PLDA_scoring.py#L25 + + Parameters + ---------- + ivectors: numpy.ndarray + Input ivectors + + Returns + ------- + numpy.ndarray + Preprocessed ivectors + """ + ivectors = ivectors.T # DX N + dim = ivectors.shape[1] + # preprocessing + # mean subtraction + # ivectors = ivectors - self.mean[:, np.newaxis] + # PCA transform + # ivectors = self.diagonalizing_transform @ ivectors + l2_norm = np.linalg.norm(ivectors, axis=0, keepdims=True) + l2_norm = l2_norm / math.sqrt(dim) + + ivectors_new = ivectors / l2_norm + + return ivectors_new.T + + def compute_pca_transform(self, ivectors: np.ndarray) -> np.ndarray: + """ + Adapted from https://github.com/prachiisc/PLDA_scoring/blob/master/PLDA_scoring.py#L53 + + Apply transform on mean shifted ivectors + + Parameters + ---------- + ivectors: numpy.ndarray + Input ivectors + + Returns + ---------- + numpy.ndarray + Transformed ivectors + """ + if PLDA_DIMENSION == IVECTOR_DIMENSION: + return ivectors + if self.pca_transform is not None: + return ivectors @ self.pca_transform + num_rows = ivectors.shape[0] + mean = np.mean(ivectors, 0, keepdims=True) + S = np.matmul(ivectors.T, ivectors) + S = S / num_rows + + S = S - mean.T @ mean + + ev_s, eig_s, _ = np.linalg.svd(S, full_matrices=True) + energy_percent = np.sum(eig_s[:PLDA_DIMENSION]) / np.sum(eig_s) + logger.debug(f"PLDA PCA transform energy with: {energy_percent*100:.2f}%") + transform = ev_s[:, :PLDA_DIMENSION] + + transxvec = ivectors @ transform + newX = transxvec + self.pca_transform = transform + self.apply_transform() + return newX + + def apply_transform(self): + """ + Adapted from https://github.com/prachiisc/PLDA_scoring/blob/master/PLDA_scoring.py#L101 + + Parameters + ---------- + transform_in : numpy.ndarray + PCA transform + """ + + mean_plda = self.mean + # transfomed mean vector + transform_in = self.pca_transform.T + new_mean = transform_in @ mean_plda[:, np.newaxis] + D = self.diagonalizing_transform + psi = self.psi + D_inv = np.linalg.inv(D) + # within class and between class covarinace + phi_b = (D_inv * psi.reshape(1, -1)) @ D_inv.T + phi_w = D_inv @ D_inv.T + # transformed with class and between class covariance + new_phi_b = transform_in @ phi_b @ transform_in.T + new_phi_w = transform_in @ phi_w @ transform_in.T + ev_w, eig_w, _ = np.linalg.svd(new_phi_w) + eig_w_inv = 1 / np.sqrt(eig_w) + Dnew = eig_w_inv.reshape(-1, 1) * ev_w.T + new_phi_b_proj = Dnew @ new_phi_b @ Dnew.T + ev_b, eig_b, _ = np.linalg.svd(new_phi_b_proj) + psi_new = eig_b + + Dnew = ev_b.T @ Dnew + self.transformed_mean = new_mean + self.transformed_diagonalizing_transform = Dnew + self.psi = psi_new + self.offset = -Dnew @ new_mean.reshape(-1, 1) + + def transform_ivectors(self, ivectors: np.ndarray, counts: np.ndarray = None) -> np.ndarray: + """ + Adapted from https://github.com/prachiisc/PLDA_scoring/blob/master/PLDA_scoring.py#L142 + Apply plda mean and diagonalizing transform to ivectors for scoring + + Parameters + ---------- + ivectors : numpy.ndarray + Input ivectors + + Returns + ------- + numpy.ndarray + transformed ivectors + """ + + offset = self.offset + offset = offset.T + if PLDA_DIMENSION == IVECTOR_DIMENSION: + D = self.diagonalizing_transform + else: + D = self.transformed_diagonalizing_transform + Dnew = D.T + X_new = ivectors @ Dnew + X_new = X_new + offset + # Get normalizing factor + # Defaults : normalize_length(true), simple_length_norm(false) + X_new_sq = X_new**2 + + if counts is not None: + dot_prod = np.zeros((X_new.shape[0], 1)) + for i in range(dot_prod.shape[0]): + inv_covar = self.psi + (1.0 / counts[i]) + inv_covar = 1.0 / inv_covar + dot_prod[i] = np.dot(X_new_sq[i], inv_covar) + else: + inv_covar = (1.0 / (1.0 + self.psi)).reshape(-1, 1) + dot_prod = X_new_sq @ inv_covar # N X 1 + Dim = D.shape[0] + normfactor = np.sqrt(Dim / dot_prod) + X_new = X_new * normfactor + + return X_new + + +class ExportIvectorsFunction(KaldiFunction): + """ + Multiprocessing function to compute voice activity detection + See Also + -------- + :meth:`.AcousticCorpusMixin.compute_vad` + Main function that calls this function in parallel + :meth:`.AcousticCorpusMixin.compute_vad_arguments` + Job method for generating arguments for this function + :kaldi_src:`compute-vad` + Relevant Kaldi binary + Parameters + ---------- + args: :class:`~montreal_forced_aligner.corpus.features.VadArguments` + Arguments for the function + """ + + def __init__(self, args: ExportIvectorsArguments): + super().__init__(args) + self.use_xvector = args.use_xvector + + def _run(self) -> typing.Generator[typing.Tuple[int, int, int]]: + """Run the function""" + engine = sqlalchemy.create_engine(self.db_string) + with sqlalchemy.orm.Session(engine) as session, mfa_open(self.log_path, "w") as log_file: + + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True)) + .filter(Job.id == self.job_name) + .first() + ) + if self.use_xvector: + ivector_column = Utterance.xvector + else: + ivector_column = Utterance.ivector + query = ( + session.query(Utterance.kaldi_id, ivector_column) + .filter(ivector_column != None, Utterance.job_id == job.id) # noqa + .order_by(Utterance.kaldi_id) + ) + + ivector_scp_path = job.construct_path(job.corpus.split_directory, "ivectors", "scp") + ivector_ark_path = job.construct_path(job.corpus.split_directory, "ivectors", "ark") + input_proc = subprocess.Popen( + [ + thirdparty_binary("copy-vector"), + "--binary=true", + "ark,t:-", + f"ark,scp:{ivector_ark_path},{ivector_scp_path}", + ], + stdin=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + for utt_id, ivector in query: + if ivector is None: + continue + ivector = " ".join([format(x, ".12g") for x in ivector]) + in_line = f"{utt_id} [ {ivector} ]\n".encode("utf8") + input_proc.stdin.write(in_line) + input_proc.stdin.flush() + input_proc.stdin.close() + self.check_call(input_proc) + with mfa_open(ivector_scp_path) as f: + for line in f: + line = line.strip() + utt_id, ark_path = line.split(maxsplit=1) + utt_id = int(utt_id.split("-")[1]) + yield utt_id, ark_path + engine.dispose() diff --git a/montreal_forced_aligner/corpus/helper.py b/montreal_forced_aligner/corpus/helper.py index d2e50903..35c05cf1 100644 --- a/montreal_forced_aligner/corpus/helper.py +++ b/montreal_forced_aligner/corpus/helper.py @@ -3,7 +3,6 @@ import datetime import subprocess -import sys import typing import soundfile @@ -115,7 +114,7 @@ def get_wav_info( duration = 0 sox_string = "" if format in {"mp3", "opus"}: - if sys.platform != "win32" and format == "mp3": + if format == "mp3": sox_proc = subprocess.Popen( ["soxi", f"{file_path}"], stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True ) diff --git a/montreal_forced_aligner/corpus/ivector_corpus.py b/montreal_forced_aligner/corpus/ivector_corpus.py index ed6979b5..6bfacea9 100644 --- a/montreal_forced_aligner/corpus/ivector_corpus.py +++ b/montreal_forced_aligner/corpus/ivector_corpus.py @@ -1,22 +1,33 @@ """Classes for corpora that use ivectors as features""" -import multiprocessing as mp +import logging import os +import pickle +import re +import subprocess import time -from queue import Empty +import typing from typing import List +import numpy as np +import sqlalchemy import tqdm +from montreal_forced_aligner.config import GLOBAL_CONFIG, IVECTOR_DIMENSION from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpusMixin from montreal_forced_aligner.corpus.features import ( ExtractIvectorsArguments, ExtractIvectorsFunction, IvectorConfigMixin, + PldaModel, ) -from montreal_forced_aligner.utils import KaldiProcessWorker, Stopped +from montreal_forced_aligner.db import Corpus, Speaker, Utterance, bulk_update +from montreal_forced_aligner.helper import mfa_open +from montreal_forced_aligner.utils import read_feats, run_kaldi_function, thirdparty_binary __all__ = ["IvectorCorpusMixin"] +logger = logging.getLogger("mfa") + class IvectorCorpusMixin(AcousticCorpusMixin, IvectorConfigMixin): """ @@ -25,7 +36,7 @@ class IvectorCorpusMixin(AcousticCorpusMixin, IvectorConfigMixin): See Also -------- :class:`~montreal_forced_aligner.corpus.acoustic_corpus.AcousticCorpusMixin` - For dictionary and corpus parsing parameters + For corpus parsing parameters :class:`~montreal_forced_aligner.corpus.features.IvectorConfigMixin` For ivector extraction parameters @@ -33,16 +44,17 @@ class IvectorCorpusMixin(AcousticCorpusMixin, IvectorConfigMixin): def __init__(self, **kwargs): super().__init__(**kwargs) + self.plda: typing.Optional[PldaModel] = None @property def ie_path(self) -> str: """Ivector extractor ie path""" - raise NotImplementedError + return os.path.join(self.working_directory, "final.ie") @property def dubm_path(self) -> str: """DUBM model path""" - raise + return os.path.join(self.working_directory, "final.dubm") def extract_ivectors_arguments(self) -> List[ExtractIvectorsArguments]: """ @@ -53,21 +65,178 @@ def extract_ivectors_arguments(self) -> List[ExtractIvectorsArguments]: list[ExtractIvectorsArguments] Arguments for processing """ - return [ - ExtractIvectorsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"extract_ivectors.{j.name}.log"), - j.construct_path(self.split_directory, "feats", "scp"), - self.ivector_options, - self.ie_path, - j.construct_path(self.split_directory, "ivectors", "scp"), - self.model_path, - self.dubm_path, + arguments = [] + for j in self.jobs: + arguments.append( + ExtractIvectorsArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"extract_ivectors.{j.id}.log"), + self.ivector_options, + self.ie_path, + j.construct_path(self.split_directory, "ivectors", "scp"), + self.dubm_path, + ) + ) + + return arguments + + @property + def utterance_ivector_path(self) -> str: + """Path to scp file containing all ivectors""" + return os.path.join(self.corpus_output_directory, "ivectors.scp") + + @property + def adapted_plda_path(self) -> str: + """Path to adapted PLDA model""" + return os.path.join(self.working_directory, "plda_adapted") + + @property + def plda_path(self) -> str: + """Path to trained PLDA model""" + return os.path.join(self.working_directory, "plda") + + def adapt_plda(self) -> None: + """Adapted a trained PLDA model with new ivectors""" + if not os.path.exists(self.utterance_ivector_path): + self.extract_ivectors() + + log_path = os.path.join(self.working_log_directory, "adapt_plda.log") + with mfa_open(log_path, "w") as log_file: + proc = subprocess.Popen( + [ + thirdparty_binary("ivector-adapt-plda"), + self.plda_path, + f"scp:{self.utterance_ivector_path}", + self.adapted_plda_path, + ], + stderr=log_file, + ) + proc.communicate() + + def compute_speaker_ivectors(self) -> None: + """Calculated and save per-speaker ivectors as the mean over their utterances""" + if not self.has_ivectors(): + self.extract_ivectors() + speaker_ivector_ark_path = os.path.join( + self.working_directory, "current_speaker_ivectors.ark" + ) + self._write_spk2utt() + spk2utt_path = os.path.join(self.corpus_output_directory, "spk2utt.scp") + + log_path = os.path.join(self.working_log_directory, "speaker_ivectors.log") + num_utts_path = os.path.join(self.working_directory, "current_num_utts.ark") + logger.info("Computing speaker ivectors...") + self.stopped.reset() + if self.stopped.stop_check(): + logger.debug("Speaker ivector computation stopped early.") + return + with mfa_open(log_path, "w") as log_file: + + normalize_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-normalize-length"), + f"scp:{self.utterance_ivector_path}", + "ark:-", + ], + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + mean_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-mean"), + f"ark:{spk2utt_path}", + "ark:-", + "ark:-", + f"ark,t:{num_utts_path}", + ], + stdin=normalize_proc.stdout, + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + speaker_normalize_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-normalize-length"), + "ark:-", + f"ark:{speaker_ivector_ark_path}", + ], + stdin=mean_proc.stdout, + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + speaker_normalize_proc.communicate() + self.collect_speaker_ivectors() + + def compute_plda(self) -> None: + """Train a PLDA model""" + if not os.path.exists(self.utterance_ivector_path): + if not self.has_any_ivectors(): + raise Exception( + "Must have either ivectors or xvectors calculated to compute PLDA." + ) + self._write_spk2utt() + spk2utt_path = os.path.join(self.corpus_output_directory, "spk2utt.scp") + + plda_path = os.path.join(self.working_directory, "plda") + log_path = os.path.join(self.working_log_directory, "plda.log") + logger.info("Computing PLDA...") + self.stopped.reset() + 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( + log_path, "w" + ) as log_file: + + normalize_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-normalize-length"), + f"scp:{self.utterance_ivector_path}", + "ark:-", + ], + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, ) - for j in self.jobs - if j.has_data - ] + plda_compute_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-compute-plda"), + f"ark:{spk2utt_path}", + "ark:-", + plda_path, + ], + stdin=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + for line in normalize_proc.stdout: + if self.stopped.stop_check(): + break + plda_compute_proc.stdin.write(line) + plda_compute_proc.stdin.flush() + if re.search(rb"\d+-\d+ ", line): + pbar.update(1) + + plda_compute_proc.stdin.close() + plda_compute_proc.wait() + if self.stopped.stop_check(): + logger.debug("PLDA computation stopped early.") + return + assert os.path.exists(plda_path) + + def _write_ivectors(self) -> None: + """Collect single scp file for all ivectors""" + with self.session() as session, mfa_open(self.utterance_ivector_path, "w") as outf: + utterances = ( + session.query(Utterance.kaldi_id, Utterance.ivector_ark) + .join(Utterance.speaker) + .filter(Utterance.ivector_ark != None, Speaker.name != "MFA_UNKNOWN") # noqa, + ) + for utt_id, ivector_ark in utterances: + outf.write(f"{utt_id} {ivector_ark}\n") def extract_ivectors(self) -> None: """ @@ -86,43 +255,190 @@ def extract_ivectors(self) -> None: log_dir = self.working_log_directory os.makedirs(log_dir, exist_ok=True) - + with self.session() as session: + c = session.query(Corpus).first() + if c.ivectors_calculated: + logger.info("Ivectors already computed, skipping!") + return + logger.info("Extracting ivectors...") arguments = self.extract_ivectors_arguments() - with tqdm.tqdm(total=self.num_speakers, disable=getattr(self, "quiet", False)) as pbar: - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(arguments): - function = ExtractIvectorsFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue + with tqdm.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() + logger.debug(f"Ivector extraction took {time.time() - begin:.3f} seconds") + + def transform_ivectors(self): + plda_transform_path = os.path.join(self.working_directory, "plda.pkl") + if os.path.exists(plda_transform_path): + with open(plda_transform_path, "rb") as f: + self.plda = pickle.load(f) + if self.has_ivectors(speaker=False) and os.path.exists(plda_transform_path): + return + plda_path = ( + self.adapted_plda_path if os.path.exists(self.adapted_plda_path) else self.plda_path + ) + if not os.path.exists(plda_path): + logger.info("Missing plda, skipping speaker ivector transformation") + return + self.adapt_plda() + plda_path = ( + self.adapted_plda_path if os.path.exists(self.adapted_plda_path) else self.plda_path + ) + self.plda = PldaModel.load(plda_path) + with self.session() as session: + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS utterance_plda_vector_index")) + session.execute(sqlalchemy.text("ALTER TABLE utterance DISABLE TRIGGER all")) + session.commit() + query = session.query(Utterance.id, Utterance.ivector).filter( + Utterance.ivector != None # noqa + ) + ivectors = np.empty((query.count(), IVECTOR_DIMENSION)) + utterance_ids = [] + for i, (u_id, ivector) in enumerate(query): + utterance_ids.append(u_id) + ivectors[i, :] = ivector + update_mapping = [] + ivectors = self.plda.process_ivectors(ivectors) + for i, utt_id in enumerate(utterance_ids): + update_mapping.append({"id": utt_id, "plda_vector": ivectors[i, :]}) + bulk_update(session, Utterance, update_mapping) + session.execute( + sqlalchemy.text( + "CREATE INDEX utterance_plda_vector_index ON utterance " + "USING ivfflat (plda_vector vector_cosine_ops) " + "WITH (lists = 100)" + ) + ) + session.execute(sqlalchemy.text("ALTER TABLE utterance ENABLE TRIGGER all")) + session.commit() + with open(plda_transform_path, "wb") as f: + pickle.dump(self.plda, f) + + def collect_utterance_ivectors(self) -> None: + """Collect trained per-utterance ivectors""" + logger.info("Collecting ivectors...") + ivector_arks = {} + for j in self.jobs: + ivector_scp_path = j.construct_path(self.split_directory, "ivectors", "scp") + with open(ivector_scp_path, "r") as f: + 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( + total=self.num_utterances, disable=GLOBAL_CONFIG.quiet + ) as pbar: + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS utterance_ivector_index")) + session.execute(sqlalchemy.text("ALTER TABLE utterance DISABLE TRIGGER all")) + session.commit() + update_mapping = {} + for j in self.jobs: + ivector_scp_path = j.construct_path(self.split_directory, "ivectors", "scp") + norm_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-normalize-length"), + f"scp:{ivector_scp_path}", + "ark:-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=os.environ, + ) + copy_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-subtract-global-mean"), + "ark:-", + "ark:-", + ], + stdin=norm_proc.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=os.environ, + ) + norm2_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-normalize-length"), + "ark:-", + "ark,t:-", + ], + stdin=copy_proc.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=os.environ, + ) + for utt_id, ivector in read_feats(norm2_proc): + update_mapping[utt_id] = { + "id": utt_id, + "ivector": ivector, + "ivector_ark": ivector_arks[utt_id], + } pbar.update(1) - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v - else: - for args in arguments: - function = ExtractIvectorsFunction(args) - for _ in function.run(): - pbar.update(1) - self.log_debug(f"Ivector extraction took {time.time() - begin}") + bulk_update(session, Utterance, list(update_mapping.values())) + session.query(Corpus).update({Corpus.ivectors_calculated: True}) + session.execute( + sqlalchemy.text( + "CREATE INDEX utterance_ivector_index ON utterance " + "USING ivfflat (ivector vector_cosine_ops) " + "WITH (lists = 100)" + ) + ) + session.execute(sqlalchemy.text("ALTER TABLE utterance ENABLE TRIGGER all")) + session.commit() + self._write_ivectors() + self.transform_ivectors() + + def collect_speaker_ivectors(self) -> None: + """Collect trained per-speaker ivectors""" + if self.has_ivectors(speaker=True): + return + if self.plda is None: + self.collect_utterance_ivectors() + logger.info("Collecting speaker ivectors...") + speaker_ivector_ark_path = os.path.join( + self.working_directory, "current_speaker_ivectors.ark" + ) + if not os.path.exists(speaker_ivector_ark_path): + self.compute_speaker_ivectors() + with self.session() as session, tqdm.tqdm( + total=self.num_speakers, disable=GLOBAL_CONFIG.quiet + ) as pbar: + session.execute(sqlalchemy.text("ALTER TABLE speaker DISABLE TRIGGER all")) + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS speaker_ivector_index")) + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS speaker_plda_vector_index")) + session.commit() + copy_proc = subprocess.Popen( + [thirdparty_binary("copy-vector"), f"ark:{speaker_ivector_ark_path}", "ark,t:-"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=os.environ, + ) + ivectors = [] + speaker_ids = [] + update_mapping = {} + for speaker_id, ivector in read_feats(copy_proc, raw_id=True): + speaker_id = int(speaker_id) + speaker_ids.append(speaker_id) + ivectors.append(ivector) + update_mapping[speaker_id] = {"id": speaker_id, "ivector": ivector} + pbar.update(1) + ivectors = np.array(ivectors) + ivectors = self.plda.process_ivectors(ivectors) + for i, speaker_id in enumerate(speaker_ids): + update_mapping[speaker_id]["plda_vector"] = ivectors[i, :] + bulk_update(session, Speaker, list(update_mapping.values())) + session.execute( + sqlalchemy.text( + "CREATE INDEX speaker_ivector_index ON speaker " + "USING ivfflat (ivector vector_cosine_ops) " + "WITH (lists = 1000)" + ) + ) + session.execute( + sqlalchemy.text( + "CREATE INDEX speaker_plda_vector_index ON speaker " + "USING ivfflat (plda_vector vector_cosine_ops) " + "WITH (lists = 1000)" + ) + ) + session.execute(sqlalchemy.text("ALTER TABLE speaker ENABLE TRIGGER all")) + session.commit() diff --git a/montreal_forced_aligner/corpus/multiprocessing.py b/montreal_forced_aligner/corpus/multiprocessing.py index c878121d..83d693a1 100644 --- a/montreal_forced_aligner/corpus/multiprocessing.py +++ b/montreal_forced_aligner/corpus/multiprocessing.py @@ -6,22 +6,78 @@ import multiprocessing as mp import os +import re +import typing from queue import Empty, Queue -from typing import Dict, Optional, Union import sqlalchemy -import sqlalchemy.engine -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload, subqueryload from montreal_forced_aligner.corpus.classes import FileData from montreal_forced_aligner.corpus.helper import find_exts -from montreal_forced_aligner.db import File, SoundFile, Speaker, SpeakerOrdering, Utterance -from montreal_forced_aligner.dictionary.multispeaker import MultispeakerSanitizationFunction +from montreal_forced_aligner.data import MfaArguments, WordType +from montreal_forced_aligner.db import ( + Dictionary, + File, + Grapheme, + Job, + SoundFile, + Speaker, + Utterance, + Word, +) from montreal_forced_aligner.exceptions import SoundFileError, TextGridParseError, TextParseError -from montreal_forced_aligner.helper import mfa_open -from montreal_forced_aligner.utils import Counter, Stopped +from montreal_forced_aligner.helper import make_re_character_set_safe, mfa_open +from montreal_forced_aligner.utils import Counter, KaldiFunction, Stopped + +if typing.TYPE_CHECKING: + from dataclasses import dataclass +else: + from dataclassy import dataclass + +__all__ = [ + "AcousticDirectoryParser", + "CorpusProcessWorker", + "ExportKaldiFilesFunction", + "ExportKaldiFilesArguments", + "NormalizeTextFunction", + "NormalizeTextArguments", + "construct_path", +] + + +def construct_path(job_name, directory: str, identifier: str, extension: str) -> str: + """ + Helper function for constructing dictionary-dependent paths for the Job + + Parameters + ---------- + directory: str + Directory to use as the root + identifier: str + Identifier for the path name, like ali or acc + extension: str + Extension of the path, like .scp or .ark + + Returns + ------- + str + Path + """ + return os.path.join(directory, f"{identifier}.{job_name}.{extension}") + -__all__ = ["AcousticDirectoryParser", "CorpusProcessWorker", "Job"] +def dictionary_ids_for_job(session, job_id): + dictionary_ids = [ + x[0] + for x in session.query(Dictionary.id) + .join(Utterance.speaker) + .join(Speaker.dictionary) + .filter(Utterance.in_subset == True) # noqa + .filter(Utterance.job_id == job_id) + .distinct() + ] + return dictionary_ids class AcousticDirectoryParser(mp.Process): @@ -106,8 +162,6 @@ def run(self) -> None: elif file_name in exts.textgrid_files: tg_name = exts.textgrid_files[file_name] transcription_path = os.path.join(root, tg_name) - if wav_path is None and transcription_path is None: # Not a file for MFA - continue if wav_path is None: continue self.job_queue.put((file_name, wav_path, transcription_path, relative_path)) @@ -141,9 +195,8 @@ def __init__( return_q: mp.Queue, stopped: Stopped, finished_adding: Stopped, - speaker_characters: Union[int, str], - sanitize_function: Optional[MultispeakerSanitizationFunction], - sample_rate: Optional[int], + speaker_characters: typing.Union[int, str], + sample_rate: typing.Optional[int], ): mp.Process.__init__(self) self.name = str(name) @@ -152,7 +205,6 @@ def __init__( self.stopped = stopped self.finished_adding = finished_adding self.finished_processing = Stopped() - self.sanitize_function = sanitize_function self.speaker_characters = speaker_characters self.sample_rate = sample_rate @@ -176,7 +228,6 @@ def run(self) -> None: text_path, relative_path, self.speaker_characters, - self.sanitize_function, self.sample_rate, ) self.return_q.put(file) @@ -193,132 +244,291 @@ def run(self) -> None: return -class Job: +@dataclass +class NormalizeTextArguments(MfaArguments): """ - Class representing information about corpus jobs that will be run in parallel. - Jobs have a set of speakers that they will process, along with all files and utterances associated with that speaker. - As such, Jobs also have a set of dictionaries that the speakers use, and argument outputs are largely dependent on - the pronunciation dictionaries in use. + Arguments for :class:`~montreal_forced_aligner.corpus.multiprocessing.NormalizeTextFunction` Parameters ---------- - name: int - Job number is the job's identifier - db_engine: sqlalchemy.engine.Engine - Database engine to use in looking up relevant information + model_path: str + Path to model file + phone_pdf_counts_path: str + Path to output PDF counts + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings + """ - Attributes - ---------- - dictionary_ids: list[int] - List of dictionary ids that the job's speakers use + word_break_markers: typing.List[str] + punctuation: typing.List[str] + clitic_markers: typing.List[str] + compound_markers: typing.List[str] + brackets: typing.List[typing.Tuple[str, str]] + laughter_word: str + oov_word: str + bracketed_word: str + ignore_case: bool + use_g2p: bool + + +@dataclass +class ExportKaldiFilesArguments(MfaArguments): """ + Arguments for :class:`~montreal_forced_aligner.corpus.multiprocessing.NormalizeTextFunction` - name: int + Parameters + ---------- - def __init__(self, name: int, db_engine: sqlalchemy.engine.Engine): - self.name = name - self.db_engine = db_engine - self.dictionary_ids = [] - with Session(self.db_engine) as session: - self.refresh_dictionaries(session) - self.has_data = True + """ - def refresh_dictionaries(self, session: Session) -> None: - """ - Refresh the dictionaries that will be processed by this job + split_directory: str + for_features: bool - Parameters - ---------- - session: :class:`~sqlalchemy.orm.session.Session` - Session to use for refreshing - """ - job_dict_query = ( - session.query(Speaker.dictionary_id).filter(Speaker.job_id == self.name).distinct() - ) - self.dictionary_ids = [x[0] for x in job_dict_query] - def construct_path_dictionary( - self, directory: str, identifier: str, extension: str - ) -> Dict[str, str]: - """ - Helper function for constructing dictionary-dependent paths for the Job +class NormalizeTextFunction(KaldiFunction): + """ + Multiprocessing function for normalizing text. + Parameters + ---------- + args: :class:`~montreal_forced_aligner.corpus.multiprocessing.NormalizeTextArguments` + Arguments for the function + """ - Parameters - ---------- - directory: str - Directory to use as the root - identifier: str - Identifier for the path name, like ali or acc - extension: str - Extension of the path, like .scp or .ark - - Returns - ------- - dict[str, str] - Path for each dictionary - """ - output = {} - for dict_id in self.dictionary_ids: - if dict_id is None: - output[dict_id] = os.path.join(directory, f"{identifier}.{self.name}.{extension}") - else: - output[dict_id] = os.path.join( - directory, f"{identifier}.{dict_id}.{self.name}.{extension}" + def __init__(self, args: NormalizeTextArguments): + super().__init__(args) + self.word_break_markers = args.word_break_markers + self.brackets = args.brackets + self.punctuation = args.punctuation + self.compound_markers = args.compound_markers + self.clitic_markers = args.clitic_markers + self.ignore_case = args.ignore_case + self.use_g2p = args.use_g2p + self.laughter_word = args.laughter_word + self.oov_word = args.oov_word + self.bracketed_word = args.bracketed_word + self.clitic_marker = None + self.clitic_cleanup_regex = None + self.compound_regex = None + self.bracket_regex = None + self.bracket_sanitize_regex = None + self.laughter_regex = None + self.word_break_regex = None + self.clitic_quote_regex = None + self.punctuation_regex = None + self.non_speech_regexes = {} + + def compile_regexes(self) -> None: + """Compile regular expressions necessary for corpus parsing""" + if len(self.clitic_markers) >= 1: + other_clitic_markers = self.clitic_markers[1:] + if other_clitic_markers: + extra = "" + if "-" in other_clitic_markers: + extra = "-" + other_clitic_markers = [x for x in other_clitic_markers if x != "-"] + self.clitic_cleanup_regex = re.compile( + rf'[{extra}{"".join(other_clitic_markers)}]' ) - return output - - def construct_path(self, directory: str, identifier: str, extension: str) -> str: - """ - Helper function for constructing dictionary-dependent paths for the Job + self.clitic_marker = self.clitic_markers[0] + if self.compound_markers: + extra = "" + compound_markers = self.compound_markers + if "-" in self.compound_markers: + extra = "-" + compound_markers = [x for x in compound_markers if x != "-"] + self.compound_regex = re.compile(rf"(?<=\w)[{extra}{''.join(compound_markers)}](?=\w)") + if self.brackets: + left_brackets = [x[0] for x in self.brackets] + right_brackets = [x[1] for x in self.brackets] + self.bracket_regex = re.compile( + rf"[{re.escape(''.join(left_brackets))}].*?[{re.escape(''.join(right_brackets))}]+" + ) + self.laughter_regex = re.compile( + rf"[{re.escape(''.join(left_brackets))}](laugh(ing|ter)?|lachen|lg)[{re.escape(''.join(right_brackets))}]+", + flags=re.IGNORECASE, + ) + all_punctuation = set() + non_word_character_set = set(self.punctuation) + non_word_character_set -= {b for x in self.brackets for b in x} + + if self.clitic_markers: + all_punctuation.update(self.clitic_markers) + if self.compound_markers: + all_punctuation.update(self.compound_markers) + self.bracket_sanitize_regex = None + if self.brackets: + word_break_set = ( + non_word_character_set | set(self.clitic_markers) | set(self.compound_markers) + ) + if self.word_break_markers: + word_break_set |= set(self.word_break_markers) + word_break_set = make_re_character_set_safe(word_break_set, [r"\s"]) + self.bracket_sanitize_regex = re.compile(f"(?= 1: + non_clitic_punctuation = all_punctuation - set(self.clitic_markers) + non_clitic_punctuation_set = make_re_character_set_safe(non_clitic_punctuation) + non_punctuation_set = "[^" + punctuation_set[1:] + self.clitic_quote_regex = re.compile( + rf"((?<=\W)|(?<=^)){non_clitic_punctuation_set}*{self.clitic_marker}{non_clitic_punctuation_set}*(?P{non_punctuation_set}+){non_clitic_punctuation_set}*{self.clitic_marker}{non_clitic_punctuation_set}*((?=\W)|(?=$))" + ) - Parameters - ---------- - directory: str - Directory to use as the root - identifier: str - Identifier for the path name, like ali or acc - extension: str - Extension of the path, like .scp or .ark - - Returns - ------- - str - Path - """ - return os.path.join(directory, f"{identifier}.{self.name}.{extension}") + if self.laughter_regex is not None: + self.non_speech_regexes[self.laughter_word] = self.laughter_regex + if self.bracket_regex is not None: + self.non_speech_regexes[self.bracketed_word] = self.bracket_regex + + def _dictionary_sanitize(self, session): + from montreal_forced_aligner.dictionary.mixins import SanitizeFunction, SplitWordsFunction + + dictionaries: typing.List[Dictionary] = session.query(Dictionary) + grapheme_mapping = {} + grapheme_query = session.query(Grapheme.grapheme, Grapheme.mapping_id) + for w, m_id in grapheme_query: + grapheme_mapping[w] = m_id + for d in dictionaries: + words_mapping = {} + words_query = session.query(Word.word, Word.mapping_id).filter( + Word.dictionary_id == d.id + ) + for w, m_id in words_query: + words_mapping[w] = m_id + sanitize_function = SanitizeFunction( + self.clitic_marker, + self.clitic_cleanup_regex, + self.clitic_quote_regex, + self.punctuation_regex, + self.word_break_regex, + self.bracket_regex, + self.bracket_sanitize_regex, + self.ignore_case, + ) + clitic_set = set( + x[0] + for x in session.query(Word.word) + .filter(Word.word_type == WordType.clitic) + .filter(Word.dictionary_id == d.id) + ) + initial_clitic_regex = None + final_clitic_regex = None + if self.clitic_marker is not None: + initial_clitics = sorted(x for x in clitic_set if x.endswith(self.clitic_marker)) + final_clitics = sorted(x for x in clitic_set if x.startswith(self.clitic_marker)) + if initial_clitics: + initial_clitic_regex = re.compile(rf"^({'|'.join(initial_clitics)})(?=\w)") + if final_clitics: + final_clitic_regex = re.compile(rf"(?<=\w)({'|'.join(final_clitics)})$") + + non_speech_regexes = {} + if self.laughter_regex is not None: + non_speech_regexes[d.laughter_word] = self.laughter_regex + if self.bracket_regex is not None: + non_speech_regexes[d.bracketed_word] = self.bracket_regex + split_function = SplitWordsFunction( + self.clitic_marker, + initial_clitic_regex, + final_clitic_regex, + self.compound_regex, + non_speech_regexes, + d.oov_word, + words_mapping, + grapheme_mapping, + ) + utterances = ( + session.query(Utterance.id, Utterance.text) + .join(Utterance.speaker) + .filter(Utterance.text != "") + .filter(Utterance.job_id == self.job_name) + .filter(Speaker.dictionary_id == d.id) + ) + for u_id, u_text in utterances: + words = sanitize_function(u_text) + normalized_text = [] + normalized_character_text = [] + oovs = set() + text = "" + for w in words: + for new_w in split_function(w): + if new_w not in words_mapping: + oovs.add(new_w) + normalized_text.append(split_function.to_str(new_w)) + if normalized_character_text: + if not self.clitic_marker or ( + not normalized_text[-1].endswith(self.clitic_marker) + and not new_w.startswith(self.clitic_marker) + ): + normalized_character_text.append("") + for c in split_function.parse_graphemes(new_w): + normalized_character_text.append(c) + if text: + text += " " + text += w + yield { + "id": u_id, + "oovs": " ".join(sorted(oovs)), + "normalized_text": " ".join(normalized_text), + "normalized_character_text": " ".join(normalized_character_text), + }, d.id + + def _no_dictionary_sanitize(self, session): + from montreal_forced_aligner.dictionary.mixins import SanitizeFunction + + sanitize_function = SanitizeFunction( + self.clitic_marker, + self.clitic_cleanup_regex, + self.clitic_quote_regex, + self.punctuation_regex, + self.word_break_regex, + self.bracket_regex, + self.bracket_sanitize_regex, + self.ignore_case, + ) + utterances = ( + session.query(Utterance.id, Utterance.text) + .join(Utterance.speaker) + .filter(Utterance.text != "") + .filter(Utterance.job_id == self.job_name) + ) + for u_id, u_text in utterances: + text = " ".join(sanitize_function(u_text)) + oovs = set() + yield { + "id": u_id, + "oovs": " ".join(sorted(oovs)), + "normalized_text": text, + }, None + + def _run(self) -> typing.Generator[typing.Tuple[int, float]]: + """Run the function""" + self.compile_regexes() + with Session(self.db_engine) as session: + dict_count = session.query(Dictionary).join(Dictionary.words).limit(1).count() + if self.use_g2p or dict_count > 0: + yield from self._dictionary_sanitize(session) + else: + yield from self._no_dictionary_sanitize(session) - def construct_dictionary_dependent_paths( - self, directory: str, identifier: str, extension: str - ) -> Dict[str, str]: - """ - Helper function for constructing paths that depend only on the dictionaries of the job, and not the job name itself. - These paths should be merged with all other jobs to get a full set of dictionary paths. - Parameters - ---------- - directory: str - Directory to use as the root - identifier: str - Identifier for the path name, like ali or acc - extension: str - Extension of the path, like .scp or .ark - - Returns - ------- - dict[str, str] - Path for each dictionary - """ - output = {} - for dict_id in self.dictionary_ids: - output[dict_id] = os.path.join(directory, f"{identifier}.{dict_id}.{extension}") - return output +class ExportKaldiFilesFunction(KaldiFunction): + """ + Multiprocessing function for normalizing text. + Parameters + ---------- + args: :class:`~montreal_forced_aligner.corpus.multiprocessing.NormalizeTextArguments` + Arguments for the function + """ - @property - def dictionary_count(self) -> int: - """Number of dictionaries currently used""" - return len(self.dictionary_ids) + def __init__(self, args: ExportKaldiFilesArguments): + super().__init__(args) + self.split_directory = args.split_directory + self.for_features = args.for_features - def output_for_features(self, split_directory: str, session) -> None: + def output_for_features(self, session: Session) -> None: """ Output the necessary files for Kaldi to generate features @@ -327,24 +537,30 @@ def output_for_features(self, split_directory: str, session) -> None: split_directory: str Split directory for the corpus """ - wav_scp_path = self.construct_path(split_directory, "wav", "scp") - segments_scp_path = self.construct_path(split_directory, "segments", "scp") + job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + wav_scp_path = job.wav_scp_path + segments_scp_path = job.segments_scp_path if os.path.exists(segments_scp_path): return with mfa_open(wav_scp_path, "w") as wav_file: files = ( session.query(File.id, SoundFile.sox_string, SoundFile.sound_file_path) - .join(File.speakers) - .join(SpeakerOrdering.speaker) .join(File.sound_file) - .distinct() - .filter(Speaker.job_id == self.name) + .join(File.utterances) + .filter(Utterance.job_id == job.id) .order_by(File.id.cast(sqlalchemy.String)) + .distinct(File.id.cast(sqlalchemy.String)) ) for f_id, sox_string, sound_file_path in files: if not sox_string: sox_string = sound_file_path wav_file.write(f"{f_id} {sox_string}\n") + yield 1 with mfa_open(segments_scp_path, "w") as segments_file: utterances = ( @@ -355,14 +571,14 @@ def output_for_features(self, split_directory: str, session) -> None: Utterance.end, Utterance.channel, ) - .join(Utterance.speaker) - .filter(Speaker.job_id == self.name) + .filter(Utterance.job_id == job.id) .order_by(Utterance.kaldi_id) ) for u_id, f_id, begin, end, channel in utterances: segments_file.write(f"{u_id} {f_id} {begin} {end} {channel}\n") + yield 1 - def output_to_directory(self, split_directory: str, session, subset=False) -> None: + def output_to_directory(self, session) -> None: """ Output job information to a directory @@ -371,98 +587,192 @@ def output_to_directory(self, split_directory: str, session, subset=False) -> No split_directory: str Directory to output to """ - if self.dictionary_ids: - for dict_id in self.dictionary_ids: - dict_pattern = f"{self.name}" - if dict_id is not None: - dict_pattern = f"{dict_id}.{self.name}" - scp_path = os.path.join(split_directory, f"utt2spk.{dict_pattern}.scp") - if not os.path.exists(scp_path): - break - else: - return - data = {} - utterances = ( - session.query( - Utterance.id, - Utterance.speaker_id, - Utterance.features, - Utterance.normalized_text, - Utterance.normalized_text_int, - Speaker.cmvn, - Speaker.dictionary_id, - ) - .join(Utterance.speaker) - .filter(Speaker.job_id == self.name) + job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + base_utterance_query = ( + session.query(sqlalchemy.func.count(Utterance.id)) + .filter(Utterance.job_id == job.id) .filter(Utterance.ignored == False) # noqa - .order_by(Utterance.kaldi_id) ) - if subset: - utterances = utterances.filter(Utterance.in_subset == True) # noqa - if utterances.count() == 0: + if job.corpus.current_subset: + base_utterance_query = base_utterance_query.filter(Utterance.in_subset == True) # noqa + + if not base_utterance_query.scalar(): return - for ( - u_id, - s_id, - features, - normalized_text, - normalized_text_int, - cmvn, - dictionary_id, - ) in utterances: - if dictionary_id not in data: - data[dictionary_id] = { - "spk2utt": {}, - "feats": {}, - "cmvns": {}, - "utt2spk": {}, - "text_ints": {}, - "texts": {}, - } - utterance = str(u_id) - speaker = str(s_id) - utterance = f"{speaker}-{utterance}" - if speaker not in data[dictionary_id]["spk2utt"]: - data[dictionary_id]["spk2utt"][speaker] = [] - data[dictionary_id]["spk2utt"][speaker].append(utterance) - data[dictionary_id]["utt2spk"][utterance] = speaker - data[dictionary_id]["feats"][utterance] = features - data[dictionary_id]["cmvns"][speaker] = cmvn - data[dictionary_id]["text_ints"][utterance] = normalized_text_int - data[dictionary_id]["texts"][utterance] = normalized_text - - for dict_id, d in data.items(): - dict_pattern = f"{self.name}" - if dict_id is not None: - dict_pattern = f"{dict_id}.{self.name}" - - scp_path = os.path.join(split_directory, f"spk2utt.{dict_pattern}.scp") - with mfa_open(scp_path, "w") as f: - for speaker in sorted(d["spk2utt"].keys()): - utts = " ".join(sorted(d["spk2utt"][speaker])) + if not job.has_dictionaries: + utterances = ( + session.query( + Utterance.id, + Utterance.speaker_id, + Utterance.features, + Utterance.vad_ark, + Utterance.ivector_ark, + Utterance.normalized_text, + Speaker.cmvn, + ) + .join(Utterance.speaker) + .filter(Utterance.job_id == job.id) + .filter(Utterance.ignored == False) # noqa + .order_by(Utterance.kaldi_id) + ) + if job.corpus.current_subset: + utterances = utterances.filter(Utterance.in_subset == True) # noqa + utt2spk_path = job.construct_path(self.split_directory, "utt2spk", "scp") + feats_path = job.construct_path(self.split_directory, "feats", "scp") + cmvns_path = job.construct_path(self.split_directory, "cmvn", "scp") + spk2utt_path = job.construct_path(self.split_directory, "spk2utt", "scp") + text_ints_path = job.construct_path(self.split_directory, "text", "int.scp") + vad_path = job.construct_path(self.split_directory, "vad", "scp") + ivectors_path = job.construct_path(self.split_directory, "ivectors", "scp") + + spk2utt = {} + vad = {} + ivectors = {} + feats = {} + cmvns = {} + utt2spk = {} + text_ints = {} + + for ( + u_id, + s_id, + features, + vad_ark, + ivector_ark, + normalized_text, + cmvn, + ) in utterances: + utterance = str(u_id) + speaker = str(s_id) + utterance = f"{speaker}-{utterance}" + if speaker not in spk2utt: + spk2utt[speaker] = [] + spk2utt[speaker].append(utterance) + utt2spk[utterance] = speaker + feats[utterance] = features + if vad_ark: + vad[utterance] = vad_ark + if ivector_ark: + ivectors[utterance] = ivector_ark + cmvns[speaker] = cmvn + text_ints[utterance] = normalized_text + yield 1 + + with mfa_open(spk2utt_path, "w") as f: + for speaker, utts in sorted(spk2utt.items()): + utts = " ".join(sorted(utts)) f.write(f"{speaker} {utts}\n") - scp_path = os.path.join(split_directory, f"cmvn.{dict_pattern}.scp") - with mfa_open(scp_path, "w") as f: - for speaker in sorted(d["cmvns"].keys()): - f.write(f"{speaker} {d['cmvns'][speaker]}\n") - - scp_path = os.path.join(split_directory, f"utt2spk.{dict_pattern}.scp") - with mfa_open(scp_path, "w") as f: - for utt in sorted(d["utt2spk"].keys()): - f.write(f"{utt} {d['utt2spk'][utt]}\n") - - scp_path = os.path.join(split_directory, f"feats.{dict_pattern}.scp") - with mfa_open(scp_path, "w") as f: - for utt in sorted(d["feats"].keys()): - f.write(f"{utt} {d['feats'][utt]}\n") - - scp_path = os.path.join(split_directory, f"text.{dict_pattern}.int.scp") - with mfa_open(scp_path, "w") as f: - for utt in sorted(d["text_ints"].keys()): - f.write(f"{utt} {d['text_ints'][utt]}\n") - - scp_path = os.path.join(split_directory, f"text.{dict_pattern}.scp") - with mfa_open(scp_path, "w") as f: - for utt in sorted(d["texts"].keys()): - f.write(f"{utt} {d['texts'][utt]}\n") + with mfa_open(cmvns_path, "w") as f: + for speaker, cmvn in sorted(cmvns.items()): + f.write(f"{speaker} {cmvn}\n") + + with mfa_open(utt2spk_path, "w") as f: + for utt, spk in sorted(utt2spk.items()): + f.write(f"{utt} {spk}\n") + + with mfa_open(feats_path, "w") as f: + for utt, feat in sorted(feats.items()): + f.write(f"{utt} {feat}\n") + + with mfa_open(text_ints_path, "w") as f: + for utt, text in sorted(text_ints.items()): + f.write(f"{utt} {text}\n") + if vad: + with mfa_open(vad_path, "w") as f: + for utt, ark in sorted(vad.items()): + f.write(f"{utt} {ark}\n") + if ivectors: + with mfa_open(ivectors_path, "w") as f: + for utt, ark in sorted(ivectors.items()): + f.write(f"{utt} {ark}\n") + + else: + base_utterance_query = ( + session.query( + Utterance.id, + Utterance.speaker_id, + Utterance.features, + Utterance.normalized_text, + Speaker.cmvn, + ) + .join(Utterance.speaker) + .filter(Utterance.job_id == job.id) + .filter(Utterance.ignored == False) # noqa + .order_by(Utterance.kaldi_id) + ) + if job.corpus.current_subset: + base_utterance_query = base_utterance_query.filter( + Utterance.in_subset == True # noqa + ) + utt2spk_paths = job.per_dictionary_utt2spk_scp_paths + feats_paths = job.per_dictionary_feats_scp_paths + cmvns_paths = job.per_dictionary_cmvn_scp_paths + spk2utt_paths = job.per_dictionary_spk2utt_scp_paths + text_ints_paths = job.per_dictionary_text_int_scp_paths + for d in job.dictionaries: + + words_mapping = {} + words_query = session.query(Word.word, Word.mapping_id).filter( + Word.dictionary_id == d.id + ) + for w, m_id in words_query: + words_mapping[w] = m_id + spk2utt = {} + feats = {} + cmvns = {} + utt2spk = {} + text_ints = {} + utterances = base_utterance_query.filter(Speaker.dictionary_id == d.id) + for ( + u_id, + s_id, + features, + normalized_text, + cmvn, + ) in utterances: + utterance = str(u_id) + speaker = str(s_id) + utterance = f"{speaker}-{utterance}" + if speaker not in spk2utt: + spk2utt[speaker] = [] + spk2utt[speaker].append(utterance) + utt2spk[utterance] = speaker + feats[utterance] = features + cmvns[speaker] = cmvn + words = normalized_text.split() + text_ints[utterance] = " ".join([str(words_mapping[x]) for x in words]) + yield 1 + + with mfa_open(spk2utt_paths[d.id], "w") as f: + for speaker, utts in sorted(spk2utt.items()): + utts = " ".join(sorted(utts)) + f.write(f"{speaker} {utts}\n") + + with mfa_open(cmvns_paths[d.id], "w") as f: + for speaker, cmvn in sorted(cmvns.items()): + f.write(f"{speaker} {cmvn}\n") + + with mfa_open(utt2spk_paths[d.id], "w") as f: + for utt, spk in sorted(utt2spk.items()): + f.write(f"{utt} {spk}\n") + + with mfa_open(feats_paths[d.id], "w") as f: + for utt, feat in sorted(feats.items()): + f.write(f"{utt} {feat}\n") + + with mfa_open(text_ints_paths[d.id], "w") as f: + for utt, text in sorted(text_ints.items()): + f.write(f"{utt} {text}\n") + + def _run(self) -> typing.Generator[typing.Tuple[int, float]]: + """Run the function""" + with Session(self.db_engine) as session: + if self.for_features: + yield from self.output_for_features(session) + else: + yield from self.output_to_directory(session) diff --git a/montreal_forced_aligner/corpus/text_corpus.py b/montreal_forced_aligner/corpus/text_corpus.py index 79af3ff8..45889ee7 100644 --- a/montreal_forced_aligner/corpus/text_corpus.py +++ b/montreal_forced_aligner/corpus/text_corpus.py @@ -1,6 +1,7 @@ """Class definitions for corpora""" from __future__ import annotations +import logging import multiprocessing as mp import os import sys @@ -10,6 +11,7 @@ import tqdm from montreal_forced_aligner.abc import MfaWorker, TemporaryDirectoryMixin +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.corpus.base import CorpusMixin from montreal_forced_aligner.corpus.classes import FileData from montreal_forced_aligner.corpus.helper import find_exts @@ -19,6 +21,8 @@ from montreal_forced_aligner.exceptions import TextGridParseError, TextParseError from montreal_forced_aligner.utils import Stopped +logger = logging.getLogger("mfa") + class TextCorpusMixin(CorpusMixin): """ @@ -39,14 +43,13 @@ def _load_corpus_from_source_mp(self) -> None: """ if self.stopped is None: self.stopped = Stopped() - sanitize_function = getattr(self, "sanitize_function", None) begin_time = time.time() job_queue = mp.Queue() return_queue = mp.Queue() error_dict = {} finished_adding = Stopped() procs = [] - for i in range(self.num_jobs): + for i in range(GLOBAL_CONFIG.num_jobs): p = CorpusProcessWorker( i, job_queue, @@ -54,7 +57,6 @@ def _load_corpus_from_source_mp(self) -> None: self.stopped, finished_adding, self.speaker_characters, - sanitize_function, sample_rate=0, ) procs.append(p) @@ -63,7 +65,7 @@ def _load_corpus_from_source_mp(self) -> None: try: file_count = 0 with tqdm.tqdm( - total=1, disable=getattr(self, "quiet", False) + 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) @@ -117,7 +119,7 @@ def _load_corpus_from_source_mp(self) -> None: pbar.update(1) import_data.add_objects(self.generate_import_objects(file)) - self.log_debug("Waiting for workers to finish...") + logger.debug("Waiting for workers to finish...") for p in procs: p.join() @@ -130,21 +132,21 @@ def _load_corpus_from_source_mp(self) -> None: for k in ["decode_error_files", "textgrid_read_errors"]: if hasattr(self, k): if k in error_dict: - self.log_info( + logger.info( "There were some issues with files in the corpus. " "Please look at the log file or run the validator for more information." ) - self.log_debug(f"{k} showed {len(error_dict[k])} errors:") + logger.debug(f"{k} showed {len(error_dict[k])} errors:") if k == "textgrid_read_errors": - getattr(self, k).update(error_dict[k]) + getattr(self, k).extend(error_dict[k]) for e in error_dict[k]: - self.log_debug(f"{e.file_name}: {e.error}") + logger.debug(f"{e.file_name}: {e.error}") else: - self.log_debug(", ".join(error_dict[k])) + logger.debug(", ".join(error_dict[k])) setattr(self, k, error_dict[k]) except KeyboardInterrupt: - self.log_info("Detected ctrl-c, please wait a moment while we clean everything up...") + logger.info("Detected ctrl-c, please wait a moment while we clean everything up...") self.stopped.stop() finished_adding.stop() job_queue.join() @@ -166,12 +168,12 @@ def _load_corpus_from_source_mp(self) -> None: for p in procs: p.join() if self.stopped.stop_check(): - self.log_info(f"Stopped parsing early ({time.time() - begin_time} seconds)") + logger.info(f"Stopped parsing early ({time.time() - begin_time:.3f} seconds)") if self.stopped.source(): sys.exit(0) else: - self.log_debug( - f"Parsed corpus directory with {self.num_jobs} jobs in {time.time() - begin_time} seconds" + logger.debug( + f"Parsed corpus directory with {GLOBAL_CONFIG.num_jobs} jobs in {time.time() - begin_time:.3f} seconds" ) def _load_corpus_from_source(self) -> None: @@ -216,23 +218,23 @@ def _load_corpus_from_source(self) -> None: self.textgrid_read_errors.append(e) self._finalize_load(session, import_data) if self.decode_error_files or self.textgrid_read_errors: - self.log_info( + logger.info( "There were some issues with files in the corpus. " "Please look at the log file or run the validator for more information." ) if self.decode_error_files: - self.log_debug( + logger.debug( f"There were {len(self.decode_error_files)} errors decoding text files:" ) - self.log_debug(", ".join(self.decode_error_files)) + logger.debug(", ".join(self.decode_error_files)) if self.textgrid_read_errors: - self.log_debug( + logger.debug( f"There were {len(self.textgrid_read_errors)} errors decoding reading TextGrid files:" ) for e in self.textgrid_read_errors: - self.log_debug(f"{e.file_name}: {e.error}") + logger.debug(f"{e.file_name}: {e.error}") - self.log_debug(f"Parsed corpus directory in {time.time()-begin_time} seconds") + logger.debug(f"Parsed corpus directory in {time.time()-begin_time} seconds") class DictionaryTextCorpusMixin(TextCorpusMixin, MultispeakerDictionaryMixin): @@ -261,8 +263,9 @@ def load_corpus(self) -> None: self.dictionary_setup() self._load_corpus() - self.write_lexicon_information() self.initialize_jobs() + self.normalize_text() + self.write_lexicon_information() self.create_corpus_split() @@ -272,11 +275,6 @@ class TextCorpus(TextCorpusMixin, MfaWorker, TemporaryDirectoryMixin): Most MFA functionality will use the :class:`~montreal_forced_aligner.corpus.text_corpus.TextCorpusMixin` class rather than this class. - Parameters - ---------- - num_jobs: int - Number of jobs to use when loading the corpus - See Also -------- :class:`~montreal_forced_aligner.corpus.text_corpus.DictionaryTextCorpusMixin` @@ -287,9 +285,8 @@ class TextCorpus(TextCorpusMixin, MfaWorker, TemporaryDirectoryMixin): For temporary directory parameters """ - def __init__(self, num_jobs=3, **kwargs): + def __init__(self, **kwargs): super().__init__(**kwargs) - self.num_jobs = num_jobs def load_corpus(self) -> None: """ @@ -309,7 +306,7 @@ def identifier(self) -> str: @property def output_directory(self) -> str: """Root temporary directory to store all corpus and dictionary files""" - return os.path.join(self.temporary_directory, self.identifier) + return os.path.join(GLOBAL_CONFIG.temporary_directory, self.identifier) @property def working_directory(self) -> str: @@ -323,11 +320,6 @@ class DictionaryTextCorpus(DictionaryTextCorpusMixin, MfaWorker, TemporaryDirect Most MFA functionality will use the :class:`~montreal_forced_aligner.corpus.text_corpus.DictionaryTextCorpusMixin` class rather than this class. - Parameters - ---------- - num_jobs: int - Number of jobs to use when loading the corpus - See Also -------- :class:`~montreal_forced_aligner.corpus.text_corpus.DictionaryTextCorpusMixin` @@ -338,9 +330,8 @@ class DictionaryTextCorpus(DictionaryTextCorpusMixin, MfaWorker, TemporaryDirect For temporary directory parameters """ - def __init__(self, num_jobs=3, **kwargs): + def __init__(self, **kwargs): super().__init__(**kwargs) - self.num_jobs = num_jobs @property def identifier(self) -> str: @@ -350,7 +341,7 @@ def identifier(self) -> str: @property def output_directory(self) -> str: """Root temporary directory to store all corpus and dictionary files""" - return os.path.join(self.temporary_directory, self.identifier) + return os.path.join(GLOBAL_CONFIG.temporary_directory, self.identifier) @property def working_directory(self) -> str: diff --git a/montreal_forced_aligner/data.py b/montreal_forced_aligner/data.py index aae1611f..2dc8a873 100644 --- a/montreal_forced_aligner/data.py +++ b/montreal_forced_aligner/data.py @@ -7,14 +7,18 @@ import collections import enum +import io import itertools +import math import re import typing import dataclassy +import pynini +import pywrapfst from praatio.utilities.constants import Interval, TextgridFormats -from .exceptions import CtmError +from montreal_forced_aligner.exceptions import CtmError __all__ = [ "MfaArguments", @@ -30,6 +34,8 @@ "PronunciationProbabilityCounter", ] +M_LOG_2PI = 1.8378770664093454835606594728112 + # noinspection PyUnresolvedReferences @dataclassy.dataclass(slots=True) @@ -83,29 +89,29 @@ class MfaArguments: """ Base class for argument classes for MFA functions - Parameters + Attributes ---------- job_name: int Integer ID of the job - db_path: str - Path to connect to database for getting necessary information + db_string: str + String for database connections log_path: str Path to save logging information during the run """ job_name: int - db_path: str + db_string: str log_path: str class TextFileType(enum.Enum): """Enum for types of text files""" - NONE = "none" - TEXTGRID = TextgridFormats.LONG_TEXTGRID - SHORT_TEXTGRID = TextgridFormats.SHORT_TEXTGRID - LAB = "lab" - JSON = TextgridFormats.JSON + NONE = "none" #: No text file + TEXTGRID = TextgridFormats.LONG_TEXTGRID #: Praat's long textgrid format + SHORT_TEXTGRID = TextgridFormats.SHORT_TEXTGRID #: Praat's short textgrid format + LAB = "lab" #: Text file + JSON = TextgridFormats.JSON #: JSON def __str__(self) -> str: """Name of phone set""" @@ -115,20 +121,24 @@ def __str__(self) -> str: class DatasetType(enum.Enum): """Enum for types of sound files""" - NONE = 0 - ACOUSTIC_CORPUS = 1 - TEXT_CORPUS = 2 - ACOUSTIC_CORPUS_WITH_DICTIONARY = 3 - TEXT_CORPUS_WITH_DICTIONARY = 4 - DICTIONARY = 5 + NONE = 0 #: Nothing has been imported + ACOUSTIC_CORPUS = 1 #: Imported corpus with sound files (and maybe text files) + TEXT_CORPUS = 2 #: Imported corpus with just text files + ACOUSTIC_CORPUS_WITH_DICTIONARY = ( + 3 #: Imported corpus and pronunciation dictionary with sound files + ) + TEXT_CORPUS_WITH_DICTIONARY = ( + 4 #: Imported corpus and pronunciation dictionary with just text files + ) + DICTIONARY = 5 #: Only imported pronunciation dictionary (for G2P) class SoundFileType(enum.Enum): """Enum for types of sound files""" - NONE = 0 - WAV = 1 - SOX = 2 + NONE = 0 #: No sound file + WAV = 1 #: Can be read as a .wav file + SOX = 2 #: Needs to use SoX to preprocess def voiceless_variants(base_phone) -> typing.Set[str]: @@ -170,32 +180,109 @@ def voiced_variants(base_phone) -> typing.Set[str]: class PhoneType(enum.Enum): """Enum for types of phones""" - non_silence = 1 - silence = 2 - disambiguation = 3 + non_silence = 1 #: Speech sounds + silence = 2 #: Silence phones + oov = 3 #: Out of vocabulary/spoken noise phones + disambiguation = 4 #: Disambiguation phones internal to Kaldi + extra = 5 #: Phones not to be included generally, i.e., loaded from reference intervals + + +class WorkflowType(enum.Enum): + """ + Enum for workflows involving corpora + + Parameters + ---------- + reference: int + Load alignments from reference directory + alignment: int + Align using corpus texts, acoustic model, and pronunciation dictionary + transcription: int + Transcribe using acoustic model, pronunciation dictionary, and language model + phone_transcription: int + Transcribe using acoustic model and phone-based language model + per_speaker_transcription: int + Transcribe using acoustic model, pronunciation dictionary, and per-speaker language model generated by corpus texts + speaker_diarization: int + Diarize speakers + online_alignment: int + Online alignment + acoustic_training: int + Acoustic model training + acoustic_model_adaptation: int + Acoustic model adaptation + segmentation: int + Segment based on speech activity + """ + + reference = 0 + alignment = 1 + transcription = 2 + phone_transcription = 3 + per_speaker_transcription = 4 + speaker_diarization = 5 + online_alignment = 6 + acoustic_training = 7 + acoustic_model_adaptation = 8 + segmentation = 9 + train_g2p = 10 + g2p = 11 + language_model_training = 12 class WordType(enum.Enum): """Enum for types of words""" - speech = 1 - clitic = 2 - silence = 3 - oov = 4 - bracketed = 5 - laughter = 6 - noise = 7 - music = 8 + speech = 1 #: General speech words + clitic = 2 #: Clitics that must attach to words + silence = 3 #: Words representing silence + oov = 4 #: Words representing out of vocabulary items + bracketed = 5 #: Words that are in brackets + cutoff = 6 #: Words that are cutoffs of particular words or hesitations of the next word + laughter = 7 #: Words that represent laughter + noise = 8 #: Words that represent non-speech noise + music = 9 #: Words that represent music + disambiguation = 10 #: Disambiguation symbols internal to Kaldi + + +class DistanceMetric(enum.Enum): + + cosine = "cosine" + plda = "plda" + euclidean = "euclidean" + + +class ClusterType(enum.Enum): + """Enum for supported clustering algorithms""" + + mfa = "mfa" + affinity = "affinity" + agglomerative = "agglomerative" + spectral = "spectral" + dbscan = "dbscan" + hdbscan = "hdbscan" + optics = "optics" + kmeans = "kmeans" + meanshift = "meanshift" + + +class ManifoldAlgorithm(enum.Enum): + """Enum for supported manifold visualization algorithms""" + + tsne = "tsne" + mds = "mds" + spectral = "spectral" + isomap = "isomap" class PhoneSetType(enum.Enum): """Enum for types of phone sets""" - UNKNOWN = "UNKNOWN" - AUTO = "AUTO" - IPA = "IPA" - ARPA = "ARPA" - PINYIN = "PINYIN" + UNKNOWN = "UNKNOWN" #: Unknown + AUTO = "AUTO" #: Inspect dictionary to pick the most common phone set type + IPA = "IPA" #: IPA-based phoneset + ARPA = "ARPA" #: US English-based Arpabet + PINYIN = "PINYIN" #: Pinyin for Mandarin def __str__(self) -> str: """Name of phone set""" @@ -1152,6 +1239,393 @@ class WordData: pronunciations: typing.Set[typing.Tuple[str, ...]] +# noinspection PyUnresolvedReferences +@dataclassy.dataclass(slots=True) +class NgramHistoryState: + """ + Data class for storing ngram history + """ + + backoff_prob: float = 1.0 + word_to_prob: dict = {} + + +class ArpaNgramModel: + """ + Wrapper class for ngram models, taken largely from :kaldi_utils`:`lang/internal/arpa2fst_constrained.py` + """ + + def __init__(self): + self.orders = {0: collections.defaultdict(NgramHistoryState)} + + @classmethod + def read(cls, input: typing.Union[io.StringIO, str]): + """ + Read an ngram model from a stream + + Parameters + ---------- + input: :class:`io.StringIO` or str + Input stream or file path to read + + Returns + ------- + :class:`~montreal_forced_aligner.data.ArpaNgramModel` + Constructed model + """ + cleanup = False + if isinstance(input, str): + cleanup = True + input = open(input, "r", encoding="utf8") + log10 = math.log(10.0) + current_order = -1 + model = ArpaNgramModel() + for line in input: + line = line.strip() + if not line: + continue + m = re.match(r"\\(?P[0-9]*)-grams:$", line) + if m: + current_order = int(m.group("order")) + model.orders[current_order] = collections.defaultdict(NgramHistoryState) + continue + if current_order < 1: + continue + if line.startswith("\\"): + continue + col = line.split() + prob = math.exp(float(col[0]) * log10) + hist = tuple(col[1:current_order]) + word = col[current_order] # a string + backoff_prob = ( + math.exp(float(col[current_order + 1]) * log10) + if len(col) == current_order + 2 + else None + ) + + model.orders[current_order - 1][hist].word_to_prob[word] = prob + if backoff_prob is not None: + model.orders[current_order][hist + (word,)].backoff_prob = backoff_prob + if cleanup: + input.close() + return model + + def history_to_fst_state_mapping( + self, min_order: int = None, max_order: int = None + ) -> typing.Tuple[ + typing.Dict[typing.Tuple[str, ...], int], typing.List[typing.Tuple[str, ...]] + ]: + """ + + This function, called from PrintAsFst, returns (hist_to_state, + state_to_hist), which map from history (as a tuple of strings) to + integer FST-state and vice versa. + + Parameters + ---------- + min_order: int, optional + Minimum order of ngrams to construct state mapping + max_order: int, optional + Maximum order of ngrams to construct state mapping + + Returns + ------- + typing.Dict[typing.Tuple[str, ...], int] + History to state mapping + typing.List[typing.Tuple[str, ...]] + State to history mapping + """ + + hist_to_state = {} + state_to_hist = [] + + # Make sure the initial bigram state comes first (and that + # we have such a state even if it was completely pruned + # away in the bigram LM.. which is unlikely of course) + hist = ("",) + hist_to_state[hist] = len(state_to_hist) + state_to_hist.append(hist) + + # create a bigram state for each of the 'real' words... even if the LM + # didn't naturally have such bigram states, we'll create them so that we + # can enforce the bigram constraints supplied in 'bigrams_file' by the + # user. + for word in self.orders[0][()].word_to_prob: + if word != "" and word != "": + hist = (word,) + hist_to_state[hist] = len(state_to_hist) + state_to_hist.append(hist) + + # note: we do not allocate an FST state for the unigram state, because + # we don't have a unigram state in the output FST, only bigram states; and + # we don't iterate over bigram histories because we covered them all above; + # that's why we start 'n' from 2 below instead of from 0. + for order, history_states in self.orders.items(): + if min_order is not None and order < min_order: + continue + if max_order is not None and order > max_order: + continue + for hist in history_states.keys(): + # note: hist is a tuple of strings. + assert hist not in hist_to_state + hist_to_state[hist] = len(state_to_hist) + state_to_hist.append(hist) + + return (hist_to_state, state_to_hist) + + def _get_prob(self, hist: typing.Tuple[str, ...], word: str) -> float: + """ + Returns the probability of word 'word' in history-state 'hist'. + Dies with error if this word is not predicted at all by the LM (not in vocab). + history-state does not exist. + + Parameters + ---------- + hist: tuple[str,...] + History for ngram + word: str + Current word + + Returns + ------- + float + Probability + """ + assert len(hist) < len(self.orders) + if len(hist) == 0: + word_to_prob = self.orders[0][()].word_to_prob + return word_to_prob[word] + else: + if hist in self.orders[len(hist)]: + hist_state = self.orders[len(hist)][hist] + if word in hist_state.word_to_prob: + return hist_state.word_to_prob[word] + else: + return hist_state.backoff_prob * self._get_prob(hist[1:], word) + else: + return self._get_prob(hist[1:], word) + + def _get_state_for_hist(self, hist_to_state, hist) -> int: + """ + This gets the state corresponding to 'hist' in 'hist_to_state', but backs + off for us if there is no such state. + + Parameters + ---------- + hist_to_state: dict[tuple[str, ...], int] + Mapping of history to states + hist: tuple[str, ...] + History to look up + + Returns + ------- + int + State for history + """ + if hist in hist_to_state: + return hist_to_state[hist] + else: + assert len(hist) > 1 + return self._get_state_for_hist(hist_to_state, hist[1:]) + + def construct_bigram_fst( + self, + disambig_symbol: str, + bigram_map: typing.Dict[str, typing.Set[str]], + symbols: pywrapfst.SymbolTable, + ) -> pynini.Fst: + """ + + This function prints the estimated language model as an FST. + disambig_symbol will be something like '#0' (a symbol introduced + to make the result determinizable). + bigram_map represent the allowed bigrams (left-word, right-word): it's a map + from left-word to a set of right-words (both are strings). + + Parameters + ---------- + disambig_symbol: str + Disambiguation symbol + bigram_map: dict[str, set[str]] + Mapping of left bigrams to allowed right bigrams + symbols: :class:`pywrapfst.SymbolTable` + Symbol table for the FST + + Returns + ------- + :class:`pynini.Fst` + Bigram FST + """ + + # History will map from history (as a tuple) to integer FST-state. + (hist_to_state, state_to_hist) = self.history_to_fst_state_mapping(min_order=2) + + # The following 3 things are just for diagnostics. + normalization_stats = [[0, 0.0] for _ in range(len(self.orders))] + num_ngrams_allowed = 0 + num_ngrams_disallowed = 0 + + fst = pynini.Fst() + for state in range(len(state_to_hist)): + s = fst.add_state() + hist = state_to_hist[state] + hist_len = len(hist) + assert hist_len > 0 + if hist_len == 1: # it's a bigram state... + context_word = hist[0] + if context_word not in bigram_map: + continue + # word list is a list of words that can follow this word. It must be nonempty. + word_list = list(bigram_map[context_word]) + + normalization_stats[hist_len][0] += 1 + + for word in word_list: + prob = self._get_prob((context_word,), word) + assert prob != 0 + normalization_stats[hist_len][1] += prob + cost = -math.log(prob) + if word == "": + fst.set_final(s, pywrapfst.Weight(fst.weight_type(), cost)) + else: + next_state = self._get_state_for_hist(hist_to_state, (context_word, word)) + k = symbols.find(word) + fst.add_arc(state, pywrapfst.Arc(k, k, cost, next_state)) + else: # it's a higher-order than bigram state. + assert hist in self.orders[hist_len] + hist_state = self.orders[hist_len][hist] + most_recent_word = hist[-1] + + normalization_stats[hist_len][0] += 1 + normalization_stats[hist_len][1] += sum( + self._get_prob(hist, word) for word in bigram_map[most_recent_word] + ) + + for word, prob in hist_state.word_to_prob.items(): + cost = -math.log(prob) + if word in bigram_map[most_recent_word]: + num_ngrams_allowed += 1 + else: + num_ngrams_disallowed += 1 + continue + if word == "": + fst.set_final(s, pywrapfst.Weight(fst.weight_type(), cost)) + else: + next_state = self._get_state_for_hist(hist_to_state, (hist) + (word,)) + k = symbols.find(word) + fst.add_arc(state, pywrapfst.Arc(k, k, cost, next_state)) + + assert hist in self.orders[hist_len] + backoff_prob = self.orders[hist_len][hist].backoff_prob + assert backoff_prob != 0.0 + cost = -math.log(backoff_prob) + backoff_hist = hist[1:] + backoff_state = self._get_state_for_hist(hist_to_state, backoff_hist) + + this_disambig_symbol = ( + disambig_symbol if len(hist_state.word_to_prob) != 0 else "" + ) + k = symbols.find(this_disambig_symbol) + eps = symbols.find("") + fst.add_arc(state, pywrapfst.Arc(k, eps, cost, backoff_state)) + fst.set_start(0) + return fst + + def export_bigram_fst( + self, + output: typing.Union[str, io.StringIO], + disambig_symbol: str, + bigram_map: typing.Dict[str, typing.Set[str]], + ) -> None: + """ + + This function prints the estimated language model as an FST. + disambig_symbol will be something like '#0' (a symbol introduced + to make the result determinizable). + bigram_map represent the allowed bigrams (left-word, right-word): it's a map + from left-word to a set of right-words (both are strings). + + Parameters + ---------- + output: :class:`io.StringIO` or str + Output stream or file name to export to + disambig_symbol: str + Disambiguation symbol to use + bigram_map: dict[str, set[str]] + Mapping of left bigrams to allowed right bigrams + + """ + + # History will map from history (as a tuple) to integer FST-state. + (hist_to_state, state_to_hist) = self.history_to_fst_state_mapping(min_order=2) + + # The following 3 things are just for diagnostics. + normalization_stats = [[0, 0.0] for _ in range(len(self.orders))] + num_ngrams_allowed = 0 + num_ngrams_disallowed = 0 + + if isinstance(output, str): + output = open(output, "w", encoding="utf8") + for state in range(len(state_to_hist)): + hist = state_to_hist[state] + hist_len = len(hist) + assert hist_len > 0 + if hist_len == 1: # it's a bigram state... + context_word = hist[0] + if context_word not in bigram_map: + continue + # word list is a list of words that can follow this word. It must be nonempty. + word_list = list(bigram_map[context_word]) + + normalization_stats[hist_len][0] += 1 + + for word in word_list: + prob = self._get_prob((context_word,), word) + assert prob != 0 + normalization_stats[hist_len][1] += prob + cost = -math.log(prob) + if word == "": + output.write(f"{state} {cost:.3f}\n") + else: + next_state = self._get_state_for_hist(hist_to_state, (context_word, word)) + output.write(f"{state} {next_state} {word} {word} {cost:.3f}\n") + else: # it's a higher-order than bigram state. + assert hist in self.orders[hist_len] + hist_state = self.orders[hist_len][hist] + most_recent_word = hist[-1] + + normalization_stats[hist_len][0] += 1 + normalization_stats[hist_len][1] += sum( + self._get_prob(hist, word) for word in bigram_map[most_recent_word] + ) + + for word, prob in hist_state.word_to_prob.items(): + cost = -math.log(prob) + if word in bigram_map[most_recent_word]: + num_ngrams_allowed += 1 + else: + num_ngrams_disallowed += 1 + continue + if word == "": + output.write(f"{state} {cost:.3f}\n") + else: + next_state = self._get_state_for_hist(hist_to_state, (hist) + (word,)) + output.write(f"{state} {next_state} {word} {word} {cost:.3f}\n") + + assert hist in self.orders[hist_len] + backoff_prob = self.orders[hist_len][hist].backoff_prob + assert backoff_prob != 0.0 + cost = -math.log(backoff_prob) + backoff_hist = hist[1:] + backoff_state = self._get_state_for_hist(hist_to_state, backoff_hist) + + this_disambig_symbol = ( + disambig_symbol if len(hist_state.word_to_prob) != 0 else "" + ) + output.write(f"{state} {backoff_state} {this_disambig_symbol} {cost:.3f}") + output.close() + + # noinspection PyUnresolvedReferences @dataclassy.dataclass(slots=True) class PronunciationProbabilityCounter: @@ -1172,7 +1646,6 @@ class PronunciationProbabilityCounter: Counts of silence before pronunciation non_silence_before_counts: collections.Counter Counts of non-silence before pronunciation - """ ngram_counts: collections.defaultdict = dataclassy.factory(collections.defaultdict) @@ -1224,19 +1697,26 @@ class CtmInterval: End time of interval label: str Text of interval - utterance: str - Utterance ID that the interval belongs to + confidence: float, optional + Confidence score of the interval """ begin: float end: float - label: str - utterance: int + label: typing.Union[int, str] + confidence: typing.Optional[float] = None def __lt__(self, other: CtmInterval): """Sorting function for CtmIntervals""" return self.begin < other.begin + def __add__(self, other): + if isinstance(other, str): + return self.label + other + else: + self.begin += other + self.end += other + def __post_init__(self) -> None: """ Check on data validity @@ -1249,7 +1729,7 @@ def __post_init__(self) -> None: if self.end < -1 or self.begin == 1000000: raise CtmError(self) - def to_tg_interval(self) -> Interval: + def to_tg_interval(self, file_duration=None) -> Interval: """ Converts the CTMInterval to `PraatIO's Interval class `_ @@ -1261,4 +1741,35 @@ def to_tg_interval(self) -> Interval: """ if self.end < -1 or self.begin == 1000000: raise CtmError(self) - return Interval(round(self.begin, 4), round(self.end, 4), self.label) + end = round(self.end, 5) + if file_duration is not None and end > file_duration: + end = file_duration + return Interval(round(self.begin, 5), end, self.label) + + +# noinspection PyUnresolvedReferences +@dataclassy.dataclass(slots=True) +class WordCtmInterval: + """ + Data class for word intervals derived from CTM files + + Parameters + ---------- + begin: float + Start time of interval + end: float + End time of interval + word_id: int + Integer id of word + pronunciation_id: int + Pronunciation integer id of word + """ + + begin: float + end: float + word_id: int + pronunciation_id: int + + def __lt__(self, other: WordCtmInterval): + """Sorting function for WordCtmIntervals""" + return self.begin < other.begin diff --git a/montreal_forced_aligner/db.py b/montreal_forced_aligner/db.py index 7760f2f2..7f3665b3 100644 --- a/montreal_forced_aligner/db.py +++ b/montreal_forced_aligner/db.py @@ -1,35 +1,45 @@ """Database classes""" from __future__ import annotations +import logging import os +import re import typing import librosa import numpy as np import sqlalchemy +from pgvector.sqlalchemy import Vector from praatio import textgrid -from praatio.utilities.constants import Interval, TextgridFormats -from sqlalchemy import Boolean, Column, Enum, Float, ForeignKey, Integer, String +from praatio.utilities.constants import Interval +from sqlalchemy import Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String from sqlalchemy.ext.orderinglist import ordering_list -from sqlalchemy.orm import Bundle, column_property, declarative_base, relationship +from sqlalchemy.orm import Bundle, declarative_base, relationship +from montreal_forced_aligner.config import IVECTOR_DIMENSION, PLDA_DIMENSION, XVECTOR_DIMENSION from montreal_forced_aligner.data import ( CtmInterval, PhoneSetType, PhoneType, TextFileType, WordType, + WorkflowType, ) from montreal_forced_aligner.helper import mfa_open if typing.TYPE_CHECKING: from montreal_forced_aligner.corpus.classes import UtteranceData +logger = logging.getLogger("mfa") + __all__ = [ + "Corpus", + "CorpusWorkflow", + "PhonologicalRule", + "RuleApplication", "DictBundle", "Dictionary", "Word", - "OovWord", "Phone", "Pronunciation", "File", @@ -40,13 +50,95 @@ "Utterance", "PhoneInterval", "WordInterval", - "ReferencePhoneInterval", + "M2MSymbol", + "Job", + "Word2Job", + "M2M2Job", + "Grapheme", "MfaSqlBase", + "bulk_update", ] MfaSqlBase = declarative_base() +def bulk_update( + session: sqlalchemy.orm.Session, + table: MfaSqlBase, + values: typing.List[typing.Dict[str, typing.Any]], + id_field=None, +) -> None: + """ + Perform a bulk update of a database. + + Parameters + ---------- + session: :class:`sqlalchemy.orm.Session` + SqlAlchemy session to use + table: :class:`~montreal_forced_aligner.db.MfaSqlBase` + Table to update + values: list[dict[str, Any]] + List of field-value dictionaries to insert + id_field: str, optional + Optional specifier of the primary key field + """ + if len(values) == 0: + return + if id_field is None: + id_field = "id" + + column_names = [x for x in values[0].keys()] + columns = [getattr(table, x)._copy() for x in column_names if x != id_field] + sql_column_names = [f'"{x}"' for x in column_names if x != id_field] + with session.begin_nested(): + temp_table = sqlalchemy.Table( + f"temp_{table.__tablename__}", + MfaSqlBase.metadata, + sqlalchemy.Column(id_field, sqlalchemy.Integer, primary_key=True), + *columns, + prefixes=["TEMPORARY"], + extend_existing=True, + ) + create_statement = str( + sqlalchemy.schema.CreateTable(temp_table).compile(session.get_bind()) + ) + session.execute(sqlalchemy.text(create_statement)) + session.execute(temp_table.insert(), values) + + set_statements = [] + for c in sql_column_names: + set_statements.append(f""" {c} = b.{c}""") + set_statements = ",\n".join(set_statements) + sql = f""" + UPDATE {table.__tablename__} + SET + {set_statements} + FROM temp_{table.__tablename__} AS b + WHERE {table.__tablename__}.{id_field}=b.{id_field}; + """ + session.execute(sqlalchemy.text(sql)) + + # drop temp table + session.execute(sqlalchemy.text(f"DROP TABLE temp_{table.__tablename__}")) + MfaSqlBase.metadata.remove(temp_table) + + +Dictionary2Job = sqlalchemy.Table( + "dictionary_job", + MfaSqlBase.metadata, + Column("dictionary_id", ForeignKey("dictionary.id"), primary_key=True), + Column("job_id", ForeignKey("job.id"), primary_key=True), +) + +SpeakerOrdering = sqlalchemy.Table( + "speaker_ordering", + MfaSqlBase.metadata, + Column("speaker_id", ForeignKey("speaker.id"), primary_key=True), + Column("file_id", ForeignKey("file.id"), primary_key=True), + Column("index", Integer, primary_key=True), +) + + class DictBundle(Bundle): """ SqlAlchemy custom Bundle class for loading variable column counts @@ -87,16 +179,76 @@ class Corpus(MfaSqlBase): __tablename__ = "corpus" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(50), unique=True, nullable=False) + path = Column(String, unique=True, nullable=False) imported = Column(Boolean, default=False) + text_normalized = Column(Boolean, default=False) + cutoffs_found = Column(Boolean, default=False) features_generated = Column(Boolean, default=False) + vad_calculated = Column(Boolean, default=False) + ivectors_calculated = Column(Boolean, default=False) + plda_calculated = Column(Boolean, default=False) + xvectors_loaded = Column(Boolean, default=False) alignment_done = Column(Boolean, default=False) transcription_done = Column(Boolean, default=False) alignment_evaluation_done = Column(Boolean, default=False) has_reference_alignments = Column(Boolean, default=False) has_sound_files = Column(Boolean, default=False) has_text_files = Column(Boolean, default=False) + num_jobs = Column(Integer, default=0) + + current_subset = Column(Integer, default=0) + data_directory = Column(String, nullable=False) + + jobs = relationship("Job", back_populates="corpus") + + @property + def split_directory(self): + return os.path.join(self.data_directory, f"split{self.num_jobs}") + + @property + def current_subset_directory(self): + if not self.current_subset: + return self.split_directory + return os.path.join(self.data_directory, f"subset_{self.current_subset}") + + @property + def speaker_ivector_column(self): + if self.plda_calculated: + return Speaker.plda_vector + elif self.xvectors_loaded: + return Speaker.xvector + return Speaker.ivector + + @property + def utterance_ivector_column(self): + if self.plda_calculated: + return Utterance.plda_vector + elif self.xvectors_loaded: + return Utterance.xvector + return Utterance.ivector + + +class Dialect(MfaSqlBase): + """ + Database class for storing information about a dialect + + Parameters + ---------- + id: int + Primary key + name: str + Dialect name + """ + + __tablename__ = "dialect" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(50), nullable=False) + + dictionaries = relationship("Dictionary", back_populates="dialect") + rules = relationship("PhonologicalRule", back_populates="dialect") class Dictionary(MfaSqlBase): @@ -109,6 +261,8 @@ class Dictionary(MfaSqlBase): Primary key name: str Dictionary name + dialect: str + Dialect of dictionary if dictionary name is in MFA format path: str Path to the dictionary phone_set_type: :class:`~montreal_forced_aligner.data.PhoneSetType` @@ -147,45 +301,63 @@ class Dictionary(MfaSqlBase): __tablename__ = "dictionary" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(50), nullable=False) - path = Column(String, unique=True, nullable=False) - phone_set_type = Column(Enum(PhoneSetType)) - root_temp_directory = Column(String, nullable=False) + path = Column(String, unique=True) + phone_set_type = Column(Enum(PhoneSetType), nullable=True) + root_temp_directory = Column(String, nullable=True) clitic_cleanup_regex = Column(String, nullable=True) bracket_regex = Column(String, nullable=True) laughter_regex = Column(String, nullable=True) - position_dependent_phones = Column(Boolean, nullable=False) + position_dependent_phones = Column(Boolean, nullable=True) default = Column(Boolean, default=False, nullable=False) clitic_marker = Column(String(1), nullable=True) - silence_word = Column(String, nullable=False) - optional_silence_phone = Column(String, nullable=False) - oov_word = Column(String, nullable=False) - bracketed_word = Column(String, nullable=False) - laughter_word = Column(String, nullable=False) - - use_g2p = Column(Boolean, nullable=False) + silence_word = Column(String, nullable=True) + optional_silence_phone = Column(String, nullable=True) + oov_word = Column(String, nullable=True) + oov_phone = Column(String, nullable=True) + bracketed_word = Column(String, nullable=True) + laughter_word = Column(String, nullable=True) + + use_g2p = Column(Boolean, nullable=False, default=False) max_disambiguation_symbol = Column(Integer, default=0, nullable=False) silence_probability = Column(Float, default=0.5, nullable=False) initial_silence_probability = Column(Float, default=0.5, nullable=False) final_silence_correction = Column(Float, nullable=True) final_non_silence_correction = Column(Float, nullable=True) + dialect_id = Column(Integer, ForeignKey("dialect.id"), index=True, nullable=True) + dialect = relationship("Dialect", back_populates="dictionaries") + words = relationship( "Word", back_populates="dictionary", order_by="Word.mapping_id", collection_class=ordering_list("mapping_id"), - cascade="all, delete-orphan", + cascade="all, delete", ) - oov_words = relationship( - "OovWord", + speakers = relationship( + "Speaker", back_populates="dictionary", - order_by="OovWord.count.desc()", - collection_class=ordering_list("count", ordering_func=lambda x: -x), cascade="all, delete-orphan", ) - speakers = relationship("Speaker", back_populates="dictionary") + + jobs = relationship( + "Job", + secondary=Dictionary2Job, + back_populates="dictionaries", + ) + + @property + def special_set(self) -> typing.Set[str]: + return { + "", + "", + self.silence_word, + self.oov_word, + self.bracketed_word, + self.laughter_word, + } @property def clitic_set(self) -> typing.Set[str]: @@ -238,6 +410,27 @@ def lexicon_disambig_fst_path(self) -> str: """ return os.path.join(self.temp_directory, "L.disambig_fst") + @property + def align_lexicon_path(self) -> str: + """ + Path of lexicon file to use for aligning lattices + """ + return os.path.join(self.temp_directory, "align_lexicon.fst") + + @property + def align_lexicon_disambig_path(self) -> str: + """ + Path of lexicon file to use for aligning lattices + """ + return os.path.join(self.temp_directory, "align_lexicon.disambig_fst") + + @property + def align_lexicon_int_path(self) -> str: + """ + Path of lexicon file to use for aligning lattices + """ + return os.path.join(self.temp_directory, "align_lexicon.int") + @property def lexicon_fst_path(self) -> str: """ @@ -262,11 +455,6 @@ def identifier(self) -> str: """Dictionary name""" return f"{self.data_source_identifier}" - @property - def output_directory(self) -> str: - """Temporary directory for the dictionary""" - return os.path.join(self.temporary_directory, self.identifier) - @property def silence_probability_info(self) -> typing.Dict[str, float]: """Dictionary of silence information""" @@ -296,12 +484,22 @@ class Phone(MfaSqlBase): __tablename__ = "phone" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) mapping_id = Column(Integer, nullable=False, unique=True) - phone = Column(String(10), unique=True, nullable=False) - count = Column(Integer, nullable=False, default=0) + phone = Column(String(10), nullable=False) + kaldi_label = Column(String(10), unique=True, nullable=False) + position = Column(String(2), nullable=True) phone_type = Column(Enum(PhoneType), nullable=False, index=True) + phone_intervals = relationship( + "PhoneInterval", + back_populates="phone", + order_by="PhoneInterval.begin", + collection_class=ordering_list("begin"), + cascade="all, delete", + passive_deletes=True, + ) + class Grapheme(MfaSqlBase): """ @@ -319,9 +517,9 @@ class Grapheme(MfaSqlBase): __tablename__ = "grapheme" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) mapping_id = Column(Integer, nullable=False, unique=True) - grapheme = Column(String(10), unique=True, nullable=False) + grapheme = Column(String(25), unique=True, nullable=False) class Word(MfaSqlBase): @@ -348,19 +546,29 @@ class Word(MfaSqlBase): __tablename__ = "word" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) mapping_id = Column(Integer, nullable=False, index=True) word = Column(String, nullable=False, index=True) count = Column(Integer, default=0, nullable=False, index=True) word_type = Column(Enum(WordType), nullable=False, index=True) dictionary_id = Column(Integer, ForeignKey("dictionary.id"), nullable=False, index=True) - dictionary: Dictionary = relationship("Dictionary", back_populates="words") - pronunciations = relationship("Pronunciation", back_populates="word") + dictionary = relationship("Dictionary", back_populates="words") + pronunciations = relationship( + "Pronunciation", back_populates="word", cascade="all, delete", passive_deletes=True + ) job = relationship( "Word2Job", back_populates="word", uselist=False, + cascade="all, delete", + ) + word_intervals = relationship( + "WordInterval", + back_populates="word", + order_by="WordInterval.begin", + collection_class=ordering_list("begin"), + cascade="all, delete", ) __table_args__ = ( @@ -369,35 +577,6 @@ class Word(MfaSqlBase): ) -class OovWord(MfaSqlBase): - """ - Database class for storing words, their integer IDs, and pronunciation information - - Parameters - ---------- - id: int - Primary key - word: str - Word label - count: int - Count frequency of word in the corpus - dictionary_id: int - Foreign key to :class:`~montreal_forced_aligner.db.Dictionary` - dictionary: :class:`~montreal_forced_aligner.db.Dictionary` - Pronunciation dictionary that the word belongs to - """ - - __tablename__ = "oov_word" - - id = Column(Integer, primary_key=True) - word = Column(String, nullable=False, index=True) - count = Column(Integer, default=0, nullable=False, index=True) - dictionary_id = Column(Integer, ForeignKey("dictionary.id"), nullable=False, index=True) - dictionary: Dictionary = relationship("Dictionary", back_populates="oov_words") - - __table_args__ = (sqlalchemy.Index("oov_word_dictionary_index", "word", "dictionary_id"),) - - class Pronunciation(MfaSqlBase): """ Database class for storing information about a pronunciation @@ -424,15 +603,239 @@ class Pronunciation(MfaSqlBase): __tablename__ = "pronunciation" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) pronunciation = Column(String, nullable=False) probability = Column(Float, nullable=True) disambiguation = Column(Integer, nullable=True) silence_after_probability = Column(Float, nullable=True) silence_before_correction = Column(Float, nullable=True) non_silence_before_correction = Column(Float, nullable=True) - word_id = Column(Integer, ForeignKey("word.id"), nullable=False, index=True) - word: Word = relationship("Word", back_populates="pronunciations") + + count = Column(Integer, nullable=False, default=0) + silence_following_count = Column(Integer, nullable=True) + non_silence_following_count = Column(Integer, nullable=True) + + word_id = Column( + Integer, ForeignKey("word.id", ondelete="CASCADE"), nullable=False, index=True + ) + word = relationship("Word", back_populates="pronunciations") + + base_pronunciation_id = Column( + Integer, ForeignKey("pronunciation.id"), nullable=False, index=True + ) + variants = relationship( + "Pronunciation", backref=sqlalchemy.orm.backref("base_pronunciation", remote_side=[id]) + ) + + rules = relationship( + "RuleApplication", + back_populates="pronunciation", + cascade="all, delete", + ) + + word_intervals = relationship( + "WordInterval", + back_populates="pronunciation", + order_by="WordInterval.begin", + collection_class=ordering_list("begin"), + cascade="all, delete", + ) + + +class PhonologicalRule(MfaSqlBase): + """ + Database class for storing information about a phonological rule + + Parameters + ---------- + id: int + Primary key + segment: str + Segment to replace + preceding_context: str + Context before segment to match + following_context: str + Context after segment to match + replacement: str + Replacement of segment + probability: float + Probability of the rule application + silence_after_probability: float + Probability of silence following forms with rule application + silence_before_correction: float + Correction factor for silence before forms with rule application + non_silence_before_correction: float + Correction factor for non-silence before forms with rule application + pronunciations: list[:class:`~montreal_forced_aligner.db.RuleApplication`] + List of rule applications + """ + + __tablename__ = "phonological_rule" + + id = Column(Integer, primary_key=True, autoincrement=True) + + segment = Column(String, nullable=False, index=True) + preceding_context = Column(String, nullable=False, index=True) + following_context = Column(String, nullable=False, index=True) + replacement = Column(String, nullable=False) + + probability = Column(Float, nullable=True) + silence_after_probability = Column(Float, nullable=True) + silence_before_correction = Column(Float, nullable=True) + non_silence_before_correction = Column(Float, nullable=True) + + dialect_id = Column(Integer, ForeignKey("dialect.id"), index=True, nullable=False) + dialect = relationship("Dialect", back_populates="rules") + + pronunciations = relationship( + "RuleApplication", + back_populates="rule", + cascade="all, delete", + ) + + def __hash__(self): + return hash( + (self.segment, self.preceding_context, self.following_context, self.replacement) + ) + + def to_json(self) -> typing.Dict[str, typing.Any]: + """ + Serializes the rule for export + + Returns + ------- + dict[str, Any] + Serialized rule + """ + return { + "segment": self.segment, + "dialect": self.dialect, + "preceding_context": self.preceding_context, + "following_context": self.following_context, + "replacement": self.replacement, + "probability": self.probability, + "silence_after_probability": self.silence_after_probability, + "silence_before_correction": self.silence_before_correction, + "non_silence_before_correction": self.non_silence_before_correction, + } + + @property + def match_regex(self): + """Regular expression of the rule""" + components = [] + initial = False + final = False + preceding = self.preceding_context + following = self.following_context + if preceding.startswith("^"): + initial = True + preceding = preceding.replace("^", "").strip() + if following.endswith("$"): + final = True + following = following.replace("$", "").strip() + if preceding: + + components.append(rf"(?P{preceding})") + if self.segment: + components.append(rf"(?P{self.segment})") + if following: + components.append(rf"(?P{following})") + pattern = " ".join(components) + if initial: + pattern = "^" + pattern + else: + pattern = r"(?:^|(?<=\s))" + pattern + if final: + pattern += "$" + else: + pattern += r"(?:$|(?=\s))" + return re.compile(pattern, flags=re.UNICODE) + + def __str__(self): + from_components = [] + to_components = [] + initial = False + final = False + preceding = self.preceding_context + following = self.following_context + if preceding.startswith("^"): + initial = True + preceding = preceding.replace("^", "").strip() + if following.endswith("$"): + final = True + following = following.replace("$", "").strip() + if preceding: + from_components.append(preceding) + to_components.append(preceding) + if self.segment: + from_components.append(self.segment) + if self.replacement: + to_components.append(self.replacement) + if following: + from_components.append(following) + to_components.append(following) + + from_string = " ".join(from_components) + to_string = " ".join(to_components) + if initial: + from_string = "^" + from_string + if final: + from_string += "$" + return f" {to_string}>" + + def apply_rule(self, pronunciation: str) -> str: + """ + Apply the rule on a pronunciation by replacing any matching segments with the replacement + + Parameters + ---------- + pronunciation: str + Pronunciation to apply rule + + Returns + ------- + str + Pronunciation with rule applied + """ + preceding = self.preceding_context + following = self.following_context + if preceding.startswith("^"): + preceding = preceding.replace("^", "").strip() + if following.startswith("$"): + following = following.replace("$", "").strip() + components = [] + if preceding: + components.append(r"\g") + if self.replacement: + components.append(self.replacement) + if following: + components.append(r"\g") + return self.match_regex.sub(" ".join(components), pronunciation).strip() + + +class RuleApplication(MfaSqlBase): + """ + Database class for mapping rules to generated pronunciations + + Parameters + ---------- + pronunciation_id: int + Foreign key to :class:`~montreal_forced_aligner.db.Pronunciation` + rule_id: int + Foreign key to :class:`~montreal_forced_aligner.db.PhonologicalRule` + pronunciation: :class:`~montreal_forced_aligner.db.Pronunciation` + Pronunciation + rule: :class:`~montreal_forced_aligner.db.PhonologicalRule` + Rule applied + """ + + __tablename__ = "rule_applications" + pronunciation_id = Column(ForeignKey("pronunciation.id", ondelete="CASCADE"), primary_key=True) + rule_id = Column(ForeignKey("phonological_rule.id", ondelete="CASCADE"), primary_key=True) + + pronunciation = relationship("Pronunciation", back_populates="rules") + + rule = relationship("PhonologicalRule", back_populates="pronunciations") class Speaker(MfaSqlBase): @@ -447,8 +850,6 @@ class Speaker(MfaSqlBase): Name of the speaker cmvn: str File index for the speaker's CMVN stats - job_id: int - Multiprocessing job ID for the speaker dictionary_id: int Foreign key to :class:`~montreal_forced_aligner.db.Dictionary` dictionary: :class:`~montreal_forced_aligner.db.Dictionary` @@ -461,43 +862,18 @@ class Speaker(MfaSqlBase): __tablename__ = "speaker" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String, unique=True, nullable=False) cmvn = Column(String) - job_id = Column(Integer, index=True) + min_f0 = Column(Float, nullable=True) + max_f0 = Column(Float, nullable=True) + ivector = Column(Vector(IVECTOR_DIMENSION), nullable=True) + plda_vector = Column(Vector(PLDA_DIMENSION), nullable=True) + xvector = Column(Vector(XVECTOR_DIMENSION), nullable=True) dictionary_id = Column(Integer, ForeignKey("dictionary.id"), nullable=True, index=True) - dictionary: Dictionary = relationship("Dictionary", back_populates="speakers") + dictionary = relationship("Dictionary", back_populates="speakers") utterances = relationship("Utterance", back_populates="speaker") - files = relationship("SpeakerOrdering", back_populates="speaker") - - __table_args__ = (sqlalchemy.Index("job_dictionary_index", "job_id", "dictionary_id"),) - - -class SpeakerOrdering(MfaSqlBase): - """ - Mapping class between :class:`~montreal_forced_aligner.db.Speaker` - and :class:`~montreal_forced_aligner.db.File` that preserves the order of tiers - - Parameters - ---------- - speaker_id: int - Foreign key to :class:`~montreal_forced_aligner.db.Speaker` - file_id: int - Foreign key to :class:`~montreal_forced_aligner.db.File` - index: int - Position of speaker in the input TextGrid - speaker: :class:`~montreal_forced_aligner.db.Speaker` - Speaker object - file: :class:`~montreal_forced_aligner.db.File` - File object - """ - - __tablename__ = "speaker_ordering" - speaker_id = Column(ForeignKey("speaker.id"), primary_key=True) - file_id = Column(ForeignKey("file.id"), primary_key=True) - index = Column(Integer) - speaker: Speaker = relationship("Speaker", back_populates="files") - file: File = relationship("File", back_populates="speakers") + files = relationship("File", secondary=SpeakerOrdering, back_populates="speakers") class File(MfaSqlBase): @@ -518,7 +894,7 @@ class File(MfaSqlBase): TextFile object with information about the transcript of a file sound_file: :class:`~montreal_forced_aligner.db.SoundFile` SoundFile object with information about the audio of a file - speakers: list[:class:`~montreal_forced_aligner.db.SpeakerOrdering`] + speakers: list[:class:`~montreal_forced_aligner.db.Speaker`] Speakers in the file ordered by their index utterances: list[:class:`~montreal_forced_aligner.db.Utterance`] Utterances in the file @@ -526,29 +902,28 @@ class File(MfaSqlBase): __tablename__ = "file" - id = Column(Integer, primary_key=True) - name = Column(String, nullable=False) + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False, index=True) relative_path = Column(String, nullable=False) - modified = Column(Boolean, nullable=False, default=False) + modified = Column(Boolean, nullable=False, default=False, index=True) speakers = relationship( - "SpeakerOrdering", - back_populates="file", - order_by="SpeakerOrdering.index", - collection_class=ordering_list("index"), - cascade="all, delete-orphan", + "Speaker", + secondary=SpeakerOrdering, + back_populates="files", + order_by=SpeakerOrdering.c.index, ) - text_file: TextFile = relationship( - "TextFile", back_populates="file", uselist=False, cascade="all, delete-orphan" + text_file = relationship( + "TextFile", back_populates="file", uselist=False, cascade="all, delete" ) - sound_file: SoundFile = relationship( - "SoundFile", back_populates="file", uselist=False, cascade="all, delete-orphan" + sound_file = relationship( + "SoundFile", back_populates="file", uselist=False, cascade="all, delete" ) utterances = relationship( "Utterance", back_populates="file", order_by="Utterance.begin", collection_class=ordering_list("begin"), - cascade="all, delete-orphan", + cascade="all, delete", ) @property @@ -578,9 +953,10 @@ def sample_rate(self) -> int: def save( self, - output_directory: typing.Optional[str] = None, + output_directory, output_format: typing.Optional[str] = None, save_transcription: bool = False, + overwrite: bool = False, ) -> None: """ Output File to TextGrid or lab. If ``text_type`` is not specified, the original file type will be used, @@ -589,16 +965,17 @@ def save( Parameters ---------- - output_directory: str, optional + output_directory: str Directory to output file, if None, then it will overwrite the original file output_format: str, optional Text type to save as, if not provided, it will use either the original file type or guess the file type save_transcription: bool Flag for whether the hypothesized transcription text should be saved instead of the default text """ + from montreal_forced_aligner.alignment.multiprocessing import construct_output_path + utterance_count = len(self.utterances) - overwrite = output_format is None - if overwrite: # Saving directly + if output_format is None: # Saving directly if ( utterance_count == 1 and self.utterances[0].begin == 0 @@ -607,7 +984,9 @@ def save( output_format = TextFileType.LAB.value else: output_format = TextFileType.TEXTGRID.value - output_path = self.construct_output_path(output_directory, output_format=output_format) + output_path = construct_output_path( + self.name, self.relative_path, output_directory, output_format=output_format + ) if overwrite: if self.text_file is None: self.text_file = TextFile( @@ -641,14 +1020,17 @@ def save( max_time = self.sound_file.duration tiers = {} for speaker in self.speakers: - tiers[speaker.speaker.name] = textgrid.IntervalTier( - speaker.speaker.name, [], minT=0, maxT=max_time + tiers[speaker.name] = textgrid.IntervalTier( + speaker.name, [], minT=0, maxT=max_time ) tg = textgrid.Textgrid() tg.maxTimestamp = max_time for utterance in self.utterances: - + if utterance.speaker.name not in tiers: + tiers[utterance.speaker.name] = textgrid.IntervalTier( + utterance.speaker.name, [], minT=0, maxT=max_time + ) if save_transcription: tiers[utterance.speaker.name].entryList.append( Interval( @@ -674,96 +1056,35 @@ def save( tg.addTier(t) tg.save(output_path, includeBlankSpaces=True, format=output_format) - def construct_transcription_tiers(self) -> typing.Dict[str, typing.List[CtmInterval]]: + def construct_transcription_tiers( + self, original_text=False + ) -> typing.Dict[str, typing.Dict[str, typing.List[CtmInterval]]]: """ Construct output transcription tiers for the file Returns ------- - dict[str, list[:class:`~montreal_forced_aligner.data.CtmInterval`]] + dict[str, dict[str, list[:class:`~montreal_forced_aligner.data.CtmInterval`]]] Tier dictionary of utterance transcriptions """ data = {} for u in self.utterances: - speaker_name = "" - for speaker in self.speakers: - if u.speaker_id == speaker.speaker.id: - speaker_name = speaker.speaker.name - break + speaker_name = u.speaker_name if speaker_name not in data: - data[speaker_name] = [] - label = u.transcription_text + data[speaker_name] = {} + if original_text: + label = u.text + key = "text" + else: + label = u.transcription_text + key = "transcription" if not label: label = "" - data[speaker_name].append(CtmInterval(u.begin, u.end, label, u.id)) + if key not in data[speaker_name]: + data[speaker_name][key] = [] + data[speaker_name][key].append(CtmInterval(u.begin, u.end, label)) return data - def construct_output_tiers( - self, - ) -> typing.Dict[str, typing.Dict[str, typing.List[CtmInterval]]]: - """ - Construct aligned output tiers for a file - - Returns - ------- - dict[str, dict[str, list[CtmInterval]]] - Per-speaker aligned "words" and "phones" tiers - """ - data = {} - for utt in self.utterances: - if utt.speaker.name not in data: - data[utt.speaker.name] = {"words": [], "phones": []} - for wi in utt.word_intervals: - data[utt.speaker.name]["words"].append( - CtmInterval(wi.begin, wi.end, wi.label, utt.id) - ) - - for pi in utt.phone_intervals: - data[utt.speaker.name]["phones"].append( - CtmInterval(pi.begin, pi.end, pi.label, utt.id) - ) - return data - - def construct_output_path( - self, - output_directory: typing.Optional[str] = None, - output_format: str = TextgridFormats.SHORT_TEXTGRID, - ) -> str: - """ - Construct the output path for the File - - Parameters - ---------- - output_directory: str, optional - Directory to output to, if None, it will overwrite the original file - output_format: str - File format to save in, one of ``lab``, ``long_textgrid``, ``short_textgrid`` (the default), or ``json`` - - Returns - ------- - str - Output path - """ - if output_format.upper() == "LAB": - extension = ".lab" - elif output_format.upper() == "JSON": - extension = ".json" - else: - extension = ".TextGrid" - if output_directory is None: - if self.text_file is None or not self.text_file.text_file_path.endswith(extension): - return os.path.splitext(self.sound_file.sound_file_path)[0] + extension - return self.text_file.text_file_path - if self.relative_path: - relative = os.path.join(output_directory, self.relative_path) - else: - relative = output_directory - output_path = os.path.join(relative, self.name + extension) - output_dir = os.path.dirname(output_path) - if output_dir: - os.makedirs(output_dir, exist_ok=True) - return output_path - class SoundFile(MfaSqlBase): """ @@ -793,7 +1114,7 @@ class SoundFile(MfaSqlBase): __tablename__ = "sound_file" file_id = Column(ForeignKey("file.id"), primary_key=True) - file: File = relationship("File", back_populates="sound_file") + file = relationship("File", back_populates="sound_file") sound_file_path = Column(String, nullable=False) format = Column(String, nullable=False) sample_rate = Column(Integer, nullable=False) @@ -801,8 +1122,6 @@ class SoundFile(MfaSqlBase): num_channels = Column(Integer, nullable=False) sox_string = Column(String) - waveform: np.array - def normalized_waveform( self, begin: float = 0, end: typing.Optional[float] = None ) -> typing.Tuple[np.array, np.array]: @@ -830,7 +1149,7 @@ def normalized_waveform( self.sound_file_path, sr=None, mono=False, offset=begin, duration=end - begin ) if len(y.shape) > 1 and y.shape[0] == 2: - y /= np.max(np.abs(y), axis=0) + y /= np.max(np.abs(y)) num_steps = y.shape[1] else: y /= np.max(np.abs(y), axis=0) @@ -859,7 +1178,7 @@ class TextFile(MfaSqlBase): __tablename__ = "text_file" file_id = Column(ForeignKey("file.id"), primary_key=True) - file: File = relationship("File", back_populates="text_file") + file = relationship("File", back_populates="text_file") text_file_path = Column(String, nullable=False) file_type = Column(String, nullable=False) @@ -890,8 +1209,6 @@ class Utterance(MfaSqlBase): normalized_text: str Normalized text for the utterance, after removing case and punctuation, and splitting up compounds and clitics if the whole word is not found in the speaker's pronunciation dictionary - normalized_text_int: str - Space-delimited list of the normalized text converted to integer IDs for use in Kaldi programs features:str File index for generated features in_subset: bool @@ -917,30 +1234,31 @@ class Utterance(MfaSqlBase): speaker: :class:`~montreal_forced_aligner.db.Speaker` Speaker object of the utterance phone_intervals: list[:class:`~montreal_forced_aligner.db.PhoneInterval`] - Aligned phone intervals - reference_phone_intervals: list[:class:`~montreal_forced_aligner.db.ReferencePhoneInterval`] Reference phone intervals word_intervals: list[:class:`~montreal_forced_aligner.db.WordInterval`] Aligned word intervals - + job_id: int + Foreign key to :class:`~montreal_forced_aligner.db.Job` + job: :class:`~montreal_forced_aligner.db.Job` + Job that processes the utterance """ __tablename__ = "utterance" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) begin = Column(Float, nullable=False, index=True) end = Column(Float, nullable=False) - duration = Column(Float, nullable=False, index=True) + duration = Column(Float, sqlalchemy.Computed('"end" - "begin"'), index=True) channel = Column(Integer, nullable=False) num_frames = Column(Integer) - text = Column(String, index=True) - oovs = Column(String, index=True) + text = Column(String) + oovs = Column(String) normalized_text = Column(String) normalized_character_text = Column(String) transcription_text = Column(String) - normalized_text_int = Column(String) - normalized_character_text_int = Column(String) features = Column(String) + ivector_ark = Column(String) + vad_ark = Column(String) in_subset = Column(Boolean, nullable=False, default=False, index=True) ignored = Column(Boolean, nullable=False, default=False, index=True) alignment_log_likelihood = Column(Float) @@ -948,42 +1266,172 @@ class Utterance(MfaSqlBase): alignment_score = Column(Float) word_error_rate = Column(Float) character_error_rate = Column(Float) + ivector = Column(Vector(IVECTOR_DIMENSION), nullable=True) + plda_vector = Column(Vector(PLDA_DIMENSION), nullable=True) + xvector = Column(Vector(XVECTOR_DIMENSION), nullable=True) file_id = Column(Integer, ForeignKey("file.id"), index=True, nullable=False) speaker_id = Column(Integer, ForeignKey("speaker.id"), index=True, nullable=False) - file: File = relationship("File", back_populates="utterances") - speaker: Speaker = relationship("Speaker", back_populates="utterances") + kaldi_id = Column( + String, + sqlalchemy.Computed("CAST(speaker_id AS text)|| '-' ||CAST(id AS text)"), + unique=True, + index=True, + ) + job_id = Column(Integer, ForeignKey("job.id"), index=True, nullable=True) + file = relationship("File", back_populates="utterances") + speaker = relationship("Speaker", back_populates="utterances") + job = relationship("Job", back_populates="utterances") phone_intervals = relationship( "PhoneInterval", back_populates="utterance", order_by="PhoneInterval.begin", collection_class=ordering_list("begin"), - ) - reference_phone_intervals = relationship( - "ReferencePhoneInterval", - back_populates="utterance", - order_by="ReferencePhoneInterval.begin", - collection_class=ordering_list("begin"), + cascade="all, delete", ) word_intervals = relationship( "WordInterval", back_populates="utterance", order_by="WordInterval.begin", collection_class=ordering_list("begin"), - ) - kaldi_id = column_property( - speaker_id.cast(sqlalchemy.VARCHAR) + "-" + id.cast(sqlalchemy.VARCHAR) + cascade="all, delete", ) __table_args__ = ( sqlalchemy.Index( "utterance_position_index", "file_id", "speaker_id", "begin", "end", "channel" ), + sqlalchemy.Index( + "utterance_text_idx", + "text", + postgresql_ops={"text": "gin_trgm_ops"}, + postgresql_using="gin", + ), ) def __repr__(self) -> str: """String representation of the utterance object""" return f"" + def phone_intervals_for_workflow(self, workflow_id: int) -> typing.List[CtmInterval]: + """ + Extract phone intervals for a given :class:`~montreal_forced_aligner.db.CorpusWorkflow` + + Parameters + ---------- + workflow_id: int + Integer ID for :class:`~montreal_forced_aligner.db.CorpusWorkflow` + + Returns + ------- + list[:class:`~montreal_forced_aligner.data.CtmInterval`] + List of phone intervals + """ + return [x.as_ctm() for x in self.phone_intervals if x.workflow_id == workflow_id] + + def word_intervals_for_workflow(self, workflow_id: int) -> typing.List[CtmInterval]: + """ + Extract word intervals for a given :class:`~montreal_forced_aligner.db.CorpusWorkflow` + + Parameters + ---------- + workflow_id: int + Integer ID for :class:`~montreal_forced_aligner.db.CorpusWorkflow` + + Returns + ------- + list[:class:`~montreal_forced_aligner.data.CtmInterval`] + List of word intervals + """ + return [x.as_ctm() for x in self.word_intervals if x.workflow_id == workflow_id] + + @property + def reference_phone_intervals(self) -> typing.List[CtmInterval]: + """ + Phone intervals from :attr:`montreal_forced_aligner.data.WorkflowType.reference` + """ + return [ + x.as_ctm() + for x in self.phone_intervals + if x.workflow.workflow_type is WorkflowType.reference + ] + + @property + def aligned_phone_intervals(self) -> typing.List[CtmInterval]: + """ + Phone intervals from :attr:`montreal_forced_aligner.data.WorkflowType.alignment` + """ + return [ + x.as_ctm() + for x in self.phone_intervals + if x.workflow.workflow_type is WorkflowType.alignment + ] + + @property + def aligned_word_intervals(self) -> typing.List[CtmInterval]: + """ + Word intervals from :attr:`montreal_forced_aligner.data.WorkflowType.alignment` + """ + return [ + x.as_ctm() + for x in self.word_intervals + if x.workflow.workflow_type is WorkflowType.alignment + ] + + @property + def transcribed_phone_intervals(self) -> typing.List[CtmInterval]: + """ + Phone intervals from :attr:`montreal_forced_aligner.data.WorkflowType.transcription` + """ + return [ + x.as_ctm() + for x in self.phone_intervals + if x.workflow.workflow_type is WorkflowType.transcription + ] + + @property + def transcribed_word_intervals(self) -> typing.List[CtmInterval]: + """ + Word intervals from :attr:`montreal_forced_aligner.data.WorkflowType.transcription` + """ + return [ + x.as_ctm() + for x in self.word_intervals + if x.workflow.workflow_type is WorkflowType.transcription + ] + + @property + def per_speaker_transcribed_phone_intervals(self) -> typing.List[CtmInterval]: + """ + Phone intervals from :attr:`montreal_forced_aligner.data.WorkflowType.per_speaker_transcription` + """ + return [ + x.as_ctm() + for x in self.phone_intervals + if x.workflow.workflow_type is WorkflowType.per_speaker_transcription + ] + + @property + def per_speaker_transcribed_word_intervals(self) -> typing.List[CtmInterval]: + """ + Word intervals from :attr:`montreal_forced_aligner.data.WorkflowType.per_speaker_transcription` + """ + return [ + x.as_ctm() + for x in self.word_intervals + if x.workflow.workflow_type is WorkflowType.per_speaker_transcription + ] + + @property + def phone_transcribed_phone_intervals(self) -> typing.List[CtmInterval]: + """ + Phone intervals from :attr:`montreal_forced_aligner.data.WorkflowType.phone_transcription` + """ + return [ + x.as_ctm() + for x in self.phone_intervals + if x.workflow.workflow_type is WorkflowType.phone_transcription + ] + @property def file_name(self) -> str: """Name of the utterance's file""" @@ -1005,6 +1453,8 @@ def to_data(self) -> UtteranceData: """ from montreal_forced_aligner.corpus.classes import UtteranceData + if self.normalized_text is None: + self.normalized_text = "" return UtteranceData( self.speaker_name, self.file_name, @@ -1013,7 +1463,6 @@ def to_data(self) -> UtteranceData: self.channel, self.text, self.normalized_text.split(), - self.normalized_text_int.split(), set(self.oovs.split()), ) @@ -1047,18 +1496,71 @@ def from_data(cls, data: UtteranceData, file: File, speaker: int, frame_shift: i return Utterance( begin=data.begin, end=data.end, - duration=data.end - data.begin, channel=data.channel, oovs=" ".join(sorted(data.oovs)), normalized_text=" ".join(data.normalized_text), text=data.text, - normalized_text_int=" ".join(str(x) for x in data.normalized_text_int), num_frames=num_frames, - file=file, + file_id=file.id, speaker_id=speaker, ) +class CorpusWorkflow(MfaSqlBase): + """ + + Database class for storing information about a particular workflow (alignment, transcription, etc) + + Parameters + ---------- + id: int + Primary key + workflow_type: :class:`~montreal_forced_aligner.data.WorkflowType` + Workflow type + time_stamp: :class:`datetime.datetime` + Time stamp for the workflow run + score: float + Log likelihood or other score for the workflow run + phone_intervals: list[:class:`~montreal_forced_aligner.db.PhoneInterval`] + Phone intervals linked to the workflow + word_intervals: list[:class:`~montreal_forced_aligner.db.WordInterval`] + Word intervals linked to the workflow + """ + + __tablename__ = "corpus_workflow" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, unique=True, index=True) + workflow_type = Column(Enum(WorkflowType), nullable=False, index=True) + working_directory = Column(String, nullable=False) + time_stamp = Column(DateTime, nullable=False, server_default=sqlalchemy.func.now(), index=True) + current = Column(Boolean, nullable=False, default=False, index=True) + done = Column(Boolean, nullable=False, default=False, index=True) + dirty = Column(Boolean, nullable=False, default=False, index=True) + alignments_collected = Column(Boolean, nullable=False, default=False, index=True) + score = Column(Float, nullable=True) + + phone_intervals = relationship( + "PhoneInterval", + back_populates="workflow", + order_by="PhoneInterval.begin", + collection_class=ordering_list("begin"), + cascade="all, delete", + ) + + word_intervals = relationship( + "WordInterval", + back_populates="workflow", + order_by="WordInterval.begin", + collection_class=ordering_list("begin"), + cascade="all, delete", + ) + + @property + def lda_mat_path(self) -> str: + return os.path.join(self.working_directory, "lda.mat") + + class PhoneInterval(MfaSqlBase): """ @@ -1072,25 +1574,64 @@ class PhoneInterval(MfaSqlBase): Beginning timestamp of the interval end: float Ending timestamp of the interval - label: str - Text label of the interval + phone_goodness: float + Confidence score, log-likelihood, etc for the phone interval + phone_id: int + Foreign key to :class:`~montreal_forced_aligner.db.Phone` + phone: :class:`~montreal_forced_aligner.db.Phone` + Phone of the interval utterance_id: int Foreign key to :class:`~montreal_forced_aligner.db.Utterance` utterance: :class:`~montreal_forced_aligner.db.Utterance` Utterance of the interval + word_interval_id: int + Foreign key to :class:`~montreal_forced_aligner.db.WordInterval` + word_interval: :class:`~montreal_forced_aligner.db.WordInterval` + Word interval that is associated with the phone interval + workflow_id: int + Foreign key to :class:`~montreal_forced_aligner.db.CorpusWorkflow` + workflow: :class:`~montreal_forced_aligner.db.CorpusWorkflow` + Workflow that generated the phone interval """ __tablename__ = "phone_interval" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) begin = Column(Float, nullable=False, index=True) end = Column(Float, nullable=False) - label = Column(String, nullable=False) - utterance_id = Column(Integer, ForeignKey("utterance.id"), index=True, nullable=False) - utterance: Utterance = relationship("Utterance", back_populates="phone_intervals") + phone_goodness = Column(Float, nullable=True) + + phone_id = Column( + Integer, ForeignKey("phone.id", ondelete="CASCADE"), index=True, nullable=False + ) + phone = relationship("Phone", back_populates="phone_intervals") + + word_interval_id = Column( + Integer, ForeignKey("word_interval.id", ondelete="CASCADE"), index=True, nullable=True + ) + word_interval = relationship("WordInterval", back_populates="phone_intervals") + + utterance_id = Column( + Integer, ForeignKey("utterance.id", ondelete="CASCADE"), index=True, nullable=False + ) + utterance = relationship("Utterance", back_populates="phone_intervals") + + workflow_id = Column( + Integer, ForeignKey("corpus_workflow.id", ondelete="CASCADE"), index=True, nullable=False + ) + workflow = relationship("CorpusWorkflow", back_populates="phone_intervals") + + __table_args__ = ( + sqlalchemy.Index("phone_utterance_workflow_index", "utterance_id", "workflow_id"), + ) + + def __repr__(self): + return f"" @classmethod - def from_ctm(self, interval: CtmInterval, utterance: Utterance) -> PhoneInterval: + def from_ctm( + self, interval: CtmInterval, utterance: Utterance, workflow_id: int + ) -> PhoneInterval: """ Construct a PhoneInterval from a CtmInterval object @@ -1100,6 +1641,8 @@ def from_ctm(self, interval: CtmInterval, utterance: Utterance) -> PhoneInterval CtmInterval containing data for the phone interval utterance: :class:`~montreal_forced_aligner.db.Utterance` Utterance object that the phone interval belongs to + workflow_id: int + Integer id for the workflow that generated the phone interval Returns ------- @@ -1107,7 +1650,11 @@ def from_ctm(self, interval: CtmInterval, utterance: Utterance) -> PhoneInterval Phone interval object """ return PhoneInterval( - begin=interval.begin, end=interval.end, label=interval.label, utterance=utterance + begin=interval.begin, + end=interval.end, + label=interval.label, + utterance=utterance, + workflow_id=workflow_id, ) def as_ctm(self) -> CtmInterval: @@ -1119,7 +1666,7 @@ def as_ctm(self) -> CtmInterval: :class:`~montreal_forced_aligner.data.CtmInterval` CTM interval object """ - return CtmInterval(self.begin, self.end, self.label, self.utterance_id) + return CtmInterval(self.begin, self.end, self.phone.phone, confidence=self.phone_goodness) class WordInterval(MfaSqlBase): @@ -1135,25 +1682,66 @@ class WordInterval(MfaSqlBase): Beginning timestamp of the interval end: float Ending timestamp of the interval - label: str - Text label of the interval + word_id: int + Foreign key to :class:`~montreal_forced_aligner.db.Word` + word: :class:`~montreal_forced_aligner.db.Word` + Word of the interval + pronunciation_id: int + Foreign key to :class:`~montreal_forced_aligner.db.Pronunciation` + pronunciation: :class:`~montreal_forced_aligner.db.Pronunciation` + Pronunciation of the word utterance_id: int Foreign key to :class:`~montreal_forced_aligner.db.Utterance` utterance: :class:`~montreal_forced_aligner.db.Utterance` Utterance of the interval + workflow_id: int + Foreign key to :class:`~montreal_forced_aligner.db.CorpusWorkflow` + workflow: :class:`~montreal_forced_aligner.db.CorpusWorkflow` + Workflow that generated the interval + phone_intervals: list[:class:`~montreal_forced_aligner.db.PhoneInterval`] + Phone intervals for the word interval """ __tablename__ = "word_interval" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) begin = Column(Float, nullable=False, index=True) end = Column(Float, nullable=False) - label = Column(String, nullable=False) - utterance_id = Column(Integer, ForeignKey("utterance.id"), index=True, nullable=False) - utterance: Utterance = relationship("Utterance", back_populates="word_intervals") + + utterance_id = Column( + Integer, ForeignKey("utterance.id", ondelete="CASCADE"), index=True, nullable=False + ) + utterance = relationship("Utterance", back_populates="word_intervals") + + word_id = Column( + Integer, ForeignKey("word.id", ondelete="CASCADE"), index=True, nullable=False + ) + word = relationship("Word", back_populates="word_intervals") + + pronunciation_id = Column(Integer, ForeignKey("pronunciation.id"), index=True, nullable=True) + pronunciation = relationship("Pronunciation", back_populates="word_intervals") + + workflow_id = Column( + Integer, ForeignKey("corpus_workflow.id", ondelete="CASCADE"), index=True, nullable=False + ) + workflow = relationship("CorpusWorkflow", back_populates="word_intervals") + + phone_intervals = relationship( + "PhoneInterval", + back_populates="word_interval", + order_by="PhoneInterval.begin", + collection_class=ordering_list("begin"), + cascade="all, delete", + ) + + __table_args__ = ( + sqlalchemy.Index("word_utterance_workflow_index", "utterance_id", "workflow_id"), + ) @classmethod - def from_ctm(self, interval: CtmInterval, utterance: Utterance) -> WordInterval: + def from_ctm( + self, interval: CtmInterval, utterance: Utterance, workflow_id: int + ) -> WordInterval: """ Construct a WordInterval from a CtmInterval object @@ -1163,6 +1751,8 @@ def from_ctm(self, interval: CtmInterval, utterance: Utterance) -> WordInterval: CtmInterval containing data for the word interval utterance: :class:`~montreal_forced_aligner.db.Utterance` Utterance object that the word interval belongs to + workflow_id: int + Integer id for the workflow that generated the phone interval Returns ------- @@ -1170,7 +1760,11 @@ def from_ctm(self, interval: CtmInterval, utterance: Utterance) -> WordInterval: Word interval object """ return WordInterval( - begin=interval.begin, end=interval.end, label=interval.label, utterance=utterance + begin=interval.begin, + end=interval.end, + label=interval.label, + utterance=utterance, + workflow_id=workflow_id, ) def as_ctm(self) -> CtmInterval: @@ -1182,66 +1776,237 @@ def as_ctm(self) -> CtmInterval: :class:`~montreal_forced_aligner.data.CtmInterval` CTM interval object """ - return CtmInterval(self.begin, self.end, self.label, self.utterance_id) + return CtmInterval(self.begin, self.end, self.word.word) -class ReferencePhoneInterval(MfaSqlBase): +class Job(MfaSqlBase): """ - - Database class for storing information about reference phone intervals + Database class for storing information about multiprocessing jobs Parameters ---------- id: int Primary key - begin: float - Beginning timestamp of the interval - end: float - Ending timestamp of the interval - label: str - Text label of the interval - utterance_id: int - Foreign key to :class:`~montreal_forced_aligner.db.Utterance` - utterance: :class:`~montreal_forced_aligner.db.Utterance` - Utterance of the interval + corpus_id: int + Foreign key to :class:`~montreal_forced_aligner.db.Corpus` + corpus: :class:`~montreal_forced_aligner.db.Corpus` + Corpus + utterances: list[:class:`~montreal_forced_aligner.db.Utterance`] + Utterances associated with the job + symbols: list[:class:`~montreal_forced_aligner.db.M2M2Job`] + Symbols associated with the job in training phonetisaurus models + words: list[:class:`~montreal_forced_aligner.db.Word2Job`] + Words associated with the job in training phonetisaurus models """ - __tablename__ = "reference_phone_interval" + __tablename__ = "job" - id = Column(Integer, primary_key=True) - begin = Column(Float, nullable=False, index=True) - end = Column(Float, nullable=False) - label = Column(String, nullable=False) - utterance_id = Column(Integer, ForeignKey("utterance.id"), index=True, nullable=False) - utterance: Utterance = relationship("Utterance", back_populates="reference_phone_intervals") + id = Column(Integer, primary_key=True, autoincrement=True) - def as_ctm(self) -> CtmInterval: + corpus_id = Column(Integer, ForeignKey("corpus.id"), index=True, nullable=True) + corpus = relationship("Corpus", back_populates="jobs") + utterances = relationship("Utterance", back_populates="job") + + symbols = relationship( + "M2M2Job", + back_populates="job", + ) + + words = relationship( + "Word2Job", + back_populates="job", + ) + + dictionaries = relationship( + "Dictionary", + secondary=Dictionary2Job, + back_populates="jobs", + ) + + def __str__(self): + return f"" + + @property + def has_dictionaries(self) -> bool: + return len(self.dictionaries) > 0 + + @property + def dictionary_ids(self) -> typing.List[int]: + return [x.id for x in self.dictionaries] + + @property + def wav_scp_path(self): + return self.construct_path(self.corpus.split_directory, "wav", "scp") + + @property + def segments_scp_path(self): + return self.construct_path(self.corpus.split_directory, "segments", "scp") + + @property + def feats_scp_path(self): + return self.construct_path(self.corpus.split_directory, "feats", "scp") + + @property + def feats_ark_path(self): + return self.construct_path(self.corpus.split_directory, "feats", "ark") + + @property + def per_dictionary_feats_scp_paths(self): + paths = {} + for d in self.dictionaries: + paths[d.id] = self.construct_path( + self.corpus.current_subset_directory, "feats", "scp", d.id + ) + return paths + + @property + def per_dictionary_utt2spk_scp_paths(self): + paths = {} + for d in self.dictionaries: + paths[d.id] = self.construct_path( + self.corpus.current_subset_directory, "utt2spk", "scp", d.id + ) + return paths + + @property + def per_dictionary_spk2utt_scp_paths(self): + paths = {} + for d in self.dictionaries: + paths[d.id] = self.construct_path( + self.corpus.current_subset_directory, "spk2utt", "scp", d.id + ) + return paths + + @property + def per_dictionary_cmvn_scp_paths(self): + paths = {} + for d in self.dictionaries: + paths[d.id] = self.construct_path( + self.corpus.current_subset_directory, "cmvn", "scp", d.id + ) + return paths + + @property + def per_dictionary_text_int_scp_paths(self): + paths = {} + for d in self.dictionaries: + paths[d.id] = self.construct_path( + self.corpus.current_subset_directory, "text", "int.scp", d.id + ) + return paths + + def construct_path( + self, directory: str, identifier: str, extension: str, dictionary_id: int = None + ) -> str: """ - Generate a CtmInterval from the database object + Helper function for constructing dictionary-dependent paths for the Job + + Parameters + ---------- + directory: str + Directory to use as the root + identifier: str + Identifier for the path name, like ali or acc + extension: str + Extension of the path, like .scp or .ark Returns ------- - :class:`~montreal_forced_aligner.data.CtmInterval` - CTM interval object + str + Path """ - return CtmInterval(self.begin, self.end, self.label, self.utterance_id) + if dictionary_id is None: + return os.path.join(directory, f"{identifier}.{self.id}.{extension}") + return os.path.join(directory, f"{identifier}.{dictionary_id}.{self.id}.{extension}") + + def construct_path_dictionary(self, directory: str, identifier: str, extension: str): + paths = {} + for d_id in self.dictionary_ids: + paths[d_id] = self.construct_path(directory, identifier, extension, d_id) + return paths + + def construct_dictionary_dependent_paths( + self, directory: str, identifier: str, extension: str + ) -> typing.Dict[int, str]: + """ + Helper function for constructing paths that depend only on the dictionaries of the job, and not the job name itself. + These paths should be merged with all other jobs to get a full set of dictionary paths. + Parameters + ---------- + directory: str + Directory to use as the root + identifier: str + Identifier for the path name, like ali or acc + extension: str + Extension of the path, like .scp or .ark + Returns + ------- + dict[int, str] + Path for each dictionary + """ + output = {} + for dict_id in self.dictionary_ids: + output[dict_id] = os.path.join(directory, f"{identifier}.{dict_id}.{extension}") + return output + def construct_online_feature_proc_string(self): + feat_path = self.construct_path(self.corpus.current_subset_directory, "feats", "scp") + return f'ark,s,cs:add-deltas scp,s,cs:"{feat_path}" ark:- |' -class Job(MfaSqlBase): + def construct_feature_proc_string( + self, + working_directory, + dictionary_id, + uses_splices: bool, + splice_left_context: int, + splice_right_context: int, + uses_speaker_adaptation: bool = False, + ) -> str: + """ + Constructs a feature processing string to supply to Kaldi binaries, taking into account corpus features and the + current working directory of the aligner (whether fMLLR or LDA transforms should be used, etc). - __tablename__ = "job" + Parameters + ---------- + uses_speaker_adaptation: bool + Flag for whether features should be speaker-independent regardless of the presence of fMLLR transforms - id = Column(Integer, primary_key=True) + Returns + ------- + dict[int, dict[str, str]] + Feature strings per job + """ + lda_mat_path = None + fmllr_trans_path = None + feat_path = self.construct_path( + self.corpus.current_subset_directory, "feats", "scp", dictionary_id=dictionary_id + ) + if working_directory is not None: + lda_mat_path = os.path.join(working_directory, "lda.mat") + if not os.path.exists(lda_mat_path): + lda_mat_path = None + fmllr_trans_path = self.construct_path( + working_directory, "trans", "ark", dictionary_id + ) + + if not os.path.exists(fmllr_trans_path): + fmllr_trans_path = None + utt2spk_path = self.construct_path( + self.corpus.current_subset_directory, "utt2spk", "scp", dictionary_id + ) + feats = "ark,s,cs:" - symbols = relationship( - "M2M2Job", - back_populates="job", - ) + if lda_mat_path is not None: + feats += f'splice-feats --left-context={splice_left_context} --right-context={splice_right_context} scp,s,cs:"{feat_path}" ark:- |' + feats += f' transform-feats "{lda_mat_path}" ark:- ark:- |' + elif uses_splices: + feats += f'splice-feats --left-context={splice_left_context} --right-context={splice_right_context} scp,s,cs:"{feat_path}" ark:- |' + else: + feats += f'add-deltas scp,s,cs:"{feat_path}" ark:- |' + if fmllr_trans_path is not None and uses_speaker_adaptation: + feats += f' transform-feats --utt2spk=ark:"{utt2spk_path}" ark:"{fmllr_trans_path}" ark:- ark:- |' - words = relationship( - "Word2Job", - back_populates="job", - ) + return feats class M2MSymbol(MfaSqlBase): @@ -1265,11 +2030,13 @@ class M2MSymbol(MfaSqlBase): Phone order weight: float Weight of arcs + jobs: list[:class:`~montreal_forced_aligner.db.M2M2Job`] + Jobs that use this symbol """ __tablename__ = "m2m_symbol" - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) symbol = Column(String, nullable=False, index=True, unique=True) total_order = Column(Integer, nullable=False) max_order = Column(Integer, nullable=False) @@ -1303,8 +2070,8 @@ class M2M2Job(MfaSqlBase): __tablename__ = "m2m_job" m2m_id = Column(ForeignKey("m2m_symbol.id"), primary_key=True) job_id = Column(ForeignKey("job.id"), primary_key=True) - m2m_symbol: M2MSymbol = relationship("M2MSymbol", back_populates="jobs") - job: Job = relationship("Job", back_populates="symbols") + m2m_symbol = relationship("M2MSymbol", back_populates="jobs") + job = relationship("Job", back_populates="symbols") class Word2Job(MfaSqlBase): @@ -1329,5 +2096,5 @@ class Word2Job(MfaSqlBase): word_id = Column(ForeignKey("word.id"), primary_key=True) job_id = Column(ForeignKey("job.id"), primary_key=True) training = Column(Boolean, index=True) - word: Word = relationship("Word", back_populates="job") - job: Job = relationship("Job", back_populates="words") + word = relationship("Word", back_populates="job") + job = relationship("Job", back_populates="words") diff --git a/montreal_forced_aligner/diarization/__init__.py b/montreal_forced_aligner/diarization/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/montreal_forced_aligner/diarization/multiprocessing.py b/montreal_forced_aligner/diarization/multiprocessing.py new file mode 100644 index 00000000..fe13c69e --- /dev/null +++ b/montreal_forced_aligner/diarization/multiprocessing.py @@ -0,0 +1,858 @@ +"""Multiprocessing functionality for speaker diarization""" +from __future__ import annotations + +import logging +import multiprocessing as mp +import os +import queue +import subprocess +import sys +import time +import typing + +import dataclassy +import hdbscan +import kneed +import librosa +import numpy as np +import sqlalchemy +from scipy.spatial import distance +from sklearn import cluster, manifold, metrics, neighbors, preprocessing +from sqlalchemy.orm import Session, joinedload + +from montreal_forced_aligner.abc import KaldiFunction +from montreal_forced_aligner.config import GLOBAL_CONFIG, IVECTOR_DIMENSION, XVECTOR_DIMENSION +from montreal_forced_aligner.corpus.features import ( + PldaModel, + classify_plda, + compute_classification_stats, + pairwise_plda_distance_matrix, +) +from montreal_forced_aligner.data import ( + ClusterType, + DistanceMetric, + ManifoldAlgorithm, + MfaArguments, +) +from montreal_forced_aligner.db import File, Job, SoundFile, Speaker, Utterance +from montreal_forced_aligner.utils import Stopped, read_feats, thirdparty_binary + +try: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + torch_logger = logging.getLogger("speechbrain.utils.torch_audio_backend") + torch_logger.setLevel(logging.ERROR) + torch_logger = logging.getLogger("speechbrain.utils.train_logger") + torch_logger.setLevel(logging.ERROR) + import torch + from speechbrain.pretrained import EncoderClassifier, SpeakerRecognition + FOUND_SPEECHBRAIN = True +except (ImportError, OSError): + FOUND_SPEECHBRAIN = False + EncoderClassifier = None + SpeakerRecognition = None + +__all__ = [ + "PldaClassificationArguments", + "PldaClassificationFunction", + "ComputeEerArguments", + "ComputeEerFunction", + "SpeechbrainArguments", + "SpeechbrainClassificationFunction", + "SpeechbrainEmbeddingFunction", + "cluster_matrix", +] + +logger = logging.getLogger("mfa") + + +# noinspection PyUnresolvedReferences +@dataclassy.dataclass(slots=True) +class PldaClassificationArguments(MfaArguments): + """Arguments for :class:`~montreal_forced_aligner.diarization.multiprocessing.PldaClassificationFunction`""" + + plda: PldaModel + train_ivector_path: str + num_utts_path: str + use_xvector: bool + + +# noinspection PyUnresolvedReferences +@dataclassy.dataclass(slots=True) +class ComputeEerArguments(MfaArguments): + """Arguments for :class:`~montreal_forced_aligner.diarization.multiprocessing.ComputeEerFunction`""" + + plda: PldaModel + metric: DistanceMetric + use_xvector: bool + limit_within_speaker: int + limit_per_speaker: int + + +# noinspection PyUnresolvedReferences +@dataclassy.dataclass(slots=True) +class SpeechbrainArguments(MfaArguments): + """Arguments for :class:`~montreal_forced_aligner.diarization.multiprocessing.SpeechbrainClassificationFunction`""" + + cuda: bool + cluster: bool + + +def visualize_clusters( + ivectors: np.ndarray, + manifold_algorithm: ManifoldAlgorithm, + metric_type: DistanceMetric, + n_neighbors: int = 10, + plda: typing.Optional[PldaModel] = None, + quick=False, +): + logger.debug(f"Generating 2D representation of ivectors with {manifold_algorithm.name}...") + begin = time.time() + to_fit = ivectors + metric = metric_type.name + tsne_angle = 0.5 + tsne_iterations = 1000 + mds_iterations = 300 + if quick: + tsne_angle = 0.8 + tsne_iterations = 500 + mds_iterations = 150 + if metric_type is DistanceMetric.plda: + logger.info("Generating precomputed distance matrix...") + to_fit = metrics.pairwise_distances( + ivectors, ivectors, metric=plda.distance, n_jobs=GLOBAL_CONFIG.current_profile.num_jobs + ) + np.fill_diagonal(to_fit, 0) + metric = "precomputed" + if manifold_algorithm is ManifoldAlgorithm.mds: + if metric_type is DistanceMetric.cosine: + to_fit = preprocessing.normalize(ivectors, norm="l2") + metric = "euclidean" + points = manifold.MDS( + dissimilarity=metric, + random_state=0, + n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, + max_iter=mds_iterations, + metric=False, + normalized_stress=True, + ).fit_transform(to_fit) + elif manifold_algorithm is ManifoldAlgorithm.tsne: + points = manifold.TSNE( + metric=metric, + random_state=0, + perplexity=n_neighbors, + init="pca" if metric != "precomputed" else "random", + n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, + angle=tsne_angle, + n_iter=tsne_iterations, + ).fit_transform(to_fit) + elif manifold_algorithm is ManifoldAlgorithm.spectral: + points = manifold.SpectralEmbedding( + affinity="nearest_neighbors", + random_state=0, + n_neighbors=n_neighbors, + n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, + ).fit_transform(to_fit) + elif manifold_algorithm is ManifoldAlgorithm.isomap: + points = manifold.Isomap( + metric=metric, n_neighbors=n_neighbors, n_jobs=GLOBAL_CONFIG.current_profile.num_jobs + ).fit_transform(to_fit) + else: + raise NotImplementedError + logger.debug(f"Generating 2D representation took {time.time() - begin:.3f} seconds") + return points + + +def calculate_distance_threshold( + metric: typing.Union[str, callable], + to_fit: np.ndarray, + min_samples: int = 5, + working_directory: str = None, + score_metric_params=None, + no_visuals: bool = False, +) -> float: + """ + Calculate a threshold for the given ivectors using a relative threshold + + Parameters + ---------- + metric: str or callable + Metric to evaluate + to_fit: numpy.ndarray + Ivectors or distance matrix + relative_distance_threshold: float + Relative threshold from 0 to 1 + + Returns + ------- + float + Absolute distance threshold + """ + logger.debug(f"Calculating distance threshold from {min_samples} nearest neighbors...") + nbrs = neighbors.NearestNeighbors( + n_neighbors=min_samples, + metric=metric, + metric_params=score_metric_params, + n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, + ).fit(to_fit) + distances, indices = nbrs.kneighbors(to_fit) + distances = distances[:, min_samples - 1] + distances = np.sort(distances, axis=0) + kneedle = kneed.KneeLocator(np.arange(distances.shape[0]), distances, curve="concave", S=5) + index = kneedle.elbow + threshold = distances[index] + + min_distance = np.min(distances) + max_distance = np.max(distances) + logger.debug( + f"Distance threshold was set to {threshold} (range = {min_distance:.4f} - {max_distance:.4f})" + ) + if GLOBAL_CONFIG.current_profile.debug and not no_visuals: + import seaborn as sns + from matplotlib import pyplot as plt + + sns.set() + plt.plot(distances) + plt.xlabel("Index") + plt.ylabel("Distance to NN") + plt.axvline(index, c="k") + plt.text( + index, max_distance, "threshold", horizontalalignment="right", verticalalignment="top" + ) + + if working_directory is not None: + plot_path = os.path.join(working_directory, "nearest_neighbor_distances.png") + close_string = f"Closing k-distance plot, it has been saved to {plot_path}." + plt.savefig(plot_path, transparent=True) + else: + close_string = "Closing k-distance plot." + if GLOBAL_CONFIG.current_profile.verbose: + plt.show(block=False) + plt.pause(10) + logger.debug(close_string) + plt.close() + return float(threshold) + + +def cluster_matrix( + ivectors: np.ndarray, + cluster_type: ClusterType, + metric: DistanceMetric = DistanceMetric.euclidean, + strict=True, + no_visuals=False, + working_directory=None, + **kwargs, +) -> np.ndarray: + """ + Wrapper function for sklearn's clustering methods + + Parameters + ---------- + ivectors: numpy.ndarray + Ivectors to cluster + cluster_type: :class:`~montreal_forced_aligner.data.ClusterType` + Clustering algorithm + metric: :class:`~montreal_forced_aligner.data.DistanceMetric` + Distance metric to use in clustering + strict: bool + Flag for whether to raise exceptions when only one cluster is found + kwargs + Extra keyword arguments to pass to sklearn cluster classes + + Returns + ------- + numpy.ndarray + Cluster labels for each utterance + """ + from montreal_forced_aligner.config import GLOBAL_CONFIG + + logger.debug(f"Running {cluster_type}...") + + if sys.platform == "win32" and cluster_type is ClusterType.kmeans: + os.environ["OMP_NUM_THREADS"] = "1" + os.environ["OPENBLAS_NUM_THREADS"] = "1" + os.environ["MKL_NUM_THREADS"] = "1" + else: + os.environ["OMP_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.num_jobs}" + os.environ["OPENBLAS_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.num_jobs}" + os.environ["MKL_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.num_jobs}" + distance_threshold = kwargs.pop("distance_threshold", None) + plda: PldaModel = kwargs.pop("plda", None) + min_cluster_size = kwargs.pop("min_cluster_size", 15) + + score_metric = metric.value + if score_metric == "plda": + score_metric = plda + to_fit = ivectors + score_metric_params = None + if score_metric == "plda" and cluster_type is not ClusterType.affinity: + logger.debug("Generating precomputed distance matrix...") + begin = time.time() + + to_fit = to_fit.astype("float64") + psi = plda.psi.astype("float64") + to_fit = pairwise_plda_distance_matrix(to_fit, psi) + logger.debug(f"Precomputed distance matrix took {time.time() - begin:.3f} seconds") + score_metric = "precomputed" + if cluster_type is ClusterType.affinity: + affinity = metric + if metric is DistanceMetric.cosine: + to_fit = preprocessing.normalize(to_fit, norm="l2") + score_metric = "euclidean" + affinity = "euclidean" + elif metric is DistanceMetric.plda: + logger.debug("Generating precomputed distance matrix...") + to_fit = metrics.pairwise_distances( + to_fit, + to_fit, + metric=plda.log_likelihood, + n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, + ) + + score_metric = "precomputed" + affinity = "precomputed" + c_labels = cluster.AffinityPropagation( + affinity=affinity, + copy=False, + random_state=GLOBAL_CONFIG.current_profile.seed, + verbose=GLOBAL_CONFIG.current_profile.verbose, + **kwargs, + ).fit_predict(to_fit) + elif cluster_type is ClusterType.agglomerative: + if metric is DistanceMetric.cosine: + to_fit = preprocessing.normalize(to_fit, norm="l2") + score_metric = "euclidean" + if not kwargs["n_clusters"]: + if distance_threshold is not None: + eps = distance_threshold + else: + eps = calculate_distance_threshold( + score_metric, + to_fit, + min_cluster_size, + working_directory, + score_metric_params=score_metric_params, + no_visuals=no_visuals, + ) + kwargs["distance_threshold"] = eps + c_labels = cluster.AgglomerativeClustering(metric=score_metric, **kwargs).fit_predict( + to_fit + ) + elif cluster_type is ClusterType.spectral: + affinity = "nearest_neighbors" + if metric is DistanceMetric.cosine: + to_fit = preprocessing.normalize(to_fit, norm="l2") + score_metric = "euclidean" + elif metric is DistanceMetric.plda: + logger.info("Generating precomputed distance matrix...") + affinity = "precomputed_nearest_neighbors" + to_fit = metrics.pairwise_distances( + to_fit, to_fit, metric=score_metric, n_jobs=GLOBAL_CONFIG.current_profile.num_jobs + ) + np.fill_diagonal(to_fit, 0) + score_metric = "precomputed" + c_labels = cluster.SpectralClustering( + affinity=affinity, + n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, + random_state=GLOBAL_CONFIG.current_profile.seed, + verbose=GLOBAL_CONFIG.current_profile.verbose, + **kwargs, + ).fit_predict(to_fit) + elif cluster_type is ClusterType.dbscan: + if distance_threshold is not None: + eps = distance_threshold + else: + eps = calculate_distance_threshold( + score_metric, + to_fit, + min_cluster_size, + working_directory, + score_metric_params=score_metric_params, + no_visuals=no_visuals, + ) + c_labels = cluster.DBSCAN( + min_samples=min_cluster_size, + metric=score_metric, + eps=eps, + n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, + **kwargs, + ).fit_predict(to_fit) + elif cluster_type is ClusterType.meanshift: + if score_metric == "cosine": + to_fit = preprocessing.normalize(to_fit, norm="l2") + score_metric = "euclidean" + c_labels = cluster.MeanShift( + n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, **kwargs + ).fit_predict(to_fit) + elif cluster_type is ClusterType.hdbscan: + if score_metric == "cosine": + to_fit = preprocessing.normalize(to_fit, norm="l2") + score_metric = "euclidean" + min_samples = max(5, int(min_cluster_size / 4)) + if distance_threshold is not None: + eps = distance_threshold + else: + eps = calculate_distance_threshold( + score_metric, + to_fit, + min_cluster_size, + working_directory, + score_metric_params=score_metric_params, + no_visuals=no_visuals, + ) + if score_metric == "precomputed" or metric is DistanceMetric.plda: + algorithm = "best" + else: + algorithm = "boruvka_balltree" + c_labels = hdbscan.HDBSCAN( + min_samples=min_samples, + min_cluster_size=min_cluster_size, + cluster_selection_epsilon=eps, + metric=score_metric, + algorithm=algorithm, + core_dist_n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, + **kwargs, + ).fit_predict(to_fit) + elif cluster_type is ClusterType.optics: + if distance_threshold is not None: + eps = distance_threshold + else: + eps = calculate_distance_threshold( + score_metric, + to_fit, + min_cluster_size, + working_directory, + score_metric_params=score_metric_params, + no_visuals=no_visuals, + ) + c_labels = cluster.OPTICS( + min_samples=min_cluster_size, + max_eps=eps, + metric=score_metric, + n_jobs=GLOBAL_CONFIG.current_profile.num_jobs, + **kwargs, + ).fit_predict(to_fit) + elif cluster_type is ClusterType.kmeans: + if score_metric == "cosine": + to_fit = preprocessing.normalize(to_fit, norm="l2") + score_metric = "euclidean" + c_labels = cluster.MiniBatchKMeans( + verbose=GLOBAL_CONFIG.current_profile.verbose, n_init="auto", **kwargs + ).fit_predict(to_fit) + else: + raise NotImplementedError(f"The cluster type '{cluster_type}' is not supported.") + num_clusters = np.unique(c_labels).shape[0] + logger.debug(f"Found {num_clusters} clusters") + try: + if score_metric == "plda": + score_metric = plda.distance + elif score_metric == "precomputed": + if cluster_type is ClusterType.affinity: + to_fit = np.max(to_fit) - to_fit + np.fill_diagonal(to_fit, 0) + score = metrics.silhouette_score(to_fit, c_labels, metric=score_metric) + logger.debug(f"Silhouette score (-1-1): {score}") + except ValueError: + if num_clusters == 1: + logger.warning( + "Only found one cluster, please adjust cluster parameters to generate more clusters." + ) + if strict: + raise + os.environ["OMP_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + os.environ["OPENBLAS_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + os.environ["MKL_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + + return c_labels + + +class PldaClassificationFunction(KaldiFunction): + """ + Multiprocessing function to compute voice activity detection + + See Also + -------- + :meth:`.AcousticCorpusMixin.compute_vad` + Main function that calls this function in parallel + :meth:`.AcousticCorpusMixin.compute_vad_arguments` + Job method for generating arguments for this function + :kaldi_src:`compute-vad` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.corpus.features.VadArguments` + Arguments for the function + """ + + def __init__(self, args: PldaClassificationArguments): + super().__init__(args) + self.plda = args.plda + self.train_ivector_path = args.train_ivector_path + self.num_utts_path = args.num_utts_path + self.use_xvector = args.use_xvector + + def _run(self) -> typing.Generator[typing.Tuple[int, int, int]]: + """Run the function""" + utterance_counts = {} + with open(self.num_utts_path) as f: + for line in f: + speaker, utt_count = line.strip().split() + utt_count = int(utt_count) + utterance_counts[int(speaker)] = utt_count + input_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-subtract-global-mean"), + f"ark:{self.train_ivector_path}", + "ark,t:-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=os.environ, + ) + speaker_ids = [] + speaker_counts = [] + if self.use_xvector: + dim = XVECTOR_DIMENSION + else: + dim = IVECTOR_DIMENSION + speaker_ivectors = np.empty((len(utterance_counts), dim)) + for speaker_id, ivector in read_feats(input_proc, raw_id=True): + speaker_id = int(speaker_id) + if speaker_id not in utterance_counts: + continue + speaker_ivectors[len(speaker_ids), :] = ivector + speaker_ids.append(speaker_id) + speaker_counts.append(utterance_counts[speaker_id]) + speaker_counts = np.array(speaker_counts) + speaker_ivectors = speaker_ivectors.astype("float64") + self.plda.psi = self.plda.psi.astype("float64") + speaker_ivectors = self.plda.process_ivectors(speaker_ivectors, counts=speaker_counts) + classification_args = compute_classification_stats( + speaker_ivectors, self.plda.psi, counts=speaker_counts + ) + lines = [] + for line in input_proc.stdout: + lines.append(line) + input_proc.wait() + for line in input_proc.stdout: + lines.append(line) + with Session(self.db_engine) as session: + + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True)) + .filter(Job.id == self.job_name) + .first() + ) + utterances = ( + session.query(Utterance.id, Utterance.plda_vector) + .filter(Utterance.plda_vector != None) # noqa + .filter(Utterance.job_id == job.id) + .order_by(Utterance.kaldi_id) + ) + for u_id, u_ivector in utterances: + ind, score = classify_plda(u_ivector.astype("float64"), *classification_args) + speaker = speaker_ids[ind] + yield u_id, speaker, score + + +class ComputeEerFunction(KaldiFunction): + """ + Multiprocessing function to compute voice activity detection + + See Also + -------- + :meth:`.AcousticCorpusMixin.compute_vad` + Main function that calls this function in parallel + :meth:`.AcousticCorpusMixin.compute_vad_arguments` + Job method for generating arguments for this function + :kaldi_src:`compute-vad` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.corpus.features.VadArguments` + Arguments for the function + """ + + def __init__(self, args: ComputeEerArguments): + super().__init__(args) + self.plda = args.plda + self.metric = args.metric + self.use_xvector = args.use_xvector + self.limit_within_speaker = args.limit_within_speaker + self.limit_per_speaker = args.limit_per_speaker + + # noinspection PyTypeChecker + def _run(self) -> typing.Generator[typing.Tuple[int, int, int]]: + """Run the function""" + if self.use_xvector: + columns = [Utterance.id, Utterance.speaker_id, Utterance.xvector] + filter = Utterance.xvector != None # noqa + else: + columns = [Utterance.id, Utterance.speaker_id, Utterance.plda_vector] + filter = Utterance.plda_vector != None # noqa + with Session(self.db_engine) as session: + speakers = ( + session.query(Speaker.id) + .join(Speaker.utterances) + .filter(Utterance.job_id == self.job_name) + .order_by(Speaker.id) + .distinct(Speaker.id) + ) + for (s_id,) in speakers: + match_scores = [] + mismatch_scores = [] + random_within_speaker = ( + session.query(*columns) + .filter(filter, Utterance.speaker_id == s_id) + .order_by(sqlalchemy.func.random()) + .limit(self.limit_within_speaker) + ) + for u_id, s_id, u_ivector in random_within_speaker: + comp_query = ( + session.query(columns[2]) + .filter(filter, Utterance.speaker_id == s_id, Utterance.id != u_id) + .order_by(sqlalchemy.func.random()) + .limit(self.limit_within_speaker) + ) + for (u2_ivector,) in comp_query: + if self.metric is DistanceMetric.plda: + score = self.plda.distance(u_ivector, u2_ivector) + elif self.metric is DistanceMetric.cosine: + score = distance.cosine(u_ivector, u2_ivector) + else: + score = distance.euclidean(u_ivector, u2_ivector) + match_scores.append(score) + other_speakers = session.query(Speaker.id).filter(Speaker.id != s_id) + for (o_s_id,) in other_speakers: + random_out_speaker = ( + session.query(columns[2]) + .filter(filter, Utterance.speaker_id == s_id) + .order_by(sqlalchemy.func.random()) + .limit(self.limit_per_speaker) + ) + for (u_ivector,) in random_out_speaker: + comp_query = ( + session.query(columns[2]) + .filter(filter, Utterance.speaker_id == o_s_id) + .order_by(sqlalchemy.func.random()) + .limit(self.limit_per_speaker) + ) + for (u2_ivector,) in comp_query: + if self.metric is DistanceMetric.plda: + score = self.plda.distance(u_ivector, u2_ivector) + elif self.metric is DistanceMetric.cosine: + score = distance.cosine(u_ivector, u2_ivector) + else: + score = distance.euclidean(u_ivector, u2_ivector) + mismatch_scores.append(score) + yield match_scores, mismatch_scores + + +class SpeechbrainClassificationFunction(KaldiFunction): + """ + Multiprocessing function to classify speakers based on a speechbrain model + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.diarization.multiprocessing.SpeechbrainArguments` + Arguments for the function + """ + + def __init__(self, args: SpeechbrainArguments): + super().__init__(args) + self.cuda = args.cuda + self.cluster = args.cluster + + def _run(self) -> typing.Generator[typing.Tuple[int, int, int]]: + """Run the function""" + run_opts = None + if self.cuda: + run_opts = {"device": "cuda"} + model = EncoderClassifier.from_hparams( + source="speechbrain/spkrec-ecapa-voxceleb", + savedir=os.path.join( + GLOBAL_CONFIG.current_profile.temporary_directory, + "models", + "SpeakerRecognition", + ), + run_opts=run_opts, + ) + device = torch.device("cuda" if self.cuda else "cpu") + with Session(self.db_engine) as session: + + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True)) + .filter(Job.id == self.job_name) + .first() + ) + utterances = session.query(Utterance.id, Utterance.xvector).filter( + Utterance.xvector != None, Utterance.job_id == job.id # noqa + ) + for u_id, ivector in utterances: + ivector = torch.tensor(ivector, device=device).unsqueeze(0).unsqueeze(0) + out_prob = model.mods.classifier(ivector).squeeze(1) + score, index = torch.max(out_prob, dim=-1) + text_lab = model.hparams.label_encoder.decode_torch(index) + new_speaker = text_lab[0] + del out_prob + del index + yield u_id, new_speaker, float(score.cpu().numpy()) + del text_lab + del new_speaker + del score + if self.cuda: + torch.cuda.empty_cache() + del model + if self.cuda: + torch.cuda.empty_cache() + + +class SpeechbrainEmbeddingFunction(KaldiFunction): + """ + Multiprocessing function to generating xvector embeddings from a speechbrain model + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.diarization.multiprocessing.SpeechbrainArguments` + Arguments for the function + """ + + def __init__(self, args: SpeechbrainArguments): + super().__init__(args) + self.cuda = args.cuda + self.cluster = args.cluster + + def _run(self) -> typing.Generator[typing.Tuple[int, int, int]]: + """Run the function""" + run_opts = None + if self.cuda: + run_opts = {"device": "cuda"} + if self.cluster: + model_class = SpeakerRecognition + else: + model_class = EncoderClassifier + + model = model_class.from_hparams( + source="speechbrain/spkrec-ecapa-voxceleb", + savedir=os.path.join( + GLOBAL_CONFIG.current_profile.temporary_directory, + "models", + "SpeakerRecognition", + ), + run_opts=run_opts, + ) + + return_q = mp.Queue(2) + finished_adding = Stopped() + stopped = Stopped() + loader = UtteranceFileLoader( + self.job_name, self.db_string, return_q, stopped, finished_adding + ) + loader.start() + exception = None + device = torch.device("cuda" if self.cuda else "cpu") + while True: + try: + result = return_q.get(timeout=1) + except queue.Empty: + if finished_adding.stop_check(): + break + continue + if stopped.stop_check(): + continue + if isinstance(result, Exception): + stopped.stop() + continue + + u_id, y = result + emb = ( + model.encode_batch( + torch.tensor(y[np.newaxis, :], device=device), normalize=self.cluster + ) + .cpu() + .numpy() + .squeeze(axis=1) + ) + yield u_id, emb[0] + del emb + if self.cuda: + torch.cuda.empty_cache() + + loader.join() + if exception: + raise Exception + + +class UtteranceFileLoader(mp.Process): + """ + Helper process for loading utterance waveforms in parallel with embedding extraction + + Parameters + ---------- + job_name: int + Job identifier + db_string: str + Connection string for database + return_q: multiprocessing.Queue + Queue to put waveforms + stopped: :class:`~montreal_forced_aligner.utils.Stopped` + Check for whether the process to exit gracefully + finished_adding: :class:`~montreal_forced_aligner.utils.Stopped` + Check for whether the worker has processed all utterances + """ + + def __init__( + self, + job_name: int, + db_string: str, + return_q: mp.Queue, + stopped: Stopped, + finished_adding: Stopped, + ): + super().__init__() + self.job_name = job_name + self.db_string = db_string + self.return_q = return_q + self.stopped = stopped + self.finished_adding = finished_adding + + def run(self) -> None: + """ + Run the waveform loading job + """ + db_engine = sqlalchemy.create_engine(self.db_string) + with Session(db_engine) as session: + try: + utterances = ( + session.query( + Utterance.id, + Utterance.begin, + Utterance.duration, + SoundFile.sound_file_path, + ) + .join(Utterance.file) + .join(File.sound_file) + .filter(Utterance.job_id == self.job_name) + ) + for u_id, begin, duration, sound_file_path in utterances: + if self.stopped.stop_check(): + break + y, _ = librosa.load( + sound_file_path, + sr=16000, + mono=False, + offset=begin, + duration=duration, + ) + self.return_q.put((u_id, y)) + except Exception as e: + self.return_q.put(e) + finally: + db_engine.dispose() + self.finished_adding.stop() diff --git a/montreal_forced_aligner/diarization/speaker_diarizer.py b/montreal_forced_aligner/diarization/speaker_diarizer.py new file mode 100644 index 00000000..0bb519a1 --- /dev/null +++ b/montreal_forced_aligner/diarization/speaker_diarizer.py @@ -0,0 +1,1578 @@ +""" +Speaker classification +====================== +""" +from __future__ import annotations + +import collections +import csv +import logging +import os +import pickle +import random +import shutil +import subprocess +import sys +import time +import typing +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +import numpy as np +import sqlalchemy +import tqdm +import yaml +from sklearn import decomposition, metrics +from sqlalchemy.orm import joinedload, selectinload + +from montreal_forced_aligner.abc import FileExporterMixin, TopLevelMfaWorker +from montreal_forced_aligner.alignment.multiprocessing import construct_output_path +from montreal_forced_aligner.config import ( + GLOBAL_CONFIG, + IVECTOR_DIMENSION, + MEMORY, + PLDA_DIMENSION, + XVECTOR_DIMENSION, +) +from montreal_forced_aligner.corpus.features import ( + ExportIvectorsArguments, + ExportIvectorsFunction, + PldaModel, +) +from montreal_forced_aligner.corpus.ivector_corpus import IvectorCorpusMixin +from montreal_forced_aligner.data import ( + ClusterType, + DistanceMetric, + ManifoldAlgorithm, + WorkflowType, +) +from montreal_forced_aligner.db import ( + Corpus, + File, + SoundFile, + Speaker, + SpeakerOrdering, + TextFile, + Utterance, + bulk_update, +) +from montreal_forced_aligner.diarization.multiprocessing import ( + ComputeEerArguments, + ComputeEerFunction, + PldaClassificationArguments, + PldaClassificationFunction, + SpeechbrainArguments, + SpeechbrainClassificationFunction, + SpeechbrainEmbeddingFunction, + cluster_matrix, + visualize_clusters, +) +from montreal_forced_aligner.exceptions import KaldiProcessingError +from montreal_forced_aligner.helper import load_configuration, mfa_open +from montreal_forced_aligner.models import IvectorExtractorModel +from montreal_forced_aligner.textgrid import export_textgrid +from montreal_forced_aligner.utils import log_kaldi_errors, run_kaldi_function, thirdparty_binary + +try: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + torch_logger = logging.getLogger("speechbrain.utils.torch_audio_backend") + torch_logger.setLevel(logging.ERROR) + torch_logger = logging.getLogger("speechbrain.utils.train_logger") + torch_logger.setLevel(logging.ERROR) + import torch + from speechbrain.pretrained import EncoderClassifier, SpeakerRecognition + from speechbrain.utils.metric_stats import EER + + FOUND_SPEECHBRAIN = True +except (ImportError, OSError): + FOUND_SPEECHBRAIN = False + EncoderClassifier = None + +if TYPE_CHECKING: + from montreal_forced_aligner.abc import MetaDict + +__all__ = ["SpeakerDiarizer"] + +logger = logging.getLogger("mfa") + + +class SpeakerDiarizer(IvectorCorpusMixin, TopLevelMfaWorker, FileExporterMixin): + """ + Class for performing speaker classification, not currently very functional, but + is planned to be expanded in the future + + Parameters + ---------- + ivector_extractor_path : str + Path to ivector extractor model, or "speechbrain" + expected_num_speakers: int, optional + Number of speakers in the corpus, if known + cluster: bool + Flag for whether speakers should be clustered instead of classified + evaluation_mode: bool + Flag for evaluating against existing speaker labels + cuda: bool + Flag for using CUDA for speechbrain models + metric: str or :class:`~montreal_forced_aligner.data.DistanceMetric` + One of "cosine", "plda", or "euclidean" + cluster_type: str or :class:`~montreal_forced_aligner.data.ClusterType` + Clustering algorithm + relative_distance_threshold: float + Threshold to use clustering based on distance + """ + + def __init__( + self, + ivector_extractor_path: str = "speechbrain", + expected_num_speakers: int = 0, + cluster: bool = True, + evaluation_mode: bool = False, + cuda: bool = False, + use_pca: bool = True, + metric: typing.Union[str, DistanceMetric] = "cosine", + cluster_type: typing.Union[str, ClusterType] = "hdbscan", + manifold_algorithm: typing.Union[str, ManifoldAlgorithm] = "tsne", + distance_threshold: float = None, + score_threshold: float = None, + min_cluster_size: int = 60, + max_iterations: int = 10, + linkage: str = "average", + **kwargs, + ): + self.use_xvector = False + self.ivector_extractor = None + self.ivector_extractor_path = ivector_extractor_path + if ivector_extractor_path == "speechbrain": + if not FOUND_SPEECHBRAIN: + logger.error( + "Could not import speechbrain, please ensure it is installed via `pip install speechbrain`" + ) + sys.exit(1) + self.use_xvector = True + else: + self.ivector_extractor = IvectorExtractorModel(ivector_extractor_path) + kwargs.update(self.ivector_extractor.parameters) + super().__init__(**kwargs) + self.expected_num_speakers = expected_num_speakers + self.cluster = cluster + self.metric = DistanceMetric[metric] + self.cuda = cuda + self.cluster_type = ClusterType[cluster_type] + self.manifold_algorithm = ManifoldAlgorithm[manifold_algorithm] + self.distance_threshold = distance_threshold + self.score_threshold = score_threshold + if self.distance_threshold is None: + if self.use_xvector: + self.distance_threshold = 0.25 + self.evaluation_mode = evaluation_mode + self.min_cluster_size = min_cluster_size + self.linkage = linkage + self.use_pca = use_pca + + self.max_iterations = max_iterations + self.current_labels = [] + self.classification_score = None + self.initial_plda_score_threshold = 0 + self.plda_score_threshold = 10 + self.initial_sb_score_threshold = 0.25 + + self.ground_truth_utt2spk = {} + self.ground_truth_speakers = {} + self.single_clusters = set() + + @classmethod + def parse_parameters( + cls, + config_path: Optional[str] = None, + args: Optional[Dict[str, Any]] = None, + unknown_args: Optional[List[str]] = None, + ) -> MetaDict: + """ + Parse parameters for speaker classification from a config path or command-line arguments + + Parameters + ---------- + config_path: str + Config path + args: dict[str, Any] + Parsed arguments + unknown_args: list[str] + Optional list of arguments that were not parsed + + Returns + ------- + dict[str, Any] + Configuration parameters + """ + global_params = {} + if config_path and os.path.exists(config_path): + data = load_configuration(config_path) + for k, v in data.items(): + if k == "features": + if "type" in v: + v["feature_type"] = v["type"] + del v["type"] + global_params.update(v) + else: + if v is None and k in cls.nullable_fields: + v = [] + global_params[k] = v + global_params.update(cls.parse_args(args, unknown_args)) + return global_params + + # noinspection PyTypeChecker + def setup(self) -> None: + """ + Sets up the corpus and speaker classifier + + Raises + ------ + :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` + If there were any errors in running Kaldi binaries + """ + if self.initialized: + return + super().setup() + self.create_new_current_workflow(WorkflowType.speaker_diarization) + wf = self.current_workflow + if wf.done: + logger.info("Diarization already done, skipping initialization.") + return + log_dir = os.path.join(self.working_directory, "log") + os.makedirs(log_dir, exist_ok=True) + try: + if self.ivector_extractor is None: # Download models if needed + _ = EncoderClassifier.from_hparams( + source="speechbrain/spkrec-ecapa-voxceleb", + savedir=os.path.join( + GLOBAL_CONFIG.current_profile.temporary_directory, + "models", + "EncoderClassifier", + ), + ) + _ = SpeakerRecognition.from_hparams( + source="speechbrain/spkrec-ecapa-voxceleb", + savedir=os.path.join( + GLOBAL_CONFIG.current_profile.temporary_directory, + "models", + "SpeakerRecognition", + ), + ) + self.initialize_database() + self._load_corpus() + self.initialize_jobs() + self.load_embeddings() + if self.cluster: + self.compute_speaker_embeddings() + else: + if not self.has_ivectors(): + if self.ivector_extractor.meta["version"] < "2.1": + logger.warning( + "The ivector extractor was trained in an earlier version of MFA. " + "There may be incompatibilities in feature generation that cause errors. " + "Please download the latest version of the model via `mfa model download`, " + "use a different ivector extractor, or use version 2.0.6 of MFA." + ) + self.ivector_extractor.export_model(self.working_directory) + self.load_corpus() + self.extract_ivectors() + self.compute_speaker_ivectors() + if self.evaluation_mode: + self.ground_truth_utt2spk = {} + with self.session() as session: + query = session.query(Utterance.id, Utterance.speaker_id, Speaker.name).join( + Utterance.speaker + ) + for u_id, s_id, name in query: + self.ground_truth_utt2spk[u_id] = s_id + self.ground_truth_speakers[s_id] = name + except Exception as e: + if isinstance(e, KaldiProcessingError): + log_kaldi_errors(e.error_logs) + e.update_log_file() + raise + self.initialized = True + + def plda_classification_arguments(self) -> List[PldaClassificationArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.diarization.multiprocessing.PldaClassificationFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.diarization.multiprocessing.PldaClassificationArguments`] + Arguments for processing + """ + return [ + PldaClassificationArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"plda_classification.{j.id}.log"), + self.plda, + self.speaker_ivector_path, + self.num_utts_path, + self.use_xvector, + ) + for j in self.jobs + ] + + def classify_speakers(self): + """Classify speakers based on ivector or speechbrain model""" + self.setup() + logger.info("Classifying utterances...") + + with self.session() as session, tqdm.tqdm( + total=self.num_utterances, disable=GLOBAL_CONFIG.quiet + ) as pbar, mfa_open( + os.path.join(self.working_directory, "speaker_classification_results.csv"), "w" + ) as f: + writer = csv.DictWriter(f, ["utt_id", "file", "begin", "end", "speaker", "score"]) + + writer.writeheader() + file_names = { + k: v for k, v in session.query(Utterance.id, File.name).join(Utterance.file) + } + utterance_times = { + k: (b, e) + for k, b, e in session.query(Utterance.id, Utterance.begin, Utterance.end) + } + utterance_mapping = [] + next_speaker_id = self.get_next_primary_key(Speaker) + speaker_mapping = {} + existing_speakers = { + name: s_id for s_id, name in session.query(Speaker.id, Speaker.name) + } + self.classification_score = 0 + if session.query(Speaker).filter(Speaker.name == "MFA_UNKNOWN").first() is None: + session.add(Speaker(id=next_speaker_id, name="MFA_UNKNOWN")) + session.commit() + next_speaker_id += 1 + unknown_speaker_id = ( + session.query(Speaker).filter(Speaker.name == "MFA_UNKNOWN").first().id + ) + + if self.use_xvector: + arguments = [ + SpeechbrainArguments(j.id, self.db_string, None, self.cuda, self.cluster) + for j in self.jobs + ] + func = SpeechbrainClassificationFunction + else: + plda_transform_path = os.path.join(self.working_directory, "plda.pkl") + with open(plda_transform_path, "rb") as f: + self.plda: PldaModel = pickle.load(f) + arguments = self.plda_classification_arguments() + func = PldaClassificationFunction + for utt_id, classified_speaker, score in run_kaldi_function( + func, arguments, pbar.update + ): + classified_speaker = str(classified_speaker) + self.classification_score += score / self.num_utterances + if self.score_threshold is not None and score < self.score_threshold: + speaker_id = unknown_speaker_id + elif classified_speaker in existing_speakers: + speaker_id = existing_speakers[classified_speaker] + else: + if classified_speaker not in speaker_mapping: + speaker_mapping[classified_speaker] = { + "id": next_speaker_id, + "name": classified_speaker, + } + next_speaker_id += 1 + speaker_id = speaker_mapping[classified_speaker]["id"] + utterance_mapping.append({"id": utt_id, "speaker_id": speaker_id}) + line = { + "utt_id": utt_id, + "file": file_names[utt_id], + "begin": utterance_times[utt_id][0], + "end": utterance_times[utt_id][1], + "speaker": classified_speaker, + "score": score, + } + writer.writerow(line) + + if self.stopped.stop_check(): + logger.debug("Stopping clustering early.") + return + if speaker_mapping: + session.bulk_insert_mappings(Speaker, list(speaker_mapping.values())) + session.flush() + session.commit() + bulk_update(session, Utterance, utterance_mapping) + session.commit() + if not self.evaluation_mode: + self.clean_up_unknown_speaker() + self.fix_speaker_ordering() + if not self.evaluation_mode: + self.cleanup_empty_speakers() + self.refresh_speaker_vectors() + if self.evaluation_mode: + self.evaluate_classification() + + def map_speakers_to_ground_truth(self): + with self.session() as session: + + utterances = session.query(Utterance.id, Utterance.speaker_id) + labels = [] + utterance_ids = [] + for utt_id, s_id in utterances: + utterance_ids.append(utt_id) + labels.append(s_id) + ground_truth = np.array([self.ground_truth_utt2spk[x] for x in utterance_ids]) + cluster_labels = np.unique(labels) + ground_truth_labels = np.unique(ground_truth) + cm = np.zeros((cluster_labels.shape[0], ground_truth_labels.shape[0]), dtype="int16") + for y_pred, y in zip(labels, ground_truth): + if y_pred < 0: + continue + cm[np.where(cluster_labels == y_pred), np.where(ground_truth_labels == y)] += 1 + + cm_argmax = cm.argmax(axis=1) + label_to_ground_truth_mapping = {} + for i in range(cluster_labels.shape[0]): + label_to_ground_truth_mapping[int(cluster_labels[i])] = int( + ground_truth_labels[cm_argmax[i]] + ) + return label_to_ground_truth_mapping + + def evaluate_clustering(self) -> None: + """Compute clustering metric scores and output clustering evaluation results""" + label_to_ground_truth_mapping = self.map_speakers_to_ground_truth() + with self.session() as session, mfa_open( + os.path.join(self.working_directory, "diarization_evaluation_results.csv"), "w" + ) as f: + + writer = csv.DictWriter( + f, + fieldnames=[ + "file", + "begin", + "end", + "text", + "predicted_speaker", + "ground_truth_speaker", + ], + ) + writer.writeheader() + predicted_utt2spk = {} + query = session.query( + Utterance.id, + File.name, + Utterance.begin, + Utterance.end, + Utterance.text, + Utterance.speaker_id, + ).join(Utterance.file) + for u_id, file_name, begin, end, text, s_id in query: + s_id = label_to_ground_truth_mapping[s_id] + predicted_utt2spk[u_id] = s_id + writer.writerow( + { + "file": file_name, + "begin": begin, + "end": end, + "text": text, + "predicted_speaker": self.ground_truth_speakers[s_id], + "ground_truth_speaker": self.ground_truth_speakers[ + self.ground_truth_utt2spk[u_id] + ], + } + ) + + ground_truth_labels = np.array([v for v in self.ground_truth_utt2spk.values()]) + predicted_labels = np.array( + [predicted_utt2spk[k] for k in self.ground_truth_utt2spk.keys()] + ) + rand_score = metrics.adjusted_rand_score(ground_truth_labels, predicted_labels) + ami_score = metrics.adjusted_mutual_info_score(ground_truth_labels, predicted_labels) + nmi_score = metrics.normalized_mutual_info_score(ground_truth_labels, predicted_labels) + homogeneity_score = metrics.homogeneity_score(ground_truth_labels, predicted_labels) + completeness_score = metrics.completeness_score(ground_truth_labels, predicted_labels) + v_measure_score = metrics.v_measure_score(ground_truth_labels, predicted_labels) + fm_score = metrics.fowlkes_mallows_score(ground_truth_labels, predicted_labels) + logger.info(f"Adjusted Rand index score (0-1, higher is better): {rand_score:.4f}") + logger.info(f"Normalized Mutual Information score (perfect=1.0): {nmi_score:.4f}") + logger.info(f"Adjusted Mutual Information score (perfect=1.0): {ami_score:.4f}") + logger.info(f"Homogeneity score (0-1, higher is better): {homogeneity_score:.4f}") + logger.info(f"Completeness score (0-1, higher is better): {completeness_score:.4f}") + logger.info(f"V measure score (0-1, higher is better): {v_measure_score:.4f}") + logger.info(f"Fowlkes-Mallows score (0-1, higher is better): {fm_score:.4f}") + + def evaluate_classification(self) -> None: + """Evaluate and output classification accuracy""" + label_to_ground_truth_mapping = self.map_speakers_to_ground_truth() + with self.session() as session, mfa_open( + os.path.join(self.working_directory, "diarization_evaluation_results.csv"), "w" + ) as f: + writer = csv.DictWriter( + f, + fieldnames=[ + "file", + "begin", + "end", + "text", + "predicted_speaker", + "ground_truth_speaker", + ], + ) + writer.writeheader() + predicted_utt2spk = {} + query = session.query( + Utterance.id, + File.name, + Utterance.begin, + Utterance.end, + Utterance.text, + Utterance.speaker_id, + ).join(Utterance.file) + for u_id, file_name, begin, end, text, s_id in query: + s_id = label_to_ground_truth_mapping[s_id] + predicted_utt2spk[u_id] = s_id + writer.writerow( + { + "file": file_name, + "begin": begin, + "end": end, + "text": text, + "predicted_speaker": self.ground_truth_speakers[s_id], + "ground_truth_speaker": self.ground_truth_speakers[ + self.ground_truth_utt2spk[u_id] + ], + } + ) + + ground_truth_labels = np.array([v for v in self.ground_truth_utt2spk.values()]) + predicted_labels = np.array( + [ + predicted_utt2spk[k] if k in predicted_utt2spk else -1 + for k in self.ground_truth_utt2spk.keys() + ] + ) + precision_score = metrics.precision_score( + ground_truth_labels, predicted_labels, average="weighted" + ) + recall_score = metrics.recall_score( + ground_truth_labels, predicted_labels, average="weighted" + ) + f1_score = metrics.f1_score(ground_truth_labels, predicted_labels, average="weighted") + logger.info(f"Precision (0-1): {precision_score:.4f}") + logger.info(f"Recall (0-1): {recall_score:.4f}") + logger.info(f"F1 (0-1): {f1_score:.4f}") + + @property + def num_utts_path(self) -> str: + """Path to archive containing number of per training speaker""" + return os.path.join(self.working_directory, "num_utts.ark") + + @property + def speaker_ivector_path(self) -> str: + """Path to archive containing training speaker ivectors""" + return os.path.join(self.working_directory, "speaker_ivectors.ark") + + def visualize_clusters(self, ivectors, cluster_labels=None): + import seaborn as sns + from matplotlib import pyplot as plt + + sns.set() + metric = self.metric + if metric is DistanceMetric.plda: + metric = DistanceMetric.cosine + points = visualize_clusters(ivectors, self.manifold_algorithm, metric, 10, self.plda) + fig = plt.figure(1) + ax = fig.add_subplot(111) + if cluster_labels is not None: + unique_labels = np.unique(cluster_labels) + num_unique_labels = unique_labels.shape[0] + has_noise = 0 in set(unique_labels) + if has_noise: + num_unique_labels -= 1 + cm = sns.color_palette("tab20", num_unique_labels) + for cluster in unique_labels: + if cluster == -1: + color = "k" + name = "Noise" + alpha = 0.75 + else: + name = cluster + if not isinstance(name, str): + name = f"Cluster {name}" + cluster_id = cluster + else: + cluster_id = np.where(unique_labels == cluster)[0][0] + if has_noise: + color = cm[cluster_id - 1] + else: + color = cm[cluster_id] + alpha = 1.0 + idx = np.where(cluster_labels == cluster) + ax.scatter(points[idx, 0], points[idx, 1], color=color, label=name, alpha=alpha) + else: + ax.scatter(points[:, 0], points[:, 1]) + handles, labels = ax.get_legend_handles_labels() + fig.subplots_adjust(bottom=0.3, wspace=0.33) + plt.axis("off") + lgd = ax.legend( + handles, + labels, + loc="upper center", + bbox_to_anchor=(0.5, -0.1), + fancybox=True, + shadow=True, + ncol=5, + ) + plot_path = os.path.join(self.working_directory, "cluster_plot.png") + plt.savefig(plot_path, bbox_extra_artists=(lgd,), bbox_inches="tight", transparent=True) + if GLOBAL_CONFIG.current_profile.verbose: + plt.show(block=False) + plt.pause(10) + logger.debug(f"Closing cluster plot, it has been saved to {plot_path}.") + plt.close() + + 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: + arguments = [ + ExportIvectorsArguments( + j.id, + self.db_string, + j.construct_path(self.working_log_directory, "export_ivectors", "log"), + self.use_xvector, + ) + for j in self.jobs + ] + utterance_mapping = [] + for utt_id, ark_path in run_kaldi_function( + ExportIvectorsFunction, arguments, pbar.update + ): + utterance_mapping.append({"id": utt_id, "ivector_ark": ark_path}) + with self.session() as session: + bulk_update(session, Utterance, utterance_mapping) + session.commit() + self._write_ivectors() + + def fix_speaker_ordering(self): + with self.session() as session: + query = ( + session.query(Speaker.id, File.id) + .join(Utterance.speaker) + .join(Utterance.file) + .distinct() + ) + speaker_ordering_mapping = [] + for s_id, f_id in query: + speaker_ordering_mapping.append({"speaker_id": s_id, "file_id": f_id, "index": 10}) + session.execute(sqlalchemy.delete(SpeakerOrdering)) + session.flush() + session.execute( + sqlalchemy.dialects.postgresql.insert(SpeakerOrdering) + .values(speaker_ordering_mapping) + .on_conflict_do_nothing() + ) + session.commit() + + def initialize_mfa_clustering(self): + + with self.session() as session: + + next_speaker_id = self.get_next_primary_key(Speaker) + speaker_mapping = {} + existing_speakers = { + name: s_id for s_id, name in session.query(Speaker.id, Speaker.name) + } + utterance_mapping = [] + self.classification_score = 0 + unk_count = 0 + if self.use_xvector: + arguments = [ + SpeechbrainArguments(j.id, self.db_string, None, self.cuda, self.cluster) + for j in self.jobs + ] + func = SpeechbrainClassificationFunction + score_threshold = self.initial_sb_score_threshold + self.export_xvectors() + else: + plda_transform_path = os.path.join(self.working_directory, "plda.pkl") + with open(plda_transform_path, "rb") as f: + self.plda: PldaModel = pickle.load(f) + arguments = self.plda_classification_arguments() + func = PldaClassificationFunction + score_threshold = self.initial_plda_score_threshold + + 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: + for utt_id, classified_speaker, score in run_kaldi_function( + func, arguments, pbar.update + ): + classified_speaker = str(classified_speaker) + self.classification_score += score / self.num_utterances + if score < score_threshold: + unk_count += 1 + utterance_mapping.append( + {"id": utt_id, "speaker_id": existing_speakers["MFA_UNKNOWN"]} + ) + continue + if classified_speaker in existing_speakers: + speaker_id = existing_speakers[classified_speaker] + else: + if classified_speaker not in speaker_mapping: + speaker_mapping[classified_speaker] = { + "id": next_speaker_id, + "name": classified_speaker, + } + next_speaker_id += 1 + speaker_id = speaker_mapping[classified_speaker]["id"] + if speaker_id == utt2spk[utt_id]: + continue + utterance_mapping.append({"id": utt_id, "speaker_id": speaker_id}) + if speaker_mapping: + session.bulk_insert_mappings(Speaker, list(speaker_mapping.values())) + session.flush() + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS ix_utterance_speaker_id")) + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS utterance_position_index")) + session.commit() + bulk_update(session, Utterance, utterance_mapping) + session.execute( + sqlalchemy.text("CREATE INDEX ix_utterance_speaker_id on utterance(speaker_id)") + ) + session.execute( + sqlalchemy.text( + 'CREATE INDEX utterance_position_index on utterance(file_id, speaker_id, begin, "end", channel)' + ) + ) + session.commit() + self.breakup_large_clusters() + self.cleanup_empty_speakers() + + def export_speaker_ivectors(self): + logger.info("Exporting current speaker ivectors...") + + with self.session() as session, tqdm.tqdm( + total=self.num_speakers, disable=GLOBAL_CONFIG.quiet + ) as pbar, mfa_open(self.num_utts_path, "w") as f: + if self.use_xvector: + ivector_column = Speaker.xvector + else: + ivector_column = Speaker.ivector + + speakers = ( + session.query(Speaker.id, ivector_column, sqlalchemy.func.count(Utterance.id)) + .join(Speaker.utterances) + .filter(Speaker.name != "MFA_UNKNOWN") + .group_by(Speaker.id) + .order_by(Speaker.id) + ) + input_proc = subprocess.Popen( + [ + thirdparty_binary("copy-vector"), + "--binary=true", + "ark,t:-", + f"ark:{self.speaker_ivector_path}", + ], + stdin=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=os.environ, + ) + for s_id, ivector, utterance_count in speakers: + if ivector is None: + continue + ivector = " ".join([format(x, ".12g") for x in ivector]) + in_line = f"{s_id} [ {ivector} ]\n".encode("utf8") + input_proc.stdin.write(in_line) + input_proc.stdin.flush() + pbar.update(1) + f.write(f"{s_id} {utterance_count}\n") + input_proc.stdin.close() + input_proc.wait() + + def classify_iteration(self, iteration=None) -> None: + logger.info("Classifying utterances...") + + low_count = None + if iteration is not None and self.min_cluster_size: + low_count = np.linspace(0, self.min_cluster_size, self.max_iterations)[iteration] + logger.debug(f"Minimum size: {low_count}") + score_threshold = self.plda_score_threshold + if iteration is not None: + score_threshold = np.linspace( + self.initial_plda_score_threshold, + self.plda_score_threshold, + self.max_iterations, + )[iteration] + logger.debug(f"Score threshold: {score_threshold}") + with self.session() as session, tqdm.tqdm( + total=self.num_utterances, disable=GLOBAL_CONFIG.quiet + ) as pbar: + + unknown_speaker_id = ( + session.query(Speaker.id).filter(Speaker.name == "MFA_UNKNOWN").first()[0] + ) + + utterance_mapping = [] + self.classification_score = 0 + plda_transform_path = os.path.join(self.working_directory, "plda.pkl") + with open(plda_transform_path, "rb") as f: + self.plda: PldaModel = pickle.load(f) + arguments = self.plda_classification_arguments() + func = PldaClassificationFunction + utt2spk = {k: v for k, v in session.query(Utterance.id, Utterance.speaker_id)} + + for utt_id, classified_speaker, score in run_kaldi_function( + func, arguments, pbar.update + ): + self.classification_score += score / self.num_utterances + if score < score_threshold: + speaker_id = unknown_speaker_id + else: + speaker_id = classified_speaker + if speaker_id == utt2spk[utt_id]: + continue + utterance_mapping.append({"id": utt_id, "speaker_id": speaker_id}) + + logger.debug(f"Updating {len(utterance_mapping)} utterances with new speakers") + + session.commit() + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS ix_utterance_speaker_id")) + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS utterance_position_index")) + session.commit() + bulk_update(session, Utterance, utterance_mapping) + session.execute( + sqlalchemy.text("CREATE INDEX ix_utterance_speaker_id on utterance(speaker_id)") + ) + session.execute( + sqlalchemy.text( + 'CREATE INDEX utterance_position_index on utterance(file_id, speaker_id, begin, "end", channel)' + ) + ) + session.commit() + if iteration is not None and iteration < self.max_iterations - 2: + self.breakup_large_clusters() + self.cleanup_empty_speakers(low_count) + + def breakup_large_clusters(self): + with self.session() as session: + unknown_speaker_id = ( + session.query(Speaker.id).filter(Speaker.name == "MFA_UNKNOWN").first()[0] + ) + sq = ( + session.query(Speaker.id, sqlalchemy.func.count().label("utterance_count")) + .join(Speaker.utterances) + .filter(Speaker.id != unknown_speaker_id) + .group_by(Speaker.id) + ) + above_threshold_speakers = [unknown_speaker_id] + threshold = 500 + for s_id, utterance_count in sq: + if threshold and utterance_count > threshold and s_id not in self.single_clusters: + above_threshold_speakers.append(s_id) + 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: + utterance_mapping = [] + new_speakers = {} + for s_id in above_threshold_speakers: + logger.debug(f"Breaking up {s_id}") + query = session.query(Utterance.id, Utterance.plda_vector).filter( + Utterance.plda_vector != None, Utterance.speaker_id == s_id # noqa + ) + pbar.update(1) + ivectors = np.empty((query.count(), PLDA_DIMENSION)) + logger.debug(f"Had {ivectors.shape[0]} utterances.") + if ivectors.shape[0] == 0: + continue + utterance_ids = [] + for i, (u_id, ivector) in enumerate(query): + if self.stopped.stop_check(): + break + utterance_ids.append(u_id) + ivectors[i, :] = ivector + if ivectors.shape[0] < self.min_cluster_size: + continue + labels = cluster_matrix( + ivectors, + ClusterType.optics, + metric=DistanceMetric.cosine, + strict=False, + no_visuals=True, + working_directory=self.working_directory, + distance_threshold=0.25, + ) + unique, counts = np.unique(labels, return_counts=True) + num_clusters = unique.shape[0] + counts = dict(zip(unique, counts)) + logger.debug(f"{num_clusters} clusters found: {counts}") + if num_clusters == 1: + if s_id != unknown_speaker_id: + logger.debug(f"Deleting {s_id} due to no clusters found") + session.execute( + sqlalchemy.update(Utterance) + .filter(Utterance.speaker_id == s_id) + .values({Utterance.speaker_id: unknown_speaker_id}) + ) + session.flush() + continue + if num_clusters == 2: + if s_id != unknown_speaker_id: + logger.debug( + f"Only found one cluster for {s_id} will skip in the future" + ) + self.single_clusters.add(s_id) + continue + for i, utt_id in enumerate(utterance_ids): + label = labels[i] + if label == -1: + speaker_id = unknown_speaker_id + else: + if s_id in self.single_clusters: + continue + if label not in new_speakers: + if s_id == unknown_speaker_id: + label = self._unknown_speaker_break_up_count + self._unknown_speaker_break_up_count += 1 + new_speakers[label] = { + "id": next_speaker_id, + "name": f"{s_id}_{label}", + } + next_speaker_id += 1 + speaker_id = new_speakers[label]["id"] + utterance_mapping.append({"id": utt_id, "speaker_id": speaker_id}) + if new_speakers: + session.bulk_insert_mappings(Speaker, list(new_speakers.values())) + session.commit() + if utterance_mapping: + bulk_update(session, Utterance, utterance_mapping) + session.commit() + logger.debug(f"Broke speakers into {len(new_speakers)} new speakers.") + + def cleanup_empty_speakers(self, threshold=None): + with self.session() as session: + session.execute(sqlalchemy.delete(SpeakerOrdering)) + session.flush() + unknown_speaker_id = ( + session.query(Speaker.id).filter(Speaker.name == "MFA_UNKNOWN").first()[0] + ) + non_empty_speakers = [unknown_speaker_id] + sq = ( + session.query(Speaker.id, sqlalchemy.func.count().label("utterance_count")) + .join(Speaker.utterances) + .filter(Speaker.id != unknown_speaker_id) + .group_by(Speaker.id) + ) + below_threshold_speakers = [] + for s_id, utterance_count in sq: + if threshold and utterance_count < threshold: + below_threshold_speakers.append(s_id) + continue + non_empty_speakers.append(s_id) + session.execute( + sqlalchemy.update(Utterance) + .where(Utterance.speaker_id.in_(below_threshold_speakers)) + .values(speaker_id=unknown_speaker_id) + ) + session.execute(sqlalchemy.delete(Speaker).where(~Speaker.id.in_(non_empty_speakers))) + session.commit() + self._num_speakers = session.query(Speaker).count() + conn = self.db_engine.connect() + try: + conn.execution_options(isolation_level="AUTOCOMMIT") + conn.execute( + sqlalchemy.text(f"ANALYZE {Speaker.__tablename__}, {Utterance.__tablename__}") + ) + finally: + conn.close() + + def cluster_utterances_mfa(self) -> None: + """ + Cluster utterances with a ivector or speechbrain model + """ + self.cluster = False + self.setup() + with self.session() as session: + if session.query(Speaker).filter(Speaker.name == "MFA_UNKNOWN").first() is None: + session.add(Speaker(id=self.get_next_primary_key(Speaker), name="MFA_UNKNOWN")) + session.commit() + self.initialize_mfa_clustering() + with self.session() as session: + uncategorized_count = ( + session.query(Utterance) + .join(Utterance.speaker) + .filter(Speaker.name == "MFA_UNKNOWN") + .count() + ) + if self.use_xvector: + logger.info(f"Initial average cosine score {self.classification_score:.4f}") + else: + logger.info(f"Initial average PLDA score {self.classification_score:.4f}") + logger.info(f"Number of speakers: {self.num_speakers}") + logger.info(f"Unclassified utterances: {uncategorized_count}") + self._unknown_speaker_break_up_count = 0 + for i in range(self.max_iterations): + logger.info(f"Iteration {i}:") + current_score = self.classification_score + self._write_ivectors() + self.compute_plda() + self.refresh_plda_vectors() + self.refresh_speaker_vectors() + self.export_speaker_ivectors() + self.classify_iteration(i) + improvement = self.classification_score - current_score + with self.session() as session: + uncategorized_count = ( + session.query(Utterance) + .join(Utterance.speaker) + .filter(Speaker.name == "MFA_UNKNOWN") + .count() + ) + logger.info(f"Average PLDA score {self.classification_score:.4f}") + logger.info(f"Improvement: {improvement:.4f}") + logger.info(f"Number of speakers: {self.num_speakers}") + logger.info(f"Unclassified utterances: {uncategorized_count}") + logger.debug(f"Found {self.num_speakers} clusters") + if GLOBAL_CONFIG.current_profile.debug: + self.visualize_current_clusters() + + def visualize_current_clusters(self): + with self.session() as session: + query = ( + session.query(Speaker.name, Utterance.plda_vector) + .join(Utterance.speaker) + .filter(Utterance.plda_vector is not None) + ) + dim = PLDA_DIMENSION + num_utterances = query.count() + if num_utterances == 0: + if self.use_xvector: + column = Utterance.xvector + dim = XVECTOR_DIMENSION + else: + column = Utterance.ivector + dim = IVECTOR_DIMENSION + query = ( + session.query(Speaker.name, column) + .join(Utterance.speaker) + .filter(column is not None) + ) + num_utterances = query.count() + if num_utterances == 0: + logger.warning("No ivectors/xvectors to visualize") + return + ivectors = np.empty((query.count(), dim)) + labels = [] + for s_name, ivector in query: + ivectors[len(labels), :] = ivector + labels.append(s_name) + self.visualize_clusters(ivectors, labels) + + def cluster_utterances(self) -> None: + """ + Cluster utterances with a ivector or speechbrain model + """ + if self.cluster_type is ClusterType.mfa: + self.cluster_utterances_mfa() + self.fix_speaker_ordering() + if not self.evaluation_mode: + self.cleanup_empty_speakers() + self.refresh_speaker_vectors() + if self.evaluation_mode: + self.evaluate_clustering() + return + self.setup() + + os.environ["OMP_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.num_jobs}" + os.environ["OPENBLAS_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.num_jobs}" + os.environ["MKL_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.num_jobs}" + if self.metric is DistanceMetric.plda: + plda_transform_path = os.path.join(self.working_directory, "plda.pkl") + with open(plda_transform_path, "rb") as f: + self.plda: PldaModel = pickle.load(f) + if self.evaluation_mode and GLOBAL_CONFIG.current_profile.debug: + self.calculate_eer() + logger.info("Clustering utterances (this may take a while, please be patient)...") + with self.session() as session: + if self.use_pca: + query = session.query(Utterance.id, Utterance.plda_vector).filter( + Utterance.plda_vector != None # noqa + ) + ivectors = np.empty((query.count(), PLDA_DIMENSION)) + elif self.use_xvector: + query = session.query(Utterance.id, Utterance.xvector).filter( + Utterance.xvector != None # noqa + ) + ivectors = np.empty((query.count(), XVECTOR_DIMENSION)) + else: + query = session.query(Utterance.id, Utterance.ivector).filter( + Utterance.ivector != None # noqa + ) + ivectors = np.empty((query.count(), IVECTOR_DIMENSION)) + utterance_ids = [] + for i, (u_id, ivector) in enumerate(query): + if self.stopped.stop_check(): + break + utterance_ids.append(u_id) + ivectors[i, :] = ivector + num_utterances = ivectors.shape[0] + kwargs = {} + + if self.stopped.stop_check(): + logger.debug("Stopping clustering early.") + return + kwargs["min_cluster_size"] = self.min_cluster_size + kwargs["distance_threshold"] = self.distance_threshold + if self.cluster_type is ClusterType.agglomerative: + kwargs["memory"] = MEMORY + kwargs["linkage"] = self.linkage + kwargs["n_clusters"] = self.expected_num_speakers + if not self.expected_num_speakers: + kwargs["n_clusters"] = None + elif self.cluster_type is ClusterType.spectral: + kwargs["n_clusters"] = self.expected_num_speakers + elif self.cluster_type is ClusterType.hdbscan: + kwargs["memory"] = MEMORY + elif self.cluster_type is ClusterType.optics: + kwargs["memory"] = MEMORY + elif self.cluster_type is ClusterType.kmeans: + kwargs["n_clusters"] = self.expected_num_speakers + labels = cluster_matrix( + ivectors, + self.cluster_type, + metric=self.metric, + plda=self.plda, + working_directory=self.working_directory, + **kwargs, + ) + if self.stopped.stop_check(): + logger.debug("Stopping clustering early.") + return + if GLOBAL_CONFIG.current_profile.debug: + self.visualize_clusters(ivectors, labels) + + utterance_clusters = collections.defaultdict(list) + for i in range(num_utterances): + u_id = utterance_ids[i] + cluster_id = int(labels[i]) + utterance_clusters[cluster_id].append(u_id) + + utterance_mapping = [] + next_speaker_id = self.get_next_primary_key(Speaker) + speaker_mapping = [] + unknown_speaker_id = None + for cluster_id, utterance_ids in sorted(utterance_clusters.items()): + if cluster_id < 0: + if unknown_speaker_id is None: + speaker_name = "MFA_UNKNOWN" + speaker_mapping.append({"id": next_speaker_id, "name": speaker_name}) + speaker_id = next_speaker_id + unknown_speaker_id = speaker_id + next_speaker_id += 1 + else: + speaker_id = unknown_speaker_id + else: + speaker_name = f"Cluster {cluster_id}" + speaker_mapping.append({"id": next_speaker_id, "name": speaker_name}) + speaker_id = next_speaker_id + next_speaker_id += 1 + for u_id in utterance_ids: + utterance_mapping.append({"id": u_id, "speaker_id": speaker_id}) + if self.stopped.stop_check(): + logger.debug("Stopping clustering early.") + return + if speaker_mapping: + session.bulk_insert_mappings(Speaker, speaker_mapping) + session.flush() + session.commit() + bulk_update(session, Utterance, utterance_mapping) + session.flush() + session.commit() + if not self.evaluation_mode: + self.clean_up_unknown_speaker() + self.fix_speaker_ordering() + if not self.evaluation_mode: + self.cleanup_empty_speakers() + self.refresh_speaker_vectors() + if self.evaluation_mode: + self.evaluate_clustering() + + os.environ["OMP_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + os.environ["OPENBLAS_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + os.environ["MKL_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + + def clean_up_unknown_speaker(self): + with self.session() as session: + unknown_speaker = session.query(Speaker).filter(Speaker.name == "MFA_UNKNOWN").first() + next_speaker_id = self.get_next_primary_key(Speaker) + if unknown_speaker is not None: + speaker_mapping = {} + utterance_mapping = [] + query = ( + session.query(File.id, File.name) + .join(File.utterances) + .filter(Utterance.speaker_id == unknown_speaker.id) + .distinct() + ) + for file_id, file_name in query: + speaker_mapping[file_id] = {"id": next_speaker_id, "name": file_name} + next_speaker_id += 1 + query = ( + session.query(Utterance.id, Utterance.file_id) + .join(File.utterances) + .filter(Utterance.speaker_id == unknown_speaker.id) + ) + for utterance_id, file_id in query: + utterance_mapping.append( + {"id": utterance_id, "speaker_id": speaker_mapping[file_id]["id"]} + ) + + session.bulk_insert_mappings(Speaker, list(speaker_mapping.values())) + session.flush() + session.execute( + sqlalchemy.delete(SpeakerOrdering).where( + SpeakerOrdering.c.speaker_id == unknown_speaker.id + ) + ) + session.commit() + bulk_update(session, Utterance, utterance_mapping) + session.commit() + + def calculate_eer(self) -> typing.Tuple[float, float]: + """ + Calculate Equal Error Rate (EER) and threshold for the diarization metric using the ground truth data. + + Returns + ------- + float + EER + float + Threshold of EER + """ + if not FOUND_SPEECHBRAIN: + logger.info("No speechbrain found, skipping EER calculation.") + return 0.0, 0.0 + logger.info("Calculating EER using ground truth speakers...") + limit_per_speaker = 5 + limit_within_speaker = 30 + begin = time.time() + with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + arguments = [ + ComputeEerArguments( + j.id, + self.db_string, + None, + self.plda, + self.metric, + self.use_xvector, + limit_within_speaker, + limit_per_speaker, + ) + for j in self.jobs + ] + match_scores = [] + mismatch_scores = [] + for matches, mismatches in run_kaldi_function( + ComputeEerFunction, arguments, pbar.update + ): + match_scores.extend(matches) + mismatch_scores.extend(mismatches) + random.shuffle(mismatches) + mismatch_scores = mismatch_scores[: len(match_scores)] + match_scores = np.array(match_scores) + mismatch_scores = np.array(mismatch_scores) + device = torch.device("cuda" if self.cuda else "cpu") + eer, thresh = EER( + torch.tensor(mismatch_scores, device=device), + torch.tensor(match_scores, device=device), + ) + logger.debug( + f"Matching scores: {np.min(match_scores):.3f}-{np.max(match_scores):.3f} (mean = {match_scores.mean():.3f}, n = {match_scores.shape[0]})" + ) + logger.debug( + f"Mismatching scores: {np.min(mismatch_scores):.3f}-{np.max(mismatch_scores):.3f} (mean = {mismatch_scores.mean():.3f}, n = {mismatch_scores.shape[0]})" + ) + logger.info(f"EER: {eer*100:.2f}%") + logger.info(f"Threshold: {thresh:.4f}") + logger.debug(f"Calculating EER took {time.time() - begin:.3f} seconds") + return eer, thresh + + def load_embeddings(self) -> None: + """Load embeddings from a speechbrain model""" + if self.has_xvectors(): + logger.info("Embeddings already loaded.") + return + logger.info("Loading SpeechBrain embeddings...") + with tqdm.tqdm( + total=self.num_utterances, disable=GLOBAL_CONFIG.quiet + ) as pbar, self.session() as session: + begin = time.time() + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS utterance_xvector_index")) + session.execute(sqlalchemy.text("ALTER TABLE utterance DISABLE TRIGGER all")) + session.commit() + update_mapping = {} + arguments = [ + SpeechbrainArguments(j.id, self.db_string, None, self.cuda, self.cluster) + for j in self.jobs + ] + embeddings = [] + utterance_ids = [] + for u_id, emb in run_kaldi_function( + SpeechbrainEmbeddingFunction, arguments, pbar.update + ): + utterance_ids.append(u_id) + embeddings.append(emb) + update_mapping[u_id] = {"id": u_id, "xvector": emb} + embeddings = np.array(embeddings) + if PLDA_DIMENSION != XVECTOR_DIMENSION: + if embeddings.shape[0] < PLDA_DIMENSION: + logger.debug("Can't run PLDA due to too few features.") + else: + pca = decomposition.PCA(PLDA_DIMENSION) + pca.fit(embeddings) + logger.debug( + f"PCA explained variance: {np.sum(pca.explained_variance_ratio_)*100:.2f}%" + ) + transformed = pca.transform(embeddings) + for i, u_id in enumerate(utterance_ids): + update_mapping[u_id]["plda_vector"] = transformed[i, :] + else: + for v in update_mapping.values(): + v["plda_vector"] = v["xvector"] + bulk_update(session, Utterance, list(update_mapping.values())) + session.query(Corpus).update({Corpus.xvectors_loaded: True}) + session.execute( + sqlalchemy.text( + "CREATE INDEX utterance_xvector_index ON utterance " + "USING ivfflat (xvector vector_cosine_ops) " + "WITH (lists = 100)" + ) + ) + session.execute(sqlalchemy.text("ALTER TABLE utterance ENABLE TRIGGER all")) + session.commit() + logger.debug(f"Loading embeddings took {time.time() - begin:.3f} seconds") + + def refresh_plda_vectors(self): + logger.info("Refreshing PLDA vectors...") + self.plda = PldaModel.load(self.plda_path) + with self.session() as session, tqdm.tqdm( + total=self.num_utterances, disable=GLOBAL_CONFIG.quiet + ) as pbar: + if self.use_xvector: + ivector_column = Utterance.xvector + else: + ivector_column = Utterance.ivector + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS utterance_plda_vector_index")) + session.execute(sqlalchemy.text("ALTER TABLE utterance DISABLE TRIGGER all")) + session.commit() + update_mapping = [] + utterance_ids = [] + ivectors = [] + utterances = session.query(Utterance.id, ivector_column).filter( + ivector_column != None # noqa + ) + for utt_id, ivector in utterances: + pbar.update(1) + utterance_ids.append(utt_id) + ivectors.append(ivector) + ivectors = np.array(ivectors) + ivectors = self.plda.process_ivectors(ivectors) + for i, utt_id in enumerate(utterance_ids): + update_mapping.append({"id": utt_id, "plda_vector": ivectors[i, :]}) + bulk_update(session, Utterance, update_mapping) + session.execute( + sqlalchemy.text( + "CREATE INDEX utterance_plda_vector_index ON utterance " + "USING ivfflat (plda_vector vector_cosine_ops) " + "WITH (lists = 100)" + ) + ) + session.execute(sqlalchemy.text("ALTER TABLE utterance ENABLE TRIGGER all")) + session.commit() + plda_transform_path = os.path.join(self.working_directory, "plda.pkl") + with open(plda_transform_path, "wb") as f: + pickle.dump(self.plda, f) + + 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( + total=self.num_speakers, disable=GLOBAL_CONFIG.quiet + ) as pbar: + if self.use_xvector: + ivector_column = Utterance.xvector + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS speaker_xvector_index")) + else: + ivector_column = Utterance.ivector + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS speaker_ivector_index")) + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS speaker_plda_vector_index")) + session.execute(sqlalchemy.text("ALTER TABLE speaker DISABLE TRIGGER all")) + session.commit() + update_mapping = {} + speaker_ids = [] + ivectors = [] + speakers = session.query(Speaker.id) + for (s_id,) in speakers: + query = session.query(ivector_column).filter(Utterance.speaker_id == s_id) + s_ivectors = [] + for (u_ivector,) in query: + s_ivectors.append(u_ivector) + if not s_ivectors: + continue + mean_ivector = np.mean(np.array(s_ivectors), axis=0) + speaker_ids.append(s_id) + ivectors.append(mean_ivector) + if self.use_xvector: + key = "xvector" + else: + key = "ivector" + update_mapping[s_id] = {"id": s_id, key: mean_ivector} + pbar.update(1) + ivectors = np.array(ivectors) + if self.plda is not None: + ivectors = self.plda.process_ivectors(ivectors) + for i, speaker_id in enumerate(speaker_ids): + update_mapping[speaker_id]["plda_vector"] = ivectors[i, :] + bulk_update(session, Speaker, list(update_mapping.values())) + if self.use_xvector: + session.execute( + sqlalchemy.text( + "CREATE INDEX speaker_xvector_index ON speaker " + "USING ivfflat (xvector vector_cosine_ops) " + "WITH (lists = 100)" + ) + ) + else: + session.execute( + sqlalchemy.text( + "CREATE INDEX speaker_ivector_index ON speaker " + "USING ivfflat (ivector vector_cosine_ops) " + "WITH (lists = 100)" + ) + ) + session.execute( + sqlalchemy.text( + "CREATE INDEX speaker_plda_vector_index ON speaker " + "USING ivfflat (plda_vector vector_cosine_ops) " + "WITH (lists = 100)" + ) + ) + session.execute(sqlalchemy.text("ALTER TABLE speaker ENABLE TRIGGER all")) + session.commit() + + if self.use_xvector: + self.compute_speaker_embeddings() + else: + self.compute_speaker_ivectors() + + def compute_speaker_embeddings(self) -> None: + """Generate per-speaker embeddings as the mean over their utterances""" + if not self.has_xvectors(): + self.load_embeddings() + logger.info("Computing SpeechBrain speaker embeddings...") + with tqdm.tqdm( + total=self.num_speakers, disable=GLOBAL_CONFIG.quiet + ) as pbar, self.session() as session: + session.execute(sqlalchemy.text("DROP INDEX IF EXISTS speaker_xvector_index")) + session.execute(sqlalchemy.text("ALTER TABLE speaker DISABLE TRIGGER all")) + session.commit() + update_mapping = [] + speakers = session.query(Speaker.id) + for (s_id,) in speakers: + u_query = session.query(Utterance.xvector).filter( + Utterance.speaker_id == s_id, Utterance.xvector != None # noqa + ) + embeddings = np.empty((u_query.count(), XVECTOR_DIMENSION)) + if embeddings.shape[0] == 0: + continue + for i, (xvector,) in enumerate(u_query): + embeddings[i, :] = xvector + speaker_xvector = np.mean(embeddings, axis=0) + update_mapping.append({"id": s_id, "xvector": speaker_xvector}) + pbar.update(1) + bulk_update(session, Speaker, update_mapping) + session.execute( + sqlalchemy.text( + "CREATE INDEX speaker_xvector_index ON speaker " + "USING ivfflat (xvector vector_cosine_ops) " + "WITH (lists = 100)" + ) + ) + session.execute(sqlalchemy.text("ALTER TABLE speaker ENABLE TRIGGER all")) + session.commit() + + def export_files(self, output_directory: str) -> None: + """ + Export files with their new speaker labels + + Parameters + ---------- + output_directory: str + Output directory to save files + """ + if not self.overwrite and os.path.exists(output_directory): + output_directory = os.path.join(self.working_directory, "speaker_classification") + os.makedirs(output_directory, exist_ok=True) + diagnostic_files = [ + "diarization_evaluation_results.csv", + "cluster_plot.png", + "nearest_neighbors.png", + ] + for fname in diagnostic_files: + path = os.path.join(self.working_directory, fname) + if os.path.exists(path): + shutil.copyfile( + path, + os.path.join(output_directory, fname), + ) + with mfa_open(os.path.join(output_directory, "parameters.yaml"), "w") as f: + yaml.safe_dump( + { + "ivector_extractor_path": self.ivector_extractor_path, + "expected_num_speakers": self.expected_num_speakers, + "cluster": self.cluster, + "cuda": self.cuda, + "metric": self.metric.name, + "cluster_type": self.cluster_type.name, + "distance_threshold": self.distance_threshold, + "min_cluster_size": self.min_cluster_size, + "linkage": self.linkage, + }, + f, + ) + with self.session() as session: + + logger.info("Writing output files...") + files = session.query(File).options( + selectinload(File.utterances), + selectinload(File.speakers), + 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: + for file in files: + utterance_count = len(file.utterances) + + if utterance_count == 0: + logger.debug(f"Could not find any utterances for {file.name}") + continue + output_format = file.text_file.file_type + output_path = construct_output_path( + file.name, + file.relative_path, + output_directory, + output_format=output_format, + ) + if output_format == "lab": + with mfa_open(output_path, "w") as f: + f.write(file.utterances[0].text) + else: + data = file.construct_transcription_tiers(original_text=True) + export_textgrid( + data, + output_path, + file.duration, + self.export_frame_shift, + output_format, + ) + pbar.update(1) diff --git a/montreal_forced_aligner/dictionary/__init__.py b/montreal_forced_aligner/dictionary/__init__.py index d411ea09..acc285b3 100644 --- a/montreal_forced_aligner/dictionary/__init__.py +++ b/montreal_forced_aligner/dictionary/__init__.py @@ -17,5 +17,4 @@ "SanitizeFunction", "MultispeakerDictionary", "MultispeakerDictionaryMixin", - "PronunciationDictionaryMixin", ] diff --git a/montreal_forced_aligner/dictionary/mixins.py b/montreal_forced_aligner/dictionary/mixins.py index 5f57fe5c..d6a6580a 100644 --- a/montreal_forced_aligner/dictionary/mixins.py +++ b/montreal_forced_aligner/dictionary/mixins.py @@ -10,15 +10,16 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple from montreal_forced_aligner.abc import DatabaseMixin -from montreal_forced_aligner.data import PhoneSetType -from montreal_forced_aligner.helper import make_re_character_set_safe, mfa_open +from montreal_forced_aligner.data import PhoneSetType, PhoneType +from montreal_forced_aligner.db import Phone +from montreal_forced_aligner.helper import mfa_open if TYPE_CHECKING: from montreal_forced_aligner.abc import MetaDict -DEFAULT_PUNCTUATION = list(r'、。।,?!@<>→"”()“„–,.:;—¿?¡:)!\\&%#*~【】,…‥「」『』〝〟″⟨⟩♪・‹›«»~′$+=‘') +DEFAULT_PUNCTUATION = list(r'、。।,?!!@<>→"”()“„–,.:;—¿?¡:)!\\&%#*~【】,…‥「」『』〝〟″⟨⟩♪・‹›«»~′$+=‘') -DEFAULT_WORD_BREAK_MARKERS = list(r'?!(),,.:;¡¿?“„"”&~%#—…‥、。【】$+=〝〟″‹›«»・⟨⟩「」『』') +DEFAULT_WORD_BREAK_MARKERS = list(r'?!!(),,.:;¡¿?“„"”&~%#—…‥、。【】$+=〝〟″‹›«»・⟨⟩「」『』') DEFAULT_QUOTE_MARKERS = list("“„\"”〝〟″「」『』‚ʻʿ‘′'") @@ -134,8 +135,6 @@ class SplitWordsFunction: Set of special words oov_word : str What to label words not in the dictionary, defaults to None - oov_word : str - What to label words that are bracketed, defaults to None """ def __init__( @@ -148,12 +147,11 @@ def __init__( oov_word: Optional[str] = None, word_mapping: Optional[Dict[str, int]] = None, grapheme_mapping: Optional[Dict[str, int]] = None, - specials_set: Optional[Set[str]] = None, ): self.clitic_marker = clitic_marker self.compound_regex = compound_regex self.oov_word = oov_word - self.specials_set = specials_set + self.specials_set = {self.oov_word, "", ""} if not word_mapping: word_mapping = None self.word_mapping = word_mapping @@ -172,7 +170,7 @@ def __init__( if self.final_clitic_regex is not None: self.has_final = True - def to_int(self, normalized_text: str) -> int: + def to_str(self, normalized_text: str) -> str: """ Convert normalized text to an integer ID @@ -183,36 +181,15 @@ def to_int(self, normalized_text: str) -> int: Returns ------- - int - Integer ID for the word + str + Normalized string """ - if normalized_text in self.word_mapping and normalized_text not in self.specials_set: - return self.word_mapping[normalized_text] + if normalized_text in self.specials_set: + return self.oov_word for word, regex in self.non_speech_regexes.items(): if regex.match(normalized_text): - return self.word_mapping[word] - return self.word_mapping[self.oov_word] - - def grapheme_to_int(self, character: str) -> int: - """ - Convert normalized text to an integer ID - - Parameters - ---------- - normalized_text: - Word to convert - - Returns - ------- - int - Integer ID for the word - """ - if character in self.grapheme_mapping and character not in self.specials_set: - return self.grapheme_mapping[character] - for word, regex in self.non_speech_regexes.items(): - if regex.match(character): - return self.word_mapping[word] - return self.grapheme_mapping[self.oov_word] + return word + return normalized_text def split_clitics( self, @@ -238,6 +215,8 @@ def split_clitics( s = [item] if self.word_mapping is None: return [item] + clean_initial_quote_regex = re.compile("^'") + clean_final_quote_regex = re.compile("'$") benefit = False for seg in s: if not seg: @@ -268,6 +247,8 @@ def split_clitics( benefit = True initial_clitics.append(clitic.group(0)) seg = seg[clitic.end(0) :] + if seg in self.word_mapping: + break if self.has_final: while True: clitic = self.final_clitic_regex.search(seg) @@ -276,10 +257,14 @@ def split_clitics( benefit = True final_clitics.append(clitic.group(0)) seg = seg[: clitic.start(0)] + if seg in self.word_mapping: + break final_clitics.reverse() - split.extend(initial_clitics) - split.append(seg) - split.extend(final_clitics) + split.extend([clean_initial_quote_regex.sub("", x) for x in initial_clitics]) + seg = clean_final_quote_regex.sub("", clean_initial_quote_regex.sub("", seg)) + if seg: + split.append(seg) + split.extend([clean_final_quote_regex.sub("", x) for x in final_clitics]) if not benefit and seg in self.word_mapping: benefit = True if not benefit: @@ -389,7 +374,7 @@ def __init__( optional_silence_phone: str = "sil", oov_phone: str = "spn", other_noise_phone: Optional[str] = None, - position_dependent_phones: bool = True, + position_dependent_phones: bool = False, num_silence_states: int = 5, num_non_silence_states: int = 3, shared_silence_phones: bool = False, @@ -411,6 +396,7 @@ def __init__( phone_set_type: typing.Union[str, PhoneSetType] = "UNKNOWN", preserve_suprasegmentals: bool = False, base_phone_mapping: Dict[str, str] = None, + use_cutoff_model: bool = False, **kwargs, ): super().__init__(**kwargs) @@ -478,7 +464,8 @@ def __init__( self.laughter_regex = None self.word_break_regex = None self.bracket_sanitize_regex = None - self.compile_regexes() + self.use_cutoff_model = use_cutoff_model + self._phone_groups = {} @property def base_phones(self) -> Dict[str, Set[str]]: @@ -538,7 +525,7 @@ def extra_questions_mapping(self) -> Dict[str, List[str]]: mapping[k].extend([x + pos for pos in self.positions]) else: mapping[k] = sorted(v) - elif self.phone_set_type == PhoneSetType.IPA: + elif self.phone_set_type is PhoneSetType.IPA: filtered_v = set() for x in self.non_silence_phones: base_phone = self.get_base_phone(x) @@ -623,7 +610,14 @@ def context_independent_csl(self) -> str: @property def specials_set(self) -> Set[str]: """Special words, like the ``oov_word`` ``silence_word``, ````, and ````""" - return {self.silence_word, "", ""} + return { + self.silence_word, + self.oov_word, + self.bracketed_word, + self.laughter_word, + "", + "", + } @property def phone_mapping(self) -> Dict[str, int]: @@ -752,25 +746,27 @@ def kaldi_non_silence_phones(self) -> List[str]: return self.positional_non_silence_phones return self._generate_non_positional_list(self.non_silence_phones) + @property + def phone_groups(self) -> typing.Dict[str, typing.List[str]]: + if not self._phone_groups: + for p in sorted(self.non_silence_phones): + base_phone = self.get_base_phone(p) + if base_phone not in self._phone_groups: + self._phone_groups[base_phone] = [base_phone] + if p not in self._phone_groups[base_phone]: + self._phone_groups[base_phone].append(p) + return self._phone_groups + @property def kaldi_grouped_phones(self) -> Dict[str, List[str]]: """Non silence phones in Kaldi format""" groups = {} - for p in sorted(self.non_silence_phones): - base_phone = self.get_base_phone(p) - if base_phone not in groups: - if self.position_dependent_phones: - groups[base_phone] = [base_phone + pos for pos in self.positions] - else: - groups[base_phone] = [base_phone] + for k, v in self.phone_groups.items(): if self.position_dependent_phones: - groups[base_phone].extend( - [p + pos for pos in self.positions if p + pos not in groups[base_phone]] - ) + groups[k] = [x + pos for pos in self.positions for x in v] else: - if p not in groups[base_phone]: - groups[base_phone].append(p) - return groups + groups[k] = v + return {k: v for k, v in groups.items() if v} @property def kaldi_silence_phones(self) -> List[str]: @@ -829,105 +825,6 @@ def check_bracketed(self, word: str) -> bool: return True return False - def compile_regexes(self) -> None: - """Compile regular expressions necessary for corpus parsing""" - if len(self.clitic_markers) >= 1: - other_clitic_markers = self.clitic_markers[1:] - if other_clitic_markers: - extra = "" - if "-" in other_clitic_markers: - extra = "-" - other_clitic_markers = [x for x in other_clitic_markers if x != "-"] - self.clitic_cleanup_regex = re.compile( - rf'[{extra}{"".join(other_clitic_markers)}]' - ) - self.clitic_marker = self.clitic_markers[0] - if self.compound_markers: - extra = "" - compound_markers = self.compound_markers - if "-" in self.compound_markers: - extra = "-" - compound_markers = [x for x in compound_markers if x != "-"] - self.compound_regex = re.compile(rf"(?<=\w)[{extra}{''.join(compound_markers)}](?=\w)") - if self.brackets: - left_brackets = [x[0] for x in self.brackets] - right_brackets = [x[1] for x in self.brackets] - self.bracket_regex = re.compile( - rf"[{re.escape(''.join(left_brackets))}].*?[{re.escape(''.join(right_brackets))}]+" - ) - self.laughter_regex = re.compile( - rf"[{re.escape(''.join(left_brackets))}](laugh(ing|ter)?|lachen|lg)[{re.escape(''.join(right_brackets))}]+", - flags=re.IGNORECASE, - ) - all_punctuation = set() - non_word_character_set = set(self.punctuation) - non_word_character_set -= {b for x in self.brackets for b in x} - - if self.clitic_markers: - all_punctuation.update(self.clitic_markers) - if self.compound_markers: - all_punctuation.update(self.compound_markers) - self.bracket_sanitize_regex = None - if self.brackets: - word_break_set = ( - non_word_character_set | set(self.clitic_markers) | set(self.compound_markers) - ) - if self.word_break_markers: - word_break_set |= set(self.word_break_markers) - word_break_set = make_re_character_set_safe(word_break_set, [r"\s"]) - self.bracket_sanitize_regex = re.compile(f"(?= 1: - non_clitic_punctuation = all_punctuation - set(self.clitic_markers) - non_clitic_punctuation_set = make_re_character_set_safe(non_clitic_punctuation) - non_punctuation_set = "[^" + punctuation_set[1:] - self.clitic_quote_regex = re.compile( - rf"((?<=\W)|(?<=^)){non_clitic_punctuation_set}*{self.clitic_marker}{non_clitic_punctuation_set}*(?P{non_punctuation_set}+){non_clitic_punctuation_set}*{self.clitic_marker}{non_clitic_punctuation_set}*((?=\W)|(?=$))" - ) - - def construct_sanitize_function(self) -> SanitizeFunction: - """ - Construct a :class:`~montreal_forced_aligner.dictionary.mixins.SanitizeFunction` to use in multiprocessing jobs - - Returns - ------- - :class:`~montreal_forced_aligner.dictionary.mixins.SanitizeFunction` - Function for sanitizing text - """ - f = SanitizeFunction( - self.clitic_marker, - self.clitic_cleanup_regex, - self.clitic_quote_regex, - self.punctuation_regex, - self.word_break_regex, - self.bracket_regex, - self.bracket_sanitize_regex, - self.ignore_case, - ) - - return f - - def sanitize(self, text: str) -> typing.Generator[str]: - """ - Sanitize text according to punctuation and clitic markers - - Parameters - ---------- - text: str - Text to sanitize - - Returns - ------- - Generator[str] - Sanitized form - """ - yield from self.construct_sanitize_function()(text) - class TemporaryDictionaryMixin(DictionaryMixin, DatabaseMixin, metaclass=abc.ABCMeta): """ @@ -1137,7 +1034,7 @@ def _write_phone_sets(self) -> None: # process nonsilence phones for group in self.kaldi_grouped_phones.values(): - + group = sorted(group, key=lambda x: self.phone_mapping[x]) phone_string = " ".join(group) phone_int_string = " ".join(str(self.phone_mapping[x]) for x in group) setf.write(f"{phone_string}\n") @@ -1187,10 +1084,15 @@ def _write_disambig(self) -> None: """ disambig = self.disambiguation_symbols_txt_path disambig_int = self.disambiguation_symbols_int_path - with mfa_open(disambig, "w") as outf, mfa_open(disambig_int, "w") as intf: - for d in sorted(self.disambiguation_symbols, key=lambda x: self.phone_mapping[x]): - outf.write(f"{d}\n") - intf.write(f"{self.phone_mapping[d]}\n") + with self.session() as session, mfa_open(disambig, "w") as outf, mfa_open( + disambig_int, "w" + ) as intf: + disambiguation_symbols = session.query(Phone.mapping_id, Phone.kaldi_label).filter( + Phone.phone_type == PhoneType.disambiguation + ) + for p_id, p in disambiguation_symbols: + outf.write(f"{p}\n") + intf.write(f"{p_id}\n") phone_disambig_path = os.path.join(self.phones_dir, "phone_disambig.txt") with mfa_open(phone_disambig_path, "w") as f: f.write(str(self.phone_mapping["#0"])) diff --git a/montreal_forced_aligner/dictionary/multispeaker.py b/montreal_forced_aligner/dictionary/multispeaker.py index f2670586..5e277849 100644 --- a/montreal_forced_aligner/dictionary/multispeaker.py +++ b/montreal_forced_aligner/dictionary/multispeaker.py @@ -4,33 +4,39 @@ import abc import collections +import logging import math import os import re import subprocess import typing -from typing import TYPE_CHECKING, Dict, Optional, Tuple +from typing import Dict, Optional, Tuple +import pynini +import pywrapfst import sqlalchemy.orm.session +import tqdm +import yaml from sqlalchemy.orm import selectinload +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.data import PhoneType, WordType from montreal_forced_aligner.db import ( + Corpus, + Dialect, DictBundle, Dictionary, Grapheme, - OovWord, Phone, + PhonologicalRule, Pronunciation, + RuleApplication, Speaker, Utterance, Word, + bulk_update, ) -from montreal_forced_aligner.dictionary.mixins import ( - SanitizeFunction, - SplitWordsFunction, - TemporaryDictionaryMixin, -) +from montreal_forced_aligner.dictionary.mixins import TemporaryDictionaryMixin from montreal_forced_aligner.exceptions import ( DictionaryError, DictionaryFileError, @@ -38,81 +44,14 @@ ) from montreal_forced_aligner.helper import mfa_open, split_phone_position from montreal_forced_aligner.models import DictionaryModel, PhoneSetType -from montreal_forced_aligner.utils import thirdparty_binary - -if TYPE_CHECKING: - from dataclasses import dataclass -else: - from dataclassy import dataclass +from montreal_forced_aligner.utils import parse_dictionary_file, thirdparty_binary __all__ = [ "MultispeakerDictionaryMixin", "MultispeakerDictionary", - "MultispeakerSanitizationFunction", ] - -@dataclass -class MultispeakerSanitizationFunction: - """ - Function for sanitizing text based on a multispeaker dictionary - - Parameters - ---------- - speaker_mapping: dict[str, str] - Mapping of speakers to dictionary names - sanitize_function: :class:`~montreal_forced_aligner.dictionary.mixins.SanitizeFunction` - Function to use for stripping punctuation - split_functions: dict[str, :class:`~montreal_forced_aligner.dictionary.mixins.SplitWordsFunction`] - Mapping of dictionary ids to functions for splitting compounds and clitics into separate words - """ - - speaker_mapping: Dict[str, int] - sanitize_function: SanitizeFunction - split_functions: Dict[int, SplitWordsFunction] - - def get_dict_id_for_speaker(self, speaker_name: str) -> int: - """ - Get the dictionary id of the speaker - - Parameters - ---------- - speaker_name: str - Speaker to look up - - Returns - ------- - int - Dictionary id - """ - if speaker_name not in self.speaker_mapping: - speaker_name = "default" - return self.speaker_mapping[speaker_name] - - def get_functions_for_speaker( - self, speaker_name: str - ) -> Tuple[SanitizeFunction, SplitWordsFunction]: - """ - Look up functions based on speaker name - - Parameters - ---------- - speaker_name - Speaker to get functions for - - Returns - ------- - :class:`~montreal_forced_aligner.dictionary.mixins.SanitizeFunction` - Function for sanitizing text - :class:`~montreal_forced_aligner.dictionary.mixins.SplitWordsFunction` - Function for splitting up words - """ - try: - dict_id = self.get_dict_id_for_speaker(speaker_name) - split_function = self.split_functions[dict_id] - except KeyError: - split_function = None - return self.sanitize_function, split_function +logger = logging.getLogger("mfa") class MultispeakerDictionaryMixin(TemporaryDictionaryMixin, metaclass=abc.ABCMeta): @@ -142,25 +81,71 @@ class MultispeakerDictionaryMixin(TemporaryDictionaryMixin, metaclass=abc.ABCMet Mapping of dictionary names to ids """ - def __init__(self, dictionary_path: str = None, **kwargs): + def __init__( + self, + dictionary_path: str = None, + rules_path: str = None, + groups_path: str = None, + **kwargs, + ): super().__init__(**kwargs) - self.dictionary_model = DictionaryModel( - dictionary_path, phone_set_type=self.phone_set_type - ) + self.dictionary_model = None + if dictionary_path is not None: + self.dictionary_model = DictionaryModel( + dictionary_path, phone_set_type=self.phone_set_type + ) self._num_dictionaries = None self.dictionary_lookup = {} self._phone_mapping = None self._grapheme_mapping = None self._words_mappings = {} + self._speaker_mapping = {} self._default_dictionary_id = None self._dictionary_base_names = None - self.bracket_regex = None - self.laughter_regex = None - self.compound_regex = None - self.clitic_cleanup_regex = None - self.clitic_quote_regex = None self.clitic_marker = None self.use_g2p = False + self.rules_path = rules_path + self.groups_path = groups_path + + def load_phone_groups(self) -> None: + """ + Load phone groups from the dictionary's groups file path + """ + if self.groups_path is not None and os.path.exists(self.groups_path): + with mfa_open(self.groups_path) as f: + self._phone_groups = yaml.safe_load(f) + if isinstance(self._phone_groups, list): + self._phone_groups = {k: v for k, v in enumerate(self._phone_groups)} + for k, v in self._phone_groups.items(): + self._phone_groups[k] = [x for x in v if x in self.non_silence_phones] + + @property + def speaker_mapping(self) -> typing.Dict[str, int]: + """Mapping of speakers to dictionaries""" + if not self._speaker_mapping: + with self.session() as session: + self._speaker_mapping = { + x[0]: x[1] for x in session.query(Speaker.name, Speaker.dictionary_id) + } + return self._speaker_mapping + + def get_dict_id_for_speaker(self, speaker_name: str) -> int: + """ + Get the dictionary id of the speaker + + Parameters + ---------- + speaker_name: str + Speaker to look up + + Returns + ------- + int + Dictionary id + """ + if speaker_name not in self.speaker_mapping: + return self._default_dictionary_id + return self.speaker_mapping[speaker_name] @property def dictionary_base_names(self) -> Dict[int, str]: @@ -192,18 +177,12 @@ def word_mapping(self, dictionary_id: int = 1) -> Dict[str, int]: """ if dictionary_id not in self._words_mappings: self._words_mappings[dictionary_id] = {} - index = 0 with self.session() as session: words = session.query(Word.word, Word.mapping_id).filter( Word.dictionary_id == dictionary_id ) for w, index in words: self._words_mappings[dictionary_id][w] = index - if index == 0: - return self._words_mappings[dictionary_id] - self._words_mappings[dictionary_id]["#0"] = index + 1 - self._words_mappings[dictionary_id][""] = index + 2 - self._words_mappings[dictionary_id][""] = index + 3 return self._words_mappings[dictionary_id] def reversed_word_mapping(self, dictionary_id: int = 1) -> Dict[int, str]: @@ -230,74 +209,11 @@ def num_dictionaries(self) -> int: """Number of pronunciation dictionaries""" return len(self.dictionary_lookup) - @property - def sanitize_function(self) -> MultispeakerSanitizationFunction: - """Sanitization function for the dictionary""" - sanitize_function = SanitizeFunction( - self.clitic_marker, - self.clitic_cleanup_regex, - self.clitic_quote_regex, - self.punctuation_regex, - self.word_break_regex, - self.bracket_regex, - self.bracket_sanitize_regex, - self.ignore_case, - ) - split_functions = {} - non_speech_regexes = {} - if self.laughter_regex is not None: - non_speech_regexes[self.laughter_word] = self.laughter_regex - if self.bracket_regex is not None: - non_speech_regexes[self.bracketed_word] = self.bracket_regex - with self.session() as session: - dictionaries = session.query(Dictionary.id, Dictionary.default) - speaker_mapping = { - x[0]: x[1] for x in session.query(Speaker.name, Speaker.dictionary_id) - } - for dict_id, default in dictionaries: - if default: - speaker_mapping["default"] = dict_id - - clitic_set = set( - x[0] - for x in session.query(Word.word) - .filter(Word.word_type == WordType.clitic) - .filter(Word.dictionary_id == dict_id) - ) - initial_clitic_regex = None - final_clitic_regex = None - if self.clitic_marker is not None: - initial_clitics = sorted( - x for x in clitic_set if x.endswith(self.clitic_marker) - ) - final_clitics = sorted( - x for x in clitic_set if x.startswith(self.clitic_marker) - ) - if initial_clitics: - initial_clitic_regex = re.compile(rf"^{'|'.join(initial_clitics)}(?=\w)") - if final_clitics: - final_clitic_regex = re.compile(rf"(?<=\w){'|'.join(final_clitics)}$") - split_functions[dict_id] = SplitWordsFunction( - self.clitic_marker, - initial_clitic_regex, - final_clitic_regex, - self.compound_regex, - non_speech_regexes, - self.oov_word, - self.word_mapping(dict_id), - self.grapheme_mapping, - self.specials_set, - ) - return MultispeakerSanitizationFunction( - speaker_mapping, sanitize_function, split_functions - ) - def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: """Set up the dictionary for processing""" - self.compile_regexes() - exist_check = os.path.exists(self.db_path) - if not exist_check: - self.initialize_database() + self.initialize_database() + if self.use_g2p: + return auto_set = {PhoneSetType.AUTO, PhoneSetType.UNKNOWN, "AUTO", "UNKNOWN"} if not isinstance(self.phone_set_type, PhoneSetType): self.phone_set_type = PhoneSetType[self.phone_set_type] @@ -310,7 +226,14 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: self._speaker_ids = getattr(self, "_speaker_ids", {}) self._current_speaker_index = getattr(self, "_current_speaker_index", 1) dictionary_id_cache = {} + dialect_id_cache = {} with self.session() as session: + self.non_silence_phones.update( + x + for x, in session.query(Phone.phone).filter( + Phone.phone_type == PhoneType.non_silence + ) + ) for speaker_id, speaker_name in session.query(Speaker.id, Speaker.name): self._speaker_ids[speaker_name] = speaker_id if speaker_id >= self._current_speaker_index: @@ -341,7 +264,17 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: pron_objs = [] speaker_objs = [] phone_counts = collections.Counter() - graphemes = set() + graphemes = set(self.clitic_markers + self.compound_markers) + clitic_cleanup_regex = None + if len(self.clitic_markers) >= 1: + other_clitic_markers = self.clitic_markers[1:] + if other_clitic_markers: + extra = "" + if "-" in other_clitic_markers: + extra = "-" + other_clitic_markers = [x for x in other_clitic_markers if x != "-"] + clitic_cleanup_regex = re.compile(rf'[{extra}{"".join(other_clitic_markers)}]') + self.clitic_marker = self.clitic_markers[0] for ( dictionary_model, speakers, @@ -350,7 +283,6 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: word_cache = {} pronunciation_cache = set() subsequences = set() - pronunciation_counts = collections.defaultdict(int) if self.phone_set_type not in auto_set: if ( self.phone_set_type != dictionary_model.phone_set_type @@ -361,23 +293,26 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: ) else: self.phone_set_type = dictionary_model.phone_set_type - + dialect = None + if "_mfa" in dictionary_model.name: + name_parts = dictionary_model.name.split("_") + dialect = "_".join(name_parts[1:-1]) + if not dialect: + dialect = "us" + if dialect not in dialect_id_cache: + dialect_obj = Dialect(name=dialect) + session.add(dialect_obj) + session.flush() + dialect_id_cache[dialect] = dialect_obj.id + dialect_id = dialect_id_cache[dialect] dictionary = Dictionary( name=dictionary_model.name, + dialect_id=dialect_id, path=dictionary_model.path, phone_set_type=self.phone_set_type, root_temp_directory=self.dictionary_output_directory, position_dependent_phones=self.position_dependent_phones, clitic_marker=self.clitic_marker if self.clitic_marker is not None else "", - bracket_regex=self.bracket_regex.pattern - if self.bracket_regex is not None - else "", - clitic_cleanup_regex=self.clitic_cleanup_regex.pattern - if self.clitic_cleanup_regex is not None - else "", - laughter_regex=self.laughter_regex.pattern - if self.laughter_regex is not None - else "", default="default" in speakers, use_g2p=False, max_disambiguation_symbol=0, @@ -386,6 +321,7 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: bracketed_word=self.bracketed_word, laughter_word=self.laughter_word, optional_silence_phone=self.optional_silence_phone, + oov_phone=self.oov_phone, ) session.add(dictionary) session.flush() @@ -416,147 +352,105 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: "silence_before_correction": None, "non_silence_before_correction": None, "word_id": word_primary_key, + "base_pronunciation_id": pronunciation_primary_key, } ) word_primary_key += 1 pronunciation_primary_key += 1 special_words = {self.oov_word: WordType.oov} - if self.bracket_regex is not None: - special_words[self.bracketed_word] = WordType.bracketed - if self.laughter_regex is not None: - special_words[self.laughter_word] = WordType.laughter + special_words[self.bracketed_word] = WordType.bracketed + special_words[self.laughter_word] = WordType.laughter specials_found = set() if not os.path.exists(dictionary_model.path): raise DictionaryFileError(dictionary_model.path) - with mfa_open(dictionary_model.path, "r") as inf: - for i, line in enumerate(inf): - line = line.strip() - if not line: - continue - if "\t" not in line: - raise DictionaryError( - f"Error parsing line {i} of {dictionary_model.path}: Did not find any tabs, " - f"please ensure that your dictionary has tabs between words and their pronunciations." - ) - line = line.split("\t") - if len(line) <= 1: - raise DictionaryError( - f'Error parsing line {i} of {dictionary_model.path}: "{line}" did not have a pronunciation' - ) - word = line.pop(0) - pron = tuple(line.pop(-1).split()) - if self.ignore_case: - word = word.lower() - if " " in word: - if hasattr(self, "log_debug"): - self.log_debug(f'Skipping "{word}" for containing whitespace.') - continue - if self.clitic_cleanup_regex is not None: - word = self.clitic_cleanup_regex.sub(self.clitic_marker, word) - if word in self.specials_set: - continue - characters = list(word) - if word not in special_words: - graphemes.update(characters) - prob = None - try: - if len(line) < 1: - raise ValueError - prob = float(line[0]) - if prob > 1 or prob < 0.01: - raise ValueError - line.pop(0) - except ValueError: - pass - silence_after_prob = None - silence_before_correct = None - non_silence_before_correct = None - try: - if len(line) < 3: - raise ValueError - silence_after_prob = float(line[0]) - if ( - silence_after_prob == dictionary.silence_probability - or silence_after_prob == 0 - ): - silence_after_prob = None - silence_before_correct = float(line[1]) - if silence_before_correct == 1.0 or silence_before_correct == 0: - silence_before_correct = None - non_silence_before_correct = float(line[2]) - if ( - non_silence_before_correct == 1.0 - or non_silence_before_correct == 0 - ): - non_silence_before_correct = None - except ValueError: - pass - if pretrained: - difference = ( - set(pron) - self.non_silence_phones - self.silence_phones - ) - if difference: - self.excluded_phones.update(difference) - self.excluded_pronunciation_count += 1 - continue - if word not in word_cache: - if pron == (self.optional_silence_phone,): - wt = WordType.silence - elif dictionary.clitic_marker is not None and ( - word.startswith(dictionary.clitic_marker) - or word.endswith(dictionary.clitic_marker) - ): - wt = WordType.clitic - else: - wt = WordType.speech - for special_w, special_wt in special_words.items(): - if word == special_w: - wt = special_wt - specials_found.add(special_w) - break - word_objs.append( - { - "id": word_primary_key, - "mapping_id": current_index, - "word": word, - "word_type": wt, - "dictionary_id": dictionary.id, - } - ) - self._words_mappings[dictionary.id][word] = current_index - current_index += 1 - word_cache[word] = word_primary_key - word_primary_key += 1 - pron_string = " ".join(pron) - if (word, pron_string) in pronunciation_cache: + for ( + word, + pron, + prob, + silence_after_prob, + silence_before_correct, + non_silence_before_correct, + ) in parse_dictionary_file(dictionary_model.path): + if self.ignore_case: + word = word.lower() + if " " in word: + logger.debug(f'Skipping "{word}" for containing whitespace.') + continue + if clitic_cleanup_regex is not None: + word = clitic_cleanup_regex.sub(self.clitic_marker, word) + if word in self.specials_set: + continue + characters = list(word) + if word not in special_words: + graphemes.update(characters) + if pretrained: + difference = set(pron) - self.non_silence_phones - self.silence_phones + if difference: + self.excluded_phones.update(difference) + self.excluded_pronunciation_count += 1 continue - - if not pretrained and word_objs[word_cache[word] - 1]["word_type"] in { - WordType.speech, - WordType.clitic, - }: - self.non_silence_phones.update(pron) - pron_objs.append( + if word not in word_cache: + if pron == (self.optional_silence_phone,): + wt = WordType.silence + elif dictionary.clitic_marker is not None and ( + word.startswith(dictionary.clitic_marker) + or word.endswith(dictionary.clitic_marker) + ): + wt = WordType.clitic + else: + wt = WordType.speech + for special_w, special_wt in special_words.items(): + if word == special_w: + wt = special_wt + specials_found.add(special_w) + break + word_objs.append( { - "id": pronunciation_primary_key, - "pronunciation": pron_string, - "probability": prob, - "disambiguation": None, - "silence_after_probability": silence_after_prob, - "silence_before_correction": silence_before_correct, - "non_silence_before_correction": non_silence_before_correct, - "word_id": word_cache[word], + "id": word_primary_key, + "mapping_id": current_index, + "word": word, + "word_type": wt, + "dictionary_id": dictionary.id, } ) - pronunciation_primary_key += 1 - pronunciation_cache.add((word, pron_string)) - phone_counts.update(pron) - pronunciation_counts[pron] += 1 + self._words_mappings[dictionary.id][word] = current_index + current_index += 1 + word_cache[word] = word_primary_key + word_primary_key += 1 + pron_string = " ".join(pron) + if (word, pron_string) in pronunciation_cache: + continue + + if not pretrained and word_objs[word_cache[word] - 1]["word_type"] in { + WordType.speech, + WordType.clitic, + }: + self.non_silence_phones.update(pron) + base_pronunciation_key = pronunciation_primary_key + + pron_objs.append( + { + "id": pronunciation_primary_key, + "pronunciation": pron_string, + "probability": prob, + "disambiguation": None, + "silence_after_probability": silence_after_prob, + "silence_before_correction": silence_before_correct, + "non_silence_before_correction": non_silence_before_correct, + "word_id": word_cache[word], + "base_pronunciation_id": base_pronunciation_key, + } + ) + self.non_silence_phones.update(pron) + + pronunciation_primary_key += 1 + pronunciation_cache.add((word, pron_string)) + phone_counts.update(pron) + pron = pron[:-1] + while pron: + subsequences.add(tuple(pron)) pron = pron[:-1] - while pron: - subsequences.add(tuple(pron)) - pron = pron[:-1] for w, wt in special_words.items(): if w in specials_found: @@ -572,7 +466,6 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: ) self._words_mappings[dictionary.id][w] = current_index current_index += 1 - pron = tuple(self.oov_phone) pron_objs.append( { "id": pronunciation_primary_key, @@ -583,32 +476,29 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: "silence_before_correction": None, "non_silence_before_correction": None, "word_id": word_primary_key, + "base_pronunciation_id": pronunciation_primary_key, } ) pronunciation_primary_key += 1 word_primary_key += 1 - pronunciation_counts[pron] += 1 - self._words_mappings[dictionary.id]["#0"] = current_index - self._words_mappings[dictionary.id][""] = current_index + 1 - self._words_mappings[dictionary.id][""] = current_index + 2 - - last_used = collections.defaultdict(int) - for p in pron_objs: - pron = tuple(p["pronunciation"].split()) - if not (pronunciation_counts[pron] == 1 and pron not in subsequences): - last_used[pron] += 1 - p["disambiguation"] = last_used[pron] - if last_used: - dictionary.max_disambiguation_symbol = max( - dictionary.max_disambiguation_symbol, max(last_used.values()) + for s in ["#0", "", ""]: + word_objs.append( + { + "id": word_primary_key, + "word": s, + "dictionary_id": dictionary.id, + "mapping_id": current_index, + "word_type": WordType.disambiguation, + } ) + self._words_mappings[dictionary.id][s] = current_index + word_primary_key += 1 + current_index += 1 + if not graphemes: raise DictionaryFileError( f"No words were found in the dictionary path {dictionary_model.path}" ) - self.max_disambiguation_symbol = max( - self.max_disambiguation_symbol, dictionary.max_disambiguation_symbol - ) self.dictionary_lookup[dictionary.name] = dictionary.id dictionary_id_cache[dictionary_model.path] = dictionary.id for speaker in speakers: @@ -626,17 +516,14 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: session.commit() self.non_silence_phones -= self.silence_phones - phone_objs = [] grapheme_objs = [] if graphemes: i = 0 session.query(Grapheme).delete() session.commit() special_graphemes = [self.silence_word, ""] - if self.bracket_regex is not None: - special_graphemes.append(self.bracketed_word) - if self.laughter_regex is not None: - special_graphemes.append(self.laughter_word) + special_graphemes.append(self.bracketed_word) + special_graphemes.append(self.laughter_word) for g in special_graphemes: grapheme_objs.append( { @@ -655,95 +542,264 @@ def dictionary_setup(self) -> Tuple[typing.Set[str], collections.Counter]: } ) i += 1 - - if self.non_silence_phones: - max_phone_ind = session.query(sqlalchemy.func.max(Phone.mapping_id)).scalar() - i = 0 - if max_phone_ind is not None: - session.query(Phone).delete() - session.commit() - phone_objs.append( - { - "id": i + 1, - "mapping_id": i, - "phone": "", - "phone_type": PhoneType.silence, - "count": 0, - } - ) - for p in self.kaldi_silence_phones: - i += 1 - phone_objs.append( - { - "id": i + 1, - "mapping_id": i, - "phone": p, - "phone_type": PhoneType.silence, - "count": 0, - } - ) - for p in self.kaldi_non_silence_phones: - i += 1 - phone_objs.append( - { - "id": i + 1, - "mapping_id": i, - "phone": p, - "phone_type": PhoneType.non_silence, - "count": phone_counts[split_phone_position(p)[0]], - } - ) - for x in range(self.max_disambiguation_symbol + 2): - p = f"#{x}" - self.disambiguation_symbols.add(p) - i += 1 - phone_objs.append( - { - "id": i + 1, - "mapping_id": i, - "phone": p, - "phone_type": PhoneType.disambiguation, - "count": 0, - } - ) - else: - phones = [ - x[0] - for x in session.query(Phone.phone).filter( - Phone.phone_type == PhoneType.non_silence - ) - ] - if self.position_dependent_phones: - phones = [split_phone_position(x)[0] for x in phones] - self.non_silence_phones.update(phones) - phones = [ - x[0] - for x in session.query(Phone.phone).filter( - Phone.phone_type == PhoneType.disambiguation - ) - ] - self.disambiguation_symbols.update(phones) with self.session() as session: with session.bind.begin() as conn: if word_objs: conn.execute(sqlalchemy.insert(Word.__table__), word_objs) + if pron_objs: conn.execute(sqlalchemy.insert(Pronunciation.__table__), pron_objs) if speaker_objs: conn.execute(sqlalchemy.insert(Speaker.__table__), speaker_objs) - if phone_objs: - conn.execute(sqlalchemy.insert(Phone.__table__), phone_objs) if grapheme_objs: conn.execute(sqlalchemy.insert(Grapheme.__table__), grapheme_objs) session.commit() + if pron_objs: + self.apply_phonological_rules() + self.calculate_disambiguation() + self.calculate_phone_mapping() + self.load_phone_groups() return graphemes, phone_counts + def calculate_disambiguation(self) -> None: + """Calculate the number of disambiguation symbols necessary for the dictionary""" + with self.session() as session: + dictionaries = session.query(Dictionary) + update_pron_objs = [] + for d in dictionaries: + subsequences = set() + words = ( + session.query(Word) + .filter(Word.dictionary_id == d.id) + .options(selectinload(Word.pronunciations)) + ) + for w in words: + for p in w.pronunciations: + + pron = p.pronunciation.split() + while pron: + subsequences.add(tuple(pron)) + pron = pron[:-1] + last_used = collections.defaultdict(int) + for p_id, pron in ( + session.query(Pronunciation.id, Pronunciation.pronunciation) + .join(Pronunciation.word) + .filter(Word.dictionary_id == d.id) + ): + pron = tuple(pron.split()) + if pron in subsequences: + last_used[pron] += 1 + + update_pron_objs.append({"id": p_id, "disambiguation": last_used[pron]}) + + if last_used: + d.max_disambiguation_symbol = max( + d.max_disambiguation_symbol, max(last_used.values()) + ) + self.max_disambiguation_symbol = max( + self.max_disambiguation_symbol, d.max_disambiguation_symbol + ) + if update_pron_objs: + bulk_update(session, Pronunciation, update_pron_objs) + session.commit() + + def apply_phonological_rules(self) -> None: + """Apply any phonological rules specified in the rules file path""" + # Set up phonological rules + if not self.rules_path or not os.path.exists(self.rules_path): + return + with mfa_open(self.rules_path) as f: + rule_data = yaml.safe_load(f) + 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: + new_pron_objs = [] + rule_application_objs = [] + dialect_ids = {d.name: d.id for d in session.query(Dialect).all()} + for rule in rule_data["rules"]: + for d_id in dialect_ids.values(): + r = PhonologicalRule(dialect_id=d_id, **rule) + session.add(r) + if r.replacement: + self.non_silence_phones.update(r.replacement.split()) + for k, v in rule_data.get("dialects", {}).items(): + d_id = dialect_ids.get(k, None) + if not d_id: + continue + for rule in v: + r = PhonologicalRule(dialect_id=d_id, **rule) + session.add(r) + if r.replacement: + self.non_silence_phones.update(r.replacement.split()) + session.flush() + pronunciation_primary_key = session.query( + sqlalchemy.func.max(Pronunciation.id) + ).scalar() + if not pronunciation_primary_key: + pronunciation_primary_key = 0 + pronunciation_primary_key += 1 + dictionaries = session.query(Dictionary) + for d in dictionaries: + words = ( + session.query(Word) + .filter(Word.dictionary_id == d.id) + .filter(Word.word_type.in_([WordType.clitic, WordType.speech])) + .options(selectinload(Word.pronunciations)) + ) + rules = ( + session.query(PhonologicalRule) + .filter(PhonologicalRule.dialect_id == d.dialect_id) + .all() + ) + for w in words: + pbar.update(1) + variant_to_rule_mapping = collections.defaultdict(set) + variant_mapping = {} + existing_prons = {p.pronunciation for p in w.pronunciations} + for p in w.pronunciations: + base_id = p.id + new_variants = [p.pronunciation] + + variant_index = 0 + while True: + s = new_variants[variant_index] + for r in rules: + n = r.apply_rule(s) + if any(x not in self.non_silence_phones for x in n.split()): + continue + if n and n not in existing_prons and n not in new_variants: + new_pron_objs.append( + { + "id": pronunciation_primary_key, + "pronunciation": n, + "probability": None, + "disambiguation": None, + "silence_after_probability": None, + "silence_before_correction": None, + "non_silence_before_correction": None, + "word_id": w.id, + "base_pronunciation_id": base_id, + } + ) + new_variants.append(n) + existing_prons.add(n) + variant_mapping[n] = pronunciation_primary_key + variant_to_rule_mapping[n].update( + variant_to_rule_mapping[s] + ) + variant_to_rule_mapping[n].add(r) + pronunciation_primary_key += 1 + variant_index += 1 + + if variant_index >= len(new_variants): + break + for v, rs in variant_to_rule_mapping.items(): + for r in rs: + rule_application_objs.append( + {"rule_id": r.id, "pronunciation_id": variant_mapping[v]} + ) + if new_pron_objs: + session.execute(sqlalchemy.insert(Pronunciation.__table__), new_pron_objs) + if rule_application_objs: + session.execute( + sqlalchemy.insert(RuleApplication.__table__), rule_application_objs + ) + session.commit() + + def calculate_phone_mapping(self) -> None: + """Calculate the necessary phones and add phone objects to the database""" + with self.session() as session: + try: + session.query(Phone).delete() + except Exception: + return + i = 1 + for r in session.query(PhonologicalRule): + if not r.replacement: + continue + self.non_silence_phones.update(r.replacement.split()) + phone_objs = [] + phone_objs.append( + { + "id": i, + "mapping_id": i - 1, + "phone": "", + "kaldi_label": "", + "phone_type": PhoneType.silence, + "count": 0, + } + ) + existing_phones = {""} + i += 1 + for p in self.kaldi_silence_phones: + if p in existing_phones: + continue + phone = p + position = None + if self.position_dependent_phones: + phone, position = split_phone_position(p) + phone_objs.append( + { + "id": i, + "mapping_id": i - 1, + "phone": phone, + "position": position, + "kaldi_label": p, + "phone_type": PhoneType.silence, + "count": 0, + } + ) + i += 1 + existing_phones.add(p) + for p in self.kaldi_non_silence_phones: + if p in existing_phones: + continue + phone = p + position = None + if self.position_dependent_phones: + phone, position = split_phone_position(p) + phone_objs.append( + { + "id": i, + "mapping_id": i - 1, + "phone": phone, + "position": position, + "kaldi_label": p, + "phone_type": PhoneType.non_silence, + "count": 0, + } + ) + i += 1 + existing_phones.add(p) + for x in range(self.max_disambiguation_symbol + 3): + p = f"#{x}" + if p in existing_phones: + continue + self.disambiguation_symbols.add(p) + phone_objs.append( + { + "id": i, + "mapping_id": i - 1, + "phone": p, + "kaldi_label": p, + "phone_type": PhoneType.disambiguation, + "count": 0, + } + ) + i += 1 + existing_phones.add(p) + if phone_objs: + session.execute(sqlalchemy.insert(Phone.__table__), phone_objs) + session.commit() + def _write_probabilistic_fst_text( self, session: sqlalchemy.orm.session.Session, dictionary: Dictionary, silence_disambiguation_symbol=None, path: typing.Optional[str] = None, + alignment: bool = False, ) -> None: """ Write the L.fst or L_disambig.fst text file to the temporary directory @@ -758,6 +814,8 @@ def _write_probabilistic_fst_text( Symbol to use for disambiguating silence for L_disambig.fst path: str, optional Full path to write L.fst to + alignment: bool + Flag for whether the FST will be used to align lattices """ base_ext = ".text_fst" disambiguation = False @@ -793,19 +851,6 @@ def _write_probabilistic_fst_text( outf.write( f"{start_state}\t{silence_state}\t{self.optional_silence_phone}\t{self.silence_word}\t{initial_silence_cost}\n" ) # initial silence - if disambiguation: - sil_disambiguation_state = next_state - next_state += 1 - outf.write( - f"{silence_state}\t{sil_disambiguation_state}\t{self.optional_silence_phone}\t{self.silence_word}\t0.0\n" - ) - outf.write( - f"{sil_disambiguation_state}\t{non_silence_state}\t{silence_disambiguation_symbol}\t{self.silence_word}\t0.0\n" - ) - else: - outf.write( - f"{silence_state}\t{non_silence_state}\t{self.optional_silence_phone}\t{self.silence_word}\t0.0\n" - ) silence_query = ( session.query(Word.word) .filter(Word.word_type == WordType.silence) @@ -836,6 +881,8 @@ def _write_probabilistic_fst_text( session.query(bn) .join(Pronunciation.word) .filter(Word.dictionary_id == dictionary.id) + .filter(Word.word_type != WordType.oov) + .filter(Word.count > 0) .filter(Word.word_type != WordType.silence) ) for row in pronunciation_query: @@ -874,7 +921,8 @@ def _write_probabilistic_fst_text( pron_cost = abs(math.log(probability)) if disambiguation and data["disambiguation"] is not None: phones += [f"#{data['disambiguation']}"] - + if alignment: + phones = ["#1"] + phones + ["#2"] new_state = next_state outf.write( f"{non_silence_state}\t{new_state}\t{phones[0]}\t{data['word']}\t{pron_cost+non_silence_before_cost}\n" @@ -883,6 +931,66 @@ def _write_probabilistic_fst_text( f"{silence_state}\t{new_state}\t{phones[0]}\t{data['word']}\t{pron_cost+silence_before_cost}\n" ) + next_state += 1 + current_state = new_state + for i in range(1, len(phones)): + new_state = next_state + next_state += 1 + outf.write(f"{current_state}\t{new_state}\t{phones[i]}\t\n") + current_state = new_state + outf.write( + f"{current_state}\t{non_silence_state}\t{silence_disambiguation_symbol}\t\t{non_silence_following_cost}\n" + ) + outf.write( + f"{current_state}\t{silence_state}\t{self.optional_silence_phone}\t\t{silence_following_cost}\n" + ) + oov_pron = ( + session.query(Pronunciation) + .join(Pronunciation.word) + .filter(Word.word == self.oov_word) + .first() + ) + if not disambiguation: + oovs = ( + session.query(Word.word) + .filter(Word.word_type == WordType.oov, Word.dictionary_id == dictionary.id) + .filter(sqlalchemy.or_(Word.count > 0, Word.word.in_(self.specials_set))) + ) + else: + oovs = session.query(Word.word).filter(Word.word == self.oov_word) + + phones = [self.oov_phone] + if self.position_dependent_phones: + phones[0] += "_S" + if alignment: + phones = ["#1"] + phones + ["#2"] + for (w,) in oovs: + silence_before_cost = 0.0 + non_silence_before_cost = 0.0 + silence_following_cost = base_silence_following_cost + non_silence_following_cost = base_non_silence_following_cost + + silence_after_probability = oov_pron.silence_after_probability + if silence_after_probability is not None: + silence_following_cost = -math.log(silence_after_probability) + non_silence_following_cost = -math.log(1 - (silence_after_probability)) + + silence_before_correction = oov_pron.silence_before_correction + if silence_before_correction is not None: + silence_before_cost = -math.log(silence_before_correction) + + non_silence_before_correction = oov_pron.non_silence_before_correction + if non_silence_before_correction is not None: + non_silence_before_cost = -math.log(non_silence_before_correction) + pron_cost = 0.0 + new_state = next_state + outf.write( + f"{non_silence_state}\t{new_state}\t{phones[0]}\t{w}\t{pron_cost+non_silence_before_cost}\n" + ) + outf.write( + f"{silence_state}\t{new_state}\t{phones[0]}\t{w}\t{pron_cost+silence_before_cost}\n" + ) + next_state += 1 current_state = new_state for i in range(1, len(phones)): @@ -900,13 +1008,231 @@ def _write_probabilistic_fst_text( outf.write(f"{silence_state}\t{final_silence_cost}\n") outf.write(f"{non_silence_state}\t{final_non_silence_cost}\n") + def _write_align_lexicon( + self, + session: sqlalchemy.orm.Session, + dictionary: Dictionary, + silence_disambiguation_symbol=None, + ) -> None: + """ + Write an alignment FST for use by :kaldi_src:`phones-to-prons` to extract pronunciations + + Parameters + ---------- + session: sqlalchemy.orm.Session + Database session + dictionary: :class:`~montreal_forced_aligner.db.Dictionary` + Dictionary object for align lexicon + """ + fst = pynini.Fst() + phone_symbol_table = pywrapfst.SymbolTable.read_text(self.phone_symbol_table_path) + word_symbol_table = pywrapfst.SymbolTable.read_text(dictionary.words_symbol_path) + start_state = fst.add_state() + loop_state = fst.add_state() + sil_state = fst.add_state() + next_state = fst.add_state() + fst.set_start(start_state) + silence_words = {self.silence_word} + silence_query = ( + session.query(Word.word) + .filter(Word.word_type == WordType.silence) + .filter(Word.dictionary_id == dictionary.id) + ) + for (word,) in silence_query: + silence_words.add(word) + word_eps_symbol = word_symbol_table.find("") + phone_eps_symbol = phone_symbol_table.find("") + sil_cost = -math.log(0.5) + non_sil_cost = sil_cost + fst.add_arc( + start_state, + pywrapfst.Arc( + phone_eps_symbol, + word_eps_symbol, + pywrapfst.Weight(fst.weight_type(), non_sil_cost), + loop_state, + ), + ) + fst.add_arc( + start_state, + pywrapfst.Arc( + phone_eps_symbol, + word_eps_symbol, + pywrapfst.Weight(fst.weight_type(), sil_cost), + sil_state, + ), + ) + for silence in silence_words: + w_s = word_symbol_table.find(silence) + fst.add_arc( + sil_state, + pywrapfst.Arc( + phone_symbol_table.find(self.optional_silence_phone), + w_s, + pywrapfst.Weight.one(fst.weight_type()), + loop_state, + ), + ) + + oovs = session.query(Word.word).filter( + Word.word_type == WordType.oov, + sqlalchemy.or_(Word.count > 0, Word.word.in_(self.specials_set)), + ) + for (w,) in oovs: + pron = [self.oov_phone] + if self.position_dependent_phones: + pron[0] += "_S" + pron = ["#1"] + pron + ["#2"] + current_state = loop_state + for i in range(len(pron) - 1): + p_s = phone_symbol_table.find(pron[i]) + if i == 0: + w_s = word_symbol_table.find(w) + else: + w_s = word_eps_symbol + fst.add_arc( + current_state, + pywrapfst.Arc(p_s, w_s, pywrapfst.Weight.one(fst.weight_type()), next_state), + ) + current_state = next_state + next_state = fst.add_state() + i = len(pron) - 1 + if i >= 0: + p_s = phone_symbol_table.find(pron[i]) + else: + p_s = phone_eps_symbol + if i <= 0: + w_s = word_symbol_table.find(w) + else: + w_s = word_eps_symbol + fst.add_arc( + current_state, + pywrapfst.Arc( + p_s, w_s, pywrapfst.Weight(fst.weight_type(), non_sil_cost), loop_state + ), + ) + fst.add_arc( + current_state, + pywrapfst.Arc(p_s, w_s, pywrapfst.Weight(fst.weight_type(), sil_cost), sil_state), + ) + pronunciation_query = ( + session.query(Word.word, Pronunciation.pronunciation) + .join(Pronunciation.word) + .filter(Word.dictionary_id == dictionary.id) + .filter( + Word.word_type != WordType.silence, Word.word_type != WordType.oov, Word.count > 0 + ) + ) + for w, pron in pronunciation_query: + pron = pron.split() + if self.position_dependent_phones: + if pron[0] != self.optional_silence_phone: + if len(pron) == 1: + pron[0] += "_S" + else: + pron[0] += "_B" + pron[-1] += "_E" + for i in range(1, len(pron) - 1): + pron[i] += "_I" + pron = ["#1"] + pron + ["#2"] + current_state = loop_state + for i in range(len(pron) - 1): + p_s = phone_symbol_table.find(pron[i]) + if i == 0: + w_s = word_symbol_table.find(w) + else: + w_s = word_eps_symbol + fst.add_arc( + current_state, + pywrapfst.Arc(p_s, w_s, pywrapfst.Weight.one(fst.weight_type()), next_state), + ) + current_state = next_state + next_state = fst.add_state() + i = len(pron) - 1 + if i >= 0: + p_s = phone_symbol_table.find(pron[i]) + else: + p_s = phone_eps_symbol + if i <= 0: + w_s = word_symbol_table.find(w) + else: + w_s = word_eps_symbol + fst.add_arc( + current_state, + pywrapfst.Arc( + p_s, w_s, pywrapfst.Weight(fst.weight_type(), non_sil_cost), loop_state + ), + ) + fst.add_arc( + current_state, + pywrapfst.Arc(p_s, w_s, pywrapfst.Weight(fst.weight_type(), sil_cost), sil_state), + ) + fst.delete_states([next_state]) + fst.set_final(loop_state, pywrapfst.Weight.one(fst.weight_type())) + fst.arcsort("olabel") + fst.write(dictionary.align_lexicon_path) + + with mfa_open(dictionary.align_lexicon_int_path, "w") as f: + pronunciation_query = ( + session.query(Word.mapping_id, Pronunciation.pronunciation) + .join(Pronunciation.word) + .filter(Word.dictionary_id == dictionary.id) + .order_by(Word.mapping_id) + ) + for m_id, pron in pronunciation_query: + pron = pron.split() + if self.position_dependent_phones: + if pron[0] != self.optional_silence_phone: + if len(pron) == 1: + pron[0] += "_S" + else: + pron[0] += "_B" + pron[-1] += "_E" + for i in range(1, len(pron) - 1): + pron[i] += "_I" + + pron_string = " ".join(map(str, [self.phone_mapping[x] for x in pron])) + f.write(f"{m_id} {m_id} {pron_string}\n") + + def export_trained_rules(self, output_directory: str) -> None: + """ + Export rules with pronunciation and silence probabilities calculated to an output directory + + Parameters + ---------- + output_directory: str + Directory for export + """ + with self.session() as session: + rules = ( + session.query(PhonologicalRule) + .filter(PhonologicalRule.probability != None) # noqa + .all() + ) + if rules: + output_rules_path = os.path.join(output_directory, "rules.yaml") + dialectal_rules = {"rules": []} + for r in rules: + d = r.to_json() + dialect = d.pop("dialect") + if dialect is None: + dialectal_rules["rules"].append(d) + else: + dialect = dialect.name + if "dialects" not in dialectal_rules: + dialectal_rules["dialects"] = {} + if dialect not in dialectal_rules["dialects"]: + dialectal_rules["dialects"][dialect] = [] + dialectal_rules["dialects"][dialect].append(d) + with mfa_open(output_rules_path, "w") as f: + yaml.safe_dump(dict(dialectal_rules), f, allow_unicode=True) + def export_lexicon( self, dictionary_id: int, path: str, write_disambiguation: typing.Optional[bool] = False, probability: typing.Optional[bool] = False, - silence_probabilities: typing.Optional[bool] = False, ) -> None: """ Export pronunciation dictionary to a text file @@ -929,15 +1255,18 @@ def export_lexicon( columns.append(Pronunciation.disambiguation) if probability: columns.append(Pronunciation.probability) - if silence_probabilities: - columns.append(Pronunciation.silence_after_probability) - columns.append(Pronunciation.silence_before_correction) - columns.append(Pronunciation.non_silence_before_correction) + columns.append(Pronunciation.silence_after_probability) + columns.append(Pronunciation.silence_before_correction) + columns.append(Pronunciation.non_silence_before_correction) bn = DictBundle("pronunciation_data", *columns) pronunciations = ( session.query(bn) .join(Pronunciation.word) - .filter(Word.dictionary_id == dictionary_id) + .filter( + Word.dictionary_id == dictionary_id, + Word.word_type.in_([WordType.speech, WordType.clitic]), + ) + .order_by(Word.word) ) for row in pronunciations: data = row.pronunciation_data @@ -948,19 +1277,18 @@ def export_lexicon( if probability and data["probability"] is not None: probability_string = f"{data['probability']}" - if silence_probabilities: - extra_probs = [ - data["silence_after_probability"], - data["silence_before_correction"], - data["non_silence_before_correction"], - ] - if all(x is None for x in extra_probs): + extra_probs = [ + data["silence_after_probability"], + data["silence_before_correction"], + data["non_silence_before_correction"], + ] + if all(x is None for x in extra_probs): + continue + for x in extra_probs: + if x is None: continue - for x in extra_probs: - if x is None: - continue - probability_string += f"\t{x}" - if probability: + probability_string += f"\t{x}" + if probability_string: f.write(f"{data['word']}\t{probability_string}\t{phones}\n") else: f.write(f"{data['word']}\t{phones}\n") @@ -1001,10 +1329,7 @@ def _write_fst_binary( binary_ext = ".fst" word_disambig_path = os.path.join(dictionary.temp_directory, "word_disambig.txt") with mfa_open(word_disambig_path, "w") as f: - try: - f.write(str(self.word_mapping(dictionary.id)["#0"])) - except KeyError: - pass + f.write(str(self.word_mapping(dictionary.id)["#0"])) if write_disambiguation: text_ext = ".disambig_text_fst" binary_ext = ".disambig_fst" @@ -1100,7 +1425,9 @@ def phone_mapping(self) -> Dict[str, int]: """Mapping of phone symbols to integer IDs for Kaldi processing""" if self._phone_mapping is None: with self.session() as session: - phones = session.query(Phone.phone, Phone.mapping_id).order_by(Phone.id).all() + phones = ( + session.query(Phone.kaldi_label, Phone.mapping_id).order_by(Phone.id).all() + ) self._phone_mapping = {x[0]: x[1] for x in phones} return self._phone_mapping @@ -1200,36 +1527,148 @@ def save_oovs_found(self, directory: str) -> None: encoding="utf8", ) as cf: oovs = ( - session.query(OovWord.word, OovWord.count) - .filter(OovWord.dictionary_id == dict_id) - .order_by(sqlalchemy.desc(OovWord.count)) + session.query(Word.word, Word.count) + .filter( + Word.dictionary_id == dict_id, + Word.word_type == WordType.oov, + Word.word != self.oov_word, + ) + .order_by(sqlalchemy.desc(Word.count)) ) for word, count in oovs: f.write(word + "\n") cf.write(f"{word}\t{count}\n") - def calculate_oovs_found(self) -> None: - """Sum the counts of oovs found in pronunciation dictionaries""" - + def find_all_cutoffs(self) -> None: + """Find all instances of cutoff words followed by actual words""" + logger.info("Finding all cutoffs...") with self.session() as session: - session.query(OovWord).delete() - session.flush() - oov_words = {} - for dict_id in self.dictionary_lookup.values(): - utterances = ( - session.query(Utterance.oovs) - .join(Utterance.speaker) - .filter(Speaker.dictionary_id == dict_id) - .filter(Utterance.oovs != "") + c = session.query(Corpus).first() + if c.cutoffs_found: + return + initial_brackets = re.escape("".join(x[0] for x in self.brackets)) + final_brackets = re.escape("".join(x[1] for x in self.brackets)) + pronunciation_mapping = {} + word_mapping = {} + max_ids = collections.defaultdict(int) + max_pron_id = session.query(sqlalchemy.func.max(Pronunciation.id)).scalar() + max_word_id = session.query(sqlalchemy.func.max(Word.id)).scalar() + for d_id, max_id in ( + session.query(Dictionary.id, sqlalchemy.func.max(Word.mapping_id)) + .join(Word.dictionary) + .group_by(Dictionary.id) + ): + max_ids[d_id] = max_id + for d_id in self.dictionary_lookup.values(): + pronunciation_mapping[d_id] = collections.defaultdict(list) + word_mapping[d_id] = {} + words = ( + session.query(Word.mapping_id, Word.word, Pronunciation.pronunciation) + .join(Pronunciation.word) + .filter(Word.dictionary_id == d_id) + ) + for m_id, w, pron in words: + pronunciation_mapping[d_id][w].append(pron) + word_mapping[d_id][w] = m_id + new_word_mapping = [] + new_pronunciation_mapping = [] + utterances = ( + session.query( + Utterance.id, + Speaker.dictionary_id, + Utterance.normalized_text, ) - for (oovs,) in utterances: - for w in oovs.split(): - if w not in oov_words: - oov_words[w] = {"word": w, "count": 0, "dictionary_id": dict_id} - oov_words[w]["count"] += 1 - session.bulk_insert_mappings(OovWord, oov_words.values()) + .join(Utterance.speaker) + .filter( + Utterance.normalized_text.regexp_match(f"[{initial_brackets}](cutoff|hes)") + ) + ) + utterance_mapping = [] + for u_id, dict_id, normalized_text in utterances: + text = normalized_text.split() + modified = False + for i, word in enumerate(text): + m = re.match( + f"^[{initial_brackets}](cutoff|hes)([-_](?P[^{final_brackets}]))?[{final_brackets}]$", + word, + ) + if not m: + continue + next_word = None + try: + next_word = m.group("word") + if next_word not in word_mapping[dict_id]: + raise ValueError + except Exception: + if i != len(text) - 1: + next_word = text[i + 1] + if ( + next_word is None + or next_word not in pronunciation_mapping[dict_id] + or self.oov_phone in pronunciation_mapping[dict_id][next_word] + or self.optional_silence_phone in pronunciation_mapping[dict_id][next_word] + ): + continue + new_word = f"" + if new_word not in word_mapping[dict_id]: + max_word_id += 1 + max_ids[dict_id] += 1 + max_pron_id += 1 + new_pronunciation_mapping.append( + { + "id": max_pron_id, + "base_pronunciation_id": max_pron_id, + "pronunciation": self.oov_phone, + "word_id": max_word_id, + } + ) + prons = pronunciation_mapping[dict_id][next_word] + pronunciation_mapping[dict_id][new_word] = [] + for p in prons: + p = p.split() + for pi in range(len(p)): + new_p = " ".join(p[: pi + 1]) + if new_p in pronunciation_mapping[dict_id][new_word]: + continue + pronunciation_mapping[dict_id][new_word].append(new_p) + max_pron_id += 1 + new_pronunciation_mapping.append( + { + "id": max_pron_id, + "pronunciation": new_p, + "word_id": max_word_id, + "base_pronunciation_id": max_pron_id, + } + ) + new_word_mapping.append( + { + "id": max_word_id, + "word": new_word, + "dictionary_id": dict_id, + "mapping_id": max_ids[dict_id], + "word_type": WordType.cutoff, + } + ) + word_mapping[dict_id][new_word] = max_ids[dict_id] + text[i] = new_word + modified = True + if modified: + utterance_mapping.append( + { + "id": u_id, + "normalized_text": " ".join(text), + } + ) + session.bulk_insert_mappings( + Word, new_word_mapping, return_defaults=False, render_nulls=True + ) + session.bulk_insert_mappings( + Pronunciation, new_pronunciation_mapping, return_defaults=False, render_nulls=True + ) + bulk_update(session, Utterance, utterance_mapping) + session.query(Corpus).update({"cutoffs_found": True}) session.commit() - self.save_oovs_found(self.output_directory) + self._words_mappings = {} def write_lexicon_information(self, write_disambiguation: Optional[bool] = False) -> None: """ @@ -1241,33 +1680,29 @@ def write_lexicon_information(self, write_disambiguation: Optional[bool] = False Flag to use disambiguation symbols in the output """ os.makedirs(self.phones_dir, exist_ok=True) + if self.use_cutoff_model: + self.find_all_cutoffs() self._write_phone_symbol_table() self._write_grapheme_symbol_table() self._write_disambig() - self._write_word_boundaries() + if self.position_dependent_phones: + self._write_word_boundaries() if self.use_g2p: return silence_disambiguation_symbol = None if write_disambiguation: silence_disambiguation_symbol = self.silence_disambiguation_symbol - debug = getattr(self, "debug", False) with self.session() as session: - dictionaries = session.query(Dictionary) + dictionaries: typing.List[Dictionary] = session.query(Dictionary) for d in dictionaries: os.makedirs(d.temp_directory, exist_ok=True) - if debug: - self.export_lexicon(d.id, os.path.join(d.temp_directory, "lexicon.txt")) self._write_word_file(d) self._write_probabilistic_fst_text(session, d, silence_disambiguation_symbol) self._write_fst_binary( d, write_disambiguation=silence_disambiguation_symbol is not None ) - if not debug: - if os.path.exists(os.path.join(d.temp_directory, "temp.fst")): - os.remove(os.path.join(d.temp_directory, "temp.fst")) - if os.path.exists(os.path.join(d.temp_directory, "lexicon.text.fst")): - os.remove(os.path.join(d.temp_directory, "lexicon.text.fst")) + self._write_align_lexicon(session, d, silence_disambiguation_symbol) def write_training_information(self) -> None: """Write phone information needed for training""" @@ -1279,8 +1714,14 @@ def _write_word_file(self, dictionary: Dictionary) -> None: """ Write the word mapping to the temporary directory """ - with mfa_open(dictionary.words_symbol_path, "w") as f: - for w, i in self.word_mapping(dictionary.id).items(): + self._words_mappings = {} + with mfa_open(dictionary.words_symbol_path, "w") as f, self.session() as session: + words = ( + session.query(Word.word, Word.mapping_id) + .filter(Word.dictionary_id == dictionary.id) + .order_by(Word.mapping_id) + ) + for w, i in words: f.write(f"{w} {i}\n") @@ -1307,4 +1748,4 @@ def identifier(self) -> str: @property def output_directory(self) -> str: """Root temporary directory to store all dictionary information""" - return os.path.join(self.temporary_directory, self.identifier) + return os.path.join(GLOBAL_CONFIG.temporary_directory, self.identifier) diff --git a/montreal_forced_aligner/exceptions.py b/montreal_forced_aligner/exceptions.py index 6115e248..b3b4fb7c 100644 --- a/montreal_forced_aligner/exceptions.py +++ b/montreal_forced_aligner/exceptions.py @@ -38,7 +38,6 @@ "CorpusError", "ModelLoadError", "CorpusReadError", - "ArgumentError", "AlignerError", "AlignmentError", "AlignmentExportError", @@ -165,6 +164,17 @@ def __init__( ) +# Feature Generation Errors + + +class FeatureGenerationError(MFAError): + """ + Exception class related to generating features + """ + + pass + + # Model Errors @@ -189,7 +199,25 @@ class ModelLoadError(ModelError): def __init__(self, path: str): super().__init__("") self.message_lines = [ - f"The archive {self.printer.error_text(path)} could not be parsed as an MFA model" + f"The archive {self.printer.error_text(path)} could not be parsed as an MFA model." + ] + + +class ModelSaveError(ModelError): + """ + Exception during saving of a model archive + + Parameters + ---------- + path: str + Path of the model archive + """ + + def __init__(self, path: str): + super().__init__("") + self.message_lines = [ + f"The archive {self.printer.error_text(path)} already exists.", + "Please specify --overwrite if you would like to overwrite it.", ] @@ -413,7 +441,7 @@ def __init__(self, num_utterances, beam_size, retry_beam_size): suggested_beam_size = beam_size * 10 suggested_retry_beam_size = suggested_beam_size * 4 self.message_lines.append( - f'You can try rerunning with a larger beam (i.e. "mfa align ... --beam={suggested_beam_size} --retry_beam={suggested_retry_beam_size}").' + f'You can try rerunning with a larger beam (i.e. "mfa align ... --beam {suggested_beam_size} --retry_beam {suggested_retry_beam_size}").' ) self.message_lines.append( 'If increasing the beam size does not help, then there are likely issues with the corpus, dictionary, or acoustic model, and can be further diagnosed with the "mfa validate" command' @@ -850,12 +878,18 @@ def __init__(self, error_logs: List[str], log_file: Optional[str] = None): def refresh_message(self) -> None: """Regenerate the exceptions message""" + from montreal_forced_aligner.config import GLOBAL_CONFIG + self.message_lines = [ f"There were {len(self.error_logs)} job(s) with errors when running Kaldi binaries.", "See the log files below for more information.", ] for error_log in self.error_logs: self.message_lines.append(error_log) + if GLOBAL_CONFIG.current_profile.verbose: + with open(error_log, "r", encoding="utf8") as f: + 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)}" @@ -873,7 +907,7 @@ def append_error_log(self, error_log: str) -> None: self.error_logs.append(error_log) self.refresh_message() - def update_log_file(self, logger: logging.Logger) -> None: + def update_log_file(self) -> None: """ Update the log file output @@ -882,6 +916,11 @@ def update_log_file(self, logger: logging.Logger) -> None: logger: logging.Logger Logger """ + + logger = logging.getLogger("mfa") if logger.handlers: - self.log_file = logger.handlers[0].baseFilename + for handler in logger.handlers: + if isinstance(handler, logging.FileHandler): + self.log_file = handler.baseFilename + break self.refresh_message() diff --git a/montreal_forced_aligner/g2p/__init__.py b/montreal_forced_aligner/g2p/__init__.py index 63a61ea6..26a2d399 100644 --- a/montreal_forced_aligner/g2p/__init__.py +++ b/montreal_forced_aligner/g2p/__init__.py @@ -3,12 +3,7 @@ ========================= """ -from montreal_forced_aligner.g2p.generator import ( - OrthographicCorpusGenerator, - OrthographicWordListGenerator, - PyniniCorpusGenerator, - PyniniWordListGenerator, -) +from montreal_forced_aligner.g2p.generator import PyniniCorpusGenerator, PyniniWordListGenerator from montreal_forced_aligner.g2p.phonetisaurus_trainer import PhonetisaurusTrainer from montreal_forced_aligner.g2p.trainer import PyniniTrainer @@ -18,7 +13,5 @@ "PyniniTrainer", "PyniniCorpusGenerator", "PyniniWordListGenerator", - "OrthographicCorpusGenerator", - "OrthographicWordListGenerator", "PhonetisaurusTrainer", ] diff --git a/montreal_forced_aligner/g2p/generator.py b/montreal_forced_aligner/g2p/generator.py index cd8b4b80..66f3d865 100644 --- a/montreal_forced_aligner/g2p/generator.py +++ b/montreal_forced_aligner/g2p/generator.py @@ -3,6 +3,7 @@ import csv import functools +import logging import multiprocessing as mp import os import queue @@ -16,7 +17,8 @@ from pynini.lib import rewrite from pywrapfst import SymbolTable -from montreal_forced_aligner.abc import TopLevelMfaWorker +from montreal_forced_aligner.abc import DatabaseMixin, TopLevelMfaWorker +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.corpus.text_corpus import TextCorpusMixin from montreal_forced_aligner.exceptions import PyniniGenerationError from montreal_forced_aligner.g2p.mixins import G2PTopLevelMixin @@ -36,6 +38,8 @@ "PyniniWordListGenerator", ] +logger = logging.getLogger("mfa") + def threshold_lattice_to_dfa( lattice: pynini.Fst, threshold: float = 1.0, state_multiplier: int = 2 @@ -355,52 +359,57 @@ def __init__(self, g2p_model_path: str, strict_graphemes: bool = False, **kwargs self.g2p_model = G2PModel( g2p_model_path, root_directory=getattr(self, "workflow_directory", None) ) + self.output_token_type = "utf8" + self.input_token_type = "utf8" + self.rewriter = None - def generate_pronunciations(self) -> Dict[str, List[str]]: - """ - Generate pronunciations - - Returns - ------- - dict[str, list[str]] - Mappings of keys to their generated pronunciations - """ - - fst = pynini.Fst.read(self.g2p_model.fst_path) + def setup(self): + self.fst = pynini.Fst.read(self.g2p_model.fst_path) if self.g2p_model.meta["architecture"] == "phonetisaurus": - output_token_type = pynini.SymbolTable.read_text(self.g2p_model.sym_path) - input_token_type = pynini.SymbolTable.read_text(self.g2p_model.grapheme_sym_path) - fst.set_input_symbols(input_token_type) - fst.set_output_symbols(output_token_type) - rewriter = PhonetisaurusRewriter( - fst, - input_token_type, - output_token_type, + self.output_token_type = pynini.SymbolTable.read_text(self.g2p_model.sym_path) + self.input_token_type = pynini.SymbolTable.read_text(self.g2p_model.grapheme_sym_path) + self.fst.set_input_symbols(self.input_token_type) + self.fst.set_output_symbols(self.output_token_type) + self.rewriter = PhonetisaurusRewriter( + self.fst, + self.input_token_type, + self.output_token_type, num_pronunciations=self.num_pronunciations, threshold=self.g2p_threshold, grapheme_order=self.g2p_model.meta["grapheme_order"], ) else: - output_token_type = "utf8" - input_token_type = "utf8" if self.g2p_model.sym_path is not None and os.path.exists(self.g2p_model.sym_path): - output_token_type = pynini.SymbolTable.read_text(self.g2p_model.sym_path) - rewriter = Rewriter( - fst, - input_token_type, - output_token_type, + self.output_token_type = pynini.SymbolTable.read_text(self.g2p_model.sym_path) + + self.rewriter = Rewriter( + self.fst, + self.input_token_type, + self.output_token_type, num_pronunciations=self.num_pronunciations, threshold=self.g2p_threshold, ) + def generate_pronunciations(self) -> Dict[str, List[str]]: + """ + Generate pronunciations + + Returns + ------- + dict[str, list[str]] + Mappings of keys to their generated pronunciations + """ + num_words = len(self.words_to_g2p) begin = time.time() missing_graphemes = set() - self.log_info("Generating pronunciations...") + if self.rewriter is None: + self.setup() + logger.info("Generating pronunciations...") to_return = {} skipped_words = 0 - if num_words < 30 or self.num_jobs == 1: - with tqdm.tqdm(total=num_words, disable=getattr(self, "quiet", False)) as pbar: + if num_words < 30 or GLOBAL_CONFIG.num_jobs == 1: + with tqdm.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) @@ -412,11 +421,11 @@ def generate_pronunciations(self) -> Dict[str, List[str]]: skipped_words += 1 continue try: - prons = rewriter(w) + prons = self.rewriter(w) except rewrite.Error: continue to_return[word] = prons - self.log_debug( + logger.debug( f"Skipping {skipped_words} words for containing the following graphemes: " f"{comma_join(sorted(missing_graphemes))}" ) @@ -433,24 +442,24 @@ def generate_pronunciations(self) -> Dict[str, List[str]]: skipped_words += 1 continue job_queue.put(w) - self.log_debug( + logger.debug( f"Skipping {skipped_words} words for containing the following graphemes: " f"{comma_join(sorted(missing_graphemes))}" ) error_dict = {} return_queue = mp.Queue() procs = [] - for _ in range(self.num_jobs): + for _ in range(GLOBAL_CONFIG.num_jobs): p = RewriterWorker( job_queue, return_queue, - rewriter, + self.rewriter, stopped, ) procs.append(p) p.start() num_words -= skipped_words - with tqdm.tqdm(total=num_words, disable=getattr(self, "quiet", False)) as pbar: + with tqdm.tqdm(total=num_words, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: word, result = return_queue.get(timeout=1) @@ -473,7 +482,7 @@ def generate_pronunciations(self) -> Dict[str, List[str]]: p.join() if error_dict: raise PyniniGenerationError(error_dict) - self.log_debug(f"Processed {num_words} in {time.time() - begin} seconds") + logger.debug(f"Processed {num_words} in {time.time() - begin:.3f} seconds") return to_return @@ -520,9 +529,13 @@ def evaluation_csv_path(self) -> str: def setup(self) -> None: """Set up the G2P validator""" + TopLevelMfaWorker.setup(self) if self.initialized: return + self._current_workflow = "validation" + os.makedirs(self.working_log_directory, exist_ok=True) self.g2p_model.validate(self.words_to_g2p) + PyniniGenerator.setup(self) self.initialized = True self.wer = None self.ler = None @@ -551,7 +564,7 @@ def compute_validation_errors( total_length = 0 # Since the edit distance algorithm is quadratic, let's do this with # multiprocessing. - self.log_debug(f"Processing results for {len(hypothesis_values)} hypotheses") + logger.debug(f"Processing results for {len(hypothesis_values)} hypotheses") to_comp = [] indices = [] hyp_pron_count = 0 @@ -594,16 +607,16 @@ def compute_validation_errors( incorrect += 1 indices.append(word) to_comp.append((gold_pronunciations, hyp)) # Multiple hypotheses to compare - self.log_debug( + logger.debug( f"For the word {word}: gold is {gold_pronunciations}, hypothesized are: {hyp}" ) hyp_pron_count += len(hyp) gold_pron_count += len(gold_pronunciations) - self.log_debug( + logger.debug( f"Generated an average of {hyp_pron_count /len(hypothesis_values)} variants " f"The gold set had an average of {gold_pron_count/len(hypothesis_values)} variants." ) - with mp.Pool(self.num_jobs) as pool: + with mp.Pool(GLOBAL_CONFIG.num_jobs) as pool: gen = pool.starmap(score_g2p, to_comp) for i, (edits, length) in enumerate(gen): word = indices[i] @@ -638,10 +651,10 @@ def compute_validation_errors( writer.writerow(line) self.wer = 100 * incorrect / (correct + incorrect) self.ler = 100 * total_edits / total_length - self.log_info(f"WER:\t{self.wer:.2f}") - self.log_info(f"LER:\t{self.ler:.2f}") - self.log_debug( - f"Computation of errors for {len(gold_values)} words took {time.time() - begin} seconds" + logger.info(f"WER:\t{self.wer:.2f}") + logger.info(f"LER:\t{self.ler:.2f}") + logger.debug( + f"Computation of errors for {len(gold_values)} words took {time.time() - begin:.3f} seconds" ) def evaluate_g2p_model(self, gold_pronunciations: Dict[str, Set[str]]) -> None: @@ -657,7 +670,7 @@ def evaluate_g2p_model(self, gold_pronunciations: Dict[str, Set[str]]) -> None: self.compute_validation_errors(gold_pronunciations, output) -class PyniniWordListGenerator(PyniniValidator): +class PyniniWordListGenerator(PyniniValidator, DatabaseMixin): """ Top-level worker for generating pronunciations from a word list and a Pynini G2P model @@ -702,6 +715,7 @@ def setup(self) -> None: self.word_list.extend(line.strip().split()) if not self.include_bracketed: self.word_list = [x for x in self.word_list if not self.check_bracketed(x)] + super().setup() self.g2p_model.validate(self.words_to_g2p) self.initialized = True @@ -728,7 +742,10 @@ def setup(self) -> None: if self.initialized: return self._load_corpus() - self.calculate_word_counts() + self.initialize_jobs() + super().setup() + self._create_dummy_dictionary() + self.normalize_text() self.g2p_model.validate(self.words_to_g2p) self.initialized = True @@ -739,80 +756,3 @@ def words_to_g2p(self) -> List[str]: if not self.include_bracketed: word_list = [x for x in word_list if not self.check_bracketed(x)] return word_list - - -class OrthographicCorpusGenerator(OrthographyGenerator, TextCorpusMixin, TopLevelMfaWorker): - """ - Top-level class for generating "pronunciations" from the orthography of a corpus - - See Also - -------- - :class:`~montreal_forced_aligner.g2p.generator.OrthographyGenerator` - For orthography-based G2P generation parameters - :class:`~montreal_forced_aligner.corpus.text_corpus.TextCorpusMixin` - For corpus parsing parameters - :class:`~montreal_forced_aligner.abc.TopLevelMfaWorker` - For top-level parameters - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - def setup(self) -> None: - """Set up the pronunciation generator""" - if self.initialized: - return - self._load_corpus() - self.calculate_word_counts() - self.initialized = True - - @property - def words_to_g2p(self) -> List[str]: - """Words to produce pronunciations""" - word_list = self.corpus_word_set - if not self.include_bracketed: - word_list = [x for x in word_list if not self.check_bracketed(x)] - return word_list - - -class OrthographicWordListGenerator(OrthographyGenerator, TopLevelMfaWorker): - """ - Top-level class for generating "pronunciations" from the orthography of a corpus - - Parameters - ---------- - word_list_path: str - Path to word list file - See Also - -------- - :class:`~montreal_forced_aligner.g2p.generator.OrthographyGenerator` - For orthography-based G2P generation parameters - :class:`~montreal_forced_aligner.abc.TopLevelMfaWorker` - For top-level parameters - - Attributes - ---------- - word_list: list[str] - Word list to generate pronunciations - """ - - def __init__(self, word_list_path: str, **kwargs): - super().__init__(**kwargs) - self.word_list_path = word_list_path - self.word_list = [] - - def setup(self) -> None: - """Set up the pronunciation generator""" - if self.initialized: - return - with mfa_open(self.word_list_path, "r") as f: - for line in f: - self.word_list.extend(line.strip().split()) - if not self.include_bracketed: - self.word_list = [x for x in self.word_list if not self.check_bracketed(x)] - self.initialized = True - - @property - def words_to_g2p(self) -> List[str]: - """Words to produce pronunciations""" - return self.word_list diff --git a/montreal_forced_aligner/g2p/mixins.py b/montreal_forced_aligner/g2p/mixins.py index 442d6095..ef7c30b8 100644 --- a/montreal_forced_aligner/g2p/mixins.py +++ b/montreal_forced_aligner/g2p/mixins.py @@ -70,11 +70,6 @@ class G2PTopLevelMixin(MfaWorker, DictionaryMixin, G2PMixin): def __init__(self, **kwargs): super().__init__(**kwargs) - @property - def workflow_identifier(self) -> str: - """G2P workflow identifier""" - return "g2p" - def generate_pronunciations(self) -> Dict[str, List[str]]: """ Generate pronunciations diff --git a/montreal_forced_aligner/g2p/phonetisaurus_trainer.py b/montreal_forced_aligner/g2p/phonetisaurus_trainer.py index bd3ed31b..e888a131 100644 --- a/montreal_forced_aligner/g2p/phonetisaurus_trainer.py +++ b/montreal_forced_aligner/g2p/phonetisaurus_trainer.py @@ -1,6 +1,7 @@ from __future__ import annotations import collections +import logging import multiprocessing as mp import os import queue @@ -17,8 +18,17 @@ from sqlalchemy.orm import scoped_session, sessionmaker from montreal_forced_aligner.abc import MetaDict, TopLevelMfaWorker -from montreal_forced_aligner.data import WordType -from montreal_forced_aligner.db import Job, M2M2Job, M2MSymbol, Pronunciation, Word, Word2Job +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.data import WordType, WorkflowType +from montreal_forced_aligner.db import ( + Job, + M2M2Job, + M2MSymbol, + Pronunciation, + Word, + Word2Job, + bulk_update, +) from montreal_forced_aligner.dictionary.multispeaker import MultispeakerDictionaryMixin from montreal_forced_aligner.exceptions import PhonetisaurusSymbolError from montreal_forced_aligner.g2p.generator import PyniniValidator @@ -29,12 +39,14 @@ __all__ = ["PhonetisaurusTrainerMixin", "PhonetisaurusTrainer"] +logger = logging.getLogger("mfa") + @dataclassy.dataclass(slots=True) class MaximizationArguments: """Arguments for the MaximizationWorker""" - db_path: str + db_string: str far_path: str penalize_em: bool batch_size: int @@ -44,16 +56,16 @@ class MaximizationArguments: class ExpectationArguments: """Arguments for the ExpectationWorker""" - db_path: str + db_string: str far_path: str batch_size: int @dataclassy.dataclass(slots=True) class AlignmentExportArguments: - """Arguments for the NgramCountWorker""" + """Arguments for the AlignmentExportWorker""" - db_path: str + db_string: str log_path: str far_path: str penalize: bool @@ -73,7 +85,7 @@ class NgramCountArguments: class AlignmentInitArguments: """Arguments for the alignment initialization worker""" - db_path: str + db_string: str log_path: str far_path: str deletions: bool @@ -132,17 +144,15 @@ def __init__( self.far_path = args.far_path self.sym_path = self.far_path.replace(".far", ".syms") self.log_path = args.log_path - self.db_path = args.db_path + self.db_string = args.db_string self.batch_size = args.batch_size def run(self) -> None: """Run the function""" + engine = sqlalchemy.create_engine(self.db_string) try: symbol_table = pynini.SymbolTable() symbol_table.add_symbol(self.eps) - engine = sqlalchemy.create_engine( - f"sqlite:///file:{self.db_path}?mode=ro&nolock=1&uri=true" - ) Session = scoped_session(sessionmaker(bind=engine, autoflush=False, autocommit=False)) valid_phone_ngrams = set() base_dir = os.path.dirname(self.far_path) @@ -297,7 +307,6 @@ def run(self) -> None: del fst count += 1 except Exception as e: # noqa - print(e) self.stopped.stop() self.return_queue.put(e) if data: @@ -306,10 +315,10 @@ def run(self) -> None: symbol_table.write_text(self.far_path.replace(".far", ".syms")) return except Exception as e: - print(e) self.stopped.stop() self.return_queue.put(e) finally: + engine.dispose() self.finished.stop() del far_writer @@ -335,7 +344,7 @@ def __init__( ): mp.Process.__init__(self) self.job_name = job_name - self.db_path = args.db_path + self.db_string = args.db_string self.far_path = args.far_path self.batch_size = args.batch_size self.return_queue = return_queue @@ -344,9 +353,7 @@ def __init__( def run(self) -> None: """Run the function""" - engine = sqlalchemy.create_engine( - f"sqlite:///file:{self.db_path}?mode=ro&nolock=1&uri=true" - ) + engine = sqlalchemy.create_engine(self.db_string) Session = scoped_session(sessionmaker(bind=engine, autoflush=False, autocommit=False)) far_reader = pywrapfst.FarReader.open(self.far_path) symbol_table = pynini.SymbolTable.read_text(self.far_path.replace(".far", ".syms")) @@ -361,6 +368,7 @@ def run(self) -> None: ) for symbol, sym_id in query: symbol_mapper[symbol_table.find(symbol)] = sym_id + engine.dispose() while not far_reader.done(): if self.stopped.stop_check(): break @@ -429,7 +437,7 @@ def __init__( self.return_queue = return_queue self.stopped = stopped self.finished = Stopped() - self.db_path = args.db_path + self.db_string = args.db_string self.penalize_em = args.penalize_em self.far_path = args.far_path self.batch_size = args.batch_size @@ -438,10 +446,8 @@ def run(self) -> None: """Run the function""" symbol_table = pynini.SymbolTable.read_text(self.far_path.replace(".far", ".syms")) count = 0 + engine = sqlalchemy.create_engine(self.db_string) try: - engine = sqlalchemy.create_engine( - f"sqlite:///file:{self.db_path}?mode=ro&nolock=1&uri=true" - ) Session = scoped_session(sessionmaker(bind=engine, autoflush=False, autocommit=False)) alignment_model = {} with Session() as session: @@ -490,6 +496,7 @@ def run(self) -> None: self.return_queue.put(e) raise finally: + engine.dispose() if count >= 1: self.return_queue.put(count) self.finished.stop() @@ -517,7 +524,7 @@ def __init__(self, return_queue: mp.Queue, stopped: Stopped, args: AlignmentExpo self.penalize = args.penalize self.far_path = args.far_path self.log_path = args.log_path - self.db_path = args.db_path + self.db_string = args.db_string def run(self) -> None: """Run the function""" @@ -743,14 +750,16 @@ def initialize_alignments(self) -> None: """ - self.log_info("Creating alignment FSTs...") + logger.info("Creating alignment FSTs...") + from montreal_forced_aligner.config import GLOBAL_CONFIG + return_queue = mp.Queue() stopped = Stopped() finished_adding = Stopped() procs = [] - for i in range(self.num_jobs): + for i in range(GLOBAL_CONFIG.num_jobs): args = AlignmentInitArguments( - self.db_path, + self.db_string, os.path.join(self.working_log_directory, f"alignment_init.{i}.log"), os.path.join(self.working_directory, f"{i}.far"), self.deletions, @@ -781,7 +790,7 @@ def initialize_alignments(self) -> None: job_symbols = {} symbol_id = 1 with tqdm.tqdm( - total=self.g2p_num_training_pronunciations, disable=getattr(self, "quiet", False) + total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session(autoflush=False, autocommit=False) as session: while True: try: @@ -835,14 +844,21 @@ def initialize_alignments(self) -> None: if error_list: for v in error_list: raise v - self.log_debug(f"Total of {len(symbols)} symbols, initial total: {self.total}") - session.bulk_insert_mappings(M2MSymbol, [x for x in symbols.values()]) + logger.debug(f"Total of {len(symbols)} symbols, initial total: {self.total}") + symbols = [x for x in symbols.values()] + for data in symbols: + data["weight"] = float(data["weight"]) + session.bulk_insert_mappings( + M2MSymbol, symbols, return_defaults=False, render_nulls=True + ) session.flush() del symbols mappings = [] for j, sym_ids in job_symbols.items(): mappings.extend({"m2m_id": x, "job_id": j} for x in sym_ids) - session.bulk_insert_mappings(M2M2Job, mappings) + session.bulk_insert_mappings( + M2M2Job, mappings, return_defaults=False, render_nulls=True + ) session.commit() @@ -855,11 +871,11 @@ def maximization(self, last_iteration=False) -> float: float Current iteration's score """ - self.log_info("Performing maximization step...") + logger.info("Performing maximization step...") change = abs(float(self.total) - float(self.prev_total)) - self.log_debug(f"Previous total: {float(self.prev_total)}") - self.log_debug(f"Current total: {float(self.total)}") - self.log_debug(f"Change: {change}") + logger.debug(f"Previous total: {float(self.prev_total)}") + logger.debug(f"Current total: {float(self.total)}") + logger.debug(f"Change: {change}") self.prev_total = self.total with self.session(autoflush=False, autocommit=False) as session: @@ -870,9 +886,9 @@ def maximization(self, last_iteration=False) -> float: return_queue = mp.Queue() stopped = Stopped() procs = [] - for i in range(self.num_jobs): + for i in range(GLOBAL_CONFIG.num_jobs): args = MaximizationArguments( - self.db_path, + self.db_string, os.path.join(self.working_directory, f"{i}.far"), self.penalize_em, self.batch_size, @@ -882,7 +898,7 @@ def maximization(self, last_iteration=False) -> float: error_list = [] with tqdm.tqdm( - total=self.g2p_num_training_pronunciations, disable=getattr(self, "quiet", False) + total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet ) as pbar: while True: try: @@ -912,28 +928,30 @@ def maximization(self, last_iteration=False) -> float: with self.session(autoflush=False, autocommit=False) as session: session.query(M2MSymbol).update({"weight": 0.0}) session.commit() - self.log_info(f"Maximization done! Change from last iteration was {change:.3f}") + logger.info(f"Maximization done! Change from last iteration was {change:.3f}") return change def expectation(self) -> None: """ Run the expectation step for training """ - self.log_info("Performing expectation step...") + logger.info("Performing expectation step...") return_queue = mp.Queue() stopped = Stopped() error_list = [] procs = [] - for i in range(self.num_jobs): + for i in range(GLOBAL_CONFIG.num_jobs): args = ExpectationArguments( - self.db_path, os.path.join(self.working_directory, f"{i}.far"), self.batch_size + self.db_string, + os.path.join(self.working_directory, f"{i}.far"), + self.batch_size, ) procs.append(ExpectationWorker(i, return_queue, stopped, args)) procs[i].start() mappings = {} zero = pynini.Weight.zero("log") with tqdm.tqdm( - total=self.g2p_num_training_pronunciations, disable=getattr(self, "quiet", False) + total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet ) as pbar: while True: try: @@ -965,26 +983,26 @@ def expectation(self) -> None: for v in error_list: raise v with self.session() as session: - session.bulk_update_mappings( - M2MSymbol, [{"id": k, "weight": v} for k, v in mappings.items()] + bulk_update( + session, M2MSymbol, [{"id": k, "weight": float(v)} for k, v in mappings.items()] ) session.commit() - self.log_info("Expectation done!") + logger.info("Expectation done!") def train_ngram_model(self) -> None: """ Train an ngram model on the aligned FSTs """ if os.path.exists(self.fst_path): - self.log_info("Ngram model already exists.") + logger.info("Ngram model already exists.") return - self.log_info("Generating ngram counts...") + logger.info("Generating ngram counts...") return_queue = mp.Queue() stopped = Stopped() error_list = [] procs = [] count_paths = [] - for i in range(self.num_jobs): + for i in range(GLOBAL_CONFIG.num_jobs): args = NgramCountArguments( os.path.join(self.working_log_directory, f"ngram_count.{i}.log"), os.path.join(self.working_directory, f"{i}.far"), @@ -996,7 +1014,7 @@ def train_ngram_model(self) -> None: procs[i].start() with tqdm.tqdm( - total=self.g2p_num_training_pronunciations, disable=getattr(self, "quiet", False) + total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet ) as pbar: while True: try: @@ -1020,9 +1038,9 @@ def train_ngram_model(self) -> None: if error_list: for v in error_list: raise v - self.log_info("Done counting ngrams!") + logger.info("Done counting ngrams!") - self.log_info("Training ngram model...") + logger.info("Training ngram model...") with mfa_open(os.path.join(self.working_log_directory, "model.log"), "w") as logf: ngrammerge_proc = subprocess.Popen( [ @@ -1125,7 +1143,6 @@ def train_ngram_model(self) -> None: arc = pywrapfst.Arc(0, 0, arc.weight, arc.nextstate) maiter.set_value(arc) else: - print(symbol) raise finally: next(maiter) @@ -1166,15 +1183,15 @@ def train_alignments(self) -> None: Run an Expectation-Maximization (EM) training on alignment FSTs to generate well-aligned FSTs for ngram modeling """ if os.path.exists(self.alignment_model_path): - self.log_info("Using existing alignments.") + logger.info("Using existing alignments.") self.symbol_table = pynini.SymbolTable.read_text(self.alignment_symbols_path) return self.initialize_alignments() self.maximization() - self.log_info("Training alignments...") + logger.info("Training alignments...") for i in range(self.num_iterations): - self.log_info(f"Iteration {i}") + logger.info(f"Iteration {i}") self.expectation() change = self.maximization(last_iteration=i == self.num_iterations - 1) if change < self.em_threshold: @@ -1189,11 +1206,6 @@ def train_iteration(self) -> None: """Train iteration, not used""" pass - @property - def workflow_identifier(self) -> str: - """Identifier for Phonetisaurus G2P trainer""" - return "phonetisaurus_train_g2p" - @property def data_source_identifier(self) -> str: """Dictionary name""" @@ -1220,7 +1232,7 @@ def export_model(self, output_model_path: str) -> None: basename, _ = os.path.splitext(output_model_path) model.dump(basename) model.clean_up() - self.log_info(f"Saved model to {output_model_path}") + logger.info(f"Saved model to {output_model_path}") @property def alignment_model_path(self) -> str: @@ -1261,16 +1273,16 @@ def export_alignments(self) -> None: """ Combine alignment training archives to a final combined FST archive to train the ngram model """ - self.log_info("Exporting final alignments...") + logger.info("Exporting final alignments...") return_queue = mp.Queue() stopped = Stopped() error_list = [] procs = [] count_paths = [] - for i in range(self.num_jobs): + for i in range(GLOBAL_CONFIG.num_jobs): args = AlignmentExportArguments( - self.db_path, + self.db_string, os.path.join(self.working_log_directory, f"ngram_count.{i}.log"), os.path.join(self.working_directory, f"{i}.far"), self.penalize, @@ -1280,7 +1292,7 @@ def export_alignments(self) -> None: procs[i].start() with tqdm.tqdm( - total=self.g2p_num_training_pronunciations, disable=getattr(self, "quiet", False) + total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet ) as pbar: while True: try: @@ -1317,7 +1329,7 @@ def export_alignments(self) -> None: stdin=subprocess.PIPE, stdout=subprocess.PIPE, ) - for j in range(self.num_jobs): + for j in range(GLOBAL_CONFIG.num_jobs): text_path = os.path.join(self.working_directory, f"{j}.far.strings") with mfa_open(text_path, "r") as f: for line in f: @@ -1326,7 +1338,7 @@ def export_alignments(self) -> None: symbols_proc.stdin.close() symbols_proc.wait() self.symbol_table = pynini.SymbolTable.read_text(self.alignment_symbols_path) - self.log_info("Done exporting alignments!") + logger.info("Done exporting alignments!") class PhonetisaurusTrainer( @@ -1350,11 +1362,6 @@ def data_directory(self) -> str: """Data directory for trainer""" return self.working_directory - @property - def workflow_identifier(self) -> str: - """Identifier for Phonetisaurus G2P trainer""" - return "phonetisaurus_train_g2p" - @property def configuration(self) -> MetaDict: """Configuration for G2P trainer""" @@ -1364,9 +1371,12 @@ def configuration(self) -> MetaDict: def setup(self) -> None: """Setup for G2P training""" - if self.initialized: + super().setup() + self.create_new_current_workflow(WorkflowType.train_g2p) + wf = self.current_workflow + if wf.done: + logger.info("G2P training already done, skipping.") return - self.initialize_database() self.dictionary_setup() os.makedirs(self.phones_dir, exist_ok=True) self.initialize_training() @@ -1379,14 +1389,14 @@ def train(self) -> None: os.makedirs(self.working_log_directory, exist_ok=True) begin = time.time() self.train_alignments() - self.log_debug( - f"Aligning {len(self.g2p_training_dictionary)} words took {time.time() - begin} seconds" + logger.debug( + f"Aligning {len(self.g2p_training_dictionary)} words took {time.time() - begin:.3f} seconds" ) self.export_alignments() begin = time.time() self.train_ngram_model() - self.log_debug( - f"Generating model for {len(self.g2p_training_dictionary)} words took {time.time() - begin} seconds" + logger.debug( + f"Generating model for {len(self.g2p_training_dictionary)} words took {time.time() - begin:.3f} seconds" ) self.finalize_training() @@ -1432,6 +1442,7 @@ def evaluate_g2p_model(self) -> None: temp_model_path = os.path.join(self.working_log_directory, "g2p_model.zip") self.export_model(temp_model_path) temp_dir = os.path.join(self.working_directory, "validation") + os.makedirs(temp_dir, exist_ok=True) with self.session() as session: validation_set = collections.defaultdict(set) query = ( @@ -1445,8 +1456,6 @@ def evaluate_g2p_model(self) -> None: gen = PyniniValidator( g2p_model_path=temp_model_path, word_list=list(validation_set.keys()), - temporary_directory=temp_dir, - num_jobs=self.num_jobs, num_pronunciations=self.num_pronunciations, ) output = gen.generate_pronunciations() @@ -1617,10 +1626,13 @@ def compute_initial_ngrams(self) -> None: def initialize_training(self) -> None: """Initialize training G2P model""" with self.session() as session: + session.query(Word2Job).delete() + session.query(M2M2Job).delete() + session.query(M2MSymbol).delete() session.query(Job).delete() session.commit() - job_objs = [{"id": j} for j in range(self.num_jobs)] + job_objs = [{"id": j} for j in range(GLOBAL_CONFIG.num_jobs)] self.g2p_num_training_pronunciations = 0 self.g2p_num_validation_pronunciations = 0 self.g2p_num_training_words = 0 @@ -1628,21 +1640,25 @@ def initialize_training(self) -> None: # Below we partition sorted list of words to try to have each process handling different symbol tables # so they're not completely overlapping and using more memory num_words = session.query(Word.id).count() - words_per_job = int(num_words / self.num_jobs) + words_per_job = int(num_words / GLOBAL_CONFIG.num_jobs) current_job = 0 words = session.query(Word.id).filter( Word.word_type.in_([WordType.speech, WordType.clitic]) ) mappings = [] for i, (w,) in enumerate(words): - if i >= (current_job + 1) * words_per_job and current_job != self.num_jobs: + if ( + i >= (current_job + 1) * words_per_job + and current_job != GLOBAL_CONFIG.num_jobs + ): current_job += 1 mappings.append({"word_id": w, "job_id": current_job, "training": 1}) - with session.bind.begin() as conn: - conn.execute(sqlalchemy.insert(Job.__table__), job_objs) - conn.execute(sqlalchemy.insert(Word2Job.__table__), mappings) + with session.begin_nested(): + session.execute(sqlalchemy.insert(Job.__table__), job_objs) + session.execute(sqlalchemy.insert(Word2Job.__table__), mappings) + session.flush() + session.commit() - session.commit() if self.evaluation_mode: validation_items = int(num_words * self.validation_proportion) validation_words = ( @@ -1657,7 +1673,9 @@ def initialize_training(self) -> None: .values(training=False) .where(Word2Job.word_id.in_(validation_words)) ) - session.execute(query) + with session.begin_nested(): + session.execute(query) + session.flush() session.commit() query = ( session.query(Word.word, Pronunciation.pronunciation) @@ -1701,9 +1719,9 @@ def initialize_training(self) -> None: self.g2p_num_training_words = ( session.query(Word2Job.word_id).filter(Word2Job.training == True).count() # noqa ) - self.log_debug(f"Graphemes in training data: {sorted(self.g2p_training_graphemes)}") - self.log_debug(f"Phones in training data: {sorted(self.g2p_training_phones)}") - self.log_debug(f"Averages phones per grapheme: {phone_count / grapheme_count}") + logger.debug(f"Graphemes in training data: {sorted(self.g2p_training_graphemes)}") + logger.debug(f"Phones in training data: {sorted(self.g2p_training_phones)}") + logger.debug(f"Averages phones per grapheme: {phone_count / grapheme_count}") if self.sequence_separator in self.g2p_training_phones | self.g2p_training_graphemes: raise PhonetisaurusSymbolError(self.sequence_separator, "sequence_separator") @@ -1713,18 +1731,18 @@ def initialize_training(self) -> None: raise PhonetisaurusSymbolError(self.alignment_separator, "alignment_separator") if self.evaluation_mode: - self.log_debug( + logger.debug( f"Graphemes in validation data: {sorted(self.g2p_validation_graphemes)}" ) - self.log_debug(f"Phones in validation data: {sorted(self.g2p_validation_phones)}") + logger.debug(f"Phones in validation data: {sorted(self.g2p_validation_phones)}") grapheme_diff = sorted(self.g2p_validation_graphemes - self.g2p_training_graphemes) phone_diff = sorted(self.g2p_validation_phones - self.g2p_training_phones) if grapheme_diff: - self.log_warning( + logger.debug( f"The following graphemes appear only in the validation set: {', '.join(grapheme_diff)}" ) if phone_diff: - self.log_warning( + logger.debug( f"The following phones appear only in the validation set: {', '.join(phone_diff)}" ) self.compute_initial_ngrams() diff --git a/montreal_forced_aligner/g2p/trainer.py b/montreal_forced_aligner/g2p/trainer.py index adaab4fd..46630e6f 100644 --- a/montreal_forced_aligner/g2p/trainer.py +++ b/montreal_forced_aligner/g2p/trainer.py @@ -2,6 +2,7 @@ from __future__ import annotations import itertools +import logging import multiprocessing as mp import operator import os @@ -19,7 +20,8 @@ from pynini import Fst from montreal_forced_aligner.abc import MetaDict, MfaWorker, TopLevelMfaWorker, TrainerMixin -from montreal_forced_aligner.data import WordType +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.data import WordType, WorkflowType from montreal_forced_aligner.db import Pronunciation, Word from montreal_forced_aligner.dictionary.multispeaker import MultispeakerDictionaryMixin from montreal_forced_aligner.exceptions import KaldiProcessingError, PyniniAlignmentError @@ -36,6 +38,8 @@ __all__ = ["RandomStartWorker", "PyniniTrainer", "G2PTrainer"] +logger = logging.getLogger("mfa") + class RandomStart(NamedTuple): """Parameters for random starts""" @@ -145,7 +149,7 @@ def run(self) -> None: with mfa_open(likelihood_path, "w") as f: f.write(str(likelihood)) log_file.write( - f"{args.seed} training took {time.time() - random_end} seconds\n" + f"{args.seed} training took {time.time() - random_end:.3f} seconds\n" ) else: with mfa_open(likelihood_path, "r") as f: @@ -342,7 +346,7 @@ def generate_model(self) -> None: """ assert os.path.exists(self.far_path) if os.path.exists(self.fst_path): - self.log_info("Model building already done, skipping!") + logger.info("Model building already done, skipping!") return with mfa_open(os.path.join(self.working_log_directory, "model.log"), "w") as logf: ngramcount_proc = subprocess.Popen( @@ -431,14 +435,16 @@ def _narcs(f: Fst) -> int: """Computes the number of arcs in an FST.""" return sum(f.num_arcs(state) for state in f.states()) - def _lexicon_covering( - self, - ) -> None: + def _lexicon_covering(self, input_path=None, output_path=None) -> None: """Builds covering grammar and lexicon FARs.""" # Sets of labels for the covering grammar. with mfa_open( os.path.join(self.working_log_directory, "covering_grammar.log"), "w" ) as log_file: + if input_path is None: + input_path = self.input_path + if output_path is None: + output_path = self.output_path com = [ thirdparty_binary("farcompilestrings"), "--fst_type=compact", @@ -451,7 +457,7 @@ def _lexicon_covering( com.append("--unknown_symbol=") else: com.append("--token_type=utf8") - com.extend([self.input_path, self.input_far_path]) + com.extend([input_path, self.input_far_path]) print(" ".join(com), file=log_file) subprocess.check_call(com, env=os.environ, stderr=log_file, stdout=log_file) com = [ @@ -459,7 +465,7 @@ def _lexicon_covering( "--fst_type=compact", "--token_type=symbol", f"--symbols={self.phone_symbol_table_path}", - self.output_path, + output_path, self.output_far_path, ] print(" ".join(com), file=log_file) @@ -488,7 +494,7 @@ def _lexicon_covering( def _alignments(self) -> None: """Trains the aligner and constructs the alignments FAR.""" if not os.path.exists(self.align_path): - self.log_info("Training aligner") + logger.info("Training aligner") train_opts = [] if self.batch_size: train_opts.append(f"--batch_size={self.batch_size}") @@ -528,17 +534,17 @@ def _alignments(self) -> None: job_queue = mp.JoinableQueue() fst_likelihoods = {} # Actually runs starts. - self.log_info("Calculating alignments...") + logger.info("Calculating alignments...") begin = time.time() with tqdm.tqdm( - total=num_commands * self.num_iterations, disable=getattr(self, "quiet", False) + total=num_commands * self.num_iterations, disable=GLOBAL_CONFIG.quiet ) as pbar: for start in starts: job_queue.put(start) error_dict = {} return_queue = mp.Queue() procs = [] - for i in range(self.num_jobs): + for i in range(GLOBAL_CONFIG.num_jobs): log_path = os.path.join(self.working_log_directory, f"baumwelch.{i}.log") p = RandomStartWorker( i, @@ -574,9 +580,9 @@ def _alignments(self) -> None: if error_dict: raise PyniniAlignmentError(error_dict) (best_fst, best_likelihood) = min(fst_likelihoods.items(), key=operator.itemgetter(1)) - self.log_info(f"Best likelihood: {best_likelihood}") - self.log_debug( - f"Ran {self.random_starts} random starts in {time.time() - begin} seconds" + logger.info(f"Best likelihood: {best_likelihood}") + logger.debug( + f"Ran {self.random_starts} random starts in {time.time() - begin:.3f} seconds" ) # Moves best likelihood solution to the requested location. shutil.move(best_fst, self.align_path) @@ -589,13 +595,13 @@ def _alignments(self) -> None: cmd.append(self.output_far_path) cmd.append(self.align_path) cmd.append(self.afst_path) - self.log_debug(f"Subprocess call: {cmd}") + logger.debug(f"Subprocess call: {cmd}") subprocess.check_call(cmd, env=os.environ) - self.log_info("Completed computing alignments!") + logger.info("Completed computing alignments!") def _encode(self) -> None: """Encodes the alignments.""" - self.log_info("Encoding the alignments as FSAs") + logger.info("Encoding the alignments as FSAs") subprocess.check_call( [ thirdparty_binary("farencode"), @@ -606,7 +612,7 @@ def _encode(self) -> None: ], env=os.environ, ) - self.log_info(f"Success! FAR path: {self.far_path}; encoder path: {self.encoder_path}") + logger.info(f"Success! FAR path: {self.far_path}; encoder path: {self.encoder_path}") class PyniniTrainer( @@ -640,11 +646,6 @@ def data_directory(self) -> str: """Data directory for trainer""" return self.working_directory - @property - def workflow_identifier(self) -> str: - """Identifier for Pynini G2P trainer""" - return "pynini_train_g2p" - @property def configuration(self) -> MetaDict: """Configuration for G2P trainer""" @@ -654,9 +655,12 @@ def configuration(self) -> MetaDict: def setup(self) -> None: """Setup for G2P training""" - if self.initialized: + super().setup() + self.create_new_current_workflow(WorkflowType.train_g2p) + wf = self.current_workflow + if wf.done: + logger.info("G2P training already done, skipping.") return - self.initialize_database() self.dictionary_setup() os.makedirs(self.phones_dir, exist_ok=True) self._write_phone_symbol_table() @@ -720,7 +724,7 @@ def initialize_training(self) -> None: self.g2p_validation_dictionary = { k: v for k, v in word_dict.items() if k in validation_words } - if self.debug: + if GLOBAL_CONFIG.debug: with mfa_open( os.path.join(self.working_directory, "validation_set.txt"), "w", @@ -738,25 +742,25 @@ def initialize_training(self) -> None: self.g2p_training_phones.update(p.split()) print(word, file=inf) print(p, file=outf) - self.log_debug(f"Graphemes in training data: {sorted(self.g2p_training_graphemes)}") - self.log_debug(f"Phones in training data: {sorted(self.g2p_training_phones)}") + logger.debug(f"Graphemes in training data: {sorted(self.g2p_training_graphemes)}") + logger.debug(f"Phones in training data: {sorted(self.g2p_training_phones)}") if self.evaluation_mode: for word, pronunciations in self.g2p_validation_dictionary.items(): self.g2p_validation_graphemes.update(word) for p in pronunciations: self.g2p_validation_phones.update(p.split()) - self.log_debug( + logger.debug( f"Graphemes in validation data: {sorted(self.g2p_validation_graphemes)}" ) - self.log_debug(f"Phones in validation data: {sorted(self.g2p_validation_phones)}") + logger.debug(f"Phones in validation data: {sorted(self.g2p_validation_phones)}") grapheme_diff = sorted(self.g2p_validation_graphemes - self.g2p_training_graphemes) phone_diff = sorted(self.g2p_validation_phones - self.g2p_training_phones) if grapheme_diff: - self.log_warning( + logger.debug( f"The following graphemes appear only in the validation set: {', '.join(grapheme_diff)}" ) if phone_diff: - self.log_warning( + logger.debug( f"The following phones appear only in the validation set: {', '.join(phone_diff)}" ) @@ -764,7 +768,7 @@ def clean_up(self) -> None: """ Clean up temporary files """ - if self.debug: + if GLOBAL_CONFIG.debug: return for name in os.listdir(self.working_directory): path = os.path.join(self.working_directory, name) @@ -795,7 +799,7 @@ def export_model(self, output_model_path: str) -> None: model.dump(basename) model.clean_up() # self.clean_up() - self.log_info(f"Saved model to {output_model_path}") + logger.info(f"Saved model to {output_model_path}") def train(self) -> None: """ @@ -804,16 +808,16 @@ def train(self) -> None: os.makedirs(self.working_log_directory, exist_ok=True) begin = time.time() if os.path.exists(self.far_path) and os.path.exists(self.encoder_path): - self.log_info("Alignment already done, skipping!") + logger.info("Alignment already done, skipping!") else: self.align_g2p() - self.log_debug( - f"Aligning {len(self.g2p_training_dictionary)} words took {time.time() - begin} seconds" + logger.debug( + f"Aligning {len(self.g2p_training_dictionary)} words took {time.time() - begin:.3f} seconds" ) begin = time.time() self.generate_model() - self.log_debug( - f"Generating model for {len(self.g2p_training_dictionary)} words took {time.time() - begin} seconds" + logger.debug( + f"Generating model for {len(self.g2p_training_dictionary)} words took {time.time() - begin:.3f} seconds" ) self.finalize_training() @@ -836,8 +840,7 @@ def evaluate_g2p_model(self) -> None: gen = PyniniValidator( g2p_model_path=temp_model_path, word_list=list(self.g2p_validation_dictionary.keys()), - temporary_directory=os.path.join(self.working_directory, "validation"), - num_jobs=self.num_jobs, + num_jobs=GLOBAL_CONFIG.num_jobs, num_pronunciations=self.num_pronunciations, ) gen.evaluate_g2p_model(self.g2p_training_dictionary) diff --git a/montreal_forced_aligner/helper.py b/montreal_forced_aligner/helper.py index 5558f184..d652c36b 100644 --- a/montreal_forced_aligner/helper.py +++ b/montreal_forced_aligner/helper.py @@ -8,7 +8,10 @@ import functools import itertools import json +import logging import re +import shutil +import sys import typing from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union @@ -39,6 +42,11 @@ "compare_labels", "overlap_scoring", "align_phones", + "split_phone_position", + "CustomFormatter", + "configure_logger", + "mfa_open", + "load_configuration", ] @@ -99,7 +107,13 @@ def split_phone_position(phone_label: str) -> List[str]: List[str] Phone and position """ - return phone_label.rsplit("_", maxsplit=1) + phone = phone_label + pos = None + try: + phone, pos = phone_label.rsplit("_", maxsplit=1) + except ValueError: + pass + return phone, pos def parse_old_features(config: MetaDict) -> MetaDict: @@ -141,6 +155,96 @@ def parse_old_features(config: MetaDict) -> MetaDict: return config +def configure_logger(identifier: str, log_file: Optional[str] = None) -> None: + """ + Configure logging for the given identifier + + Parameters + ---------- + identifier: str + Logger identifier + log_file: str + Path to file to write all messages to + quiet: bool + Flag for whether logger should write to stdout + verbose: bool + Flag for writing debug level information to stdout + """ + from montreal_forced_aligner.config import MfaConfiguration + + config = MfaConfiguration() + logger = logging.getLogger(identifier) + logger.setLevel(logging.DEBUG) + if log_file is not None: + file_handler = logging.FileHandler(log_file, encoding="utf8") + file_handler.setLevel(logging.DEBUG) + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + elif not config.current_profile.quiet: + handler = logging.StreamHandler(sys.stdout) + if config.current_profile.verbose: + handler.setLevel(logging.DEBUG) + else: + handler.setLevel(logging.INFO) + handler.setFormatter(CustomFormatter()) + 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 @@ -162,9 +266,8 @@ def __init__(self, print_function: typing.Callable = None): self.print_function = print_function else: self.print_function = print - from .config import load_global_config + from montreal_forced_aligner.config import GLOBAL_CONFIG - c = load_global_config() self.colors = {} self.colors["bright"] = "" self.colors["green"] = "" @@ -174,10 +277,9 @@ def __init__(self, print_function: typing.Callable = None): self.colors["yellow"] = "" self.colors["reset"] = "" self.colors["normal"] = "" - self.width = c["terminal_width"] self.indent_level = 0 self.indent_size = 2 - if c["terminal_colors"]: + if GLOBAL_CONFIG.terminal_colors: self.colors["bright"] = Style.BRIGHT self.colors["green"] = Fore.GREEN self.colors["red"] = Fore.RED @@ -266,12 +368,12 @@ def print_header(self, header: str) -> None: Section header string """ self.indent_level = 0 - self.print_function() + 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.print_function("") self.indent_level += 1 def print_sub_header(self, header: str) -> None: @@ -286,13 +388,13 @@ def print_sub_header(self, header: str) -> None: 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.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() + self.print_function("") def format_info_lines(self, lines: Union[list[str], str]) -> List[str]: """ @@ -316,7 +418,7 @@ def format_info_lines(self, lines: Union[list[str], str]) -> List[str]: line, initial_indent=self.indent_string, subsequent_indent=" " * self.indent_size * (self.indent_level + 1), - width=self.width, + width=shutil.get_terminal_size().columns, break_on_hyphens=False, break_long_words=False, drop_whitespace=False, @@ -420,7 +522,7 @@ def print_block(self, block: dict, starting_level: int = 1) -> None: 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() + self.print_function("") def print_config(self, configuration: MetaDict) -> None: """ @@ -487,7 +589,7 @@ def print_information_line( self.print_function( ansiwrap.fill( f"{self.colorize(key, key_color)} {value}", - width=self.width, + width=shutil.get_terminal_size().columns, initial_indent=indent, subsequent_indent=subsequent_indent, ) @@ -867,6 +969,7 @@ def align_phones( silence_phone: str, ignored_phones: typing.Set[str] = None, custom_mapping: Optional[Dict[str, str]] = None, + debug: bool = False, ) -> Tuple[float, float]: """ Align phones based on how much they overlap and their phone label, with the ability to specify a custom mapping for @@ -899,17 +1002,7 @@ def align_phones( score_func = functools.partial( overlap_scoring, silence_phone=silence_phone, mapping=custom_mapping ) - coalesced_phones = {tuple(x.split()) for x in custom_mapping.keys() if " " in x} - if coalesced_phones: - for cp in coalesced_phones: - custom_mapping["".join(cp)] = custom_mapping[" ".join(cp)] - coalesced = [] - for t in test: - if coalesced and (coalesced[-1].label, t.label) in coalesced_phones: - coalesced[-1].label += t.label - coalesced[-1].end = t.end - continue - coalesced.append(t) + alignments = pairwise2.align.globalcs( ref, test, score_func, -5, -5, gap_char=["-"], one_alignment_only=True ) @@ -938,9 +1031,27 @@ def align_phones( overlap_count += 1 if compare_labels(sa.label, sb.label, silence_phone, mapping=custom_mapping) > 0: num_substitutions += 1 + if debug: + import logging + + logger = logging.getLogger("mfa") + logger.debug(pairwise2.format_alignment(*alignments[0])) if overlap_count: score = overlap_sum / overlap_count else: score = None phone_error_rate = (num_insertions + num_deletions + (2 * num_substitutions)) / len(ref) return score, phone_error_rate + + +def format_probability(probability_value: float) -> float: + """Format a probability to have two decimal places and be between 0.01 and 0.99""" + return min(max(round(probability_value, 2), 0.01), 0.99) + + +def format_correction(correction_value: float, positive_only=True) -> float: + """Format a probability correction value to have two decimal places and be greater than 0.01""" + correction_value = round(correction_value, 2) + if correction_value <= 0 and positive_only: + correction_value = 0.01 + return correction_value diff --git a/montreal_forced_aligner/ivector/multiprocessing.py b/montreal_forced_aligner/ivector/multiprocessing.py new file mode 100644 index 00000000..4c2ad75c --- /dev/null +++ b/montreal_forced_aligner/ivector/multiprocessing.py @@ -0,0 +1,346 @@ +"""Multiprocessing functions for training ivector extractors""" +from __future__ import annotations + +import os +import re +import subprocess +import typing + +from sqlalchemy.orm import Session, joinedload + +from montreal_forced_aligner.abc import MetaDict +from montreal_forced_aligner.data import MfaArguments +from montreal_forced_aligner.db import Job +from montreal_forced_aligner.helper import mfa_open +from montreal_forced_aligner.utils import KaldiFunction, thirdparty_binary + +__all__ = [ + "GmmGselectFunction", + "GmmGselectArguments", + "GaussToPostFunction", + "GaussToPostArguments", + "AccGlobalStatsFunction", + "AccGlobalStatsArguments", + "AccIvectorStatsFunction", + "AccIvectorStatsArguments", +] + + +class GmmGselectArguments(MfaArguments): + """Arguments for :func:`~montreal_forced_aligner.ivector.trainer.GmmGselectFunction`""" + + feature_options: MetaDict + ivector_options: MetaDict + dubm_model: str + gselect_path: str + + +class AccGlobalStatsArguments(MfaArguments): + """Arguments for :func:`~montreal_forced_aligner.ivector.trainer.AccGlobalStatsFunction`""" + + feature_options: MetaDict + ivector_options: MetaDict + gselect_path: str + acc_path: str + dubm_model: str + + +class GaussToPostArguments(MfaArguments): + """Arguments for :func:`~montreal_forced_aligner.ivector.trainer.GaussToPostFunction`""" + + feature_options: MetaDict + ivector_options: MetaDict + post_path: str + dubm_model: str + + +class AccIvectorStatsArguments(MfaArguments): + """Arguments for :func:`~montreal_forced_aligner.ivector.trainer.AccIvectorStatsFunction`""" + + feature_options: MetaDict + ivector_options: MetaDict + ie_path: str + post_path: str + acc_path: str + + +class GmmGselectFunction(KaldiFunction): + """ + Multiprocessing function for selecting GMM indices. + + See Also + -------- + :meth:`.DubmTrainer.gmm_gselect` + Main function that calls this function in parallel + :meth:`.DubmTrainer.gmm_gselect_arguments` + Job method for generating arguments for this function + :kaldi_src:`subsample-feats` + Relevant Kaldi binary + :kaldi_src:`gmm-gselect` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.ivector.trainer.GmmGselectArguments` + Arguments for the function + """ + + progress_pattern = re.compile(r"^LOG.*For (?P\d+)'th.*") + + def __init__(self, args: GmmGselectArguments): + super().__init__(args) + self.feature_options = args.feature_options + self.ivector_options = args.ivector_options + self.dubm_model = args.dubm_model + self.gselect_path = args.gselect_path + + def _run(self) -> typing.Generator[None]: + """Run the function""" + if os.path.exists(self.gselect_path): + return + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True)) + .filter(Job.id == self.job_name) + .first() + ) + current_done_count = 0 + feature_string = job.construct_online_feature_proc_string() + + gselect_proc = subprocess.Popen( + [ + thirdparty_binary("gmm-gselect"), + f"--n={self.ivector_options['num_gselect']}", + self.dubm_model, + feature_string, + f"ark:{self.gselect_path}", + ], + stderr=subprocess.PIPE, + env=os.environ, + encoding="utf8", + ) + for line in gselect_proc.stderr: + log_file.write(line) + m = self.progress_pattern.match(line) + if m: + new_done_count = int(m.group("done_count")) + yield new_done_count - current_done_count + current_done_count = new_done_count + self.check_call(gselect_proc) + + +class GaussToPostFunction(KaldiFunction): + """ + Multiprocessing function to get posteriors during UBM training. + + See Also + -------- + :meth:`.IvectorTrainer.gauss_to_post` + Main function that calls this function in parallel + :meth:`.IvectorTrainer.gauss_to_post_arguments` + Job method for generating arguments for this function + :kaldi_src:`subsample-feats` + Relevant Kaldi binary + :kaldi_src:`gmm-global-get-post` + Relevant Kaldi binary + :kaldi_src:`scale-post` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.ivector.trainer.GaussToPostArguments` + Arguments for the function + """ + + progress_pattern = re.compile( + r"^VLOG.*Processed utterance (?P.*), average likelihood.*$" + ) + + def __init__(self, args: GaussToPostArguments): + super().__init__(args) + self.feature_options = args.feature_options + self.ivector_options = args.ivector_options + self.dubm_model = args.dubm_model + self.post_path = args.post_path + + def _run(self) -> typing.Generator[None]: + """Run the function""" + if os.path.exists(self.post_path): + return + modified_posterior_scale = ( + self.ivector_options["posterior_scale"] * self.ivector_options["subsample"] + ) + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True)) + .filter(Job.id == self.job_name) + .first() + ) + feature_string = job.construct_online_feature_proc_string() + gmm_global_get_post_proc = subprocess.Popen( + [ + thirdparty_binary("gmm-global-get-post"), + "--verbose=2", + f"--n={self.ivector_options['num_gselect']}", + f"--min-post={self.ivector_options['min_post']}", + self.dubm_model, + feature_string, + "ark:-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=os.environ, + ) + scale_post_proc = subprocess.Popen( + [ + thirdparty_binary("scale-post"), + "ark,s,cs:-", + str(modified_posterior_scale), + f"ark:{self.post_path}", + ], + stdin=gmm_global_get_post_proc.stdout, + stderr=log_file, + env=os.environ, + ) + for line in gmm_global_get_post_proc.stderr: + line = line.decode("utf8") + log_file.write(line) + log_file.flush() + m = self.progress_pattern.match(line) + if m: + utterance = int(m.group("utterance").split("-")[-1]) + yield utterance + self.check_call(scale_post_proc) + + +class AccGlobalStatsFunction(KaldiFunction): + """ + Multiprocessing function for accumulating global model stats. + + See Also + -------- + :meth:`.DubmTrainer.acc_global_stats` + Main function that calls this function in parallel + :meth:`.DubmTrainer.acc_global_stats_arguments` + Job method for generating arguments for this function + :kaldi_src:`subsample-feats` + Relevant Kaldi binary + :kaldi_src:`gmm-global-acc-stats` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.ivector.trainer.AccGlobalStatsArguments` + Arguments for the function + """ + + progress_pattern = re.compile(r"^VLOG.*File '(?P.*)': Average likelihood =.*$") + + def __init__(self, args: AccGlobalStatsArguments): + super().__init__(args) + self.feature_options = args.feature_options + self.ivector_options = args.ivector_options + self.dubm_model = args.dubm_model + self.gselect_path = args.gselect_path + self.acc_path = args.acc_path + + def _run(self) -> typing.Generator[None]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True)) + .filter(Job.id == self.job_name) + .first() + ) + feature_string = job.construct_online_feature_proc_string() + command = [ + thirdparty_binary("gmm-global-acc-stats"), + "--verbose=2", + f"--gselect=ark,s,cs:{self.gselect_path}", + self.dubm_model, + feature_string, + self.acc_path, + ] + gmm_global_acc_proc = subprocess.Popen( + command, + stderr=subprocess.PIPE, + env=os.environ, + encoding="utf8", + ) + for line in gmm_global_acc_proc.stderr: + log_file.write(line) + log_file.flush() + m = self.progress_pattern.match(line) + if m: + utt_id = int(m.group("file").split("-")[-1]) + yield utt_id + self.check_call(gmm_global_acc_proc) + + +class AccIvectorStatsFunction(KaldiFunction): + """ + Multiprocessing function that accumulates stats for ivector training. + + See Also + -------- + :meth:`.IvectorTrainer.acc_ivector_stats` + Main function that calls this function in parallel + :meth:`.IvectorTrainer.acc_ivector_stats_arguments` + Job method for generating arguments for this function + :kaldi_src:`subsample-feats` + Relevant Kaldi binary + :kaldi_src:`ivector-extractor-acc-stats` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.ivector.trainer.AccIvectorStatsArguments` + Arguments for the function + """ + + progress_pattern = re.compile(r"VLOG.* Per frame, auxf is: weight.*") + + def __init__(self, args: AccIvectorStatsArguments): + super().__init__(args) + self.feature_options = args.feature_options + self.ivector_options = args.ivector_options + self.ie_path = args.ie_path + self.post_path = args.post_path + self.acc_path = args.acc_path + + def _run(self) -> typing.Generator[None]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True)) + .filter(Job.id == self.job_name) + .first() + ) + feature_string = job.construct_online_feature_proc_string() + acc_stats_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-extractor-acc-stats"), + "--verbose=4", + self.ie_path, + feature_string, + f"ark,s,cs:{self.post_path}", + self.acc_path, + ], + stderr=subprocess.PIPE, + env=os.environ, + encoding="utf8", + ) + for line in acc_stats_proc.stderr: + m = self.progress_pattern.match(line) + if m: + yield 1 + continue + elif "VLOG" in line: + continue + log_file.write(line) + log_file.flush() + self.check_call(acc_stats_proc) diff --git a/montreal_forced_aligner/ivector/trainer.py b/montreal_forced_aligner/ivector/trainer.py index b38d6f40..3ca28d3f 100644 --- a/montreal_forced_aligner/ivector/trainer.py +++ b/montreal_forced_aligner/ivector/trainer.py @@ -1,50 +1,53 @@ """Class definition for TrainableIvectorExtractor""" from __future__ import annotations -import multiprocessing as mp +import logging import os -import queue +import re import shutil import subprocess import time import typing -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple + +import tqdm from montreal_forced_aligner.abc import MetaDict, ModelExporterMixin, TopLevelMfaWorker from montreal_forced_aligner.acoustic_modeling.base import AcousticModelTrainingMixin +from montreal_forced_aligner.config import GLOBAL_CONFIG, PLDA_DIMENSION from montreal_forced_aligner.corpus.features import IvectorConfigMixin from montreal_forced_aligner.corpus.ivector_corpus import IvectorCorpusMixin -from montreal_forced_aligner.data import MfaArguments +from montreal_forced_aligner.data import WorkflowType +from montreal_forced_aligner.db import CorpusWorkflow from montreal_forced_aligner.exceptions import ConfigError, KaldiProcessingError from montreal_forced_aligner.helper import load_configuration, mfa_open +from montreal_forced_aligner.ivector.multiprocessing import ( + AccGlobalStatsArguments, + AccGlobalStatsFunction, + AccIvectorStatsArguments, + AccIvectorStatsFunction, + GaussToPostArguments, + GaussToPostFunction, + GmmGselectArguments, + GmmGselectFunction, +) from montreal_forced_aligner.models import IvectorExtractorModel from montreal_forced_aligner.utils import ( - KaldiFunction, - KaldiProcessWorker, - Stopped, log_kaldi_errors, parse_logs, + run_kaldi_function, thirdparty_binary, ) -if TYPE_CHECKING: - from argparse import Namespace - __all__ = [ "TrainableIvectorExtractor", "DubmTrainer", "IvectorTrainer", "IvectorModelTrainingMixin", - "GmmGselectFunction", - "GmmGselectArguments", - "GaussToPostFunction", - "GaussToPostArguments", - "AccGlobalStatsFunction", - "AccGlobalStatsArguments", - "AccIvectorStatsFunction", - "AccIvectorStatsArguments", ] +logger = logging.getLogger("mfa") + class IvectorModelTrainingMixin(AcousticModelTrainingMixin): """ @@ -71,6 +74,10 @@ def meta(self) -> MetaDict: } return data + def compute_calculated_properties(self) -> None: + """Not implemented""" + pass + def export_model(self, output_model_path: str) -> None: """ Output IvectorExtractor model @@ -91,298 +98,6 @@ def export_model(self, output_model_path: str) -> None: ivector_extractor.dump(basename) -class GmmGselectArguments(MfaArguments): - """Arguments for :func:`~montreal_forced_aligner.ivector.trainer.GmmGselectFunction`""" - - feature_string: str - ivector_options: MetaDict - dubm_model: str - gselect_path: str - - -class AccGlobalStatsArguments(MfaArguments): - """Arguments for :func:`~montreal_forced_aligner.ivector.trainer.AccGlobalStatsFunction`""" - - feature_string: str - ivector_options: MetaDict - gselect_path: str - acc_path: str - dubm_model: str - - -class GaussToPostArguments(MfaArguments): - """Arguments for :func:`~montreal_forced_aligner.ivector.trainer.GaussToPostFunction`""" - - feature_string: str - ivector_options: MetaDict - post_path: str - dubm_model: str - - -class AccIvectorStatsArguments(MfaArguments): - """Arguments for :func:`~montreal_forced_aligner.ivector.trainer.AccIvectorStatsFunction`""" - - feature_string: str - ivector_options: MetaDict - ie_path: str - post_path: str - acc_init_path: str - - -class GmmGselectFunction(KaldiFunction): - """ - Multiprocessing function for selecting GMM indices. - - See Also - -------- - :meth:`.DubmTrainer.gmm_gselect` - Main function that calls this function in parallel - :meth:`.DubmTrainer.gmm_gselect_arguments` - Job method for generating arguments for this function - :kaldi_src:`subsample-feats` - Relevant Kaldi binary - :kaldi_src:`gmm-gselect` - Relevant Kaldi binary - - Parameters - ---------- - args: :class:`~montreal_forced_aligner.ivector.trainer.GmmGselectArguments` - Arguments for the function - """ - - def __init__(self, args: GmmGselectArguments): - super().__init__(args) - self.feature_string = args.feature_string - self.ivector_options = args.ivector_options - self.dubm_model = args.dubm_model - self.gselect_path = args.gselect_path - - def _run(self) -> typing.Generator[None]: - """Run the function""" - with mfa_open(self.log_path, "w") as log_file: - subsample_feats_proc = subprocess.Popen( - [ - thirdparty_binary("subsample-feats"), - f"--n={self.ivector_options['subsample']}", - self.feature_string, - "ark:-", - ], - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - - gselect_proc = subprocess.Popen( - [ - thirdparty_binary("gmm-gselect"), - f"--n={self.ivector_options['num_gselect']}", - self.dubm_model, - "ark:-", - f"ark:{self.gselect_path}", - ], - stdin=subsample_feats_proc.stdout, - stderr=log_file, - env=os.environ, - ) - gselect_proc.communicate() - yield None - - -class GaussToPostFunction(KaldiFunction): - """ - Multiprocessing function to get posteriors during UBM training. - - See Also - -------- - :meth:`.IvectorTrainer.gauss_to_post` - Main function that calls this function in parallel - :meth:`.IvectorTrainer.gauss_to_post_arguments` - Job method for generating arguments for this function - :kaldi_src:`subsample-feats` - Relevant Kaldi binary - :kaldi_src:`gmm-global-get-post` - Relevant Kaldi binary - :kaldi_src:`scale-post` - Relevant Kaldi binary - - Parameters - ---------- - args: :class:`~montreal_forced_aligner.ivector.trainer.GaussToPostArguments` - Arguments for the function - """ - - def __init__(self, args: GaussToPostArguments): - super().__init__(args) - self.feature_string = args.feature_string - self.ivector_options = args.ivector_options - self.dubm_model = args.dubm_model - self.post_path = args.post_path - - def _run(self) -> typing.Generator[None]: - """Run the function""" - modified_posterior_scale = ( - self.ivector_options["posterior_scale"] * self.ivector_options["subsample"] - ) - with mfa_open(self.log_path, "w") as log_file: - subsample_feats_proc = subprocess.Popen( - [ - thirdparty_binary("subsample-feats"), - f"--n={self.ivector_options['subsample']}", - self.feature_string, - "ark:-", - ], - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - gmm_global_get_post_proc = subprocess.Popen( - [ - thirdparty_binary("gmm-global-get-post"), - f"--n={self.ivector_options['num_gselect']}", - f"--min-post={self.ivector_options['min_post']}", - self.dubm_model, - "ark:-", - "ark:-", - ], - stdout=subprocess.PIPE, - stdin=subsample_feats_proc.stdout, - stderr=log_file, - env=os.environ, - ) - scale_post_proc = subprocess.Popen( - [ - thirdparty_binary("scale-post"), - "ark:-", - str(modified_posterior_scale), - f"ark:{self.post_path}", - ], - stdin=gmm_global_get_post_proc.stdout, - stderr=log_file, - env=os.environ, - ) - scale_post_proc.communicate() - yield None - - -class AccGlobalStatsFunction(KaldiFunction): - """ - Multiprocessing function for accumulating global model stats. - - See Also - -------- - :meth:`.DubmTrainer.acc_global_stats` - Main function that calls this function in parallel - :meth:`.DubmTrainer.acc_global_stats_arguments` - Job method for generating arguments for this function - :kaldi_src:`subsample-feats` - Relevant Kaldi binary - :kaldi_src:`gmm-global-acc-stats` - Relevant Kaldi binary - - Parameters - ---------- - args: :class:`~montreal_forced_aligner.ivector.trainer.AccGlobalStatsArguments` - Arguments for the function - """ - - def __init__(self, args: AccGlobalStatsArguments): - super().__init__(args) - self.feature_string = args.feature_string - self.ivector_options = args.ivector_options - self.dubm_model = args.dubm_model - self.gselect_path = args.gselect_path - self.acc_path = args.acc_path - - def _run(self) -> typing.Generator[None]: - """Run the function""" - with mfa_open(self.log_path, "w") as log_file: - subsample_feats_proc = subprocess.Popen( - [ - thirdparty_binary("subsample-feats"), - f"--n={self.ivector_options['subsample']}", - self.feature_string, - "ark:-", - ], - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - gmm_global_acc_proc = subprocess.Popen( - [ - thirdparty_binary("gmm-global-acc-stats"), - f"--gselect=ark:{self.gselect_path}", - self.dubm_model, - "ark:-", - self.acc_path, - ], - stderr=log_file, - stdin=subsample_feats_proc.stdout, - env=os.environ, - ) - gmm_global_acc_proc.communicate() - yield None - - -class AccIvectorStatsFunction(KaldiFunction): - """ - Multiprocessing function that accumulates stats for ivector training. - - See Also - -------- - :meth:`.IvectorTrainer.acc_ivector_stats` - Main function that calls this function in parallel - :meth:`.IvectorTrainer.acc_ivector_stats_arguments` - Job method for generating arguments for this function - :kaldi_src:`subsample-feats` - Relevant Kaldi binary - :kaldi_src:`ivector-extractor-acc-stats` - Relevant Kaldi binary - - Parameters - ---------- - args: :class:`~montreal_forced_aligner.ivector.trainer.AccIvectorStatsArguments` - Arguments for the function - """ - - def __init__(self, args: AccIvectorStatsArguments): - super().__init__(args) - self.feature_string = args.feature_string - self.ivector_options = args.ivector_options - self.ie_path = args.ie_path - self.post_path = args.post_path - self.acc_init_path = args.acc_init_path - - def _run(self) -> typing.Generator[None]: - """Run the function""" - with mfa_open(self.log_path, "w") as log_file: - subsample_feats_proc = subprocess.Popen( - [ - thirdparty_binary("subsample-feats"), - f"--n={self.ivector_options['subsample']}", - self.feature_string, - "ark:-", - ], - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - acc_stats_proc = subprocess.Popen( - [ - thirdparty_binary("ivector-extractor-acc-stats"), - "--num-threads=1", - self.ie_path, - "ark:-", - f"ark:{self.post_path}", - self.acc_init_path, - ], - stdin=subsample_feats_proc.stdout, - stderr=log_file, - env=os.environ, - ) - acc_stats_proc.communicate() - yield None - - class DubmTrainer(IvectorModelTrainingMixin): """ Trainer for diagonal universal background models @@ -438,10 +153,7 @@ def __init__( self.initial_gaussian_proportion = initial_gaussian_proportion self.min_gaussian_weight = min_gaussian_weight self.remove_low_count_gaussians = remove_low_count_gaussians - - def compute_calculated_properties(self) -> None: - """Not implemented""" - pass + self.use_alignment_features = False @property def train_type(self) -> str: @@ -462,20 +174,20 @@ def gmm_gselect_arguments(self) -> List[GmmGselectArguments]: list[:class:`~montreal_forced_aligner.ivector.trainer.GmmGselectArguments`] Arguments for processing """ - feat_strings = self.construct_feature_proc_strings() - return [ - GmmGselectArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"gmm_gselect.{j.name}.log"), - feat_strings[j.name][None], - self.dubm_options, - self.model_path, - j.construct_path(self.working_directory, "gselect", "ark"), + arguments = [] + for j in self.jobs: + arguments.append( + GmmGselectArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"gmm_gselect.{j.id}.log"), + self.feature_options, + self.dubm_options, + self.model_path, + j.construct_path(self.working_directory, "gselect", "ark"), + ) ) - for j in self.jobs - if j.has_data - ] + return arguments def acc_global_stats_arguments( self, @@ -489,24 +201,24 @@ def acc_global_stats_arguments( list[:class:`~montreal_forced_aligner.ivector.trainer.AccGlobalStatsArguments`] Arguments for processing """ - feat_strings = self.construct_feature_proc_strings() - return [ - AccGlobalStatsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join( - self.working_log_directory, - f"acc_global_stats.{self.iteration}.{j.name}.log", - ), - feat_strings[j.name][None], - self.dubm_options, - j.construct_path(self.working_directory, "gselect", "ark"), - j.construct_path(self.working_directory, f"global.{self.iteration}", "acc"), - self.model_path, + arguments = [] + for j in self.jobs: + arguments.append( + AccGlobalStatsArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join( + self.working_log_directory, + f"acc_global_stats.{self.iteration}.{j.id}.log", + ), + self.feature_options, + self.dubm_options, + j.construct_path(self.working_directory, "gselect", "ark"), + j.construct_path(self.working_directory, f"global.{self.iteration}", "acc"), + self.model_path, + ) ) - for j in self.jobs - if j.has_data - ] + return arguments def gmm_gselect(self) -> None: """ @@ -523,86 +235,70 @@ def gmm_gselect(self) -> None: """ begin = time.time() - self.log_info("Selecting gaussians...") + logger.info("Selecting gaussians...") arguments = self.gmm_gselect_arguments() + with tqdm.tqdm( + total=int(self.num_current_utterances / 10), disable=GLOBAL_CONFIG.quiet + ) as pbar: + for _ in run_kaldi_function(GmmGselectFunction, arguments, pbar.update): + pass - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(arguments): - function = GmmGselectFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except queue.Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v - - else: - self.log_debug("Not using multiprocessing...") - for args in arguments: - function = GmmGselectFunction(args) - for _ in function.run(): - pass - - self.log_debug(f"Gaussian selection took {time.time() - begin}") + logger.debug(f"Gaussian selection took {time.time() - begin:.3f} seconds") def _trainer_initialization(self, initial_alignment_directory: Optional[str] = None) -> None: """DUBM training initialization""" - # Initialize model from E-M in memory - log_directory = os.path.join(self.working_directory, "log") - if initial_alignment_directory and os.path.exists(initial_alignment_directory): - jobs = self.align_arguments() - for j in jobs: - for p in j.ali_paths.values(): - shutil.copyfile( - p.replace(self.working_directory, initial_alignment_directory), p - ) - shutil.copyfile( - os.path.join(initial_alignment_directory, "final.mdl"), - os.path.join(self.working_directory, "final.mdl"), - ) - num_gauss_init = int(self.initial_gaussian_proportion * int(self.num_gaussians)) - log_path = os.path.join(log_directory, "gmm_init.log") - feature_string = self.construct_base_feature_string(all_feats=True) - self.iteration = 1 - with mfa_open(log_path, "w") as log_file: - gmm_init_proc = subprocess.Popen( - [ - thirdparty_binary("gmm-global-init-from-feats"), - f"--num-threads={self.worker.num_jobs}", - f"--num-frames={self.num_frames}", - f"--num_gauss={self.num_gaussians}", - f"--num_gauss_init={num_gauss_init}", - f"--num_iters={self.num_iterations_init}", - feature_string, - self.model_path, - ], - stderr=log_file, + log_path = os.path.join(self.working_log_directory, "gmm_init.log") + with self.session() as session, mfa_open(log_path, "w") as log_file: + alignment_workflow: CorpusWorkflow = ( + session.query(CorpusWorkflow) + .filter(CorpusWorkflow.workflow_type == WorkflowType.alignment) + .first() ) - gmm_init_proc.communicate() - # Store Gaussian selection indices on disk - self.gmm_gselect() - parse_logs(log_directory) + num_gauss_init = int(self.initial_gaussian_proportion * int(self.num_gaussians)) + self.iteration = 1 + if self.use_alignment_features and alignment_workflow is not None: + model_path = os.path.join(alignment_workflow.working_directory, "final.mdl") + occs_path = os.path.join(alignment_workflow.working_directory, "final.occs") + gmm_init_proc = subprocess.Popen( + [ + thirdparty_binary("init-ubm"), + "--fullcov=false", + "--intermediate-num-gauss=2000", + f"--num-frames={self.num_frames}", + f"--ubm-num-gauss={self.num_gaussians}", + model_path, + occs_path, + self.model_path, + ], + stderr=log_file, + ) + gmm_init_proc.communicate() + else: + job = self.jobs[0] + feature_string = job.construct_online_feature_proc_string() + feature_string = feature_string.replace(f".{job.id}.scp", ".scp") + feature_string = feature_string.replace( + job.corpus.current_subset_directory, job.corpus.data_directory + ) + gmm_init_proc = subprocess.Popen( + [ + thirdparty_binary("gmm-global-init-from-feats"), + "--verbose=4", + f"--num-threads={GLOBAL_CONFIG.num_jobs}", + f"--num-frames={self.num_frames}", + f"--num_gauss={self.num_gaussians}", + f"--num_gauss_init={num_gauss_init}", + f"--num_iters={self.num_iterations_init}", + feature_string, + self.model_path, + ], + stderr=log_file, + ) + gmm_init_proc.communicate() + + # Store Gaussian selection indices on disk + self.gmm_gselect() + parse_logs(self.working_log_directory) def acc_global_stats(self) -> None: """ @@ -621,48 +317,14 @@ def acc_global_stats(self) -> None: """ begin = time.time() - self.log_info("Accumulating global stats...") + logger.info("Accumulating global stats...") arguments = self.acc_global_stats_arguments() - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(arguments): - function = AccGlobalStatsFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except queue.Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v - - else: - self.log_debug("Not using multiprocessing...") - for args in arguments: - function = AccGlobalStatsFunction(args) - for _ in function.run(): - pass + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + for _ in run_kaldi_function(AccGlobalStatsFunction, arguments, pbar.update): + pass - self.log_debug(f"Accumulating stats took {time.time() - begin}") + logger.debug(f"Accumulating stats took {time.time() - begin:.3f} seconds") # Don't remove low-count Gaussians till the last tier, # or gselect info won't be valid anymore @@ -696,7 +358,7 @@ def acc_global_stats(self) -> None: ) gmm_global_est_proc.communicate() # Clean up - if not self.debug: + if not GLOBAL_CONFIG.debug: for p in acc_files: os.remove(p) @@ -720,8 +382,12 @@ def finalize_training(self) -> None: os.path.join(self.working_directory, f"{self.num_iterations+1}.dubm"), final_dubm_path, ) + # Update VAD with dubm likelihoods self.export_model(self.exported_model_path) - self.training_complete = True + wf = self.worker.current_workflow + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update({"done": True}) + session.commit() @property def model_path(self) -> str: @@ -767,10 +433,6 @@ def __init__( self.num_iterations = num_iterations self.gaussian_min_count = gaussian_min_count - def compute_calculated_properties(self) -> None: - """Not implemented""" - pass - @property def exported_model_path(self) -> str: """Temporary directory path that trainer will save ivector extractor model""" @@ -785,49 +447,49 @@ def acc_ivector_stats_arguments(self) -> List[AccIvectorStatsArguments]: list[:class:`~montreal_forced_aligner.ivector.trainer.AccIvectorStatsArguments`] Arguments for processing """ - feat_strings = self.construct_feature_proc_strings() - arguments = [ - AccIvectorStatsArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"ivector_acc.{j.name}.log"), - feat_strings[j.name][None], - self.ivector_options, - self.ie_path, - j.construct_path(self.working_directory, "post", "ark"), - j.construct_path(self.working_directory, "ivector", "acc"), + arguments = [] + for j in self.jobs: + arguments.append( + AccIvectorStatsArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join( + self.working_log_directory, f"ivector_acc.{self.iteration}.{j.id}.log" + ), + self.feature_options, + self.ivector_options, + self.ie_path, + j.construct_path(self.working_directory, "post", "ark"), + j.construct_path(self.working_directory, "ivector", "acc"), + ) ) - for j in self.jobs - if j.has_data - ] - return arguments def _trainer_initialization(self) -> None: """Ivector extractor training initialization""" self.iteration = 1 - self.training_complete = False # Initialize job_name-vector extractor log_directory = os.path.join(self.working_directory, "log") log_path = os.path.join(log_directory, "init.log") diag_ubm_path = os.path.join(self.working_directory, "final.dubm") full_ubm_path = os.path.join(self.working_directory, "final.ubm") - with mfa_open(log_path, "w") as log_file: - subprocess.call( - [thirdparty_binary("gmm-global-to-fgmm"), diag_ubm_path, full_ubm_path], - stderr=log_file, - ) - subprocess.call( - [ - thirdparty_binary("ivector-extractor-init"), - f"--ivector-dim={self.ivector_dimension}", - "--use-weights=false", - full_ubm_path, - self.ie_path, - ], - stderr=log_file, - ) + if not os.path.exists(self.ie_path): + with mfa_open(log_path, "w") as log_file: + subprocess.check_call( + [thirdparty_binary("gmm-global-to-fgmm"), diag_ubm_path, full_ubm_path], + stderr=log_file, + ) + subprocess.check_call( + [ + thirdparty_binary("ivector-extractor-init"), + f"--ivector-dim={self.ivector_dimension}", + "--use-weights=false", + full_ubm_path, + self.ie_path, + ], + stderr=log_file, + ) # Do Gaussian selection and posterior extraction self.gauss_to_post() @@ -842,20 +504,20 @@ def gauss_to_post_arguments(self) -> List[GaussToPostArguments]: list[:class:`~montreal_forced_aligner.ivector.trainer.GaussToPostArguments`] Arguments for processing """ - feat_strings = self.construct_feature_proc_strings() - return [ - GaussToPostArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"gauss_to_post.{j.name}.log"), - feat_strings[j.name][None], - self.ivector_options, - j.construct_path(self.working_directory, "post", "ark"), - self.dubm_path, + arguments = [] + for j in self.jobs: + arguments.append( + GaussToPostArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"gauss_to_post.{j.id}.log"), + self.feature_options, + self.ivector_options, + j.construct_path(self.working_directory, "post", "ark"), + self.dubm_path, + ) ) - for j in self.jobs - if j.has_data - ] + return arguments def gauss_to_post(self) -> None: """ @@ -871,48 +533,14 @@ def gauss_to_post(self) -> None: Reference Kaldi script """ begin = time.time() - self.log_info("Extracting posteriors...") + logger.info("Extracting posteriors...") arguments = self.gauss_to_post_arguments() - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(arguments): - function = GaussToPostFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if stopped.stop_check(): - continue - except queue.Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - if isinstance(result, KaldiProcessingError): - error_dict[result.job_name] = result - continue - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v - - else: - self.log_debug("Not using multiprocessing...") - for args in arguments: - function = GaussToPostFunction(args) - for _ in function.run(): - pass + with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + for _ in run_kaldi_function(GaussToPostFunction, arguments, pbar.update): + pass - self.log_debug(f"Extracting posteriors took {time.time() - begin}") + logger.debug(f"Extracting posteriors took {time.time() - begin:.3f} seconds") @property def train_type(self) -> str: @@ -978,55 +606,21 @@ def acc_ivector_stats(self) -> None: """ begin = time.time() - self.log_info("Accumulating ivector stats...") + logger.info("Accumulating ivector stats...") arguments = self.acc_ivector_stats_arguments() - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(arguments): - function = AccIvectorStatsFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if stopped.stop_check(): - continue - except queue.Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - if isinstance(result, KaldiProcessingError): - error_dict[result.job_name] = result - continue - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v + with tqdm.tqdm(total=self.worker.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + for _ in run_kaldi_function(AccIvectorStatsFunction, arguments, pbar.update): + pass - else: - self.log_debug("Not using multiprocessing...") - for args in arguments: - function = AccIvectorStatsFunction(args) - for _ in function.run(): - pass - - self.log_debug(f"Accumulating stats took {time.time() - begin}") + logger.debug(f"Accumulating stats took {time.time() - begin:.3f} seconds") log_path = os.path.join(self.working_log_directory, f"sum_acc.{self.iteration}.log") acc_path = os.path.join(self.working_directory, f"acc.{self.iteration}") with mfa_open(log_path, "w") as log_file: accinits = [] for j in arguments: - accinits.append(j.acc_init_path) + accinits.append(j.acc_path) sum_accs_proc = subprocess.Popen( [thirdparty_binary("ivector-extractor-sum-accs"), "--parallel=true"] + accinits @@ -1038,7 +632,8 @@ def acc_ivector_stats(self) -> None: sum_accs_proc.communicate() # clean up for p in accinits: - os.remove(p) + if os.path.exists(p): + os.remove(p) # Est extractor log_path = os.path.join(self.working_log_directory, f"update.{self.iteration}.log") with mfa_open(log_path, "w") as log_file: @@ -1051,21 +646,38 @@ def acc_ivector_stats(self) -> None: os.path.join(self.working_directory, f"acc.{self.iteration}"), self.next_ie_path, ], - stderr=log_file, + stderr=subprocess.PIPE, env=os.environ, ) - extractor_est_proc.communicate() + iteration_improvement = None + explained_variance = None + for line in extractor_est_proc.stderr: + line = line.decode("utf8") + log_file.write(line) + log_file.flush() + m = re.match( + r"LOG.*Overall objective-function improvement per frame was (?P[0-9.]+)", + line, + ) + if m: + iteration_improvement = float(m.group("improvement")) + m = re.match( + r"LOG.*variance explained by the iVectors is (?P[0-9.]+)\.", line + ) + if m: + explained_variance = float(m.group("variance")) + extractor_est_proc.wait() + logger.debug( + f"For iteration {self.iteration}, objective-function improvement was {iteration_improvement} per frame and variance explained by ivectors was {explained_variance}." + ) def train_iteration(self) -> None: """ Run an iteration of training """ - if os.path.exists(self.next_ie_path): - self.iteration += 1 - return - # Accumulate stats and sum - self.acc_ivector_stats() - + if not os.path.exists(self.next_ie_path): + # Accumulate stats and sum + self.acc_ivector_stats() self.iteration += 1 def finalize_training(self) -> None: @@ -1077,7 +689,81 @@ def finalize_training(self) -> None: os.path.join(self.working_directory, f"{self.num_iterations}.ie"), os.path.join(self.working_directory, "final.ie"), ) - self.training_complete = True + self.export_model(self.exported_model_path) + wf = self.worker.current_workflow + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update({"done": True}) + session.commit() + + +class PldaTrainer(IvectorTrainer): + """ + Trainer for a PLDA models + + """ + + worker: TrainableIvectorExtractor + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def _trainer_initialization(self) -> None: + """No initialization""" + pass + + def compute_lda(self): + + lda_path = os.path.join(self.working_directory, "ivector_lda.mat") + log_path = os.path.join(self.working_log_directory, "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: + normalize_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-normalize-length"), + f"scp:{self.worker.utterance_ivector_path}", + "ark:-", + ], + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + lda_compute_proc = subprocess.Popen( + [ + thirdparty_binary("ivector-compute-lda"), + f"--dim={PLDA_DIMENSION}", + "--total-covariance-factor=0.1", + "ark:-", + f"ark:{utt2spk_path}", + lda_path, + ], + stdin=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + for line in normalize_proc.stdout: + lda_compute_proc.stdin.write(line) + lda_compute_proc.stdin.flush() + pbar.update(1) + + lda_compute_proc.stdin.close() + lda_compute_proc.wait() + assert os.path.exists(lda_path) + + def train(self): + """Train PLDA""" + self.compute_lda() + self.worker.compute_plda() + self.worker.compute_speaker_ivectors() + os.rename( + os.path.join(self.working_directory, "current_speaker_ivectors.ark"), + os.path.join(self.working_directory, "speaker_ivectors.ark"), + ) + os.rename( + os.path.join(self.working_directory, "current_num_utts.ark"), + os.path.join(self.working_directory, "num_utts.ark"), + ) class TrainableIvectorExtractor(IvectorCorpusMixin, TopLevelMfaWorker, ModelExporterMixin): @@ -1105,7 +791,7 @@ def __init__(self, training_configuration: List[Tuple[str, Dict[str, Any]]] = No for k, v in kwargs.items() if not k.endswith("_directory") and not k.endswith("_path") - and k not in ["clean", "num_jobs", "speaker_characters"] + and k not in ["speaker_characters"] } self.final_identifier = None super().__init__(**kwargs) @@ -1116,21 +802,37 @@ def __init__(self, training_configuration: List[Tuple[str, Dict[str, Any]]] = No training_configuration = [("dubm", {}), ("ivector", {})] for k, v in training_configuration: self.add_config(k, v) + self.uses_voiced = True def setup(self) -> None: """Setup ivector extractor training""" + TopLevelMfaWorker.setup(self) if self.initialized: return - self.check_previous_run() try: - self.load_corpus() + super().load_corpus() + with self.session() as session: + workflows: typing.Dict[str, CorpusWorkflow] = { + x.name: x for x in session.query(CorpusWorkflow) + } + for i, (identifier, config) in enumerate(self.training_configs.items()): + if isinstance(config, str): + continue + if identifier not in workflows: + self.create_new_current_workflow( + WorkflowType.acoustic_training, name=identifier + ) + else: + wf = workflows[identifier] + if wf.dirty and not wf.done: + shutil.rmtree(wf.working_directory, ignore_errors=True) + if i == 0: + wf.current = True + session.commit() except Exception as e: if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise self.initialized = True @@ -1141,7 +843,7 @@ def add_config(self, train_type: str, params: MetaDict) -> None: Parameters ---------- train_type: str - Type of trainer to add, one of "dubm" or "ivector" + Type of trainer to add, one of "dubm", "ivector", or "plda" params: dict[str, Any] Parameters to initialize trainer @@ -1163,21 +865,23 @@ def add_config(self, train_type: str, params: MetaDict) -> None: config = DubmTrainer(identifier=identifier, worker=self, **p) elif train_type == "ivector": config = IvectorTrainer(identifier=identifier, worker=self, **p) + elif train_type == "plda": + config = PldaTrainer(identifier=identifier, worker=self, **p) else: raise ConfigError(f"Invalid training type '{train_type}' in config file") self.training_configs[identifier] = config - @property - def workflow_identifier(self) -> str: - """Ivector training identifier""" - return "train_ivector" - @property def meta(self) -> MetaDict: """Metadata about the final round of training""" return self.training_configs[self.final_identifier].meta + @property + def model_path(self) -> str: + """Current model path""" + return self.training_configs[self.current_workflow.name].model_path + def train(self) -> None: """ Run through the training configurations to produce a final ivector extractor model @@ -1189,11 +893,12 @@ def train(self) -> None: self.current_subset = trainer.subset if previous is not None: self.current_model = IvectorExtractorModel(previous.exported_model_path) - os.makedirs(trainer.working_directory, exist_ok=True) + os.makedirs(trainer.working_log_directory, exist_ok=True) self.current_model.export_model(trainer.working_directory) + self.set_current_workflow(trainer.identifier) trainer.train() previous = trainer - self.log_info(f"Completed training in {time.time()-begin} seconds!") + logger.info(f"Completed training in {time.time()-begin} seconds!") def export_model(self, output_model_path: str) -> None: """ @@ -1206,14 +911,14 @@ def export_model(self, output_model_path: str) -> None: """ self.training_configs[self.final_identifier].export_model(output_model_path) - self.log_info(f"Saved model to {output_model_path}") + logger.info(f"Saved model to {output_model_path}") @classmethod def parse_parameters( cls, config_path: Optional[str] = None, - args: Optional[Namespace] = None, - unknown_args: Optional[List[str]] = None, + args: Optional[Dict[str, Any]] = None, + unknown_args: Optional[typing.Iterable[str]] = None, ) -> MetaDict: """ Parse configuration parameters from a config file and command line arguments @@ -1222,10 +927,10 @@ def parse_parameters( ---------- config_path: str, optional Path to yaml configuration file - args: :class:`~argparse.Namespace`, optional - Arguments parsed by argparse - unknown_args: list[str], optional - List of unknown arguments from argparse + args: dict[str, Any] + Parsed arguments + unknown_args: list[str] + Optional list of arguments that were not parsed Returns ------- @@ -1259,8 +964,8 @@ def parse_parameters( use_default = False if use_default: # default training configuration training_params.append(("dubm", {})) - # training_params.append(("ubm", {})) training_params.append(("ivector", {})) + training_params.append(("plda", {})) if training_params: if training_params[0][0] != "dubm": raise ConfigError("The first round of training must be dubm.") diff --git a/montreal_forced_aligner/language_modeling/multiprocessing.py b/montreal_forced_aligner/language_modeling/multiprocessing.py new file mode 100644 index 00000000..0836cc07 --- /dev/null +++ b/montreal_forced_aligner/language_modeling/multiprocessing.py @@ -0,0 +1,427 @@ +"""Multiprocessing functions for training language models""" +from __future__ import annotations + +import os +import subprocess +import typing + +import sqlalchemy +from sqlalchemy.orm import Session, joinedload, subqueryload + +from montreal_forced_aligner.data import MfaArguments, WordType +from montreal_forced_aligner.db import Job, Phone, PhoneInterval, Speaker, Utterance, Word +from montreal_forced_aligner.helper import mfa_open +from montreal_forced_aligner.transcription.multiprocessing import ( + compose_clg, + compose_hclg, + compose_lg, +) +from montreal_forced_aligner.utils import KaldiFunction, thirdparty_binary + +if typing.TYPE_CHECKING: + from dataclasses import dataclass + + from montreal_forced_aligner.abc import MetaDict + +else: + from dataclassy import dataclass + + +@dataclass +class TrainSpeakerLmArguments(MfaArguments): + """ + Arguments for :class:`~montreal_forced_aligner.language_modeling.multiprocessing.TrainSpeakerLmFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + model_directory: str + Path to model directory + word_symbols_paths: dict[int, str] + Per dictionary words symbol table paths + speaker_mapping: dict[int, str] + Mapping of dictionaries to speakers + speaker_paths: dict[int, str] + Per speaker output LM paths + oov_word: str + OOV word + order: int + Ngram order of the language models + method: str + Ngram smoothing method + target_num_ngrams: int + Target number of ngrams + """ + + model_path: str + order: int + method: str + target_num_ngrams: int + hclg_options: MetaDict + + +@dataclass +class TrainLmArguments(MfaArguments): + """ + Arguments for :class:`~montreal_forced_aligner.language_modeling.multiprocessing.TrainSpeakerLmFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + model_directory: str + Path to model directory + word_symbols_paths: dict[int, str] + Per dictionary words symbol table paths + speaker_mapping: dict[int, str] + Mapping of dictionaries to speakers + speaker_paths: dict[int, str] + Per speaker output LM paths + oov_word: str + OOV word + order: int + Ngram order of the language models + method: str + Ngram smoothing method + target_num_ngrams: int + Target number of ngrams + """ + + working_directory: str + symbols_path: str + order: int + oov_word: str + + +class TrainLmFunction(KaldiFunction): + """ + Multiprocessing function to training small language models for each speaker + + See Also + -------- + :openfst_src:`farcompilestrings` + Relevant OpenFst binary + :ngram_src:`ngramcount` + Relevant OpenGrm-Ngram binary + :ngram_src:`ngrammake` + Relevant OpenGrm-Ngram binary + :ngram_src:`ngramshrink` + Relevant OpenGrm-Ngram binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.language_modeling.multiprocessing.TrainSpeakerLmArguments` + Arguments for the function + """ + + def __init__(self, args: TrainLmArguments): + super().__init__(args) + self.working_directory = args.working_directory + self.symbols_path = args.symbols_path + self.order = args.order + self.oov_word = args.oov_word + + def _run(self) -> typing.Generator[bool]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + word_query = session.query(Word.word).filter(Word.word_type == WordType.speech) + included_words = set(x[0] for x in word_query) + utterance_query = session.query(Utterance.normalized_text, Utterance.text).filter( + Utterance.job_id == self.job_name + ) + + farcompile_proc = subprocess.Popen( + [ + thirdparty_binary("farcompilestrings"), + "--token_type=symbol", + "--generate_keys=16", + "--keep_symbols", + f"--symbols={self.symbols_path}", + ], + stderr=log_file, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + env=os.environ, + ) + ngramcount_proc = subprocess.Popen( + [ + thirdparty_binary("ngramcount"), + "--round_to_int", + f"--order={self.order}", + "-", + os.path.join(self.working_directory, f"{self.job_name}.cnts"), + ], + stderr=log_file, + stdin=farcompile_proc.stdout, + env=os.environ, + ) + for (normalized_text, text) in utterance_query: + if not normalized_text: + normalized_text = text + text = " ".join( + x if x in included_words else self.oov_word for x in normalized_text.split() + ) + farcompile_proc.stdin.write(f"{text}\n".encode("utf8")) + farcompile_proc.stdin.flush() + yield 1 + farcompile_proc.stdin.close() + self.check_call(ngramcount_proc) + + +class TrainPhoneLmFunction(KaldiFunction): + """ + Multiprocessing function to training small language models for each speaker + + See Also + -------- + :openfst_src:`farcompilestrings` + Relevant OpenFst binary + :ngram_src:`ngramcount` + Relevant OpenGrm-Ngram binary + :ngram_src:`ngrammake` + Relevant OpenGrm-Ngram binary + :ngram_src:`ngramshrink` + Relevant OpenGrm-Ngram binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.language_modeling.multiprocessing.TrainSpeakerLmArguments` + Arguments for the function + """ + + def __init__(self, args: TrainLmArguments): + super().__init__(args) + self.working_directory = args.working_directory + self.symbols_path = args.symbols_path + self.order = args.order + + def _run(self) -> typing.Generator[bool]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + pronunciation_query = ( + sqlalchemy.select(Utterance.id, sqlalchemy.func.string_agg(Phone.kaldi_label, " ")) + .select_from(Utterance) + .join(Utterance.phone_intervals) + .join(PhoneInterval.phone) + .where(Utterance.job_id == self.job_name) + .group_by(Utterance.id) + ) + farcompile_proc = subprocess.Popen( + [ + thirdparty_binary("farcompilestrings"), + "--token_type=symbol", + "--generate_keys=16", + f"--symbols={self.symbols_path}", + ], + stderr=log_file, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + env=os.environ, + ) + ngramcount_proc = subprocess.Popen( + [ + thirdparty_binary("ngramcount"), + "--require_symbols=false", + "--round_to_int", + f"--order={self.order}", + "-", + os.path.join(self.working_directory, f"{self.job_name}.cnts"), + ], + stderr=log_file, + stdin=farcompile_proc.stdout, + env=os.environ, + ) + for utt_id, phones in session.execute(pronunciation_query): + farcompile_proc.stdin.write(f"{phones}\n".encode("utf8")) + farcompile_proc.stdin.flush() + yield utt_id, phones + farcompile_proc.stdin.close() + self.check_call(ngramcount_proc) + + +class TrainSpeakerLmFunction(KaldiFunction): + """ + Multiprocessing function to training small language models for each speaker + + See Also + -------- + :openfst_src:`farcompilestrings` + Relevant OpenFst binary + :ngram_src:`ngramcount` + Relevant OpenGrm-Ngram binary + :ngram_src:`ngrammake` + Relevant OpenGrm-Ngram binary + :ngram_src:`ngramshrink` + Relevant OpenGrm-Ngram binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.language_modeling.multiprocessing.TrainSpeakerLmArguments` + Arguments for the function + """ + + def __init__(self, args: TrainSpeakerLmArguments): + super().__init__(args) + self.model_path = args.model_path + self.order = args.order + self.method = args.method + self.target_num_ngrams = args.target_num_ngrams + self.hclg_options = args.hclg_options + + def _run(self) -> typing.Generator[bool]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + + for d in job.dictionaries: + dict_id = d.id + word_symbols_path = d.words_symbol_path + speakers = ( + session.query(Speaker.id) + .join(Utterance.speaker) + .filter(Utterance.job_id == job.id) + .filter(Speaker.dictionary_id == dict_id) + .distinct() + ) + for (speaker_id,) in speakers: + + hclg_path = os.path.join(d.temp_directory, f"{speaker_id}.fst") + if os.path.exists(hclg_path): + continue + utterances = ( + session.query(Utterance.normalized_text) + .filter(Utterance.speaker_id == speaker_id) + .order_by(Utterance.kaldi_id) + ) + mod_path = os.path.join(d.temp_directory, f"g.{speaker_id}.fst") + farcompile_proc = subprocess.Popen( + [ + thirdparty_binary("farcompilestrings"), + "--fst_type=compact", + f"--unknown_symbol={d.oov_word}", + f"--symbols={word_symbols_path}", + "--keep_symbols", + "--generate_keys=16", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + count_proc = subprocess.Popen( + [thirdparty_binary("ngramcount"), f"--order={self.order}"], + stdin=farcompile_proc.stdout, + stdout=subprocess.PIPE, + stderr=log_file, + ) + make_proc = subprocess.Popen( + [thirdparty_binary("ngrammake"), "--method=kneser_ney"], + stdin=count_proc.stdout, + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + shrink_proc = subprocess.Popen( + [ + thirdparty_binary("ngramshrink"), + "--method=relative_entropy", + f"--target_number_of_ngrams={self.target_num_ngrams}", + "--shrink_opt=2", + "--theta=0.001", + "-", + mod_path, + ], + stdin=make_proc.stdout, + stderr=log_file, + env=os.environ, + ) + for (text,) in utterances: + farcompile_proc.stdin.write(f"{text}\n".encode("utf8")) + farcompile_proc.stdin.flush() + farcompile_proc.stdin.close() + shrink_proc.wait() + context_width = self.hclg_options["context_width"] + central_pos = self.hclg_options["central_pos"] + path_template = os.path.join( + d.temp_directory, f"{{file_name}}.{speaker_id}.fst" + ) + lg_path = path_template.format(file_name="LG") + hclga_path = path_template.format(file_name="HCLGa") + clg_path = path_template.format(file_name=f"CLG_{context_width}_{central_pos}") + ilabels_temp = path_template.format( + file_name=f"ilabels_{context_width}_{central_pos}" + ).replace(".fst", "") + out_disambig = path_template.format( + file_name=f"disambig_ilabels_{context_width}_{central_pos}" + ).replace(".fst", ".int") + log_file.write("Generating LG.fst...") + compose_lg(d.lexicon_disambig_fst_path, mod_path, lg_path, log_file) + log_file.write("Generating CLG.fst...") + compose_clg( + d.disambiguation_symbols_int_path, + out_disambig, + context_width, + central_pos, + ilabels_temp, + lg_path, + clg_path, + log_file, + ) + log_file.write("Generating HCLGa.fst...") + compose_hclg( + self.model_path, + ilabels_temp, + self.hclg_options["transition_scale"], + clg_path, + hclga_path, + log_file, + ) + log_file.write("Generating HCLG.fst...") + self_loop_proc = subprocess.Popen( + [ + thirdparty_binary("add-self-loops"), + f"--self-loop-scale={self.hclg_options['self_loop_scale']}", + "--reorder=true", + self.model_path, + hclga_path, + ], + stderr=log_file, + stdout=subprocess.PIPE, + env=os.environ, + ) + convert_proc = subprocess.Popen( + [ + thirdparty_binary("fstconvert"), + "--v=100", + "--fst_type=const", + "-", + hclg_path, + ], + stdin=self_loop_proc.stdout, + stderr=log_file, + env=os.environ, + ) + convert_proc.communicate() + self.check_call(convert_proc) + os.remove(mod_path) + os.remove(lg_path) + os.remove(clg_path) + os.remove(hclga_path) + os.remove(ilabels_temp) + os.remove(out_disambig) + yield os.path.exists(hclg_path) diff --git a/montreal_forced_aligner/language_modeling/trainer.py b/montreal_forced_aligner/language_modeling/trainer.py index cae5a9b0..b3d8c97f 100644 --- a/montreal_forced_aligner/language_modeling/trainer.py +++ b/montreal_forced_aligner/language_modeling/trainer.py @@ -1,20 +1,33 @@ """Classes for training language models""" from __future__ import annotations +import logging +import multiprocessing as mp import os import re import subprocess -from typing import TYPE_CHECKING, Generator +import typing +from queue import Empty -from montreal_forced_aligner.abc import TopLevelMfaWorker, TrainerMixin +import sqlalchemy +import tqdm + +from montreal_forced_aligner.abc import DatabaseMixin, TopLevelMfaWorker, TrainerMixin +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.corpus.text_corpus import MfaWorker, TextCorpusMixin -from montreal_forced_aligner.db import Dictionary, Utterance +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 from montreal_forced_aligner.dictionary.multispeaker import MultispeakerDictionaryMixin from montreal_forced_aligner.helper import mfa_open +from montreal_forced_aligner.language_modeling.multiprocessing import ( + TrainLmArguments, + TrainLmFunction, +) from montreal_forced_aligner.models import LanguageModel +from montreal_forced_aligner.utils import KaldiProcessWorker, Stopped, thirdparty_binary -if TYPE_CHECKING: +if typing.TYPE_CHECKING: from montreal_forced_aligner.abc import MetaDict __all__ = [ @@ -26,6 +39,8 @@ "MfaLmDictionaryCorpusTrainer", ] +logger = logging.getLogger("mfa") + class LmTrainerMixin(DictionaryMixin, TrainerMixin, MfaWorker): """ @@ -105,7 +120,7 @@ def finalize_training(self) -> None: def prune_large_language_model(self) -> None: """Prune the large language model into small and medium versions""" - self.log_info("Pruning large ngram model to medium and small versions...") + logger.info("Pruning large ngram model to medium and small versions...") small_mod_path = self.mod_path.replace(".mod", "_small.mod") med_mod_path = self.mod_path.replace(".mod", "_med.mod") subprocess.check_call( @@ -118,10 +133,21 @@ def prune_large_language_model(self) -> None: ] ) assert os.path.exists(med_mod_path) - subprocess.check_call(["ngramprint", "--ARPA", med_mod_path, self.medium_arpa_path]) + if getattr(self, "sym_path", None): + subprocess.check_call( + [ + "ngramprint", + "--ARPA", + f"--symbols={self.sym_path}", + med_mod_path, + self.medium_arpa_path, + ] + ) + else: + subprocess.check_call(["ngramprint", "--ARPA", med_mod_path, self.medium_arpa_path]) assert os.path.exists(self.medium_arpa_path) - self.log_debug("Finished pruning medium arpa!") + logger.debug("Finished pruning medium arpa!") subprocess.check_call( [ "ngramshrink", @@ -132,11 +158,22 @@ def prune_large_language_model(self) -> None: ] ) assert os.path.exists(small_mod_path) - subprocess.check_call(["ngramprint", "--ARPA", small_mod_path, self.small_arpa_path]) + if getattr(self, "sym_path", None): + subprocess.check_call( + [ + "ngramprint", + "--ARPA", + f"--symbols={self.sym_path}", + small_mod_path, + self.small_arpa_path, + ] + ) + else: + subprocess.check_call(["ngramprint", "--ARPA", small_mod_path, self.small_arpa_path]) assert os.path.exists(self.small_arpa_path) - self.log_debug("Finished pruning small arpa!") - self.log_info("Done pruning!") + logger.debug("Finished pruning small arpa!") + logger.info("Done pruning!") def export_model(self, output_model_path: str) -> None: """ @@ -183,9 +220,8 @@ class LmCorpusTrainerMixin(LmTrainerMixin, TextCorpusMixin): For top-level parameters """ - def __init__(self, count_threshold: int = 1, **kwargs): + def __init__(self, **kwargs): super().__init__(**kwargs) - self.count_threshold = count_threshold self.large_perplexity = None self.medium_perplexity = None self.small_perplexity = None @@ -217,6 +253,19 @@ def meta(self) -> MetaDict: from ..utils import get_mfa_version + with self.session() as session: + word_count = ( + session.query(sqlalchemy.func.sum(Word.count)) + .filter(Word.word_type == WordType.speech) + .scalar() + ) + oov_count = ( + session.query(sqlalchemy.func.sum(Word.count)) + .filter(Word.word_type == WordType.oov) + .scalar() + ) + if not oov_count: + oov_count = 0 return { "architecture": "ngram", "order": self.order, @@ -224,8 +273,8 @@ def meta(self) -> MetaDict: "train_date": str(datetime.now()), "version": get_mfa_version(), "training": { - "num_words": sum(self.word_counts.values()), - "num_oovs": sum(self.oovs_found.values()), + "num_words": word_count, + "num_oovs": oov_count, }, "evaluation_training": { "large_perplexity": self.large_perplexity, @@ -242,10 +291,39 @@ def evaluate(self) -> None: small_mod_path = self.mod_path.replace(".mod", "_small.mod") med_mod_path = self.mod_path.replace(".mod", "_med.mod") - with mfa_open(log_path, "w") as log_file: + with self.session() as session, mfa_open(log_path, "w") as log_file: + word_query = session.query(Word.word).filter(Word.word_type == WordType.speech) + included_words = set(x[0] for x in word_query) + utterance_query = session.query(Utterance.normalized_text, Utterance.text) + + with open(self.far_path, "wb") as f: + farcompile_proc = subprocess.Popen( + [ + thirdparty_binary("farcompilestrings"), + "--token_type=symbol", + "--generate_keys=16", + f"--symbols={self.sym_path}", + "--keep_symbols", + ], + stderr=log_file, + stdin=subprocess.PIPE, + stdout=f, + env=os.environ, + ) + for (normalized_text, text) in utterance_query: + if not normalized_text: + normalized_text = text + text = " ".join( + x if x in included_words else self.oov_word + for x in normalized_text.split() + ) + farcompile_proc.stdin.write(f"{text}\n".encode("utf8")) + farcompile_proc.stdin.flush() + farcompile_proc.stdin.close() + farcompile_proc.wait() perplexity_proc = subprocess.Popen( [ - "ngramperplexity", + thirdparty_binary("ngramperplexity"), f"--OOV_symbol={self.oov_word}", self.mod_path, self.far_path, @@ -276,12 +354,12 @@ def evaluate(self) -> None: self.num_sentences = num_sentences self.num_words = num_words self.num_oovs = num_oovs - self.log_info(f"{num_sentences}, {num_words}, {num_oovs}") - self.log_info(f"Perplexity of large model: {perplexity}") + logger.info(f"{num_sentences}, {num_words}, {num_oovs}") + logger.info(f"Perplexity of large model: {perplexity}") perplexity_proc = subprocess.Popen( [ - "ngramperplexity", + thirdparty_binary("ngramperplexity"), f"--OOV_symbol={self.oov_word}", med_mod_path, self.far_path, @@ -298,10 +376,10 @@ def evaluate(self) -> None: if m: perplexity = float(m.group("perplexity")) self.medium_perplexity = perplexity - self.log_info(f"Perplexity of medium model: {perplexity}") + logger.info(f"Perplexity of medium model: {perplexity}") perplexity_proc = subprocess.Popen( [ - "ngramperplexity", + thirdparty_binary("ngramperplexity"), f"--OOV_symbol={self.oov_word}", small_mod_path, self.far_path, @@ -318,61 +396,92 @@ def evaluate(self) -> None: if m: perplexity = float(m.group("perplexity")) self.small_perplexity = perplexity - self.log_info(f"Perplexity of small model: {perplexity}") - - def normalized_text_iter(self, min_count: int = 1) -> Generator: - """ - Construct an iterator over the normalized texts in the corpus - - Parameters - ---------- - min_count: int - Minimum word count to include in the output, otherwise will use OOV code, defaults to 1 - - Yields - ------- - str - Normalized text - """ - unk_words = {k for k, v in self.word_counts.items() if v <= min_count} | self.specials_set - - with self.session() as session: - utterances = session.query(Utterance.normalized_text, Utterance.text) - for (normalized_text, text) in utterances: - if not normalized_text: - normalized_text = text - text = normalized_text.split() - yield " ".join(x if x not in unk_words else self.oov_word for x in text) + logger.info(f"Perplexity of small model: {perplexity}") def train_large_lm(self) -> None: """Train a large language model""" - self.log_info("Beginning training large ngram model...") - subprocess.check_call( - [ - "farcompilestrings", - "--fst_type=compact", - f"--unknown_symbol={self.oov_word}", - f"--symbols={self.sym_path}", - "--keep_symbols", - self.training_path, - self.far_path, - ] - ) - assert os.path.exists(self.far_path) - subprocess.check_call( - ["ngramcount", f"--order={self.order}", self.far_path, self.cnts_path] - ) - - assert os.path.exists(self.cnts_path) - subprocess.check_call( - ["ngrammake", f"--method={self.method}", self.cnts_path, self.mod_path] - ) - assert os.path.exists(self.mod_path) - self.log_info("Done!") - subprocess.check_call(["ngramprint", "--ARPA", self.mod_path, self.large_arpa_path]) - assert os.path.exists(self.large_arpa_path) + logger.info("Beginning training large ngram model...") + log_path = os.path.join(self.working_log_directory, "lm_training.log") + return_queue = mp.Queue() + stopped = Stopped() + error_dict = {} + procs = [] + count_paths = [] + + for j in self.jobs: + args = TrainLmArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"ngram_count.{j.id}.log"), + self.working_directory, + self.sym_path, + self.order, + self.oov_word, + ) + function = TrainLmFunction(args) + p = KaldiProcessWorker(j.id, return_queue, function, stopped) + procs.append(p) + p.start() + count_paths.append(os.path.join(self.working_directory, f"{j.id}.cnts")) + with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + while True: + try: + result = return_queue.get(timeout=1) + if isinstance(result, Exception): + error_dict[getattr(result, "job_name", 0)] = result + continue + if stopped.stop_check(): + continue + except Empty: + for proc in procs: + if not proc.finished.stop_check(): + break + else: + break + continue + pbar.update(1) + logger.info("Training model...") + with mfa_open(log_path, "w") as log_file: + merged_file = os.path.join(self.working_directory, "merged.cnts") + if len(count_paths) > 1: + ngrammerge_proc = subprocess.Popen( + [ + thirdparty_binary("ngrammerge"), + f"--ofile={merged_file}", + *count_paths, + ], + stderr=log_file, + env=os.environ, + ) + ngrammerge_proc.communicate() + else: + os.rename(count_paths[0], merged_file) + ngrammake_proc = subprocess.Popen( + [ + thirdparty_binary("ngrammake"), + "--v=2", + "--method=kneser_ney", + merged_file, + self.mod_path, + ], + stderr=log_file, + env=os.environ, + ) + ngrammake_proc.communicate() + subprocess.check_call( + [ + "ngramprint", + "--ARPA", + f"--symbols={self.sym_path}", + self.mod_path, + self.large_arpa_path, + ], + stderr=log_file, + stdout=log_file, + ) + assert os.path.exists(self.large_arpa_path) - self.log_info("Large ngram model created!") + logger.info("Large ngram model created!") def train(self) -> None: """ @@ -399,12 +508,12 @@ class LmDictionaryCorpusTrainerMixin(MultispeakerDictionaryMixin, LmCorpusTraine def sym_path(self) -> str: """Internal path to symbols file""" with self.session() as session: - default_dictionary = session.query(Dictionary).get(self._default_dictionary_id) + default_dictionary = session.get(Dictionary, self._default_dictionary_id) words_path = default_dictionary.words_symbol_path return words_path -class MfaLmArpaTrainer(LmTrainerMixin, TopLevelMfaWorker): +class MfaLmArpaTrainer(LmTrainerMixin, TopLevelMfaWorker, DatabaseMixin): """ Top-level worker to convert an existing ARPA-format language model to MFA format @@ -421,8 +530,13 @@ def __init__(self, arpa_path: str, keep_case: bool = False, **kwargs): self.keep_case = keep_case super().__init__(**kwargs) + @property + def working_directory(self) -> str: + return os.path.join(self.output_directory, self.data_source_identifier) + def setup(self) -> None: """Set up language model training""" + super().setup() os.makedirs(self.working_log_directory, exist_ok=True) with mfa_open(self.arpa_path, "r") as inf, mfa_open( self.large_arpa_path, "w", newline="" @@ -438,11 +552,6 @@ def data_directory(self) -> str: """Data directory""" return "" - @property - def workflow_identifier(self) -> str: - """Workflow identifier""" - return "train_lm_from_arpa" - @property def data_source_identifier(self) -> str: """Data source identifier""" @@ -455,7 +564,7 @@ def meta(self) -> MetaDict: def train(self) -> None: """Convert the arpa model to MFA format""" - self.log_info("Parsing large ngram model...") + logger.info("Parsing large ngram model...") with mfa_open(os.path.join(self.working_log_directory, "read.log"), "w") as log_file: subprocess.check_call( @@ -463,7 +572,7 @@ def train(self) -> None: ) assert os.path.exists(self.mod_path) - self.log_info("Large ngram model parsed!") + logger.info("Large ngram model parsed!") self.prune_large_language_model() @@ -482,26 +591,21 @@ class MfaLmDictionaryCorpusTrainer(LmDictionaryCorpusTrainerMixin, TopLevelMfaWo def setup(self) -> None: """Set up language model training""" + super().setup() if self.initialized: return + self.create_new_current_workflow(WorkflowType.language_model_training) os.makedirs(self.working_log_directory, exist_ok=True) self.dictionary_setup() self._load_corpus() + self.initialize_jobs() + self.normalize_text() self.write_lexicon_information() - with mfa_open(self.training_path, "w") as f: - for text in self.normalized_text_iter(self.count_threshold): - f.write(f"{text}\n") - self.save_oovs_found(self.working_directory) self.initialized = True - @property - def workflow_identifier(self) -> str: - """Language model trainer identifier""" - return "train_lm_corpus_dictionary" - class MfaLmCorpusTrainer(LmCorpusTrainerMixin, TopLevelMfaWorker): """ @@ -510,21 +614,19 @@ class MfaLmCorpusTrainer(LmCorpusTrainerMixin, TopLevelMfaWorker): def setup(self) -> None: """Set up language model training""" + super().setup() if self.initialized: return + self.create_new_current_workflow(WorkflowType.language_model_training) os.makedirs(self.working_log_directory, exist_ok=True) self._load_corpus() + self._create_dummy_dictionary() + self.initialize_jobs() + self.normalize_text() + with mfa_open(self.sym_path, "w") as f, self.session() as session: + words = session.query(Word.mapping_id, Word.word) + f.write(f"{self.silence_word} 0\n") + for m_id, w in words: + f.write(f"{w} {m_id}\n") - with mfa_open(self.training_path, "w") as f: - for text in self.normalized_text_iter(self.count_threshold): - f.write(f"{text}\n") - - subprocess.call( - ["ngramsymbols", f"--OOV_symbol={self.oov_word}", self.training_path, self.sym_path] - ) self.initialized = True - - @property - def workflow_identifier(self) -> str: - """Language model trainer identifier""" - return "train_lm_corpus" diff --git a/montreal_forced_aligner/models.py b/montreal_forced_aligner/models.py index 3a614847..a26f4a58 100644 --- a/montreal_forced_aligner/models.py +++ b/montreal_forced_aligner/models.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import logging import os import shutil import typing @@ -21,15 +22,13 @@ LanguageModelNotFoundError, ModelLoadError, ModelsConnectionError, - PretrainedModelNotFoundError, PronunciationAcousticMismatchError, + RemoteModelNotFoundError, ) from montreal_forced_aligner.helper import EnhancedJSONEncoder, TerminalPrinter, mfa_open -from montreal_forced_aligner.utils import configure_logger if TYPE_CHECKING: from dataclasses import dataclass - from logging import Logger from montreal_forced_aligner.abc import MetaDict from montreal_forced_aligner.dictionary.mixins import DictionaryMixin @@ -40,6 +39,8 @@ # default format for output FORMAT = "zip" +logger = logging.getLogger("mfa") + __all__ = [ "Archive", "LanguageModel", @@ -258,7 +259,7 @@ def add_meta_file(self, trainer: ModelExporterMixin) -> None: The trainer to construct the metadata from """ with mfa_open(os.path.join(self.dirname, "meta.json"), "w") as f: - json.dump(trainer.meta, f) + json.dump(trainer.meta, f, ensure_ascii=False) @classmethod def empty( @@ -337,8 +338,10 @@ class AcousticModel(Archive): files = [ "final.mdl", "final.alimdl", - "final.occs", "lda.mat", + "phone_pdf.counts", + # "rules.yaml", + "phone_lm.fst", "tree", "phones.txt", "graphemes.txt", @@ -363,7 +366,7 @@ def add_meta_file(self, trainer: ModelExporterMixin) -> None: Trainer to supply metadata information about the acoustic model """ with mfa_open(os.path.join(self.dirname, "meta.json"), "w") as f: - json.dump(trainer.meta, f) + json.dump(trainer.meta, f, ensure_ascii=False) @property def parameters(self) -> MetaDict: @@ -381,6 +384,18 @@ def parameters(self) -> MetaDict: params["final_silence_correction"] = self.meta.get("final_silence_correction", None) if "other_noise_phone" in self.meta: params["other_noise_phone"] = self.meta["other_noise_phone"] + # rules_path = os.path.join(self.dirname, "rules.yaml") + # if os.path.exists(rules_path): + # params["rules_path"] = rules_path + if ( + "dictionaries" in self.meta + and "position_dependent_phones" in self.meta["dictionaries"] + ): + params["position_dependent_phones"] = self.meta["dictionaries"][ + "position_dependent_phones" + ] + else: + params["position_dependent_phones"] = self.meta.get("position_dependent_phones", True) return params @property @@ -399,6 +414,7 @@ def meta(self) -> MetaDict: "allow_downsample": True, "allow_upsample": True, "use_pitch": False, + "use_voicing": False, "uses_cmvn": True, "uses_deltas": True, "uses_splices": False, @@ -431,6 +447,11 @@ def meta(self) -> MetaDict: self._meta["features"] = default_features if "pitch" in self._meta["features"]: self._meta["features"]["use_pitch"] = self._meta["features"].pop("pitch") + if ( + self._meta["features"].get("use_pitch", False) + and self._meta["version"] < "2.0.6" + ): + self._meta["features"]["use_delta_pitch"] = True if "phone_type" not in self._meta: self._meta["phone_type"] = "triphone" if "optional_silence_phone" not in self._meta: @@ -460,6 +481,22 @@ def meta(self) -> MetaDict: ) if self._meta["features"]["uses_splices"]: self._meta["features"]["uses_deltas"] = False + if ( + self._meta["features"].get("use_pitch", False) + and "use_voicing" not in self._meta["features"] + ): + self._meta["features"]["use_voicing"] = True + if ( + "dictionaries" in self._meta + and "position_dependent_phones" not in self._meta["dictionaries"] + ): + if self._meta["version"] < "2.0": + default_value = True + else: + default_value = False + self._meta["dictionaries"]["position_dependent_phones"] = self._meta.get( + "position_dependent_phones", default_value + ) self.parse_old_features() return self._meta @@ -525,9 +562,9 @@ def add_pronunciation_models( Base names of dictionaries to add pronunciation models """ for base_name in dictionary_base_names: - f = f"{base_name}.fst" - if os.path.exists(os.path.join(source, f)): - copyfile(os.path.join(source, f), os.path.join(self.dirname, f)) + for f in [f"{base_name}.fst", f"{base_name}_align.fst"]: + if os.path.exists(os.path.join(source, f)): + copyfile(os.path.join(source, f), os.path.join(self.dirname, f)) def export_model(self, destination: str) -> None: """ @@ -543,14 +580,9 @@ def export_model(self, destination: str) -> None: if os.path.exists(os.path.join(self.dirname, f)): copyfile(os.path.join(self.dirname, f), os.path.join(destination, f)) - def log_details(self, logger: Logger) -> None: + def log_details(self) -> None: """ Log metadata information to a logger - - Parameters - ---------- - logger: :class:`~logging.Logger` - Logger to send debug information to """ logger.debug("") logger.debug("====ACOUSTIC MODEL INFO====") @@ -586,7 +618,8 @@ def validate(self, dictionary: DictionaryMixin) -> None: missing_phones = dictionary.meta["phones"] - set(self.meta["phones"]) else: missing_phones = dictionary.non_silence_phones - set(self.meta["phones"]) - if missing_phones and missing_phones != {"sp"}: # Compatibility + missing_phones -= {"sp", ""} + if missing_phones: # Compatibility raise (PronunciationAcousticMismatchError(missing_phones)) @@ -601,11 +634,15 @@ class IvectorExtractorModel(Archive): "final.ie", "final.ubm", "final.dubm", + "ivector_lda.mat", "plda", - "mean.vec", - "trans.mat", + "num_utts.ark", + "speaker_ivectors.ark", + ] + extensions = [ + ".ivector", + ".zip", ] - extensions = [".zip", ".ivector"] def __init__(self, source: str, root_directory: Optional[str] = None): if source in IvectorExtractorModel.get_available_models(): @@ -1022,9 +1059,7 @@ def pretty_print(self) -> None: temp_directory = os.path.join(self.dirname, "temp") if os.path.exists(temp_directory): shutil.rmtree(temp_directory) - dictionary = MultispeakerDictionary( - self.path, temporary_directory=temp_directory, phone_set_type=self.phone_set_type - ) + dictionary = MultispeakerDictionary(self.path, phone_set_type=self.phone_set_type) graphemes, phone_counts = dictionary.dictionary_setup() configuration_data["Dictionary"]["data"]["phones"] = sorted(dictionary.non_silence_phones) configuration_data["Dictionary"]["data"]["detailed_phone_info"] = {} @@ -1193,10 +1228,11 @@ def __init__(self, token=None): k: {} for k in MODEL_TYPES.keys() } self.token = token + environment_token = os.environ.get("GITHUB_TOKEN", None) + if self.token is not None: + self.token = environment_token self.synced_remote = False self.printer = TerminalPrinter() - - self.logger = configure_logger("models") self._cache_info = {} self.refresh_local() @@ -1236,6 +1272,8 @@ def refresh_remote(self) -> None: headers = {"Accept": "application/vnd.github.v3+json"} if self.token: headers["Authorization"] = f"token {self.token}" + else: + logger.debug("No Github Token supplied") page = 1 etags = {} if "list_etags" in self._cache_info: @@ -1292,7 +1330,8 @@ def refresh_remote(self) -> None: ] page += 1 with mfa_open(self.cache_path, "w") as f: - json.dump(self._cache_info, f) + json.dump(self._cache_info, f, ensure_ascii=False) + self.synced_remote = True def has_local_model(self, model_type: str, model_name: str) -> bool: """Check for local model""" @@ -1386,7 +1425,7 @@ def download_model( if not self.synced_remote: self.refresh_remote() if model_name not in self.remote_models[model_type]: - raise PretrainedModelNotFoundError( + raise RemoteModelNotFoundError( model_name, model_type, sorted(self.remote_models[model_type].keys()) ) release = self.remote_models[model_type][model_name] @@ -1407,6 +1446,6 @@ def download_model( with mfa_open(local_path, "wb") as f: f.write(r.content) self.refresh_local() - self.logger.info( - f"Saved model to f{local_path}, you can now use {model_name} in place of {model_type} paths in mfa commands." + logger.info( + f"Saved model to {local_path}, you can now use {model_name} in place of {model_type} paths in mfa commands." ) diff --git a/montreal_forced_aligner/online/alignment.py b/montreal_forced_aligner/online/alignment.py index 380efa47..babd6810 100644 --- a/montreal_forced_aligner/online/alignment.py +++ b/montreal_forced_aligner/online/alignment.py @@ -5,14 +5,28 @@ import subprocess import typing +from sqlalchemy.orm import Session + from montreal_forced_aligner.abc import KaldiFunction, MetaDict from montreal_forced_aligner.corpus.classes import UtteranceData -from montreal_forced_aligner.data import CtmInterval, MfaArguments -from montreal_forced_aligner.helper import make_safe, mfa_open -from montreal_forced_aligner.textgrid import process_ctm_line -from montreal_forced_aligner.utils import thirdparty_binary +from montreal_forced_aligner.corpus.features import ( + compute_mfcc_process, + compute_pitch_process, + compute_transform_process, +) +from montreal_forced_aligner.data import CtmInterval, MfaArguments, WordCtmInterval, WordType +from montreal_forced_aligner.db import Dictionary, Phone, Pronunciation, Word +from montreal_forced_aligner.helper import mfa_open +from montreal_forced_aligner.utils import parse_ctm_output, thirdparty_binary + +if typing.TYPE_CHECKING: + from dataclasses import dataclass +else: + from dataclassy import dataclass + +@dataclass class OnlineAlignmentArguments(MfaArguments): """ Arguments for performing alignment online on single utterances @@ -24,15 +38,11 @@ class OnlineAlignmentArguments(MfaArguments): mfcc_options: MetaDict pitch_options: MetaDict feature_options: MetaDict + lda_options: MetaDict align_options: MetaDict model_path: str tree_path: str - disambig_path: str - lexicon_fst_path: str - word_boundary_int_path: str - reversed_phone_mapping: typing.Dict[int, str] - optional_silence_phone: str - silence_words: typing.Set[str] + dictionary_id: int class OnlineAlignmentFunction(KaldiFunction): @@ -58,19 +68,23 @@ def __init__(self, args: OnlineAlignmentArguments): self.mfcc_options = args.mfcc_options self.pitch_options = args.pitch_options self.feature_options = args.feature_options + self.lda_options = args.lda_options self.align_options = args.align_options self.model_path = args.model_path self.tree_path = args.tree_path - self.disambig_path = args.disambig_path - self.lexicon_fst_path = args.lexicon_fst_path - self.word_boundary_int_path = args.word_boundary_int_path - self.reversed_phone_mapping = args.reversed_phone_mapping - self.optional_silence_phone = args.optional_silence_phone - self.silence_words = args.silence_words + self.dictionary_id = args.dictionary_id + self.reversed_phone_mapping = {} + self.reversed_word_mapping = {} + self.pronunciation_mapping = {} + self.phone_mapping = {} + self.silence_words = set() def cleanup_intervals( - self, utterance_name: int, intervals: typing.List[CtmInterval] - ) -> typing.Tuple[typing.List[CtmInterval], typing.List[CtmInterval]]: + self, + utterance_name: int, + intervals: typing.List[CtmInterval], + word_pronunciations: typing.List[typing.Tuple[str, typing.List[str]]], + ) -> typing.Tuple[typing.List[CtmInterval], typing.List[CtmInterval], typing.List[int]]: """ Clean up phone intervals to remove silence @@ -86,48 +100,105 @@ def cleanup_intervals( """ actual_phone_intervals = [] actual_word_intervals = [] - utterance_name = utterance_name utterance_begin = self.utterance.begin current_word_begin = None - words = self.utterance.normalized_text words_index = 0 + phone_word_mapping = [] + current_phones = [] for interval in intervals: interval.begin += utterance_begin interval.end += utterance_begin - phone_label = self.reversed_phone_mapping[int(interval.label)] - if phone_label == self.optional_silence_phone: - if words_index < len(words) and words[words_index] in self.silence_words: - interval.label = phone_label - else: - interval.label = phone_label - actual_phone_intervals.append(interval) - continue - phone, position = phone_label.split("_") - if position in {"B", "S"}: - current_word_begin = interval.begin - if position in {"E", "S"}: + if interval.label == self.optional_silence_phone: + interval.label = self.phone_to_phone_id[interval.label] + cur_word = word_pronunciations[words_index] + actual_phone_intervals.append(interval) actual_word_intervals.append( - CtmInterval( - current_word_begin, interval.end, words[words_index], utterance_name + WordCtmInterval( + interval.begin, + interval.end, + word_pronunciations[words_index][0], + self.pronunciation_mapping[(cur_word[0], " ".join(cur_word[1]))], ) ) + phone_word_mapping.append(len(actual_word_intervals) - 1) + current_word_begin = None + current_phones = [] words_index += 1 + continue + if current_word_begin is None: + current_word_begin = interval.begin + current_phones.append(interval.label) + cur_word = word_pronunciations[words_index] + if current_phones == cur_word[1]: + actual_word_intervals.append( + WordCtmInterval( + current_word_begin, + interval.end, + cur_word[0], + self.pronunciation_mapping[(cur_word[0], " ".join(cur_word[1]))], + ) + ) + for _ in range(len(current_phones)): + phone_word_mapping.append(len(actual_word_intervals) - 1) current_word_begin = None - interval.label = phone + current_phones = [] + words_index += 1 + interval.label = self.phone_to_phone_id[interval.label] actual_phone_intervals.append(interval) - return actual_word_intervals, actual_phone_intervals + + return actual_word_intervals, actual_phone_intervals, phone_word_mapping def _run(self) -> typing.Tuple[typing.List[CtmInterval], typing.List[CtmInterval], float]: """Run the function""" + with Session(self.db_engine) as session: + d: Dictionary = session.get(Dictionary, self.dictionary_id) + + self.clitic_marker = d.clitic_marker + self.silence_word = d.silence_word + self.oov_word = d.oov_word + self.optional_silence_phone = d.optional_silence_phone + lexicon_path = d.lexicon_fst_path + align_lexicon_path = d.align_lexicon_path + disambig_path = d.disambiguation_symbols_int_path + silence_words = ( + session.query(Word.id) + .filter(Word.dictionary_id == self.dictionary_id) + .filter(Word.word_type == WordType.silence) + ) + self.silence_words.update(x for x, in silence_words) + + words = session.query(Word.mapping_id, Word.id).filter( + Word.dictionary_id == self.dictionary_id + ) + for m_id, w in words: + self.reversed_word_mapping[m_id] = w + self.phone_to_phone_id = {} + ds = session.query(Phone.phone, Phone.id, Phone.mapping_id).all() + for phone, p_id, mapping_id in ds: + self.reversed_phone_mapping[mapping_id] = phone + self.phone_to_phone_id[phone] = p_id + self.phone_mapping[phone] = mapping_id + + pronunciations = ( + session.query(Word.id, Pronunciation.pronunciation, Pronunciation.id) + .join(Pronunciation.word) + .filter(Word.dictionary_id == self.dictionary_id) + ) + for w_id, pron, p_id in pronunciations: + self.pronunciation_mapping[(w_id, pron)] = p_id wav_path = os.path.join(self.working_directory, "wav.scp") likelihood_path = os.path.join(self.working_directory, "likelihoods.scp") feat_path = os.path.join(self.working_directory, "feats.scp") - cmvn_path = os.path.join(self.working_directory, "cmvn.scp") utt2spk_path = os.path.join(self.working_directory, "utt2spk.scp") segment_path = os.path.join(self.working_directory, "segments.scp") text_int_path = os.path.join(self.working_directory, "text.int") lda_mat_path = os.path.join(self.working_directory, "lda.mat") fst_path = os.path.join(self.working_directory, "fsts.ark") + mfcc_ark_path = os.path.join(self.working_directory, "mfcc.ark") + pitch_ark_path = os.path.join(self.working_directory, "pitch.ark") + feats_ark_path = os.path.join(self.working_directory, "feats.ark") + ali_path = os.path.join(self.working_directory, "ali.ark") + min_length = 0.1 if self.align_options["boost_silence"] != 1.0: mdl_string = f"gmm-boost-silence --boost={self.align_options['boost_silence']} {self.align_options['optional_silence_csl']} {self.model_path} - |" @@ -139,10 +210,10 @@ def _run(self) -> typing.Tuple[typing.List[CtmInterval], typing.List[CtmInterval proc = subprocess.Popen( [ thirdparty_binary("compile-train-graphs"), - f"--read-disambig-syms={self.disambig_path}", + f"--read-disambig-syms={disambig_path}", self.tree_path, self.model_path, - self.lexicon_fst_path, + lexicon_path, f"ark:{text_int_path}", f"ark:{fst_path}", ], @@ -152,14 +223,10 @@ def _run(self) -> typing.Tuple[typing.List[CtmInterval], typing.List[CtmInterval ) proc.communicate() if not os.path.exists(feat_path): - use_pitch = self.pitch_options.pop("use-pitch") - mfcc_base_command = [thirdparty_binary("compute-mfcc-feats"), "--verbose=2"] - for k, v in self.mfcc_options.items(): - mfcc_base_command.append(f"--{k.replace('_', '-')}={make_safe(v)}") - mfcc_base_command += ["ark:-", "ark:-"] seg_proc = subprocess.Popen( [ thirdparty_binary("extract-segments"), + f"--min-segment-length={min_length}", f"scp:{wav_path}", segment_path, "ark:-", @@ -168,86 +235,76 @@ def _run(self) -> typing.Tuple[typing.List[CtmInterval], typing.List[CtmInterval stderr=log_file, env=os.environ, ) - mfcc_proc = subprocess.Popen( - mfcc_base_command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=seg_proc.stdout, + mfcc_proc = compute_mfcc_process( + log_file, wav_path, subprocess.PIPE, self.mfcc_options + ) + cmvn_proc = subprocess.Popen( + [ + "apply-cmvn-sliding", + "--norm-vars=false", + "--center=true", + "--cmn-window=300", + "ark:-", + f"ark:{mfcc_ark_path}", + ], env=os.environ, + stdin=mfcc_proc.stdout, + stderr=log_file, ) + + use_pitch = self.pitch_options["use-pitch"] or self.pitch_options["use-voicing"] if use_pitch: - pitch_base_command = [ - thirdparty_binary("compute-and-process-kaldi-pitch-feats"), - "--verbose=2", - ] - for k, v in self.pitch_options.items(): - pitch_base_command.append(f"--{k.replace('_', '-')}={make_safe(v)}") - if k == "delta-pitch": - pitch_base_command.append(f"--delta-pitch-noise-stddev={make_safe(v)}") - pitch_command = " ".join(pitch_base_command) - if os.path.exists(segment_path): - segment_command = ( - f'extract-segments scp:"{wav_path}" "{segment_path}" ark:- | ' - ) - pitch_input = "ark:-" - else: - segment_command = "" - pitch_input = f'scp:"{wav_path}"' - pitch_feat_string = ( - f"ark,s,cs:{segment_command}{pitch_command} {pitch_input} ark:- |" + pitch_proc = compute_pitch_process( + log_file, wav_path, subprocess.PIPE, self.pitch_options ) - length_tolerance = 2 - feature_proc = subprocess.Popen( + pitch_copy_proc = subprocess.Popen( [ - thirdparty_binary("paste-feats"), - f"--length-tolerance={length_tolerance}", - "ark:-", - pitch_feat_string, + thirdparty_binary("copy-feats"), + "--compress=true", "ark:-", + f"ark:{pitch_ark_path}", ], - stdin=mfcc_proc.stdout, - env=os.environ, - stdout=subprocess.PIPE, + stdin=pitch_proc.stdout, stderr=log_file, + env=os.environ, ) - else: - feature_proc = mfcc_proc - cvmn_proc = subprocess.Popen( - [thirdparty_binary("apply-cmvn-sliding"), "--center", "ark:-", "ark:-"], - stdin=feature_proc.stdout, - env=os.environ, - stdout=subprocess.PIPE, - stderr=log_file, - ) - if lda_mat_path is not None: - splice_proc = subprocess.Popen( + for line in seg_proc.stdout: + mfcc_proc.stdin.write(line) + mfcc_proc.stdin.flush() + if use_pitch: + pitch_proc.stdin.write(line) + pitch_proc.stdin.flush() + mfcc_proc.stdin.close() + if use_pitch: + pitch_proc.stdin.close() + cmvn_proc.wait() + if use_pitch: + pitch_copy_proc.wait() + if use_pitch: + paste_proc = subprocess.Popen( [ - thirdparty_binary("splice-feats"), - f'--left-context={self.feature_options["splice_left_context"]}', - f'--right-context={self.feature_options["splice_right_context"]}', - "ark:-", - "ark:-", + thirdparty_binary("paste-feats"), + "--length-tolerance=2", + f"ark:{mfcc_ark_path}", + f"ark:{pitch_ark_path}", + f"ark:{feats_ark_path}", ], - stdin=cvmn_proc.stdout, - env=os.environ, - stdout=subprocess.PIPE, - stderr=log_file, - ) - transform_proc = subprocess.Popen( - [thirdparty_binary("transform-feats"), lda_mat_path, "ark:-", "ark:-"], - stdin=splice_proc.stdout, - env=os.environ, - stdout=subprocess.PIPE, stderr=log_file, - ) - elif self.feature_options["uses_deltas"]: - transform_proc = subprocess.Popen( - [thirdparty_binary("add-deltas"), "ark:-", "ark:-"], - stdin=cvmn_proc.stdout, env=os.environ, - stdout=subprocess.PIPE, - stderr=log_file, ) + paste_proc.wait() + else: + feats_ark_path = mfcc_ark_path + + trans_proc = compute_transform_process( + log_file, + feats_ark_path, + utt2spk_path, + lda_mat_path, + None, + self.lda_options, + ) + # Features done, alignment align_proc = subprocess.Popen( [ @@ -261,109 +318,106 @@ def _run(self) -> typing.Tuple[typing.List[CtmInterval], typing.List[CtmInterval mdl_string, f"ark:{fst_path}", "ark:-", - "ark:-", + f"ark:{ali_path}", f"ark,t:{likelihood_path}", ], stdout=subprocess.PIPE, stderr=log_file, encoding="utf8", - stdin=transform_proc.stdout, + stdin=trans_proc.stdout, env=os.environ, ) else: - feat_string = f'ark,s,cs:apply-cmvn --utt2spk=ark:"{utt2spk_path}" scp:"{cmvn_path}" scp:"{feat_path}" ark:- |' - if lda_mat_path is not None: - feat_string += f" splice-feats --left-context={self.feature_options['splice_left_context']} --right-context={self.feature_options['splice_right_context']} ark:- ark:- |" - feat_string += f' transform-feats "{lda_mat_path}" ark:- ark:- |' - align_proc = subprocess.Popen( - [ - thirdparty_binary("gmm-align-compiled"), - f"--transition-scale={self.align_options['transition_scale']}", - f"--acoustic-scale={self.align_options['acoustic_scale']}", - f"--self-loop-scale={self.align_options['self_loop_scale']}", - f"--beam={self.align_options['beam']}", - f"--retry-beam={self.align_options['retry_beam']}", - "--careful=false", - mdl_string, - f"ark:{fst_path}", - feat_string, - "ark:-", - f"ark,t:{likelihood_path}", - ], - stdout=subprocess.PIPE, - stderr=log_file, - encoding="utf8", - env=os.environ, - ) - lin_proc = subprocess.Popen( - [ - thirdparty_binary("linear-to-nbest"), - "ark:-", - f"ark:{text_int_path}", - "", - "", - "ark:-", - ], - stdin=align_proc.stdout, - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - align_words_proc = subprocess.Popen( + feat_string = f"ark,s,cs:splice-feats --left-context={self.lda_options['splice_left_context']} --right-context={self.lda_options['splice_right_context']} scp,s,cs:\"{feat_path}\" ark:- |" + feat_string += f' transform-feats "{lda_mat_path}" ark:- ark:- |' + align_proc = subprocess.Popen( + [ + thirdparty_binary("gmm-align-compiled"), + f"--transition-scale={self.align_options['transition_scale']}", + f"--acoustic-scale={self.align_options['acoustic_scale']}", + f"--self-loop-scale={self.align_options['self_loop_scale']}", + f"--beam={self.align_options['beam']}", + f"--retry-beam={self.align_options['retry_beam']}", + "--careful=false", + mdl_string, + f"ark:{fst_path}", + feat_string, + f"ark:{ali_path}", + f"ark,t:{likelihood_path}", + ], + stdout=subprocess.PIPE, + stderr=log_file, + encoding="utf8", + env=os.environ, + ) + align_proc.communicate() + ctm_proc = subprocess.Popen( [ - thirdparty_binary("lattice-align-words"), - self.word_boundary_int_path, + thirdparty_binary("ali-to-phones"), + "--ctm-output", + f"--frame-shift={round(self.feature_options['frame_shift']/1000,4)}", self.model_path, - "ark:-", - "ark:-", + f"ark:{ali_path}", + "-", ], - stdin=lin_proc.stdout, - stdout=subprocess.PIPE, stderr=log_file, + stdout=subprocess.PIPE, env=os.environ, + encoding="utf8", ) - phone_proc = subprocess.Popen( + + phones_proc = subprocess.Popen( [ - thirdparty_binary("lattice-to-phone-lattice"), + thirdparty_binary("ali-to-phones"), self.model_path, - "ark:-", - "ark:-", + f"ark:{ali_path}", + "ark,t:-", ], - stdout=subprocess.PIPE, - stdin=align_words_proc.stdout, stderr=log_file, + stdout=subprocess.PIPE, env=os.environ, + encoding="utf8", ) - nbest_proc = subprocess.Popen( + prons_proc = subprocess.Popen( [ - thirdparty_binary("nbest-to-ctm"), - "--print-args=false", - f"--frame-shift={round(self.feature_options['frame_shift']/1000,4)}", + thirdparty_binary("phones-to-prons"), + align_lexicon_path, + str(self.phone_mapping["#1"]), + str(self.phone_mapping["#2"]), "ark:-", - "-", + f"ark:{text_int_path}", + "ark,t:-", ], - stdin=phone_proc.stdout, + stdin=phones_proc.stdout, stderr=log_file, + encoding="utf8", stdout=subprocess.PIPE, env=os.environ, - encoding="utf8", ) - intervals = [] - log_likelihood = None - for line in nbest_proc.stdout: - line = line.strip() - if not line: - continue - try: - interval = process_ctm_line(line) - intervals.append(interval) - except ValueError: - pass - nbest_proc.wait() - with mfa_open(likelihood_path, "r") as f: - try: - log_likelihood = float(f.read().split()[-1]) - except ValueError: - pass - actual_word_intervals, actual_phone_intervals = self.cleanup_intervals(0, intervals) - return actual_word_intervals, actual_phone_intervals, log_likelihood + for utterance, intervals in parse_ctm_output(ctm_proc, self.reversed_phone_mapping): + while True: + prons_line = prons_proc.stdout.readline().strip() + if prons_line: + break + utt_id, prons_line = prons_line.split(maxsplit=1) + prons = prons_line.split(";") + word_pronunciations = [] + for pron in prons: + pron = pron.strip() + if not pron: + continue + pron = pron.split() + word = pron.pop(0) + word = self.reversed_word_mapping[int(word)] + pron = [self.reversed_phone_mapping[int(x)] for x in pron] + word_pronunciations.append((word, pron)) + word_intervals, phone_intervals, phone_word_mapping = self.cleanup_intervals( + utterance, intervals, word_pronunciations + ) + log_likelihood = None + with mfa_open(likelihood_path, "r") as f: + for line in f: + line = line.strip().split() + log_likelihood = float(line[-1]) + yield utterance, word_intervals, phone_intervals, phone_word_mapping, log_likelihood + self.check_call(ctm_proc) diff --git a/montreal_forced_aligner/segmenter.py b/montreal_forced_aligner/segmenter.py deleted file mode 100644 index 99426312..00000000 --- a/montreal_forced_aligner/segmenter.py +++ /dev/null @@ -1,461 +0,0 @@ -""" -Segmenter -========= - -""" -from __future__ import annotations - -import multiprocessing as mp -import os -import re -import typing -from queue import Empty -from typing import TYPE_CHECKING, Dict, List, Optional, Union - -import tqdm -from sqlalchemy.orm import joinedload, selectinload - -from montreal_forced_aligner.abc import FileExporterMixin, MetaDict, TopLevelMfaWorker -from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpusMixin -from montreal_forced_aligner.corpus.features import VadConfigMixin -from montreal_forced_aligner.data import MfaArguments, TextFileType -from montreal_forced_aligner.db import File, SpeakerOrdering, Utterance -from montreal_forced_aligner.exceptions import KaldiProcessingError -from montreal_forced_aligner.helper import load_configuration, load_scp, mfa_open -from montreal_forced_aligner.utils import ( - KaldiFunction, - KaldiProcessWorker, - Stopped, - log_kaldi_errors, - parse_logs, -) - -if TYPE_CHECKING: - from argparse import Namespace - -SegmentationType = List[Dict[str, float]] - -__all__ = ["Segmenter", "SegmentVadFunction", "SegmentVadArguments"] - - -class SegmentVadArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.segmenter.SegmentVadFunction`""" - - vad_path: str - segmentation_options: MetaDict - - -def get_initial_segmentation(frames: List[Union[int, str]], frame_shift: int) -> SegmentationType: - """ - Compute initial segmentation over voice activity - - Parameters - ---------- - frames: list[Union[int, str]] - List of frames with VAD output - frame_shift: int - Frame shift of features in ms - - Returns - ------- - SegmentationType - Initial segmentation - """ - segs = [] - cur_seg = None - silent_frames = 0 - non_silent_frames = 0 - for i, f in enumerate(frames): - if int(f) > 0: - non_silent_frames += 1 - if cur_seg is None: - cur_seg = {"begin": i * frame_shift} - else: - silent_frames += 1 - if cur_seg is not None: - cur_seg["end"] = (i - 1) * frame_shift - segs.append(cur_seg) - cur_seg = None - if cur_seg is not None: - cur_seg["end"] = len(frames) * frame_shift - segs.append(cur_seg) - return segs - - -def merge_segments( - segments: SegmentationType, - min_pause_duration: float, - max_segment_length: float, - snap_boundary_threshold: float, -) -> SegmentationType: - """ - Merge segments together - - Parameters - ---------- - segments: SegmentationType - Initial segments - min_pause_duration: float - Minimum amount of silence time to mark an utterance boundary - max_segment_length: float - Maximum length of segments before they're broken up - snap_boundary_threshold: - Boundary threshold to snap boundaries together - - Returns - ------- - SegmentationType - Merged segments - """ - merged_segs = [] - for s in segments: - if ( - not merged_segs - or s["begin"] > merged_segs[-1]["end"] + min_pause_duration - or s["end"] - merged_segs[-1]["begin"] > max_segment_length - ): - if s["end"] - s["begin"] > min_pause_duration: - if merged_segs and snap_boundary_threshold: - boundary_gap = s["begin"] - merged_segs[-1]["end"] - if boundary_gap < snap_boundary_threshold: - half_boundary = boundary_gap / 2 - else: - half_boundary = snap_boundary_threshold / 2 - merged_segs[-1]["end"] += half_boundary - s["begin"] -= half_boundary - - merged_segs.append(s) - else: - merged_segs[-1]["end"] = s["end"] - return merged_segs - - -class SegmentVadFunction(KaldiFunction): - """ - Multiprocessing function to generate segments from VAD output. - - See Also - -------- - :meth:`montreal_forced_aligner.segmenter.Segmenter.segment_vad` - Main function that calls this function in parallel - :meth:`montreal_forced_aligner.segmenter.Segmenter.segment_vad_arguments` - Job method for generating arguments for this function - :kaldi_utils:`segmentation.pl` - Kaldi utility - - Parameters - ---------- - args: :class:`~montreal_forced_aligner.segmenter.SegmentVadArguments` - Arguments for the function - """ - - progress_pattern = re.compile( - r"^LOG.*processed (?P\d+) utterances.*(?P\d+) had.*(?P\d+) were.*" - ) - - def __init__(self, args: SegmentVadArguments): - super().__init__(args) - self.vad_path = args.vad_path - self.segmentation_options = args.segmentation_options - - def _run(self) -> typing.Generator[typing.Tuple[int, float, float]]: - """Run the function""" - - vad = load_scp(self.vad_path, data_type=int) - for recording, frames in vad.items(): - initial_segments = get_initial_segmentation( - frames, self.segmentation_options["frame_shift"] - ) - - merged = merge_segments( - initial_segments, - self.segmentation_options["min_pause_duration"], - self.segmentation_options["max_segment_length"], - self.segmentation_options["snap_boundary_threshold"], - ) - for seg in merged: - yield int(recording.split("-")[-1]), seg["begin"], seg["end"] - - -class Segmenter(VadConfigMixin, AcousticCorpusMixin, FileExporterMixin, TopLevelMfaWorker): - """ - Class for performing speaker classification - - Parameters - ---------- - max_segment_length : float - Maximum duration of segments - min_pause_duration : float - Minimum duration of pauses - snap_boundary_threshold : float - Threshold for snapping segment boundaries to each other - """ - - def __init__( - self, - max_segment_length: float = 30, - min_pause_duration: float = 0.05, - snap_boundary_threshold: float = 0.15, - **kwargs, - ): - super().__init__(**kwargs) - self.max_segment_length = max_segment_length - self.min_pause_duration = min_pause_duration - self.snap_boundary_threshold = snap_boundary_threshold - - @classmethod - def parse_parameters( - cls, - config_path: Optional[str] = None, - args: Optional[Namespace] = None, - unknown_args: Optional[List[str]] = None, - ) -> MetaDict: - """ - Parse parameters for segmentation from a config path or command-line arguments - - Parameters - ---------- - config_path: str - Config path - args: :class:`~argparse.Namespace` - Command-line arguments from argparse - unknown_args: list[str], optional - Extra command-line arguments - - Returns - ------- - dict[str, Any] - Configuration parameters - """ - global_params = {} - if config_path and os.path.exists(config_path): - data = load_configuration(config_path) - for k, v in data.items(): - if k == "features": - if "type" in v: - v["feature_type"] = v["type"] - del v["type"] - global_params.update(v) - else: - if v is None and k in cls.nullable_fields: - v = [] - global_params[k] = v - global_params.update(cls.parse_args(args, unknown_args)) - return global_params - - def segment_vad_arguments(self) -> List[SegmentVadArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.segmenter.SegmentVadFunction` - - Returns - ------- - list[SegmentVadArguments] - Arguments for processing - """ - return [ - SegmentVadArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"segment_vad.{j.name}.log"), - j.construct_path(self.split_directory, "vad", "scp"), - self.segmentation_options, - ) - for j in self.jobs - if j.has_data - ] - - @property - def segmentation_options(self) -> MetaDict: - """Options for segmentation""" - return { - "max_segment_length": self.max_segment_length, - "min_pause_duration": self.min_pause_duration, - "snap_boundary_threshold": self.snap_boundary_threshold, - "frame_shift": round(self.frame_shift / 1000, 2), - } - - @property - def workflow_identifier(self) -> str: - """Segmentation workflow""" - return "segmentation" - - def segment_vad(self) -> None: - """ - Run segmentation based off of VAD. - - See Also - -------- - :class:`~montreal_forced_aligner.segmenter.SegmentVadFunction` - Multiprocessing helper function for each job - segment_vad_arguments - Job method for generating arguments for helper function - """ - - arguments = self.segment_vad_arguments() - old_utts = set() - new_utts = [] - - with tqdm.tqdm( - total=self.num_utterances, disable=getattr(self, "quiet", False) - ) as pbar, self.session() as session: - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(arguments): - function = SegmentVadFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - utt, begin, end = result - old_utts.add(utt) - channel, speaker_id, file_id = ( - session.query( - Utterance.channel, Utterance.speaker_id, Utterance.file_id - ) - .filter(Utterance.id == utt) - .first() - ) - new_utts.append( - { - "begin": begin, - "end": end, - "text": "speech", - "speaker_id": speaker_id, - "file_id": file_id, - "oovs": "", - "normalized_text": "", - "normalized_text_int": "", - "features": "", - "in_subset": False, - "ignored": False, - "channel": channel, - "duration": end - begin, - } - ) - - pbar.update(1) - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v - else: - for args in arguments: - function = SegmentVadFunction(args) - for utt, begin, end in function.run(): - old_utts.add(utt) - channel, speaker_id, file_id = ( - session.query( - Utterance.channel, Utterance.speaker_id, Utterance.file_id - ) - .filter(Utterance.id == utt) - .first() - ) - new_utts.append( - { - "begin": begin, - "end": end, - "text": "speech", - "speaker_id": speaker_id, - "file_id": file_id, - "oovs": "", - "normalized_text": "", - "normalized_text_int": "", - "features": "", - "in_subset": False, - "ignored": False, - "channel": channel, - "duration": end - begin, - } - ) - pbar.update(1) - session.query(Utterance).filter(Utterance.id.in_(old_utts)).delete() - session.bulk_insert_mappings( - Utterance, new_utts, return_defaults=False, render_nulls=True - ) - session.commit() - - def setup(self) -> None: - """Setup segmentation""" - self.check_previous_run() - log_dir = os.path.join(self.working_directory, "log") - os.makedirs(log_dir, exist_ok=True) - try: - self.load_corpus() - except Exception as e: - if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) - raise - - def segment(self) -> None: - """ - Performs VAD and segmentation into utterances - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` - If there were any errors in running Kaldi binaries - """ - self.setup() - log_directory = os.path.join(self.working_directory, "log") - done_path = os.path.join(self.working_directory, "done") - if os.path.exists(done_path): - self.log_info("Classification already done, skipping.") - return - try: - self.compute_vad() - self.uses_vad = True - self.segment_vad() - parse_logs(log_directory) - except Exception as e: - if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) - raise - with mfa_open(done_path, "w"): - pass - - def export_files(self, output_directory: str, output_format: Optional[str] = None) -> None: - """ - Export the results of segmentation as TextGrids - - Parameters - ---------- - output_directory: str - Directory to save segmentation TextGrids - """ - if output_format is None: - output_format = TextFileType.TEXTGRID.value - os.makedirs(output_directory, exist_ok=True) - with self.session() as session: - for f in session.query(File).options( - selectinload(File.utterances).joinedload(Utterance.speaker, innerjoin=True), - joinedload(File.sound_file, innerjoin=True), - joinedload(File.text_file, innerjoin=True), - selectinload(File.speakers).joinedload(SpeakerOrdering.speaker, innerjoin=True), - ): - f.save(output_directory, output_format=output_format) diff --git a/montreal_forced_aligner/speaker_classifier.py b/montreal_forced_aligner/speaker_classifier.py deleted file mode 100644 index 8fbd9c2f..00000000 --- a/montreal_forced_aligner/speaker_classifier.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Speaker classification -====================== - - -""" -from __future__ import annotations - -import os -from typing import TYPE_CHECKING, List, Optional - -from praatio import textgrid -from sqlalchemy.orm import joinedload, selectinload - -from montreal_forced_aligner.abc import FileExporterMixin, TopLevelMfaWorker -from montreal_forced_aligner.alignment.multiprocessing import construct_output_path -from montreal_forced_aligner.corpus.ivector_corpus import IvectorCorpusMixin -from montreal_forced_aligner.data import TextFileType -from montreal_forced_aligner.db import File, SoundFile, SpeakerOrdering, TextFile -from montreal_forced_aligner.exceptions import KaldiProcessingError -from montreal_forced_aligner.helper import load_configuration, load_scp, mfa_open -from montreal_forced_aligner.models import IvectorExtractorModel -from montreal_forced_aligner.utils import log_kaldi_errors - -if TYPE_CHECKING: - from argparse import Namespace - - from .abc import MetaDict -__all__ = ["SpeakerClassifier"] - - -class SpeakerClassifier( - IvectorCorpusMixin, TopLevelMfaWorker, FileExporterMixin -): # pragma: no cover - """ - Class for performing speaker classification, not currently very functional, but - is planned to be expanded in the future - - Parameters - ---------- - ivector_extractor_path : str - Path to ivector extractor model - expected_num_speakers: int, optional - Number of speakers in the corpus, if known - cluster: bool, optional - Flag for whether speakers should be clustered instead of classified - """ - - def __init__( - self, - ivector_extractor_path: str, - expected_num_speakers: int = 0, - cluster: bool = True, - **kwargs, - ): - self.ivector_extractor = IvectorExtractorModel(ivector_extractor_path) - kwargs.update(self.ivector_extractor.parameters) - super().__init__(**kwargs) - self.classifier = None - self.speaker_labels = {} - self.ivectors = {} - self.expected_num_speakers = expected_num_speakers - self.cluster = cluster - - @classmethod - def parse_parameters( - cls, - config_path: Optional[str] = None, - args: Optional[Namespace] = None, - unknown_args: Optional[List[str]] = None, - ) -> MetaDict: - """ - Parse parameters for speaker classification from a config path or command-line arguments - - Parameters - ---------- - config_path: str - Config path - args: :class:`~argparse.Namespace` - Command-line arguments from argparse - unknown_args: list[str], optional - Extra command-line arguments - - Returns - ------- - dict[str, Any] - Configuration parameters - """ - global_params = {} - if config_path and os.path.exists(config_path): - data = load_configuration(config_path) - for k, v in data.items(): - if k == "features": - if "type" in v: - v["feature_type"] = v["type"] - del v["type"] - global_params.update(v) - else: - if v is None and k in cls.nullable_fields: - v = [] - global_params[k] = v - global_params.update(cls.parse_args(args, unknown_args)) - return global_params - - @property - def workflow_identifier(self) -> str: - """Speaker classification identifier""" - return "speaker_classification" - - @property - def ie_path(self) -> str: - """Path for the ivector extractor model file""" - return os.path.join(self.working_directory, "final.ie") - - @property - def model_path(self) -> str: - """Path for the acoustic model file""" - return os.path.join(self.working_directory, "final.mdl") - - @property - def dubm_path(self) -> str: - """Path for the DUBM model""" - return os.path.join(self.working_directory, "final.dubm") - - def setup(self) -> None: - """ - Sets up the corpus and speaker classifier - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` - If there were any errors in running Kaldi binaries - """ - - self.check_previous_run() - done_path = os.path.join(self.working_directory, "done") - if os.path.exists(done_path): - self.log_info("Classification already done, skipping initialization.") - return - log_dir = os.path.join(self.working_directory, "log") - os.makedirs(log_dir, exist_ok=True) - try: - self.load_corpus() - self.ivector_extractor.export_model(self.working_directory) - self.extract_ivectors() - except Exception as e: - if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) - raise - - def load_ivectors(self) -> None: - """ - Load ivectors from the temporary directory - """ - self.ivectors = {} - for ivectors_args in self.extract_ivectors_arguments(): - ivec = load_scp(ivectors_args.ivectors_path) - for utt, ivector in ivec.items(): - ivector = [float(x) for x in ivector] - self.ivectors[utt] = ivector - - def cluster_utterances(self) -> None: - """ - Cluster utterances based on their ivectors - """ - self.log_error( - "Speaker diarization functionality is currently under construction and not working in the current version." - ) - raise NotImplementedError( - "Speaker diarization functionality is currently under construction and not working in the current version." - ) - - def export_files(self, output_directory: str) -> None: - """ - Export files with their new speaker labels - - Parameters - ---------- - output_directory: str - Output directory to save files - """ - if not self.overwrite and os.path.exists(output_directory): - output_directory = os.path.join(self.working_directory, "transcriptions") - os.makedirs(output_directory, exist_ok=True) - - with self.session() as session: - files = session.query(File).options( - selectinload(File.utterances), - selectinload(File.speakers).selectinload(SpeakerOrdering.speaker), - joinedload(File.sound_file, innerjoin=True).load_only(SoundFile.duration), - joinedload(File.text_file, innerjoin=True).load_only(TextFile.file_type), - ) - for file in files: - utterance_count = len(file.utterances) - duration = file.sound_file.duration - - if utterance_count == 0: - self.log_debug(f"Could not find any utterances for {file.name}") - output_path = construct_output_path( - file.name, - file.relative_path, - self.output_directory, - output_format=file.text_file.file_type, - ) - data = file.construct_transcription_tiers() - if file.text_file.file_type == TextFileType.LAB: - for intervals in data.values(): - with mfa_open(output_path, "w") as f: - f.write(intervals[0].label) - else: - - tg = textgrid.Textgrid() - tg.minTimestamp = 0 - tg.maxTimestamp = duration - for speaker in file.speakers: - speaker = speaker.speaker.name - intervals = data[speaker] - tier = textgrid.IntervalTier( - speaker, [x.to_tg_interval() for x in intervals], minT=0, maxT=duration - ) - - tg.addTier(tier) - tg.save(output_path, includeBlankSpaces=True, format=file.text_file.file_type) diff --git a/montreal_forced_aligner/textgrid.py b/montreal_forced_aligner/textgrid.py index dd98475a..04f79274 100644 --- a/montreal_forced_aligner/textgrid.py +++ b/montreal_forced_aligner/textgrid.py @@ -26,33 +26,42 @@ ] -def process_ctm_line(line: str) -> CtmInterval: +def process_ctm_line( + line: str, reversed_phone_mapping: Dict[int, int], raw_id=False +) -> typing.Tuple[int, CtmInterval]: """ Helper function for parsing a line of CTM file to construct a CTMInterval + CTM format is: + + utt_id channel_num start_time phone_dur phone_id [confidence] + Parameters ---------- line: str Input string + reversed_phone_mapping: dict[int, str] + Mapping from integer IDs to phone labels Returns ------- :class:`~montreal_forced_aligner.data.CtmInterval` Extracted data from the line """ - line = line.split(" ") - utt = int(line[0].split("-")[-1]) - if len(line) == 5: - begin = round(float(line[2]), 4) - duration = float(line[3]) - end = round(begin + duration, 4) - label = line[4] - else: - begin = round(float(line[1]), 4) - duration = float(line[2]) - end = round(begin + duration, 4) - label = line[3] - return CtmInterval(begin, end, label, utt) + line = line.split() + utt = line[0] + if not raw_id: + utt = int(line[0].split("-")[-1]) + begin = round(float(line[2]), 4) + duration = float(line[3]) + end = round(begin + duration, 4) + label = line[4] + conf = None + if len(line) > 5: + conf = round(float(line[5]), 4) + + label = reversed_phone_mapping[int(label)] + return utt, CtmInterval(begin, end, label, confidence=conf) def output_textgrid_writing_errors( @@ -127,7 +136,7 @@ def parse_aligned_textgrid( begin, end = round(begin, 4), round(end, 4) if end - begin < 0.01: continue - interval = CtmInterval(begin, end, text, 0) + interval = CtmInterval(begin, end, text) data[speaker_name].append(interval) return data @@ -136,7 +145,7 @@ def export_textgrid( speaker_data: Dict[str, Dict[str, List[CtmInterval]]], output_path: str, duration: float, - frame_shift: int, + frame_shift: float, output_format: str = TextFileType.TEXTGRID.value, ) -> None: """ @@ -150,13 +159,11 @@ def export_textgrid( Output path of the file duration: float Duration of the file - frame_shift: int - Frame shift of features, in ms + frame_shift: float + Frame shift of features, in seconds output_format: str, optional Output format, one of: "long_textgrid" (default), "short_textgrid", "json", or "csv" """ - if frame_shift > 1: - frame_shift = round(frame_shift / 1000, 4) has_data = False if output_format == "csv": csv_data = [] @@ -200,7 +207,7 @@ def export_textgrid( json_data["tiers"][tier_name]["entries"].append([a.begin, a.end, a.label]) if has_data: with mfa_open(output_path, "w") as f: - json.dump(json_data, f) + json.dump(json_data, f, indent=4, ensure_ascii=False) else: # Create initial textgrid tg = tgio.Textgrid() @@ -216,7 +223,7 @@ def export_textgrid( tier_name = annotation_type if tier_name not in tg.tierNameList: tg.addTier(tgio.IntervalTier(tier_name, [], minT=0, maxT=duration)) - for a in intervals: + for a in sorted(intervals, key=lambda x: x.begin): if duration - a.end < (frame_shift * 2): # Fix rounding issues a.end = duration tg.tierDict[tier_name].entryList.append(a.to_tg_interval()) diff --git a/montreal_forced_aligner/transcription/multiprocessing.py b/montreal_forced_aligner/transcription/multiprocessing.py index 774ca0e7..55b635ba 100644 --- a/montreal_forced_aligner/transcription/multiprocessing.py +++ b/montreal_forced_aligner/transcription/multiprocessing.py @@ -11,8 +11,12 @@ import typing from typing import TYPE_CHECKING, Dict, List, TextIO +import pynini +from sqlalchemy.orm import Session, joinedload, subqueryload + from montreal_forced_aligner.abc import KaldiFunction, MetaDict from montreal_forced_aligner.data import MfaArguments +from montreal_forced_aligner.db import Job, Phone, Utterance from montreal_forced_aligner.helper import mfa_open from montreal_forced_aligner.utils import thirdparty_binary @@ -33,7 +37,6 @@ "FinalFmllrFunction", "InitialFmllrFunction", "LatGenFmllrFunction", - "ScoreFunction", "CarpaLmRescoreFunction", "DecodeFunction", "LmRescoreFunction", @@ -43,7 +46,42 @@ @dataclass class CreateHclgArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.CreateHclgFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.CreateHclgFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + working_directory: str + Current working directory + path_template: str + Path template for intermediate files + words_path: str + Path to words symbol table + carpa_path: str + Path to .carpa file + small_arpa_path: str + Path to small ARPA file + medium_arpa_path: str + Path to medium ARPA file + big_arpa_path: str + Path to big ARPA file + model_path: str + Acoustic model path + disambig_L_path: str + Path to disambiguated lexicon file + disambig_int_path: str + Path to disambiguation symbol integer file + hclg_options: dict[str, Any] + HCLG options + words_mapping: dict[str, int] + Words mapping + """ working_directory: str path_template: str @@ -66,7 +104,32 @@ def hclg_path(self) -> str: @dataclass class DecodeArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.DecodeFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.DecodeFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings + decode_options: dict[str, Any] + Decoding options + model_path: str + Path to model file + lat_paths: dict[int, str] + Per dictionary lattice paths + word_symbol_paths: dict[int, str] + Per dictionary word symbol table paths + hclg_paths: dict[int, str] + Per dictionary HCLG.fst paths + """ dictionaries: List[int] feature_strings: Dict[int, str] @@ -78,22 +141,69 @@ class DecodeArguments(MfaArguments): @dataclass -class ScoreArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.ScoreFunction`""" +class DecodePhoneArguments(MfaArguments): + """ + Arguments for :class:`~montreal_forced_aligner.validation.corpus_validator.DecodePhoneFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings + decode_options: dict[str, Any] + Decoding options + model_path: str + Path to model file + lat_paths: dict[int, str] + Per dictionary lattice paths + phone_symbol_path: str + Phone symbol table paths + hclg_path: str + HCLG.fst paths + """ dictionaries: List[int] - score_options: MetaDict + feature_strings: Dict[int, str] + decode_options: MetaDict + model_path: str lat_paths: Dict[int, str] - rescored_lat_paths: Dict[int, str] - carpa_rescored_lat_paths: Dict[int, str] - words_paths: Dict[int, str] - tra_paths: Dict[int, str] - ali_paths: Dict[int, str] + phone_symbol_path: str + hclg_path: str @dataclass class LmRescoreArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + lm_rescore_options: dict[str, Any] + Rescoring options + lat_paths: dict[int, str] + Per dictionary lattice paths + rescored_lat_paths: dict[int, str] + Per dictionary rescored lattice paths + old_g_paths: dict[int, str] + Mapping of dictionaries to small G.fst paths + new_g_paths: dict[int, str] + Mapping of dictionaries to medium G.fst paths + """ dictionaries: List[int] lm_rescore_options: MetaDict @@ -105,7 +215,28 @@ class LmRescoreArguments(MfaArguments): @dataclass class CarpaLmRescoreArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + lat_paths: dict[int, str] + Per dictionary lattice paths + rescored_lat_paths: dict[int, str] + Per dictionary rescored lattice paths + old_g_paths: dict[int, str] + Mapping of dictionaries to medium G.fst paths + new_g_paths: dict[int, str] + Mapping of dictionaries to G.carpa paths + """ dictionaries: List[int] lat_paths: Dict[int, str] @@ -116,7 +247,32 @@ class CarpaLmRescoreArguments(MfaArguments): @dataclass class InitialFmllrArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.InitialFmllrFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.InitialFmllrFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings + model_path: str + Path to model file + fmllr_options: dict[str, Any] + fMLLR options + pre_trans_paths: dict[int, str] + Per dictionary pre-fMLLR lattice paths + lat_paths: dict[int, str] + Per dictionary lattice paths + spk2utt_paths: dict[int, str] + Per dictionary speaker to utterance mapping paths + """ dictionaries: List[int] feature_strings: Dict[int, str] @@ -129,20 +285,68 @@ class InitialFmllrArguments(MfaArguments): @dataclass class LatGenFmllrArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.LatGenFmllrFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.LatGenFmllrFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings + model_path: str + Path to model file + decode_options: dict[str, Any] + Decoding options + hclg_paths: dict[int, str] + Per dictionary HCLG.fst paths + tmp_lat_paths: dict[int, str] + Per dictionary temporary lattice paths + """ dictionaries: List[int] feature_strings: Dict[int, str] model_path: str decode_options: MetaDict word_symbol_paths: Dict[int, str] - hclg_paths: Dict[int, str] + hclg_paths: typing.Union[Dict[int, str], str] tmp_lat_paths: Dict[int, str] @dataclass class FinalFmllrArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.FinalFmllrFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.FinalFmllrFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings + model_path: str + Path to model file + fmllr_options: dict[str, Any] + fMLLR options + trans_paths: dict[int, str] + Per dictionary transform paths + spk2utt_paths: dict[int, str] + Per dictionary speaker to utterance mapping paths + tmp_lat_paths: dict[int, str] + Per dictionary temporary lattice paths + """ dictionaries: List[int] feature_strings: Dict[int, str] @@ -155,7 +359,30 @@ class FinalFmllrArguments(MfaArguments): @dataclass class FmllrRescoreArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.FmllrRescoreFunction`""" + """ + Arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.FmllrRescoreFunction` + + Parameters + ---------- + job_name: int + Integer ID of the job + db_string: str + String for database connections + log_path: str + Path to save logging information during the run + dictionaries: list[int] + List of dictionary ids + feature_strings: dict[int, str] + Mapping of dictionaries to feature generation strings + model_path: str + Path to model file + fmllr_options: dict[str, Any] + fMLLR options + tmp_lat_paths: dict[int, str] + Per dictionary temporary lattice paths + final_lat_paths: dict[int, str] + Per dictionary lattice paths + """ dictionaries: List[int] feature_strings: Dict[int, str] @@ -230,8 +457,8 @@ def compose_lg(dictionary_path: str, small_g_path: str, lg_path: str, log_file: def compose_clg( - in_disambig: str, - out_disambig: str, + in_disambig: typing.Optional[str], + out_disambig: typing.Optional[str], context_width: int, central_pos: int, ilabels_temp: str, @@ -268,16 +495,18 @@ def compose_clg( log_file: TextIO Log file handler to output logging info to """ + com = [ + thirdparty_binary("fstcomposecontext"), + f"--context-size={context_width}", + f"--central-position={central_pos}", + ] + if in_disambig: + com.append(f"--read-disambig-syms={in_disambig}") + if out_disambig: + com.append(f"--write-disambig-syms={out_disambig}") + com.extend([ilabels_temp, lg_path]) compose_proc = subprocess.Popen( - [ - thirdparty_binary("fstcomposecontext"), - f"--context-size={context_width}", - f"--central-position={central_pos}", - f"--read-disambig-syms={in_disambig}", - f"--write-disambig-syms={out_disambig}", - ilabels_temp, - lg_path, - ], + com, stdout=subprocess.PIPE, stderr=log_file, ) @@ -291,7 +520,7 @@ def compose_clg( def compose_hclg( - model_directory: str, + model_path: str, ilabels_temp: str, transition_scale: float, clg_path: str, @@ -320,8 +549,8 @@ def compose_hclg( Parameters ---------- - model_directory: str - Model working directory with acoustic model information + model_path: str + Path to acoustic model ilabels_temp: str Path to temporary ilabels file transition_scale: float @@ -333,8 +562,7 @@ def compose_hclg( log_file: TextIO Log file handler to output logging info to """ - model_path = os.path.join(model_directory, "final.mdl") - tree_path = os.path.join(model_directory, "tree") + tree_path = model_path.replace("final.mdl", "tree") ha_path = hclga_path.replace("HCLGa", "Ha") ha_out_disambig = hclga_path.replace("HCLGa", "disambig_tid") make_h_proc = subprocess.Popen( @@ -533,10 +761,6 @@ class CreateHclgFunction(KaldiFunction): Arguments for the function """ - progress_pattern = re.compile( - r"^LOG.*Log-like per frame for utterance (?P.*) is (?P[-\d.]+) over (?P\d+) frames." - ) - def __init__(self, args: CreateHclgArguments): super().__init__(args) self.working_directory = args.working_directory @@ -577,9 +801,11 @@ def _run(self) -> typing.Generator[typing.Tuple[bool, str]]: if not os.path.exists(small_g_path): log_file.write("Generating small_G.fst...") compose_g(self.small_arpa_path, self.words_path, small_g_path, log_file) + yield 1 if not os.path.exists(medium_g_path): log_file.write("Generating med_G.fst...") compose_g(self.medium_arpa_path, self.words_path, medium_g_path, log_file) + yield 1 if not os.path.exists(self.carpa_path): log_file.write("Generating G.carpa...") temp_carpa_path = self.carpa_path + ".temp" @@ -590,9 +816,11 @@ def _run(self) -> typing.Generator[typing.Tuple[bool, str]]: self.carpa_path, log_file, ) + yield 1 if not os.path.exists(lg_path): log_file.write("Generating LG.fst...") compose_lg(self.disambig_L_path, small_g_path, lg_path, log_file) + yield 1 if not os.path.exists(clg_path): log_file.write("Generating CLG.fst...") compose_clg( @@ -605,16 +833,18 @@ def _run(self) -> typing.Generator[typing.Tuple[bool, str]]: clg_path, log_file, ) + yield 1 if not os.path.exists(hclga_path): log_file.write("Generating HCLGa.fst...") compose_hclg( - self.working_directory, + self.model_path, ilabels_temp, self.hclg_options["transition_scale"], clg_path, hclga_path, log_file, ) + yield 1 log_file.write("Generating HCLG.fst...") self_loop_proc = subprocess.Popen( [ @@ -654,9 +884,9 @@ class DecodeFunction(KaldiFunction): See Also -------- - :meth:`.Transcriber.transcribe` + :meth:`.TranscriberMixin.transcribe_utterances` Main function that calls this function in parallel - :meth:`.Transcriber.decode_arguments` + :meth:`.TranscriberMixin.decode_arguments` Job method for generating arguments for this function :kaldi_src:`gmm-latgen-faster` Relevant Kaldi binary @@ -730,107 +960,7 @@ def _run(self) -> typing.Generator[typing.Tuple[str, float, int]]: yield m.group("utterance"), float(m.group("loglike")), int( m.group("num_frames") ) - self.check_call(decode_proc) - - -class ScoreFunction(KaldiFunction): - """ - Multiprocessing function for scoring lattices - - See Also - -------- - :meth:`~montreal_forced_aligner.transcription.Transcriber.score_transcriptions` - Main function that calls this function in parallel - :meth:`.Transcriber.score_arguments` - Job method for generating arguments for this function - :kaldi_src:`lattice-scale` - Relevant Kaldi binary - :kaldi_src:`lattice-add-penalty` - Relevant Kaldi binary - :kaldi_src:`lattice-best-path` - Relevant Kaldi binary - - Parameters - ---------- - args: :class:`~montreal_forced_aligner.transcription.multiprocessing.ScoreArguments` - Arguments for the function - """ - - progress_pattern = re.compile( - r"^LOG .* For utterance (?P.*), best cost (?P[-\d.]+) \+ (?P[-\d.]+) = (?P[-\d.]+) over (?P\d+) frames." - ) - - def __init__(self, args: ScoreArguments): - super().__init__(args) - self.dictionaries = args.dictionaries - self.score_options = args.score_options - self.lat_paths = args.lat_paths - self.rescored_lat_paths = args.rescored_lat_paths - self.carpa_rescored_lat_paths = args.carpa_rescored_lat_paths - self.words_paths = args.words_paths - self.tra_paths = args.tra_paths - self.ali_paths = args.ali_paths - - def _run(self) -> typing.Generator[typing.Tuple[str, float, float, float, int]]: - """Run the function""" - with mfa_open(self.log_path, "w") as log_file: - for dict_id in self.dictionaries: - language_model_weight = self.score_options["language_model_weight"] - word_insertion_penalty = self.score_options["word_insertion_penalty"] - carpa_rescored_lat_path = self.carpa_rescored_lat_paths[dict_id] - rescored_lat_path = self.rescored_lat_paths[dict_id] - lat_path = self.lat_paths[dict_id] - words_path = self.words_paths[dict_id] - tra_path = self.tra_paths[dict_id] - ali_path = self.ali_paths[dict_id] - if os.path.exists(carpa_rescored_lat_path): - lat_path = carpa_rescored_lat_path - elif os.path.exists(rescored_lat_path): - lat_path = rescored_lat_path - scale_proc = subprocess.Popen( - [ - thirdparty_binary("lattice-scale"), - f"--inv-acoustic-scale={language_model_weight}", - f"ark:{lat_path}", - "ark:-", - ], - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - penalty_proc = subprocess.Popen( - [ - thirdparty_binary("lattice-add-penalty"), - f"--word-ins-penalty={word_insertion_penalty}", - "ark:-", - "ark:-", - ], - stdin=scale_proc.stdout, - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - best_path_proc = subprocess.Popen( - [ - thirdparty_binary("lattice-best-path"), - f"--word-symbol-table={words_path}", - "ark:-", - f"ark,t:{tra_path}", - f"ark:{ali_path}", - ], - stdin=penalty_proc.stdout, - stderr=subprocess.PIPE, - env=os.environ, - encoding="utf8", - ) - for line in best_path_proc.stderr: - log_file.write(line) - m = self.progress_pattern.match(line.strip()) - if m: - yield m.group("utterance"), float(m.group("graph_cost")), float( - m.group("acoustic_cost") - ), float(m.group("total_cost")), int(m.group("num_frames")) - self.check_call(best_path_proc) + self.check_call(decode_proc) class LmRescoreFunction(KaldiFunction): @@ -839,9 +969,9 @@ class LmRescoreFunction(KaldiFunction): See Also -------- - :func:`~montreal_forced_aligner.transcription.Transcriber.transcribe` + :meth:`.TranscriberMixin.transcribe_utterances` Main function that calls this function in parallel - :meth:`.Transcriber.lm_rescore_arguments` + :meth:`.TranscriberMixin.lm_rescore_arguments` Job method for generating arguments for this function :kaldi_src:`lattice-lmrescore-pruned` Relevant Kaldi binary @@ -893,7 +1023,7 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: f"--acoustic-scale={self.lm_rescore_options['acoustic_scale']}", "-", f"fstproject {project_type_arg} {new_g_path} |", - f"ark:{lat_path}", + f"ark,s,cs:{lat_path}", f"ark:{rescored_lat_path}", ], stdin=project_proc.stdout, @@ -915,9 +1045,9 @@ class CarpaLmRescoreFunction(KaldiFunction): See Also -------- - :func:`~montreal_forced_aligner.transcription.Transcriber.transcribe` + :meth:`.TranscriberMixin.transcribe_utterances` Main function that calls this function in parallel - :meth:`.Transcriber.carpa_lm_rescore_arguments` + :meth:`.TranscriberMixin.carpa_lm_rescore_arguments` Job method for generating arguments for this function :openfst_src:`fstproject` Relevant OpenFst binary @@ -965,7 +1095,7 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: [ thirdparty_binary("lattice-lmrescore"), "--lm-scale=-1.0", - f"ark:{lat_path}", + f"ark,s,cs:{lat_path}", "-", "ark:-", ], @@ -978,7 +1108,7 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: [ thirdparty_binary("lattice-lmrescore-const-arpa"), "--lm-scale=1.0", - "ark:-", + "ark,s,cs:-", new_g_path, f"ark:{rescored_lat_path}", ], @@ -1001,9 +1131,9 @@ class InitialFmllrFunction(KaldiFunction): See Also -------- - :func:`~montreal_forced_aligner.transcription.Transcriber.transcribe_fmllr` + :meth:`.TranscriberMixin.transcribe_fmllr` Main function that calls this function in parallel - :meth:`.Transcriber.initial_fmllr_arguments` + :meth:`.TranscriberMixin.initial_fmllr_arguments` Job method for generating arguments for this function :kaldi_src:`lattice-to-post` Relevant Kaldi binary @@ -1047,7 +1177,7 @@ def _run(self) -> typing.Generator[int]: [ thirdparty_binary("lattice-to-post"), f"--acoustic-scale={self.fmllr_options['acoustic_scale']}", - f"ark:{lat_path}", + f"ark,s,cs:{lat_path}", "ark:-", ], stdout=subprocess.PIPE, @@ -1085,7 +1215,7 @@ def _run(self) -> typing.Generator[int]: [ thirdparty_binary("gmm-est-fmllr-gpost"), f"--fmllr-update-type={self.fmllr_options['fmllr_update_type']}", - f"--spk2utt=ark:{spk2utt_path}", + f"--spk2utt=ark,s,cs:{spk2utt_path}", self.model_path, feature_string, "ark,s,cs:-", @@ -1111,9 +1241,9 @@ class LatGenFmllrFunction(KaldiFunction): See Also -------- - :func:`~montreal_forced_aligner.transcription.Transcriber.transcribe_fmllr` + :meth:`.TranscriberMixin.transcribe_fmllr` Main function that calls this function in parallel - :meth:`.Transcriber.lat_gen_fmllr_arguments` + :meth:`.TranscriberMixin.lat_gen_fmllr_arguments` Job method for generating arguments for this function :kaldi_src:`gmm-latgen-faster` Relevant Kaldi binary @@ -1143,8 +1273,12 @@ def _run(self) -> typing.Generator[typing.Tuple[str, float, int]]: with mfa_open(self.log_path, "w") as log_file: for dict_id in self.dictionaries: feature_string = self.feature_strings[dict_id] - words_path = self.word_symbol_paths[dict_id] - hclg_path = self.hclg_paths[dict_id] + if isinstance(self.hclg_paths, dict): + words_path = self.word_symbol_paths[dict_id] + hclg_path = self.hclg_paths[dict_id] + else: + words_path = self.word_symbol_paths + hclg_path = self.hclg_paths tmp_lat_path = self.tmp_lat_paths[dict_id] lat_gen_proc = subprocess.Popen( [ @@ -1167,6 +1301,7 @@ def _run(self) -> typing.Generator[typing.Tuple[str, float, int]]: ) for line in lat_gen_proc.stderr: log_file.write(line) + log_file.flush() m = self.progress_pattern.match(line.strip()) if m: yield m.group("utterance"), float(m.group("loglike")), int( @@ -1182,9 +1317,9 @@ class FinalFmllrFunction(KaldiFunction): See Also -------- - :func:`~montreal_forced_aligner.transcription.Transcriber.transcribe_fmllr` + :meth:`.TranscriberMixin.transcribe_fmllr` Main function that calls this function in parallel - :meth:`.Transcriber.final_fmllr_arguments` + :meth:`.TranscriberMixin.final_fmllr_arguments` Job method for generating arguments for this function :kaldi_src:`lattice-determinize-pruned` Relevant Kaldi binary @@ -1232,7 +1367,7 @@ def _run(self) -> typing.Generator[int]: thirdparty_binary("lattice-determinize-pruned"), f"--acoustic-scale={self.fmllr_options['acoustic_scale']}", "--beam=4.0", - f"ark:{tmp_lat_path}", + f"ark,s,cs:{tmp_lat_path}", "ark:-", ], stderr=log_file, @@ -1244,7 +1379,7 @@ def _run(self) -> typing.Generator[int]: [ thirdparty_binary("lattice-to-post"), f"--acoustic-scale={self.fmllr_options['acoustic_scale']}", - "ark:-", + "ark,s,cs:-", "ark:-", ], stdin=determinize_proc.stdout, @@ -1258,7 +1393,7 @@ def _run(self) -> typing.Generator[int]: f"{self.fmllr_options['silence_weight']}", self.fmllr_options["sil_phones"], self.model_path, - "ark:-", + "ark,s,cs:-", "ark:-", ], stdin=latt_post_proc.stdout, @@ -1270,7 +1405,7 @@ def _run(self) -> typing.Generator[int]: [ thirdparty_binary("gmm-est-fmllr"), f"--fmllr-update-type={self.fmllr_options['fmllr_update_type']}", - f"--spk2utt=ark:{spk2utt_path}", + f"--spk2utt=ark,s,cs:{spk2utt_path}", self.model_path, feature_string, "ark,s,cs:-", @@ -1313,9 +1448,9 @@ class FmllrRescoreFunction(KaldiFunction): See Also -------- - :func:`~montreal_forced_aligner.transcription.Transcriber.transcribe_fmllr` + :meth:`.TranscriberMixin.transcribe_fmllr` Main function that calls this function in parallel - :meth:`.Transcriber.fmllr_rescore_arguments` + :meth:`.TranscriberMixin.fmllr_rescore_arguments` Job method for generating arguments for this function :kaldi_src:`gmm-rescore-lattice` Relevant Kaldi binary @@ -1352,7 +1487,7 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: [ thirdparty_binary("gmm-rescore-lattice"), self.model_path, - f"ark:{tmp_lat_path}", + f"ark,s,cs:{tmp_lat_path}", feature_string, "ark:-", ], @@ -1365,7 +1500,7 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: thirdparty_binary("lattice-determinize-pruned"), f"--acoustic-scale={self.fmllr_options['acoustic_scale']}", f"--beam={self.fmllr_options['lattice_beam']}", - "ark:-", + "ark,s,cs:-", f"ark:{final_lat_path}", ], stdin=rescore_proc.stdout, @@ -1379,3 +1514,214 @@ def _run(self) -> typing.Generator[typing.Tuple[int, int]]: if m: yield int(m.group("done")), int(m.group("errors")) self.check_call(determinize_proc) + + +@dataclass +class PerSpeakerDecodeArguments(MfaArguments): + """Arguments for :class:`~montreal_forced_aligner.validation.corpus_validator.PerSpeakerDecodeFunction`""" + + model_directory: str + feature_strings: Dict[int, str] + lat_paths: Dict[int, str] + model_path: str + disambiguation_symbols_int_path: str + decode_options: MetaDict + tree_path: str + order: int + method: str + + +class PerSpeakerDecodeFunction(KaldiFunction): + """ + Multiprocessing function to test utterance transcriptions with utterance and speaker ngram models + + See Also + -------- + :kaldi_src:`compile-train-graphs-fsts` + Relevant Kaldi binary + :kaldi_src:`gmm-latgen-faster` + Relevant Kaldi binary + :kaldi_src:`lattice-oracle` + Relevant Kaldi binary + :openfst_src:`farcompilestrings` + Relevant OpenFst binary + :ngram_src:`ngramcount` + Relevant OpenGrm-Ngram binary + :ngram_src:`ngrammake` + Relevant OpenGrm-Ngram binary + :ngram_src:`ngramshrink` + Relevant OpenGrm-Ngram binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.validation.corpus_validator.PerSpeakerDecodeArguments` + Arguments for the function + """ + + progress_pattern = re.compile( + r"^LOG.*Log-like per frame for utterance (?P.*) is (?P[-\d.]+) over (?P\d+) frames." + ) + + def __init__(self, args: PerSpeakerDecodeArguments): + super().__init__(args) + self.feature_strings = args.feature_strings + self.disambiguation_symbols_int_path = args.disambiguation_symbols_int_path + self.model_directory = args.model_directory + self.model_path = args.model_path + self.decode_options = args.decode_options + self.lat_paths = args.lat_paths + self.tree_path = args.tree_path + self.order = args.order + self.method = args.method + self.word_symbols_paths = {} + + def _run(self) -> typing.Generator[typing.Tuple[int, str]]: + """Run the function""" + with mfa_open(self.log_path, "w") as log_file, Session(self.db_engine) as session: + + job: Job = ( + session.query(Job) + .options(joinedload(Job.corpus, innerjoin=True), subqueryload(Job.dictionaries)) + .filter(Job.id == self.job_name) + .first() + ) + for d in job.dictionaries: + + self.oov_word = d.oov_word + self.word_symbols_paths[d.id] = d.words_symbol_path + feature_string = self.feature_strings[d.id] + lat_path = self.lat_paths[d.id] + latgen_proc = subprocess.Popen( + [ + thirdparty_binary("gmm-latgen-faster"), + f"--acoustic-scale={self.decode_options['acoustic_scale']}", + f"--beam={self.decode_options['beam']}", + f"--max-active={self.decode_options['max_active']}", + f"--lattice-beam={self.decode_options['lattice_beam']}", + f"--word-symbol-table={d.words_symbol_path}", + self.model_path, + "ark,s,cs:-", + feature_string, + f"ark:{lat_path}", + ], + stderr=subprocess.PIPE, + stdin=subprocess.PIPE, + env=os.environ, + ) + current_speaker = None + for utt_id, speaker_id in ( + session.query(Utterance.kaldi_id, Utterance.speaker_id) + .filter(Utterance.job_id == job.id) + .order_by(Utterance.kaldi_id) + ): + if speaker_id != current_speaker: + lm_path = os.path.join(d.temp_directory, f"{speaker_id}.fst") + fst = pynini.Fst.read(lm_path) + fst_string = fst.write_to_string() + del fst + + latgen_proc.stdin.write(utt_id.encode("utf8") + b" " + fst_string) + latgen_proc.stdin.flush() + + while True: + line = latgen_proc.stderr.readline().decode("utf8") + line = line.strip() + if not line: + break + log_file.write(line + "\n") + log_file.flush() + m = self.progress_pattern.match(line.strip()) + if m: + yield m.group("utterance"), float(m.group("loglike")), int( + m.group("num_frames") + ) + break + latgen_proc.stdin.close() + self.check_call(latgen_proc) + + +class DecodePhoneFunction(KaldiFunction): + """ + Multiprocessing function for performing decoding + + See Also + -------- + :meth:`.TranscriberMixin.transcribe_utterances` + Main function that calls this function in parallel + :meth:`.TranscriberMixin.decode_arguments` + Job method for generating arguments for this function + :kaldi_src:`gmm-latgen-faster` + Relevant Kaldi binary + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.transcription.multiprocessing.DecodeArguments` + Arguments for the function + """ + + progress_pattern = re.compile( + r"^LOG.*Log-like per frame for utterance (?P.*) is (?P[-\d.]+) over (?P\d+) frames." + ) + + def __init__(self, args: DecodePhoneArguments): + super().__init__(args) + self.dictionaries = args.dictionaries + self.feature_strings = args.feature_strings + self.lat_paths = args.lat_paths + self.phone_symbol_path = args.phone_symbol_path + self.hclg_path = args.hclg_path + self.decode_options = args.decode_options + self.model_path = args.model_path + + def _run(self) -> typing.Generator[typing.Tuple[str, float, int]]: + """Run the function""" + with Session(self.db_engine) as session, mfa_open(self.log_path, "w") as log_file: + phones = session.query(Phone.mapping_id, Phone.phone) + reversed_phone_mapping = {} + for p_id, phone in phones: + reversed_phone_mapping[p_id] = phone + for dict_id in self.dictionaries: + feature_string = self.feature_strings[dict_id] + lat_path = self.lat_paths[dict_id] + if os.path.exists(lat_path): + continue + if ( + self.decode_options["uses_speaker_adaptation"] + and self.decode_options["first_beam"] is not None + ): + beam = self.decode_options["first_beam"] + else: + beam = self.decode_options["beam"] + if ( + self.decode_options["uses_speaker_adaptation"] + and self.decode_options["first_max_active"] is not None + ): + max_active = self.decode_options["first_max_active"] + else: + max_active = self.decode_options["max_active"] + decode_proc = subprocess.Popen( + [ + thirdparty_binary("gmm-latgen-faster"), + f"--max-active={max_active}", + f"--beam={beam}", + f"--lattice-beam={self.decode_options['lattice_beam']}", + "--allow-partial=true", + f"--word-symbol-table={self.phone_symbol_path}", + f"--acoustic-scale={self.decode_options['acoustic_scale']}", + self.model_path, + self.hclg_path, + feature_string, + f"ark:{lat_path}", + ], + stderr=subprocess.PIPE, + env=os.environ, + encoding="utf8", + ) + for line in decode_proc.stderr: + log_file.write(line) + m = self.progress_pattern.match(line.strip()) + if m: + yield m.group("utterance"), float(m.group("loglike")), int( + m.group("num_frames") + ) + self.check_call(decode_proc) diff --git a/montreal_forced_aligner/transcription/transcriber.py b/montreal_forced_aligner/transcription/transcriber.py index a654a958..1316f016 100644 --- a/montreal_forced_aligner/transcription/transcriber.py +++ b/montreal_forced_aligner/transcription/transcriber.py @@ -5,8 +5,8 @@ """ from __future__ import annotations +import collections import csv -import itertools import logging import multiprocessing as mp import os @@ -17,6 +17,7 @@ from queue import Empty from typing import TYPE_CHECKING, Dict, List, Optional, Tuple +import pywrapfst import tqdm from praatio import textgrid from sqlalchemy.orm import joinedload, selectinload @@ -24,15 +25,22 @@ from montreal_forced_aligner.abc import TopLevelMfaWorker from montreal_forced_aligner.alignment.base import CorpusAligner from montreal_forced_aligner.alignment.multiprocessing import construct_output_path -from montreal_forced_aligner.data import TextFileType, TextgridFormats +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.data import ( + ArpaNgramModel, + TextFileType, + TextgridFormats, + WorkflowType, +) from montreal_forced_aligner.db import ( - Corpus, + CorpusWorkflow, Dictionary, File, + Phone, SoundFile, Speaker, - SpeakerOrdering, Utterance, + bulk_update, ) from montreal_forced_aligner.exceptions import KaldiProcessingError from montreal_forced_aligner.helper import ( @@ -41,6 +49,12 @@ parse_old_features, score_wer, ) +from montreal_forced_aligner.language_modeling.multiprocessing import ( + TrainLmArguments, + TrainPhoneLmFunction, + TrainSpeakerLmArguments, + TrainSpeakerLmFunction, +) from montreal_forced_aligner.models import AcousticModel, LanguageModel from montreal_forced_aligner.transcription.multiprocessing import ( CarpaLmRescoreArguments, @@ -49,6 +63,8 @@ CreateHclgFunction, DecodeArguments, DecodeFunction, + DecodePhoneArguments, + DecodePhoneFunction, FinalFmllrArguments, FinalFmllrFunction, FmllrRescoreArguments, @@ -59,25 +75,27 @@ LatGenFmllrFunction, LmRescoreArguments, LmRescoreFunction, - ScoreArguments, - ScoreFunction, + PerSpeakerDecodeArguments, + PerSpeakerDecodeFunction, ) from montreal_forced_aligner.utils import ( KaldiProcessWorker, Stopped, log_kaldi_errors, + run_kaldi_function, thirdparty_binary, ) if TYPE_CHECKING: - from argparse import Namespace from montreal_forced_aligner.abc import MetaDict __all__ = ["Transcriber", "TranscriberMixin"] +logger = logging.getLogger("mfa") -class TranscriberMixin: + +class TranscriberMixin(CorpusAligner): """Abstract class for MFA transcribers Parameters @@ -113,12 +131,11 @@ def __init__( self_loop_scale: float = 0.1, beam: int = 10, silence_weight: float = 0.01, - max_active: int = 7000, - lattice_beam: int = 6, first_beam: int = 10, first_max_active: int = 2000, language_model_weight: int = 10, word_insertion_penalty: float = 0.5, + evaluation_mode: bool = False, **kwargs, ): super().__init__(**kwargs) @@ -128,12 +145,553 @@ def __init__( self.self_loop_scale = self_loop_scale self.transition_scale = transition_scale self.silence_weight = silence_weight - self.max_active = max_active - self.lattice_beam = lattice_beam self.first_beam = first_beam self.first_max_active = first_max_active self.language_model_weight = language_model_weight self.word_insertion_penalty = word_insertion_penalty + self.evaluation_mode = evaluation_mode + + def train_speaker_lm_arguments( + self, + ) -> List[TrainSpeakerLmArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.language_modeling.multiprocessing.TrainSpeakerLmFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.language_modeling.multiprocessing.TrainSpeakerLmArguments`] + Arguments for processing + """ + arguments = [] + with self.session() as session: + for j in self.jobs: + speaker_mapping = {} + speaker_paths = {} + words_symbol_paths = {} + + speakers = ( + session.query(Speaker) + .join(Speaker.utterances) + .options(joinedload(Speaker.dictionary, innerjoin=True)) + .filter(Utterance.job_id == j.id) + .distinct() + ) + for s in speakers: + dict_id = s.dictionary_id + if dict_id not in speaker_mapping: + speaker_mapping[dict_id] = [] + words_symbol_paths[dict_id] = s.dictionary.words_symbol_path + speaker_mapping[dict_id].append(s.id) + speaker_paths[s.id] = os.path.join(self.data_directory, f"{s.id}.txt") + arguments.append( + TrainSpeakerLmArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"train_lm.{j.id}.log"), + self.model_path, + self.order, + self.method, + self.target_num_ngrams, + self.hclg_options, + ) + ) + return arguments + + def train_speaker_lms(self) -> None: + """Train language models for each speaker based on their utterances""" + begin = time.time() + log_directory = self.model_log_directory + 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: + if GLOBAL_CONFIG.use_mp: + error_dict = {} + return_queue = mp.Queue() + stopped = Stopped() + procs = [] + + for i, args in enumerate(arguments): + function = TrainSpeakerLmFunction(args) + p = KaldiProcessWorker(i, return_queue, function, stopped) + procs.append(p) + p.start() + while True: + try: + result = return_queue.get(timeout=1) + if stopped.stop_check(): + continue + except Empty: + for proc in procs: + if not proc.finished.stop_check(): + break + else: + break + continue + if isinstance(result, KaldiProcessingError): + error_dict[result.job_name] = result + continue + pbar.update(1) + if error_dict: + for v in error_dict.values(): + raise v + else: + logger.debug("Not using multiprocessing...") + for args in arguments: + function = TrainSpeakerLmFunction(args) + for _ in function.run(): + pbar.update(1) + logger.debug(f"Compiling speaker language models took {time.time() - begin:.3f} seconds") + + @property + def model_directory(self) -> str: + """Model directory for the transcriber""" + return os.path.join(self.output_directory, "models") + + @property + def model_log_directory(self) -> str: + """Model directory for the transcriber""" + return os.path.join(self.model_directory, "log") + + def lm_rescore(self) -> None: + """ + Rescore lattices with bigger language model + + See Also + ------- + :class:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreFunction` + Multiprocessing function + :meth:`.TranscriberMixin.lm_rescore_arguments` + Arguments for function + """ + logger.info("Rescoring lattices with medium G.fst...") + if GLOBAL_CONFIG.use_mp: + error_dict = {} + return_queue = mp.Queue() + stopped = Stopped() + procs = [] + for i, args in enumerate(self.lm_rescore_arguments()): + function = LmRescoreFunction(args) + 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: + while True: + try: + result = return_queue.get(timeout=1) + if isinstance(result, Exception): + error_dict[getattr(result, "job_name", 0)] = result + continue + if stopped.stop_check(): + continue + except Empty: + for proc in procs: + if not proc.finished.stop_check(): + break + else: + break + continue + succeeded, failed = result + if failed: + logger.warning("Some lattices failed to be rescored") + pbar.update(succeeded + failed) + for p in procs: + p.join() + if error_dict: + for v in error_dict.values(): + raise v + 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: + for succeeded, failed in function.run(): + if failed: + logger.warning("Some lattices failed to be rescored") + pbar.update(succeeded + failed) + + def carpa_lm_rescore(self) -> None: + """ + Rescore lattices with CARPA language model + + See Also + ------- + :class:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreFunction` + Multiprocessing function + :meth:`.TranscriberMixin.carpa_lm_rescore_arguments` + Arguments for function + """ + logger.info("Rescoring lattices with large G.carpa...") + if GLOBAL_CONFIG.use_mp: + error_dict = {} + return_queue = mp.Queue() + stopped = Stopped() + procs = [] + for i, args in enumerate(self.carpa_lm_rescore_arguments()): + function = CarpaLmRescoreFunction(args) + 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: + while True: + try: + result = return_queue.get(timeout=1) + if isinstance(result, Exception): + error_dict[getattr(result, "job_name", 0)] = result + continue + if stopped.stop_check(): + continue + except Empty: + for proc in procs: + if not proc.finished.stop_check(): + break + else: + break + continue + succeeded, failed = result + if failed: + logger.warning("Some lattices failed to be rescored") + pbar.update(succeeded + failed) + for p in procs: + p.join() + if error_dict: + for v in error_dict.values(): + raise v + 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: + for succeeded, failed in function.run(): + if failed: + logger.warning("Some lattices failed to be rescored") + pbar.update(succeeded + failed) + + def train_phone_lm(self): + """Train a phone-based language model (i.e., not using words).""" + if not self.has_alignments(self.current_workflow.id): + logger.error("Cannot train phone LM without alignments") + return + if self.use_g2p: + return + logger.info("Beginning phone LM training...") + logger.info("Collecting training data...") + + ngram_order = 4 + num_ngrams = 20000 + phone_lm_path = os.path.join(self.phones_dir, "phone_lm.fst") + log_path = os.path.join(self.phones_dir, "phone_lm_training.log") + unigram_phones = set() + return_queue = mp.Queue() + stopped = Stopped() + error_dict = {} + procs = [] + count_paths = [] + allowed_bigrams = collections.defaultdict(set) + with self.session() as session, tqdm.tqdm( + total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet + ) as pbar: + + with mfa_open(os.path.join(self.phones_dir, "phone_boundaries.int"), "w") as f: + for p in session.query(Phone): + f.write(f"{p.mapping_id} singleton\n") + for j in self.jobs: + args = TrainLmArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"ngram_count.{j.id}.log"), + self.phones_dir, + self.phone_symbol_table_path, + ngram_order, + self.oov_word, + ) + function = TrainPhoneLmFunction(args) + p = KaldiProcessWorker(j.id, return_queue, function, stopped) + procs.append(p) + p.start() + count_paths.append(os.path.join(self.phones_dir, f"{j.id}.cnts")) + while True: + try: + result = return_queue.get(timeout=1) + if isinstance(result, Exception): + error_dict[getattr(result, "job_name", 0)] = result + continue + if stopped.stop_check(): + continue + except Empty: + for proc in procs: + if not proc.finished.stop_check(): + break + else: + break + continue + _, phones = result + phones = phones.split() + unigram_phones.update(phones) + phones = [""] + phones + [""] + for i in range(len(phones) - 1): + allowed_bigrams[phones[i]].add(phones[i + 1]) + + pbar.update(1) + for p in procs: + p.join() + if error_dict: + for v in error_dict.values(): + raise v + logger.info("Training model...") + with mfa_open(log_path, "w") as log_file: + merged_file = os.path.join(self.phones_dir, "merged.cnts") + if len(count_paths) > 1: + ngrammerge_proc = subprocess.Popen( + [ + thirdparty_binary("ngrammerge"), + f"--ofile={merged_file}", + *count_paths, + ], + stderr=log_file, + env=os.environ, + ) + ngrammerge_proc.communicate() + else: + os.rename(count_paths[0], merged_file) + ngrammake_proc = subprocess.Popen( + [thirdparty_binary("ngrammake"), "--v=2", "--method=kneser_ney", merged_file], + stderr=log_file, + stdout=subprocess.PIPE, + env=os.environ, + ) + ngramshrink_proc = subprocess.Popen( + [ + thirdparty_binary("ngramshrink"), + "--v=2", + "--method=relative_entropy", + f"--target_number_of_ngrams={num_ngrams}", + ], + stderr=log_file, + stdin=ngrammake_proc.stdout, + stdout=subprocess.PIPE, + env=os.environ, + ) + print_proc = subprocess.Popen( + [ + thirdparty_binary("ngramprint"), + "--ARPA", + f"--symbols={self.phone_symbol_table_path}", + ], + stdin=ngramshrink_proc.stdout, + stderr=log_file, + encoding="utf8", + stdout=subprocess.PIPE, + env=os.environ, + ) + model = ArpaNgramModel.read(print_proc.stdout) + phone_symbols = pywrapfst.SymbolTable() + for _, phone in sorted(self.reversed_phone_mapping.items()): + phone_symbols.add_symbol(phone) + log_file.write("Done training initial ngram model\n") + log_file.flush() + bigram_fst = model.construct_bigram_fst("#1", allowed_bigrams, phone_symbols) + + bigram_fst.write(os.path.join(self.phones_dir, "bigram.fst")) + bigram_fst.project("output") + push_special_proc = subprocess.Popen( + [thirdparty_binary("fstpushspecial")], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + minimize_proc = subprocess.Popen( + [thirdparty_binary("fstminimizeencoded")], + stdin=push_special_proc.stdout, + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + rm_syms_proc = subprocess.Popen( + [ + thirdparty_binary("fstrmsymbols"), + "--remove-from-output=true", + self.disambiguation_symbols_int_path, + "-", + phone_lm_path, + ], + stdin=minimize_proc.stdout, + stderr=log_file, + env=os.environ, + ) + push_special_proc.stdin.write(bigram_fst.write_to_string()) + push_special_proc.stdin.flush() + push_special_proc.stdin.close() + rm_syms_proc.communicate() + + def setup_phone_lm(self) -> None: + """Setup phone language model for phone-based transcription""" + from montreal_forced_aligner.transcription.multiprocessing import compose_clg, compose_hclg + + self.train_phone_lm() + with mfa_open(os.path.join(self.working_log_directory, "hclg.log"), "w") as log_file: + context_width = self.hclg_options["context_width"] + central_pos = self.hclg_options["central_pos"] + + clg_path = os.path.join( + self.working_directory, f"CLG_{context_width}_{central_pos}.fst" + ) + hclga_path = os.path.join(self.working_directory, "HCLGa.fst") + hclg_path = os.path.join(self.working_directory, "HCLG_phone.fst") + ilabels_temp = os.path.join( + self.working_directory, f"ilabels_{context_width}_{central_pos}" + ) + out_disambig = os.path.join( + self.working_directory, f"disambig_ilabels_{context_width}_{central_pos}.int" + ) + + compose_clg( + self.disambiguation_symbols_int_path, + out_disambig, + context_width, + central_pos, + ilabels_temp, + os.path.join(self.phones_dir, "phone_lm.fst"), + clg_path, + log_file, + ) + log_file.write("Generating HCLGa.fst...") + compose_hclg( + self.model_path, + ilabels_temp, + self.hclg_options["transition_scale"], + clg_path, + hclga_path, + log_file, + ) + log_file.write("Generating HCLG.fst...") + self_loop_proc = subprocess.Popen( + [ + thirdparty_binary("add-self-loops"), + f"--self-loop-scale={self.hclg_options['self_loop_scale']}", + "--reorder=true", + self.model_path, + hclga_path, + ], + stderr=log_file, + stdout=subprocess.PIPE, + env=os.environ, + ) + convert_proc = subprocess.Popen( + [ + thirdparty_binary("fstconvert"), + "--v=100", + "--fst_type=const", + "-", + hclg_path, + ], + stdin=self_loop_proc.stdout, + stderr=log_file, + env=os.environ, + ) + convert_proc.communicate() + + def transcribe(self, workflow_type: WorkflowType = WorkflowType.transcription): + self.initialize_database() + previous_working_directory = self.working_directory + self.create_new_current_workflow(workflow_type) + if workflow_type is WorkflowType.phone_transcription: + self.setup_phone_lm() + for a in self.calc_fmllr_arguments(): + for p in a.trans_paths.values(): + shutil.copyfile( + p.replace(self.working_directory, previous_working_directory), p + ) + elif workflow_type is WorkflowType.per_speaker_transcription: + for a in self.calc_fmllr_arguments(): + for p in a.trans_paths.values(): + if os.path.exists(p): + shutil.copyfile( + p.replace(self.working_directory, previous_working_directory), p + ) + self.acoustic_model.export_model(self.working_directory) + self.transcribe_utterances() + + def transcribe_utterances(self) -> None: + """ + Transcribe the corpus + + See Also + -------- + :func:`~montreal_forced_aligner.transcription.multiprocessing.DecodeFunction` + Multiprocessing helper function for each job + :func:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreFunction` + Multiprocessing helper function for each job + :func:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreFunction` + Multiprocessing helper function for each job + + Raises + ------ + :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` + If there were any errors in running Kaldi binaries + """ + logger.info("Beginning transcription...") + workflow = self.current_workflow + if workflow.done: + logger.info("Transcription already done, skipping!") + return + try: + if workflow.workflow_type is WorkflowType.transcription: + self.uses_speaker_adaptation = False + + self.decode() + if workflow.workflow_type is WorkflowType.transcription: + done = True + for a in self.carpa_lm_rescore_arguments(): + for p in a.rescored_lat_paths.values(): + if not os.path.exists(p): + done = False + break + if done: + logger.info("Rescoring already done.") + else: + logger.info("Performing speaker adjusted transcription...") + self.transcribe_fmllr() + self.lm_rescore() + self.carpa_lm_rescore() + self.collect_alignments() + if self.fine_tune: + self.fine_tune_alignments() + if self.evaluation_mode: + os.makedirs(self.working_log_directory, exist_ok=True) + self.evaluate_transcriptions() + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == workflow.id).update( + {"done": True} + ) + session.commit() + except Exception as e: + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == workflow.id).update( + {"dirty": True} + ) + session.commit() + if isinstance(e, KaldiProcessingError): + log_kaldi_errors(e.error_logs) + e.update_log_file() + raise + + def evaluate_transcriptions(self) -> Tuple[float, float]: + """ + Evaluates the transcripts if there are reference transcripts + + Returns + ------- + float, float + Sentence error rate and word error rate + + Raises + ------ + :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` + If there were any errors in running Kaldi binaries + """ + logger.info("Evaluating transcripts...") + ser, wer, cer = self.compute_wer() + logger.info(f"SER: {100 * ser:.2f}%, WER: {100 * wer:.2f}%, CER: {100 * cer:.2f}%") def save_transcription_evaluation(self, output_directory: str) -> None: """ @@ -222,7 +780,7 @@ def compute_wer(self) -> typing.Tuple[float, float, float]: """ if not hasattr(self, "db_engine"): raise Exception("Must be used as part of a class with a database engine") - self.log_info("Evaluating transcripts...") + logger.info("Evaluating transcripts...") # Sentence-level measures incorrect = 0 total_count = 0 @@ -268,7 +826,7 @@ def compute_wer(self) -> typing.Tuple[float, float, float]: {"id": utt.id, "word_error_rate": 0.0, "character_error_rate": 0.0} ) - with mp.Pool(self.num_jobs) as pool: + with mp.Pool(GLOBAL_CONFIG.num_jobs) as pool: gen = pool.starmap(score_wer, to_comp) for i, (word_edits, word_length, character_edits, character_length) in enumerate( gen @@ -284,34 +842,13 @@ def compute_wer(self) -> typing.Tuple[float, float, float]: total_word_edits += word_edits total_character_edits += character_edits - session.bulk_update_mappings(Utterance, update_mappings) + bulk_update(session, Utterance, update_mappings) session.commit() ser = incorrect / total_count wer = total_word_edits / total_word_length cer = total_character_edits / total_character_length return ser, wer, cer - @property - def decode_options(self) -> MetaDict: - """Options needed for decoding""" - return { - "first_beam": self.first_beam, - "beam": self.beam, - "first_max_active": self.first_max_active, - "max_active": self.max_active, - "lattice_beam": self.lattice_beam, - "acoustic_scale": self.acoustic_scale, - "uses_speaker_adaptation": self.uses_speaker_adaptation, - } - - @property - def score_options(self) -> MetaDict: - """Options needed for scoring lattices""" - return { - "language_model_weight": self.language_model_weight, - "word_insertion_penalty": self.word_insertion_penalty, - } - @property def transcribe_fmllr_options(self) -> MetaDict: """Options needed for calculating fMLLR transformations""" @@ -328,743 +865,38 @@ def lm_rescore_options(self) -> MetaDict: "acoustic_scale": self.acoustic_scale, } - -class Transcriber(TranscriberMixin, CorpusAligner, TopLevelMfaWorker): - """ - Class for performing transcription. - - Parameters - ---------- - acoustic_model_path : str - Path to acoustic model - language_model_path : str - Path to language model model - evaluation_mode: bool - Flag for evaluating generated transcripts against the actual transcripts, defaults to False - min_language_model_weight: int - Minimum language model weight to use in evaluation mode, defaults to 7 - max_language_model_weight: int - Maximum language model weight to use in evaluation mode, defaults to 17 - word_insertion_penalties: list[float] - List of word insertion penalties to use in evaluation mode, defaults to [0, 0.5, 1.0] - - See Also - -------- - :class:`~montreal_forced_aligner.transcription.transcriber.TranscriberMixin` - For transcription parameters - :class:`~montreal_forced_aligner.corpus.acoustic_corpus.AcousticCorpusPronunciationMixin` - For corpus and dictionary parsing parameters - :class:`~montreal_forced_aligner.abc.FileExporterMixin` - For file exporting parameters - :class:`~montreal_forced_aligner.abc.TopLevelMfaWorker` - For top-level parameters - - Attributes - ---------- - acoustic_model: AcousticModel - Acoustic model - language_model: LanguageModel - Language model - """ - - def __init__( - self, - acoustic_model_path: str, - language_model_path: str, - evaluation_mode: bool = False, - min_language_model_weight: int = 7, - max_language_model_weight: int = 17, - word_insertion_penalties: List[float] = None, - output_type: str = "transcription", - **kwargs, - ): - self.acoustic_model = AcousticModel(acoustic_model_path) - kwargs.update(self.acoustic_model.parameters) - super(Transcriber, self).__init__(**kwargs) - self.language_model = LanguageModel(language_model_path, self.model_directory) - if word_insertion_penalties is None: - word_insertion_penalties = [0, 0.5, 1.0] - self.min_language_model_weight = min_language_model_weight - self.max_language_model_weight = max_language_model_weight - self.evaluation_mode = evaluation_mode - self.word_insertion_penalties = word_insertion_penalties - self.output_type = output_type - - @classmethod - def parse_parameters( - cls, - config_path: Optional[str] = None, - args: Optional[Namespace] = None, - unknown_args: Optional[List[str]] = None, - ) -> MetaDict: - """ - Parse configuration parameters from a config file and command line arguments - - Parameters - ---------- - config_path: str, optional - Path to yaml configuration file - args: :class:`~argparse.Namespace`, optional - Arguments parsed by argparse - unknown_args: list[str], optional - List of unknown arguments from argparse - - Returns - ------- - dict[str, Any] - Dictionary of specified configuration parameters - """ - global_params = {} - if config_path and os.path.exists(config_path): - data = load_configuration(config_path) - data = parse_old_features(data) - for k, v in data.items(): - if k == "features": - global_params.update(v) - else: - if v is None and k in cls.nullable_fields: - v = [] - global_params[k] = v - - global_params.update(cls.parse_args(args, unknown_args)) - if hasattr(args, "language_model_weight") and args.language_model_weight is not None: - global_params["min_language_model_weight"] = args.language_model_weight - global_params["max_language_model_weight"] = args.language_model_weight + 1 - if hasattr(args, "word_insertion_penalty") and args.word_insertion_penalty is not None: - global_params["word_insertion_penalties"] = [args.word_insertion_penalty] - return global_params - - def setup(self) -> None: - """Set up transcription""" - if self.initialized: - return - begin = time.time() - os.makedirs(self.working_log_directory, exist_ok=True) - check = self.check_previous_run() - if check: - self.log_debug( - "There were some differences in the current run compared to the last one. " - "This may cause issues, run with --clean, if you hit an error." - ) - self.load_corpus() - dirty_path = os.path.join(self.working_directory, "dirty") - if os.path.exists(dirty_path): - shutil.rmtree(self.working_directory, ignore_errors=True) - os.makedirs(self.working_log_directory, exist_ok=True) - dirty_path = os.path.join(self.model_directory, "dirty") - - if os.path.exists(dirty_path): # if there was an error, let's redo from scratch - shutil.rmtree(self.model_directory) - log_dir = os.path.join(self.model_directory, "log") - os.makedirs(log_dir, exist_ok=True) - self.acoustic_model.validate(self) - self.acoustic_model.export_model(self.model_directory) - self.acoustic_model.export_model(self.working_directory) - logger = logging.getLogger(self.identifier) - self.acoustic_model.log_details(logger) - self.create_decoding_graph() - self.initialized = True - self.log_debug(f"Setup for transcription in {time.time() - begin} seconds") - - def create_hclgs_arguments(self) -> Dict[str, CreateHclgArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.CreateHclgFunction` - - Returns - ------- - dict[str, :class:`~montreal_forced_aligner.transcription.multiprocessing.CreateHclgArguments`] - Per dictionary arguments for HCLG - """ - args = {} - with self.session() as session: - for d in session.query(Dictionary): - args[d.id] = CreateHclgArguments( - d.id, - getattr(self, "db_path", ""), - os.path.join(self.model_directory, "log", f"hclg.{d.id}.log"), - self.model_directory, - os.path.join(self.model_directory, "{file_name}" + f".{d.id}.fst"), - os.path.join(self.model_directory, f"words.{d.id}.txt"), - os.path.join(self.model_directory, f"G.{d.id}.carpa"), - self.language_model.small_arpa_path, - self.language_model.medium_arpa_path, - self.language_model.carpa_path, - self.model_path, - d.lexicon_disambig_fst_path, - d.disambiguation_symbols_int_path, - self.hclg_options, - self.word_mapping(d.id), - ) - return args - - def decode_arguments(self) -> List[DecodeArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.DecodeFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.transcription.multiprocessing.DecodeArguments`] - Arguments for processing - """ - feat_string = self.construct_feature_proc_strings() - return [ - DecodeArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"decode.{j.name}.log"), - j.dictionary_ids, - feat_string[j.name], - self.decode_options, - self.alignment_model_path, - j.construct_path_dictionary(self.working_directory, "lat", "ark"), - j.construct_dictionary_dependent_paths(self.model_directory, "words", "txt"), - j.construct_dictionary_dependent_paths(self.model_directory, "HCLG", "fst"), - ) - for j in self.jobs - if j.has_data - ] - - def score_arguments(self) -> List[ScoreArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.ScoreFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.transcription.multiprocessing.ScoreArguments`] - Arguments for processing - """ - return [ - ScoreArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.evaluation_directory, f"score.{j.name}.log"), - j.dictionary_ids, - self.score_options, - j.construct_path_dictionary(self.working_directory, "lat", "ark"), - j.construct_path_dictionary(self.working_directory, "lat.rescored", "ark"), - j.construct_path_dictionary(self.working_directory, "lat.carpa.rescored", "ark"), - j.construct_dictionary_dependent_paths(self.model_directory, "words", "txt"), - j.construct_path_dictionary(self.evaluation_directory, "tra", "scp"), - j.construct_path_dictionary(self.evaluation_directory, "ali", "ark"), - ) - for j in self.jobs - if j.has_data - ] - - def lm_rescore_arguments(self) -> List[LmRescoreArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreArguments`] - Arguments for processing - """ - return [ - LmRescoreArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"lm_rescore.{j.name}.log"), - j.dictionary_ids, - self.lm_rescore_options, - j.construct_path_dictionary(self.working_directory, "lat", "ark"), - j.construct_path_dictionary(self.working_directory, "lat.rescored", "ark"), - j.construct_dictionary_dependent_paths(self.model_directory, "G.small", "fst"), - j.construct_dictionary_dependent_paths(self.model_directory, "G.med", "fst"), - ) - for j in self.jobs - if j.has_data - ] - - def carpa_lm_rescore_arguments(self) -> List[CarpaLmRescoreArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreArguments`] - Arguments for processing - """ - return [ - CarpaLmRescoreArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"carpa_lm_rescore.{j.name}.log"), - j.dictionary_ids, - j.construct_path_dictionary(self.working_directory, "lat.rescored", "ark"), - j.construct_path_dictionary(self.working_directory, "lat.carpa.rescored", "ark"), - j.construct_dictionary_dependent_paths(self.model_directory, "G.med", "fst"), - j.construct_dictionary_dependent_paths(self.model_directory, "G", "carpa"), - ) - for j in self.jobs - if j.has_data - ] - - @property - def fmllr_options(self) -> MetaDict: - """Options for calculating fMLLR""" - options = super().fmllr_options - options["acoustic_scale"] = self.acoustic_scale - options["sil_phones"] = self.silence_csl - options["lattice_beam"] = self.lattice_beam - return options - - def initial_fmllr_arguments(self) -> List[InitialFmllrArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.InitialFmllrFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.transcription.multiprocessing.InitialFmllrArguments`] - Arguments for processing - """ - feat_strings = self.construct_feature_proc_strings() - return [ - InitialFmllrArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"initial_fmllr.{j.name}.log"), - j.dictionary_ids, - feat_strings[j.name], - self.model_path, - self.fmllr_options, - j.construct_path_dictionary(self.working_directory, "trans", "ark"), - j.construct_path_dictionary(self.working_directory, "lat", "ark"), - j.construct_path_dictionary(self.data_directory, "spk2utt", "scp"), - ) - for j in self.jobs - if j.has_data - ] - - def lat_gen_fmllr_arguments(self) -> List[LatGenFmllrArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.LatGenFmllrFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.transcription.multiprocessing.LatGenFmllrArguments`] - Arguments for processing - """ - feat_strings = self.construct_feature_proc_strings() - return [ - LatGenFmllrArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"lat_gen_fmllr.{j.name}.log"), - j.dictionary_ids, - feat_strings[j.name], - self.model_path, - self.decode_options, - j.construct_dictionary_dependent_paths(self.model_directory, "words", "txt"), - j.construct_dictionary_dependent_paths(self.model_directory, "HCLG", "fst"), - j.construct_path_dictionary(self.working_directory, "lat.tmp", "ark"), - ) - for j in self.jobs - if j.has_data - ] - - def final_fmllr_arguments(self) -> List[FinalFmllrArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.FinalFmllrFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.transcription.multiprocessing.FinalFmllrArguments`] - Arguments for processing - """ - feat_strings = self.construct_feature_proc_strings() - return [ - FinalFmllrArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"final_fmllr.{j.name}.log"), - j.dictionary_ids, - feat_strings[j.name], - self.model_path, - self.fmllr_options, - j.construct_path_dictionary(self.working_directory, "trans", "ark"), - j.construct_path_dictionary(self.data_directory, "spk2utt", "scp"), - j.construct_path_dictionary(self.working_directory, "lat.tmp", "ark"), - ) - for j in self.jobs - if j.has_data - ] - - def fmllr_rescore_arguments(self) -> List[FmllrRescoreArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.FmllrRescoreFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.transcription.multiprocessing.FmllrRescoreArguments`] - Arguments for processing - """ - feat_strings = self.construct_feature_proc_strings() - return [ - FmllrRescoreArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"fmllr_rescore.{j.name}.log"), - j.dictionary_ids, - feat_strings[j.name], - self.model_path, - self.fmllr_options, - j.construct_path_dictionary(self.working_directory, "lat.tmp", "ark"), - j.construct_path_dictionary(self.working_directory, "lat", "ark"), - ) - for j in self.jobs - if j.has_data - ] - - @property - def workflow_identifier(self) -> str: - """Transcriber identifier""" - return "transcriber" - - @property - def evaluation_directory(self) -> str: - """Evaluation directory path for the current language model weight and word insertion penalty""" - eval_string = f"eval_{self.language_model_weight}_{self.word_insertion_penalty}" - path = os.path.join(self.working_directory, eval_string) - os.makedirs(path, exist_ok=True) - return path - - @property - def evaluation_log_directory(self) -> str: - """Log directory for the current evaluation""" - return os.path.join(self.evaluation_directory, "log") - - @property - def model_directory(self) -> str: - """Model directory for the transcriber""" - return os.path.join(self.output_directory, "models") - - @property - def model_path(self) -> str: - """Acoustic model file path""" - return os.path.join(self.working_directory, "final.mdl") - - @property - def alignment_model_path(self) -> str: - """Alignment (speaker-independent) acoustic model file path""" - path = os.path.join(self.working_directory, "final.alimdl") - if os.path.exists(path): - return path - return self.model_path - - @property - def hclg_options(self) -> MetaDict: - """Options for constructing HCLG FSTs""" - context_width, central_pos = self.get_tree_info() - return { - "context_width": context_width, - "central_pos": central_pos, - "self_loop_scale": self.self_loop_scale, - "transition_scale": self.transition_scale, - } - - def get_tree_info(self) -> Tuple[int, int]: - """ - Get the context width and central position for the acoustic model - - Returns - ------- - int - Context width - int - Central position - """ - tree_proc = subprocess.Popen( - [thirdparty_binary("tree-info"), os.path.join(self.model_directory, "tree")], - encoding="utf8", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - stdout, _ = tree_proc.communicate() - context_width = 1 - central_pos = 0 - for line in stdout.split("\n"): - text = line.strip().split(" ") - if text[0] == "context-width": - context_width = int(text[1]) - elif text[0] == "central-position": - central_pos = int(text[1]) - return context_width, central_pos - - def create_hclgs(self) -> None: - """ - Create HCLG.fst files for every dictionary being used by a :class:`~montreal_forced_aligner.transcription.transcriber.Transcriber` - """ - - dict_arguments = self.create_hclgs_arguments() - dict_arguments = list(dict_arguments.values()) - self.log_info("Generating HCLG.fst...") - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(dict_arguments): - function = CreateHclgFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - with tqdm.tqdm( - total=len(dict_arguments), disable=getattr(self, "quiet", False) - ) as pbar: - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - result, hclg_path = result - if result: - self.log_debug(f"Done generating {hclg_path}!") - else: - self.log_warning(f"There was an error in generating {hclg_path}") - pbar.update(1) - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v - else: - for args in dict_arguments: - function = CreateHclgFunction(args) - with tqdm.tqdm( - total=len(dict_arguments), disable=getattr(self, "quiet", False) - ) as pbar: - for result, hclg_path in function.run(): - if result: - self.log_debug(f"Done generating {hclg_path}!") - else: - self.log_warning(f"There was an error in generating {hclg_path}") - pbar.update(1) - error_logs = [] - for arg in dict_arguments: - if not os.path.exists(arg.hclg_path): - error_logs.append(arg.log_path) - if error_logs: - raise KaldiProcessingError(error_logs) - - def create_decoding_graph(self) -> None: - """ - Create decoding graph for use in transcription - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` - If there were any errors in running Kaldi binaries - """ - done_path = os.path.join(self.model_directory, "done") - if os.path.exists(done_path): - self.log_info("Graph construction already done, skipping!") - log_dir = os.path.join(self.model_directory, "log") - os.makedirs(log_dir, exist_ok=True) - self.write_lexicon_information(write_disambiguation=True) - with self.session() as session: - for d in session.query(Dictionary): - words_path = os.path.join(self.model_directory, f"words.{d.id}.txt") - shutil.copyfile(d.words_symbol_path, words_path) - - big_arpa_path = self.language_model.carpa_path - small_arpa_path = self.language_model.small_arpa_path - medium_arpa_path = self.language_model.medium_arpa_path - if not os.path.exists(small_arpa_path) or not os.path.exists(medium_arpa_path): - self.log_warning( - "Creating small and medium language models from scratch, this may take some time. " - "Running `mfa train_lm` on the ARPA file will remove this warning." - ) - self.log_info("Parsing large ngram model...") - mod_path = os.path.join(self.model_directory, "base_lm.mod") - new_carpa_path = os.path.join(self.model_directory, "base_lm.arpa") - with mfa_open(big_arpa_path, "r") as inf, mfa_open(new_carpa_path, "w") as outf: - for line in inf: - outf.write(line.lower()) - big_arpa_path = new_carpa_path - subprocess.call(["ngramread", "--ARPA", big_arpa_path, mod_path]) - - if not os.path.exists(small_arpa_path): - self.log_info( - "Generating small model from the large ARPA with a pruning threshold of 3e-7" - ) - prune_thresh_small = 0.0000003 - small_mod_path = mod_path.replace(".mod", "_small.mod") - subprocess.call( - [ - "ngramshrink", - "--method=relative_entropy", - f"--theta={prune_thresh_small}", - mod_path, - small_mod_path, - ] - ) - subprocess.call(["ngramprint", "--ARPA", small_mod_path, small_arpa_path]) - - if not os.path.exists(medium_arpa_path): - self.log_info( - "Generating medium model from the large ARPA with a pruning threshold of 1e-7" - ) - prune_thresh_medium = 0.0000001 - med_mod_path = mod_path.replace(".mod", "_med.mod") - subprocess.call( - [ - "ngramshrink", - "--method=relative_entropy", - f"--theta={prune_thresh_medium}", - mod_path, - med_mod_path, - ] - ) - subprocess.call(["ngramprint", "--ARPA", med_mod_path, medium_arpa_path]) - try: - self.create_hclgs() - except Exception as e: - dirty_path = os.path.join(self.model_directory, "dirty") - with mfa_open(dirty_path, "w"): - pass - if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) - raise - - def score(self) -> None: - """ - Score the decoded transcriptions + def decode(self) -> None: + """ + Generate lattices See Also ------- - :class:`~montreal_forced_aligner.transcription.multiprocessing.ScoreFunction` + :class:`~montreal_forced_aligner.transcription.multiprocessing.DecodeFunction` Multiprocessing function - :class:`~montreal_forced_aligner.transcription.multiprocessing.ScoreArguments` + :meth:`.TranscriberMixin.decode_arguments` Arguments for function """ - with tqdm.tqdm( - total=self.num_utterances, disable=getattr(self, "quiet", False) - ) as pbar, mfa_open( - os.path.join(self.evaluation_directory, "score_costs.csv"), "w" - ) as log_file: - log_file.write("utterance,graph_cost,acoustic_cost,total_cost,num_frames\n") - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(self.score_arguments()): - function = ScoreFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - pbar.update(1) - - ( - utterance, - graph_cost, - acoustic_cost, - total_cost, - num_frames, - ) = result - log_file.write( - f"{utterance},{graph_cost},{acoustic_cost},{total_cost},{num_frames}\n" - ) - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v + logger.info("Generating lattices...") + with tqdm.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 + log_likelihood_count = 0 + if workflow.workflow_type is WorkflowType.per_speaker_transcription: + decode_function = PerSpeakerDecodeFunction + elif workflow.workflow_type is WorkflowType.phone_transcription: + decode_function = DecodePhoneFunction else: - for args in self.score_arguments(): - function = ScoreFunction(args) - for ( - utterance, - graph_cost, - acoustic_cost, - total_cost, - num_frames, - ) in function.run(): - log_file.write( - f"{utterance},{graph_cost},{acoustic_cost},{total_cost},{num_frames}\n" - ) - pbar.update(1) - - def score_transcriptions(self) -> None: - """ - Score transcriptions if reference text is available in the corpus - - See Also - -------- - :func:`~montreal_forced_aligner.transcription.multiprocessing.ScoreFunction` - Multiprocessing helper function for each job - :meth:`.Transcriber.score_arguments` - Job method for generating arguments for this function - - """ - if self.evaluation_mode: - best_wer = 10000 - best = None - evaluations = list( - itertools.product( - range(self.min_language_model_weight, self.max_language_model_weight), - self.word_insertion_penalties, - ) - ) - with tqdm.tqdm(total=len(evaluations), disable=getattr(self, "quiet", False)) as pbar: - for lmwt, wip in evaluations: - pbar.update(1) - self.language_model_weight = lmwt - self.word_insertion_penalty = wip - os.makedirs(self.evaluation_log_directory, exist_ok=True) - - self.log_debug( - f"Evaluating with language model weight={lmwt} and word insertion penalty={wip}..." - ) - self.score() - - ser, wer = self.evaluate_transcriptions() - if wer < best_wer: - best = (lmwt, wip) - best_wer = wer - self.language_model_weight = best[0] - self.word_insertion_penalty = best[1] - self.log_info( - f"Best language model weight={best[0]}, best word insertion penalty={best[1]}, WER={best_wer:.2f}%" - ) - for score_args in self.score_arguments(): - for p in score_args.tra_paths.values(): - shutil.copyfile( - p, - p.replace(self.evaluation_directory, self.working_directory), - ) - else: - self.score() + decode_function = DecodeFunction + for _, log_likelihood, _ in run_kaldi_function( + decode_function, arguments, pbar.update + ): + log_likelihood_sum += log_likelihood + log_likelihood_count += 1 + if log_likelihood_count: + with self.session() as session: + workflow.score = log_likelihood_sum / log_likelihood_count + session.commit() def calc_initial_fmllr(self) -> None: """ @@ -1074,13 +906,13 @@ def calc_initial_fmllr(self) -> None: ------- :class:`~montreal_forced_aligner.transcription.multiprocessing.InitialFmllrFunction` Multiprocessing function - :meth:`.Transcriber.initial_fmllr_arguments` + :meth:`.TranscriberMixin.initial_fmllr_arguments` Arguments for function """ - self.log_info("Calculating initial fMLLR transforms...") + logger.info("Calculating initial fMLLR transforms...") sum_errors = 0 - with tqdm.tqdm(total=self.num_speakers, disable=getattr(self, "quiet", False)) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -1117,7 +949,7 @@ def calc_initial_fmllr(self) -> None: for _ in function.run(): pbar.update(1) if sum_errors: - self.log_warning(f"{sum_errors} utterances had errors on calculating fMLLR.") + logger.warning(f"{sum_errors} utterances had errors on calculating fMLLR.") def lat_gen_fmllr(self) -> None: """ @@ -1127,24 +959,24 @@ def lat_gen_fmllr(self) -> None: ------- :class:`~montreal_forced_aligner.transcription.multiprocessing.LatGenFmllrFunction` Multiprocessing function - :meth:`.Transcriber.lat_gen_fmllr_arguments` + :meth:`.TranscriberMixin.lat_gen_fmllr_arguments` Arguments for function """ - self.log_info("Regenerating lattices with fMLLR transforms...") - with tqdm.tqdm( - total=self.num_utterances, disable=getattr(self, "quiet", False) - ) as pbar, mfa_open( + 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( os.path.join(self.working_log_directory, "lat_gen_fmllr_log_like.csv"), "w", encoding="utf8", ) as log_file: log_file.write("utterance,log_likelihood,num_frames\n") - if self.use_mp: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() procs = [] - for i, args in enumerate(self.lat_gen_fmllr_arguments()): + for i, args in enumerate(arguments): function = LatGenFmllrFunction(args) p = KaldiProcessWorker(i, return_queue, function, stopped) procs.append(p) @@ -1173,7 +1005,7 @@ def lat_gen_fmllr(self) -> None: for v in error_dict.values(): raise v else: - for args in self.lat_gen_fmllr_arguments(): + for args in arguments: function = LatGenFmllrFunction(args) for utterance, log_likelihood, num_frames in function.run(): log_file.write(f"{utterance},{log_likelihood},{num_frames}\n") @@ -1187,13 +1019,13 @@ def calc_final_fmllr(self) -> None: ------- :class:`~montreal_forced_aligner.transcription.multiprocessing.FinalFmllrFunction` Multiprocessing function - :meth:`.Transcriber.final_fmllr_arguments` + :meth:`.TranscriberMixin.final_fmllr_arguments` Arguments for function """ - self.log_info("Calculating final fMLLR transforms...") + logger.info("Calculating final fMLLR transforms...") sum_errors = 0 - with tqdm.tqdm(total=self.num_speakers, disable=getattr(self, "quiet", False)) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -1230,7 +1062,7 @@ def calc_final_fmllr(self) -> None: for _ in function.run(): pbar.update(1) if sum_errors: - self.log_warning(f"{sum_errors} utterances had errors on calculating fMLLR.") + logger.warning(f"{sum_errors} utterances had errors on calculating fMLLR.") def fmllr_rescore(self) -> None: """ @@ -1240,13 +1072,13 @@ def fmllr_rescore(self) -> None: ------- :class:`~montreal_forced_aligner.transcription.multiprocessing.FmllrRescoreFunction` Multiprocessing function - :meth:`.Transcriber.fmllr_rescore_arguments` + :meth:`.TranscriberMixin.fmllr_rescore_arguments` Arguments for function """ - self.log_info("Rescoring fMLLR lattices with final transform...") + logger.info("Rescoring fMLLR lattices with final transform...") sum_errors = 0 - with tqdm.tqdm(total=self.num_utterances, disable=getattr(self, "quiet", False)) as pbar: - if self.use_mp: + with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() @@ -1286,7 +1118,7 @@ def fmllr_rescore(self) -> None: sum_errors += errors pbar.update(done + errors) if sum_errors: - self.log_warning(f"{errors} utterances had errors on calculating fMLLR.") + logger.warning(f"{errors} utterances had errors on calculating fMLLR.") def transcribe_fmllr(self) -> None: """ @@ -1308,167 +1140,425 @@ def transcribe_fmllr(self) -> None: Multiprocessing helper function for each job """ + workflow = self.current_workflow self.calc_initial_fmllr() - - self.speaker_independent = False - + self.uses_speaker_adaptation = True self.lat_gen_fmllr() - self.calc_final_fmllr() + for decode_args in self.decode_arguments(workflow.workflow_type): + for lat_path in decode_args.lat_paths.values(): + os.remove(lat_path) self.fmllr_rescore() - self.lm_rescore() + def decode_arguments( + self, workflow: WorkflowType = WorkflowType.transcription + ) -> List[typing.Union[DecodeArguments, PerSpeakerDecodeArguments]]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.DecodeFunction` - self.carpa_lm_rescore() + Returns + ------- + list[:class:`~montreal_forced_aligner.transcription.multiprocessing.DecodeArguments`] + Arguments for processing + """ + arguments = [] + for j in self.jobs: + feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + if workflow is WorkflowType.per_speaker_transcription: + arguments.append( + PerSpeakerDecodeArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"per_speaker_decode.{j.id}.log"), + self.model_directory, + feat_strings, + j.construct_path_dictionary(self.working_directory, "lat", "ark"), + self.model_path, + self.disambiguation_symbols_int_path, + self.decode_options, + self.tree_path, + self.order, + self.method, + ) + ) + elif workflow is WorkflowType.phone_transcription: + arguments.append( + DecodePhoneArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"decode.{j.id}.log"), + j.dictionary_ids, + feat_strings, + self.decode_options, + self.alignment_model_path, + j.construct_path_dictionary(self.working_directory, "lat", "ark"), + self.phone_symbol_table_path, + os.path.join(self.working_directory, "HCLG_phone.fst"), + ) + ) + else: + arguments.append( + DecodeArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"decode.{j.id}.log"), + j.dictionary_ids, + feat_strings, + self.decode_options, + self.alignment_model_path, + j.construct_path_dictionary(self.working_directory, "lat", "ark"), + j.construct_dictionary_dependent_paths( + self.model_directory, "words", "txt" + ), + j.construct_dictionary_dependent_paths( + self.model_directory, "HCLG", "fst" + ), + ) + ) + return arguments - def decode(self) -> None: + def lm_rescore_arguments(self) -> List[LmRescoreArguments]: """ - Generate lattices + Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreFunction` - See Also + Returns ------- - :class:`~montreal_forced_aligner.transcription.multiprocessing.DecodeFunction` - Multiprocessing function - :meth:`.Transcriber.decode_arguments` - Arguments for function + list[:class:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreArguments`] + Arguments for processing """ - self.log_info("Generating lattices...") - with tqdm.tqdm( - total=self.num_utterances, disable=getattr(self, "quiet", False) - ) as pbar, mfa_open( - os.path.join(self.working_log_directory, "decode_log_like.csv"), "w" - ) as log_file: - log_file.write("utterance,log_likelihood,num_frames\n") - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(self.decode_arguments()): - function = DecodeFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - utterance, log_likelihood, num_frames = result - log_file.write(f"{utterance},{log_likelihood},{num_frames}\n") - pbar.update(1) - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v + return [ + LmRescoreArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"lm_rescore.{j.id}.log"), + j.dictionary_ids, + self.lm_rescore_options, + j.construct_path_dictionary(self.working_directory, "lat", "ark"), + j.construct_path_dictionary(self.working_directory, "lat.rescored", "ark"), + j.construct_dictionary_dependent_paths(self.model_directory, "G.small", "fst"), + j.construct_dictionary_dependent_paths(self.model_directory, "G.med", "fst"), + ) + for j in self.jobs + ] + + def carpa_lm_rescore_arguments(self) -> List[CarpaLmRescoreArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreArguments`] + Arguments for processing + """ + return [ + CarpaLmRescoreArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"carpa_lm_rescore.{j.id}.log"), + j.dictionary_ids, + j.construct_path_dictionary(self.working_directory, "lat.rescored", "ark"), + j.construct_path_dictionary(self.working_directory, "lat.carpa.rescored", "ark"), + j.construct_dictionary_dependent_paths(self.model_directory, "G.med", "fst"), + j.construct_dictionary_dependent_paths(self.model_directory, "G", "carpa"), + ) + for j in self.jobs + ] + + @property + def fmllr_options(self) -> MetaDict: + """Options for calculating fMLLR""" + options = super().fmllr_options + options["acoustic_scale"] = self.acoustic_scale + options["sil_phones"] = self.silence_csl + options["lattice_beam"] = self.lattice_beam + return options + + def initial_fmllr_arguments(self) -> List[InitialFmllrArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.InitialFmllrFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.transcription.multiprocessing.InitialFmllrArguments`] + Arguments for processing + """ + arguments = [] + for j in self.jobs: + feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + arguments.append( + InitialFmllrArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"initial_fmllr.{j.id}.log"), + j.dictionary_ids, + feat_strings, + self.model_path, + self.fmllr_options, + j.construct_path_dictionary(self.working_directory, "trans", "ark"), + j.construct_path_dictionary(self.working_directory, "lat", "ark"), + j.construct_path_dictionary(self.data_directory, "spk2utt", "scp"), + ) + ) + return arguments + + def lat_gen_fmllr_arguments( + self, workflow: WorkflowType = WorkflowType.transcription + ) -> List[LatGenFmllrArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.LatGenFmllrFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.transcription.multiprocessing.LatGenFmllrArguments`] + Arguments for processing + """ + arguments = [] + for j in self.jobs: + feat_strings = {} + word_paths = {} + hclg_paths = {} + if workflow is not WorkflowType.phone_transcription: + for d in j.dictionaries: + word_paths[d.id] = d.words_symbol_path + hclg_paths[d.id] = os.path.join(self.model_directory, f"HCLG.{d.id}.fst") + + feat_strings[d.id] = j.construct_feature_proc_string( + self.working_directory, + d.id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) else: - for args in self.decode_arguments(): - function = DecodeFunction(args) - for utterance, log_likelihood, num_frames in function.run(): - log_file.write(f"{utterance},{log_likelihood},{num_frames}\n") - pbar.update(1) + hclg_paths = os.path.join(self.working_directory, "HCLG_phone.fst") + word_paths = self.phone_symbol_table_path + + feat_strings = j.construct_feature_proc_string( + self.working_directory, + None, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + + arguments.append( + LatGenFmllrArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"lat_gen_fmllr.{j.id}.log"), + j.dictionary_ids, + feat_strings, + self.model_path, + self.decode_options, + word_paths, + hclg_paths, + j.construct_path_dictionary(self.working_directory, "lat.tmp", "ark"), + ) + ) + + return arguments + + def final_fmllr_arguments(self) -> List[FinalFmllrArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.FinalFmllrFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.transcription.multiprocessing.FinalFmllrArguments`] + Arguments for processing + """ + arguments = [] + for j in self.jobs: + feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + arguments.append( + FinalFmllrArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"final_fmllr.{j.id}.log"), + j.dictionary_ids, + feat_strings, + self.model_path, + self.fmllr_options, + j.construct_path_dictionary(self.working_directory, "trans", "ark"), + j.construct_path_dictionary(self.data_directory, "spk2utt", "scp"), + j.construct_path_dictionary(self.working_directory, "lat.tmp", "ark"), + ) + ) + return arguments + + def fmllr_rescore_arguments(self) -> List[FmllrRescoreArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.FmllrRescoreFunction` + + Returns + ------- + list[:class:`~montreal_forced_aligner.transcription.multiprocessing.FmllrRescoreArguments`] + Arguments for processing + """ + arguments = [] + for j in self.jobs: + feat_strings = {} + for d_id in j.dictionary_ids: + feat_strings[d_id] = j.construct_feature_proc_string( + self.working_directory, + d_id, + self.feature_options["uses_splices"], + self.feature_options["splice_left_context"], + self.feature_options["splice_right_context"], + self.feature_options["uses_speaker_adaptation"], + ) + arguments.append( + FmllrRescoreArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"fmllr_rescore.{j.id}.log"), + j.dictionary_ids, + feat_strings, + self.model_path, + self.fmllr_options, + j.construct_path_dictionary(self.working_directory, "lat.tmp", "ark"), + j.construct_path_dictionary(self.working_directory, "lat", "ark"), + ) + ) + return arguments + + +class Transcriber(TranscriberMixin, TopLevelMfaWorker): + """ + Class for performing transcription. + + Parameters + ---------- + acoustic_model_path : str + Path to acoustic model + language_model_path : str + Path to language model model + evaluation_mode: bool + Flag for evaluating generated transcripts against the actual transcripts, defaults to False + + See Also + -------- + :class:`~montreal_forced_aligner.transcription.transcriber.TranscriberMixin` + For transcription parameters + :class:`~montreal_forced_aligner.corpus.acoustic_corpus.AcousticCorpusPronunciationMixin` + For corpus and dictionary parsing parameters + :class:`~montreal_forced_aligner.abc.FileExporterMixin` + For file exporting parameters + :class:`~montreal_forced_aligner.abc.TopLevelMfaWorker` + For top-level parameters + + Attributes + ---------- + acoustic_model: AcousticModel + Acoustic model + language_model: LanguageModel + Language model + """ + + def __init__( + self, + acoustic_model_path: str, + language_model_path: str, + output_type: str = "transcription", + **kwargs, + ): + self.acoustic_model = AcousticModel(acoustic_model_path) + kwargs.update(self.acoustic_model.parameters) + super(Transcriber, self).__init__(**kwargs) + self.language_model = LanguageModel(language_model_path) + self.output_type = output_type + self.ignore_empty_utterances = False - def lm_rescore(self) -> None: + def create_hclgs_arguments(self) -> Dict[int, CreateHclgArguments]: """ - Rescore lattices with bigger language model + Generate Job arguments for :class:`~montreal_forced_aligner.transcription.multiprocessing.CreateHclgFunction` - See Also + Returns ------- - :class:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreFunction` - Multiprocessing function - :meth:`.Transcriber.lm_rescore_arguments` - Arguments for function + dict[str, :class:`~montreal_forced_aligner.transcription.multiprocessing.CreateHclgArguments`] + Per dictionary arguments for HCLG """ - self.log_info("Rescoring lattices with medium G.fst...") - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(self.lm_rescore_arguments()): - function = LmRescoreFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - with tqdm.tqdm( - total=self.num_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - while True: - try: - result = return_queue.get(timeout=1) - if isinstance(result, Exception): - error_dict[getattr(result, "job_name", 0)] = result - continue - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - succeeded, failed = result - if failed: - self.log_warning("Some lattices failed to be rescored") - pbar.update(succeeded + failed) - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v - else: - for args in self.lm_rescore_arguments(): - function = LmRescoreFunction(args) - with tqdm.tqdm(total=self.num_jobs, disable=getattr(self, "quiet", False)) as pbar: - for succeeded, failed in function.run(): - if failed: - self.log_warning("Some lattices failed to be rescored") - pbar.update(succeeded + failed) + args = {} + with self.session() as session: + for d in session.query(Dictionary): + args[d.id] = CreateHclgArguments( + d.id, + getattr(self, "db_string", ""), + os.path.join(self.model_directory, "log", f"hclg.{d.id}.log"), + self.model_directory, + os.path.join(self.model_directory, f"{{file_name}}.{d.id}.fst"), + os.path.join(self.model_directory, f"words.{d.id}.txt"), + os.path.join(self.model_directory, f"G.{d.id}.carpa"), + self.language_model.small_arpa_path, + self.language_model.medium_arpa_path, + self.language_model.carpa_path, + self.model_path, + d.lexicon_disambig_fst_path, + d.disambiguation_symbols_int_path, + self.hclg_options, + self.word_mapping(d.id), + ) + return args - def carpa_lm_rescore(self) -> None: + def create_hclgs(self) -> None: """ - Rescore lattices with CARPA language model - - See Also - ------- - :class:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreFunction` - Multiprocessing function - :meth:`.Transcriber.carpa_lm_rescore_arguments` - Arguments for function + Create HCLG.fst files for every dictionary being used by a + :class:`~montreal_forced_aligner.transcription.transcriber.Transcriber` """ - self.log_info("Rescoring lattices with large G.carpa...") - if self.use_mp: + + dict_arguments = self.create_hclgs_arguments() + dict_arguments = list(dict_arguments.values()) + logger.info("Generating HCLG.fst...") + if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() stopped = Stopped() procs = [] - for i, args in enumerate(self.carpa_lm_rescore_arguments()): - function = CarpaLmRescoreFunction(args) + for i, args in enumerate(dict_arguments): + function = CreateHclgFunction(args) p = KaldiProcessWorker(i, return_queue, function, stopped) procs.append(p) p.start() - with tqdm.tqdm( - total=self.num_utterances, disable=getattr(self, "quiet", False) - ) as pbar: + with tqdm.tqdm(total=len(dict_arguments) * 7, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: result = return_queue.get(timeout=1) if isinstance(result, Exception): error_dict[getattr(result, "job_name", 0)] = result continue + elif not isinstance(result, tuple): + pbar.update(1) + continue if stopped.stop_check(): continue except Empty: @@ -1478,134 +1568,203 @@ def carpa_lm_rescore(self) -> None: else: break continue - succeeded, failed = result - if failed: - self.log_warning("Some lattices failed to be rescored") - pbar.update(succeeded + failed) + result, hclg_path = result + if result: + logger.debug(f"Done generating {hclg_path}!") + else: + logger.warning(f"There was an error in generating {hclg_path}") + pbar.update(1) for p in procs: p.join() if error_dict: for v in error_dict.values(): raise v else: - for args in self.carpa_lm_rescore_arguments(): - function = CarpaLmRescoreFunction(args) - with tqdm.tqdm( - total=self.num_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - for succeeded, failed in function.run(): - if failed: - self.log_warning("Some lattices failed to be rescored") - pbar.update(succeeded + failed) + for args in dict_arguments: + function = CreateHclgFunction(args) + with tqdm.tqdm(total=len(dict_arguments), disable=GLOBAL_CONFIG.quiet) as pbar: + for result in function.run(): + if not isinstance(result, tuple): + pbar.update(1) + continue + result, hclg_path = result + if result: + logger.debug(f"Done generating {hclg_path}!") + else: + logger.warning(f"There was an error in generating {hclg_path}") + pbar.update(1) + error_logs = [] + for arg in dict_arguments: + if not os.path.exists(arg.hclg_path): + error_logs.append(arg.log_path) + if error_logs: + raise KaldiProcessingError(error_logs) - def transcribe(self) -> None: + def create_decoding_graph(self) -> None: """ - Transcribe the corpus - - See Also - -------- - :func:`~montreal_forced_aligner.transcription.multiprocessing.DecodeFunction` - Multiprocessing helper function for each job - :func:`~montreal_forced_aligner.transcription.multiprocessing.LmRescoreFunction` - Multiprocessing helper function for each job - :func:`~montreal_forced_aligner.transcription.multiprocessing.CarpaLmRescoreFunction` - Multiprocessing helper function for each job + Create decoding graph for use in transcription Raises ------ :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` If there were any errors in running Kaldi binaries """ - self.log_info("Beginning transcription...") - done_path = os.path.join(self.working_directory, "done") - dirty_path = os.path.join(self.working_directory, "dirty") - try: - if not os.path.exists(done_path): - self.speaker_independent = True + done_path = os.path.join(self.model_directory, "done") + if os.path.exists(done_path): + logger.info("Graph construction already done, skipping!") + log_dir = os.path.join(self.model_directory, "log") + os.makedirs(log_dir, exist_ok=True) + self.write_lexicon_information(write_disambiguation=True) + with self.session() as session: + for d in session.query(Dictionary): + words_path = os.path.join(self.model_directory, f"words.{d.id}.txt") + shutil.copyfile(d.words_symbol_path, words_path) - self.decode() - if self.uses_speaker_adaptation: - self.log_info("Performing speaker adjusted transcription...") - self.transcribe_fmllr() - else: - self.lm_rescore() - self.carpa_lm_rescore() - else: - self.log_info("Transcription already done, skipping!") - self.score_transcriptions() + big_arpa_path = self.language_model.carpa_path + small_arpa_path = self.language_model.small_arpa_path + medium_arpa_path = self.language_model.medium_arpa_path + if not os.path.exists(small_arpa_path) or not os.path.exists(medium_arpa_path): + logger.warning( + "Creating small and medium language models from scratch, this may take some time. " + "Running `mfa train_lm` on the ARPA file will remove this warning." + ) + logger.info("Parsing large ngram model...") + mod_path = os.path.join(self.model_directory, "base_lm.mod") + new_carpa_path = os.path.join(self.model_directory, "base_lm.arpa") + with mfa_open(big_arpa_path, "r") as inf, mfa_open(new_carpa_path, "w") as outf: + for line in inf: + outf.write(line.lower()) + big_arpa_path = new_carpa_path + subprocess.call(["ngramread", "--ARPA", big_arpa_path, mod_path]) + + if not os.path.exists(small_arpa_path): + logger.info( + "Generating small model from the large ARPA with a pruning threshold of 3e-7" + ) + prune_thresh_small = 0.0000003 + small_mod_path = mod_path.replace(".mod", "_small.mod") + subprocess.call( + [ + "ngramshrink", + "--method=relative_entropy", + f"--theta={prune_thresh_small}", + mod_path, + small_mod_path, + ] + ) + subprocess.call(["ngramprint", "--ARPA", small_mod_path, small_arpa_path]) + + if not os.path.exists(medium_arpa_path): + logger.info( + "Generating medium model from the large ARPA with a pruning threshold of 1e-7" + ) + prune_thresh_medium = 0.0000001 + med_mod_path = mod_path.replace(".mod", "_med.mod") + subprocess.call( + [ + "ngramshrink", + "--method=relative_entropy", + f"--theta={prune_thresh_medium}", + mod_path, + med_mod_path, + ] + ) + subprocess.call(["ngramprint", "--ARPA", med_mod_path, medium_arpa_path]) + try: + self.create_hclgs() except Exception as e: + dirty_path = os.path.join(self.model_directory, "dirty") with mfa_open(dirty_path, "w"): pass if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise - def evaluate_transcriptions(self) -> Tuple[float, float]: + @classmethod + def parse_parameters( + cls, + config_path: Optional[str] = None, + args: Optional[Dict[str, typing.Any]] = None, + unknown_args: Optional[typing.Iterable[str]] = None, + ) -> MetaDict: """ - Evaluates the transcripts if there are reference transcripts + Parse configuration parameters from a config file and command line arguments + + Parameters + ---------- + config_path: str, optional + Path to yaml configuration file + args: dict[str, Any] + Parsed arguments + unknown_args: list[str] + Optional list of arguments that were not parsed Returns ------- - float, float - Sentence error rate and word error rate - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` - If there were any errors in running Kaldi binaries + dict[str, Any] + Dictionary of specified configuration parameters """ - self.log_info("Evaluating transcripts...") - self._load_transcripts() - ser, wer, cer = self.compute_wer() - self.log_info(f"SER: {100 * ser:.2f}%, WER: {100 * wer:.2f}%, CER: {100 * cer:.2f}%") - return ser, wer + global_params = {} + if config_path and os.path.exists(config_path): + data = load_configuration(config_path) + data = parse_old_features(data) + for k, v in data.items(): + if k == "features": + global_params.update(v) + else: + if v is None and k in cls.nullable_fields: + v = [] + global_params[k] = v - def _load_transcripts(self) -> None: - """Load transcripts from Kaldi temporary files""" - with self.session() as session: - records = [] - for score_args in self.score_arguments(): - for dict_id, tra_path in score_args.tra_paths.items(): - lookup = self.reversed_word_mapping(dict_id) - with mfa_open(tra_path, "r") as f: - for line in f: - t = line.strip().split(" ") - utt = int(t[0].split("-")[-1]) - ints = t[1:] - if not ints: - continue - records.append( - { - "id": utt, - "transcription_text": " ".join(lookup[int(x)] for x in ints), - } - ) - session.bulk_update_mappings(Utterance, records) - self.transcription_done = True - session.query(Corpus).update({"transcription_done": True}) - session.commit() + global_params.update(cls.parse_args(args, unknown_args)) + if args.get("language_model_weight", None) is not None: + global_params["min_language_model_weight"] = args["language_model_weight"] + global_params["max_language_model_weight"] = args["language_model_weight"] + 1 + if args.get("word_insertion_penalty", None) is not None: + global_params["word_insertion_penalties"] = [args["word_insertion_penalty"]] + return global_params - def collect_alignments(self) -> None: - """ - Collect word and phone alignments from alignment archives - """ - if self.alignment_done: - if self.export_output_directory is not None: - self.export_textgrids() + def setup(self) -> None: + """Set up transcription""" + super().setup() + if self.initialized: return - self._collect_alignments() + self.create_new_current_workflow(WorkflowType.transcription) + begin = time.time() + os.makedirs(self.working_log_directory, exist_ok=True) + self.load_corpus() + dirty_path = os.path.join(self.working_directory, "dirty") + if os.path.exists(dirty_path): + shutil.rmtree(self.working_directory, ignore_errors=True) + os.makedirs(self.working_log_directory, exist_ok=True) + dirty_path = os.path.join(self.model_directory, "dirty") + + if os.path.exists(dirty_path): # if there was an error, let's redo from scratch + shutil.rmtree(self.model_directory) + log_dir = os.path.join(self.model_directory, "log") + os.makedirs(log_dir, exist_ok=True) + if self.acoustic_model.meta["version"] < "2.1": + logger.warning( + "The acoustic model was trained in an earlier version of MFA. " + "There may be incompatibilities in feature generation that cause errors. " + "Please download the latest version of the model via `mfa model download`, " + "use a different acoustic model, or use version 2.0.6 of MFA." + ) + self.acoustic_model.validate(self) + self.acoustic_model.export_model(self.model_directory) + self.acoustic_model.export_model(self.working_directory) + self.acoustic_model.log_details() + self.create_decoding_graph() + self.initialized = True + logger.debug(f"Setup for transcription in {time.time() - begin:.3f} seconds") def export_transcriptions(self) -> None: """Export transcriptions""" - self._load_transcripts() with self.session() as session: files = session.query(File).options( selectinload(File.utterances), - selectinload(File.speakers).selectinload(SpeakerOrdering.speaker), + selectinload(File.speakers), joinedload(File.sound_file, innerjoin=True).load_only(SoundFile.duration), ) for file in files: @@ -1613,7 +1772,7 @@ def export_transcriptions(self) -> None: duration = file.sound_file.duration if utterance_count == 0: - self.log_debug(f"Could not find any utterances for {file.name}") + logger.debug(f"Could not find any utterances for {file.name}") elif ( utterance_count == 1 and file.utterances[0].begin == 0 @@ -1632,17 +1791,19 @@ def export_transcriptions(self) -> None: if output_format == "lab": for intervals in data.values(): with mfa_open(output_path, "w") as f: - f.write(intervals[0].label) + f.write(intervals["transcription"][0].label) else: - tg = textgrid.Textgrid() tg.minTimestamp = 0 - tg.maxTimestamp = duration + tg.maxTimestamp = round(duration, 5) for speaker in file.speakers: - speaker = speaker.speaker.name - intervals = data[speaker] + speaker = speaker.name + intervals = data[speaker]["transcription"] tier = textgrid.IntervalTier( - speaker, [x.to_tg_interval() for x in intervals], minT=0, maxT=duration + speaker, + [x.to_tg_interval() for x in intervals], + minT=0, + maxT=round(duration, 5), ) tg.addTier(tier) diff --git a/montreal_forced_aligner/utils.py b/montreal_forced_aligner/utils.py index 3ac5d081..27d72013 100644 --- a/montreal_forced_aligner/utils.py +++ b/montreal_forced_aligner/utils.py @@ -5,41 +5,47 @@ """ from __future__ import annotations +import datetime import logging import multiprocessing as mp import os +import re import shutil import subprocess -import sys +import time +import typing from queue import Empty from typing import Any, Callable, Dict, List, Optional, Tuple, Union -import ansiwrap +import numpy as np import sqlalchemy -from colorama import Fore, Style from sqlalchemy.orm import Session from montreal_forced_aligner.abc import KaldiFunction -from montreal_forced_aligner.data import DatasetType, MfaArguments +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.data import CtmInterval, DatasetType, MfaArguments from montreal_forced_aligner.db import Corpus, Dictionary from montreal_forced_aligner.exceptions import ( + DictionaryError, KaldiProcessingError, MultiprocessingError, ThirdpartyError, ) from montreal_forced_aligner.helper import mfa_open +from montreal_forced_aligner.textgrid import process_ctm_line __all__ = [ "thirdparty_binary", "log_kaldi_errors", "get_mfa_version", "parse_logs", - "CustomFormatter", "Counter", "Stopped", "ProcessWorker", + "KaldiProcessWorker", "run_mp", "run_non_mp", + "run_kaldi_function", ] canary_kaldi_bins = [ "compute-mfcc-feats", @@ -54,40 +60,47 @@ "gmm-rescore-lattice", ] +logger = logging.getLogger("mfa") -def inspect_database(path: str) -> DatasetType: + +def inspect_database(name: str) -> DatasetType: """ Inspect the database file to generate its DatasetType Parameters ---------- - path: str - Path to the sqlite database + name: str + Name of database Returns ------- DatasetType Dataset type of the database """ - if not os.path.exists(path): - return DatasetType.NONE - engine = sqlalchemy.create_engine(f"sqlite:///file:{path}?mode=ro&nolock=1&uri=true") - with Session(engine) as session: - corpus = session.query(Corpus).first() - dictionary = session.query(Dictionary).first() - if corpus is None and dictionary is None: - return DatasetType.NONE - elif corpus is None: - return DatasetType.DICTIONARY - elif dictionary is None: + + string = ( + f"postgresql+psycopg2://localhost:{GLOBAL_CONFIG.current_profile.database_port}/{name}" + ) + try: + engine = sqlalchemy.create_engine(string, future=True) + with Session(engine) as session: + corpus = session.query(Corpus).first() + dictionary = session.query(Dictionary).first() + if corpus is None and dictionary is None: + return DatasetType.NONE + elif corpus is None: + return DatasetType.DICTIONARY + elif dictionary is None: + if corpus.has_sound_files: + return DatasetType.ACOUSTIC_CORPUS + else: + return DatasetType.TEXT_CORPUS if corpus.has_sound_files: - return DatasetType.ACOUSTIC_CORPUS + return DatasetType.ACOUSTIC_CORPUS_WITH_DICTIONARY else: - return DatasetType.TEXT_CORPUS - if corpus.has_sound_files: - return DatasetType.ACOUSTIC_CORPUS_WITH_DICTIONARY - else: - return DatasetType.TEXT_CORPUS_WITH_DICTIONARY + return DatasetType.TEXT_CORPUS_WITH_DICTIONARY + except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.ProgrammingError): + return DatasetType.NONE def get_class_for_dataset_type(dataset_type: DatasetType): @@ -122,6 +135,111 @@ def get_class_for_dataset_type(dataset_type: DatasetType): return mapping[dataset_type] +def parse_dictionary_file( + path: str, +) -> typing.Generator[ + typing.Tuple[ + str, + typing.List[str], + typing.Optional[float], + typing.Optional[float], + typing.Optional[float], + typing.Optional[float], + ] +]: + """ + Parses a lexicon file and yields parsed pronunciation lines + + Parameters + ---------- + path: str + Path to lexicon file + + Yields + ------ + str + Orthographic word + list[str] + Pronunciation + float or None + Pronunciation probability + float or None + Probability of silence following the pronunciation + float or None + Correction factor for silence before the pronunciation + float or None + Correction factor for no silence before the pronunciation + """ + prob_pattern = re.compile(r"\b\d+\.\d+\b") + with mfa_open(path) as f: + for i, line in enumerate(f): + line = line.strip() + if not line: + continue + line = line.split() + if len(line) <= 1: + raise DictionaryError( + f'Error parsing line {i} of {path}: "{line}" did not have a pronunciation' + ) + word = line.pop(0) + prob = None + silence_after_prob = None + silence_before_correct = None + non_silence_before_correct = None + if prob_pattern.match(line[0]): + prob = float(line.pop(0)) + if prob_pattern.match(line[0]): + silence_after_prob = float(line.pop(0)) + if prob_pattern.match(line[0]): + silence_before_correct = float(line.pop(0)) + if prob_pattern.match(line[0]): + non_silence_before_correct = float(line.pop(0)) + pron = tuple(line) + yield word, pron, prob, silence_after_prob, silence_before_correct, non_silence_before_correct + + +def parse_ctm_output( + proc: subprocess.Popen, reversed_phone_mapping: Dict[int, Any], raw_id: bool = False +) -> typing.Generator[typing.Tuple[typing.Union[int, str], typing.List[CtmInterval]]]: + """ + Parse stdout of a process into intervals grouped by utterance + + Parameters + ---------- + proc: :class:`subprocess.Popen` + reversed_phone_mapping: dict[int, Any] + Mapping from kaldi integer IDs to phones + raw_id: bool + Flag for returning the kaldi internal ID of the utterance rather than its integer ID + + Yields + ------- + int or str + Utterance ID + list[:class:`~montreal_forced_aligner.data.CtmInterval`] + List of CTM intervals for the utterance + """ + current_utt = None + intervals = [] + for line in proc.stdout: + line = line.strip() + if not line: + continue + try: + utt, interval = process_ctm_line(line, reversed_phone_mapping, raw_id=raw_id) + except ValueError: + continue + if current_utt is None: + current_utt = utt + if current_utt != utt: + yield current_utt, intervals + intervals = [] + current_utt = utt + intervals.append(interval) + if intervals: + yield current_utt, intervals + + def get_mfa_version() -> str: """ Get the current MFA version @@ -157,7 +275,10 @@ def check_third_party(): if p.returncode == 1 and p.stderr: raise ThirdpartyError("fstcompile", open_fst=True, error_text=p.stderr) for fn in canary_kaldi_bins: - p = subprocess.run([thirdparty_binary(fn), "--help"], capture_output=True, text=True) + try: + p = subprocess.run([thirdparty_binary(fn), "--help"], capture_output=True, text=True) + except Exception as e: + raise ThirdpartyError(fn, error_text=str(e)) if p.returncode == 1 and p.stderr: raise ThirdpartyError(fn, error_text=p.stderr) @@ -191,7 +312,7 @@ def thirdparty_binary(binary_name: str) -> str: return bin_path -def log_kaldi_errors(error_logs: List[str], logger: logging.Logger) -> None: +def log_kaldi_errors(error_logs: List[str]) -> None: """ Save details of Kaldi processing errors to a logger @@ -199,8 +320,6 @@ def log_kaldi_errors(error_logs: List[str], logger: logging.Logger) -> None: ---------- error_logs: list[str] Kaldi log files with errors - logger: :class:`~logging.Logger` - Logger to output to """ logger.debug(f"There were {len(error_logs)} kaldi processing files that had errors:") for path in error_logs: @@ -211,101 +330,71 @@ def log_kaldi_errors(error_logs: List[str], logger: logging.Logger) -> None: logger.debug("\t" + line.strip()) -def configure_logger( - identifier: str, log_file: Optional[str] = None, quiet: bool = False, verbose: bool = False -) -> logging.Logger: +def read_feats(proc: subprocess.Popen, raw_id=False) -> Dict[str, np.array]: """ - Configure logging for the given identifier + Inspired by https://github.com/it-muslim/kaldi-helpers/blob/master/kaldi-helpers/kaldi_io.py#L87 + + Reading from stdout, import feats (or feats-like) data as a numpy array + As feats are generated "on-fly" in kaldi, there is no a feats file + (except most simple cases like raw mfcc, plp or fbank). So, that is why + we take feats as a command rather that a file path. Can be applied to + other commands (like gmm-compute-likes) generating an output in same + format as feats, i.e: + utterance_id_1 [ + 70.31843 -2.872698 -0.06561285 22.71824 -15.57525 ... + 78.39457 -1.907646 -1.593253 23.57921 -14.74229 ... + ... + 57.27236 -16.17824 -15.33368 -5.945696 0.04276848 ... -0.5812851 ] + utterance_id_2 [ + 64.00951 -8.952017 4.134113 33.16264 11.09073 ... + ... Parameters ---------- - identifier: str - Logger identifier - log_file: str - Path to file to write all messages to - quiet: bool - Flag for whether logger should write to stdout - verbose: bool - Flag for writing debug level information to stdout + proc : subprocess.Popen + A process that generates features or feature-like specifications Returns ------- - logging.Logger - Configured logger instance - """ - logger = logging.getLogger(identifier) - logger.setLevel(logging.DEBUG) - if log_file is not None: - file_handler = logging.FileHandler(log_file) - file_handler.setLevel(logging.DEBUG) - formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - if not quiet: - handler = logging.StreamHandler(sys.stdout) - if verbose: - handler.setLevel(logging.DEBUG) - else: - handler.setLevel(logging.INFO) - handler.setFormatter(CustomFormatter()) - logger.addHandler(handler) - return logger - - -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 .config import load_global_config - - config = load_global_config() - self.width = config["terminal_width"] - use_colors = config.get("terminal_colors", True) - 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=self.width, - ) + feats : numpy.array + A dict of pairs {utterance: feats} + """ + feats = [] + # current_row = 0 + current_id = None + for line in proc.stdout: + line = line.decode("ascii").strip() + if "[" in line and "]" in line: + line = line.replace("]", "").replace("[", "").split() + ids = line.pop(0) + if raw_id: + utt_id = ids + else: + utt_id = int(ids.split("-")[-1]) + feats = np.array([float(x) for x in line]) + yield utt_id, feats + feats = [] + continue + elif "[" in line: + ids = line.strip().split()[0] + if raw_id: + utt_id = ids + else: + utt_id = int(ids.split("-")[-1]) + if current_id is None: + current_id = utt_id + if current_id != utt_id: + feats = np.array(feats) + yield current_id, feats + feats = [] + current_id = utt_id + continue + if not line: + continue + feats.append([float(x) for x in line.replace("]", "").split()]) + if current_id is not None: + feats = np.array(feats) + yield current_id, feats def parse_logs(log_directory: str) -> None: @@ -372,6 +461,10 @@ def value(self) -> int: class ProgressCallback(object): + """ + Class for sending progress indications back to the main process + """ + def __init__(self, callback=None, total_callback=None): self._total = 0 self.callback = callback @@ -379,39 +472,76 @@ def __init__(self, callback=None, total_callback=None): self._progress = 0 self.callback_interval = 1 self.lock = mp.Lock() + self.start_time = None @property def total(self) -> int: + """Total entries to process""" with self.lock: return self._total @property def progress(self) -> int: + """Current number of entries processed""" with self.lock: return self._progress @property def progress_percent(self) -> float: + """Current progress as percetage""" with self.lock: if not self._total: return 0.0 return self._progress / self._total def update_total(self, total: int) -> None: + """ + Update the total for the callback + + Parameters + ---------- + total: int + New total + """ with self.lock: + if self._total == 0 and total != 0: + self.start_time = time.time() self._total = total if self.total_callback is not None: self.total_callback(self._total) - def set_progress(self, total_progress: int) -> None: + def set_progress(self, progress: int) -> None: + """ + Update the number of entries processed for the callback + + Parameters + ---------- + progress: int + New progress + """ with self.lock: - self._progress = total_progress + self._progress = progress def increment_progress(self, increment: int) -> None: + """ + Increment the number of entries processed for the callback + + Parameters + ---------- + increment: int + Update the progress by this amount + """ with self.lock: self._progress += increment if self.callback is not None: - self.callback(self._progress) + current_time = time.time() + current_duration = current_time - self.start_time + time_per_iteration = current_duration / self._progress + remaining_iterations = self._total - self._progress + remaining_time = datetime.timedelta( + seconds=int(time_per_iteration * remaining_iterations) + ) + self.callback(self._progress, str(remaining_time)) class Stopped(object): @@ -433,6 +563,11 @@ def __init__(self, initval: Union[bool, int] = False): self.lock = mp.Lock() self._source = mp.Value("i", 0) + def reset(self) -> None: + """Signal that work should stop asap""" + with self.lock: + self.val.value = False + def stop(self) -> None: """Signal that work should stop asap""" with self.lock: @@ -549,10 +684,10 @@ def run(self) -> None: """ Run through the arguments in the queue apply the function to them """ - from .config import BLAS_THREADS - os.environ["OPENBLAS_NUM_THREADS"] = f"{BLAS_THREADS}" - os.environ["MKL_NUM_THREADS"] = f"{BLAS_THREADS}" + os.environ["OMP_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + os.environ["OPENBLAS_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + os.environ["MKL_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" try: for result in self.function.run(): self.return_q.put(result) @@ -565,6 +700,52 @@ def run(self) -> None: self.finished.stop() +def run_kaldi_function(function, arguments, progress_callback, stopped: Stopped = None): + if stopped is None: + stopped = Stopped() + if GLOBAL_CONFIG.use_mp: + error_dict = {} + return_queue = mp.Queue(10000) + procs = [] + for i, args in enumerate(arguments): + f = function(args) + p = KaldiProcessWorker(i, return_queue, f, stopped) + procs.append(p) + p.start() + while True: + try: + result = return_queue.get(timeout=1) + if isinstance(result, Exception): + error_dict[getattr(result, "job_name", 0)] = result + continue + if stopped.stop_check(): + continue + except Empty: + for proc in procs: + if not proc.finished.stop_check(): + break + else: + break + continue + yield result + progress_callback(1) + for p in procs: + p.join() + + if error_dict: + for v in error_dict.values(): + raise v + + else: + for args in arguments: + f = function(args) + for result in f.run(): + if stopped.stop_check(): + break + yield result + progress_callback(1) + + def run_non_mp( function: Callable, argument_list: List[Union[Tuple[Any, ...], MfaArguments]], @@ -628,10 +809,10 @@ def run_mp( return_info: dict, optional If the function returns information, supply the return dict to populate """ - from .config import BLAS_THREADS - os.environ["OPENBLAS_NUM_THREADS"] = f"{BLAS_THREADS}" - os.environ["MKL_NUM_THREADS"] = f"{BLAS_THREADS}" + os.environ["OMP_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + os.environ["OPENBLAS_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" + os.environ["MKL_NUM_THREADS"] = f"{GLOBAL_CONFIG.current_profile.blas_num_threads}" stopped = Stopped() job_queue = mp.Queue() return_queue = mp.Queue() diff --git a/montreal_forced_aligner/vad/__init__.py b/montreal_forced_aligner/vad/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/montreal_forced_aligner/vad/multiprocessing.py b/montreal_forced_aligner/vad/multiprocessing.py new file mode 100644 index 00000000..a8c63c62 --- /dev/null +++ b/montreal_forced_aligner/vad/multiprocessing.py @@ -0,0 +1,183 @@ +"""Multiprocessing functionality for VAD""" +from __future__ import annotations + +import logging +import os +import re +import subprocess +import typing +from typing import TYPE_CHECKING, List, Union + +from montreal_forced_aligner.abc import KaldiFunction +from montreal_forced_aligner.data import CtmInterval, MfaArguments +from montreal_forced_aligner.helper import mfa_open +from montreal_forced_aligner.utils import read_feats, thirdparty_binary + +try: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + torch_logger = logging.getLogger("speechbrain.utils.torch_audio_backend") + torch_logger.setLevel(logging.ERROR) + torch_logger = logging.getLogger("speechbrain.utils.train_logger") + torch_logger.setLevel(logging.ERROR) + from speechbrain.pretrained import VAD + + FOUND_SPEECHBRAIN = True +except (ImportError, OSError): + FOUND_SPEECHBRAIN = False + VAD = None + +if TYPE_CHECKING: + SpeakerCharacterType = Union[str, int] + from montreal_forced_aligner.abc import MetaDict + + +class SegmentVadArguments(MfaArguments): + """Arguments for :class:`~montreal_forced_aligner.segmenter.SegmentVadFunction`""" + + vad_path: str + segmentation_options: MetaDict + + +def get_initial_segmentation(frames: List[Union[int, str]], frame_shift: int) -> List[CtmInterval]: + """ + Compute initial segmentation over voice activity + + Parameters + ---------- + frames: list[Union[int, str]] + List of frames with VAD output + frame_shift: int + Frame shift of features in ms + + Returns + ------- + List[CtmInterval] + Initial segmentation + """ + segs = [] + cur_seg = None + silent_frames = 0 + non_silent_frames = 0 + for i, f in enumerate(frames): + if int(f) > 0: + non_silent_frames += 1 + if cur_seg is None: + cur_seg = CtmInterval(begin=i * frame_shift, end=0, label="speech") + else: + silent_frames += 1 + if cur_seg is not None: + cur_seg.end = (i - 1) * frame_shift + segs.append(cur_seg) + cur_seg = None + if cur_seg is not None: + cur_seg.end = len(frames) * frame_shift + segs.append(cur_seg) + return segs + + +def merge_segments( + segments: List[CtmInterval], + min_pause_duration: float, + max_segment_length: float, + min_segment_length: float, +) -> List[CtmInterval]: + """ + Merge segments together + + Parameters + ---------- + segments: SegmentationType + Initial segments + min_pause_duration: float + Minimum amount of silence time to mark an utterance boundary + max_segment_length: float + Maximum length of segments before they're broken up + snap_boundary_threshold: + Boundary threshold to snap boundaries together + + Returns + ------- + List[CtmInterval] + Merged segments + """ + merged_segs = [] + snap_boundary_threshold = min_pause_duration / 2 + for s in segments: + if ( + not merged_segs + or s.begin > merged_segs[-1].end + min_pause_duration + or s.end - merged_segs[-1].begin > max_segment_length + ): + if s.end - s.begin > min_pause_duration: + if merged_segs and snap_boundary_threshold: + boundary_gap = s.begin - merged_segs[-1].end + if boundary_gap < snap_boundary_threshold: + half_boundary = boundary_gap / 2 + else: + half_boundary = snap_boundary_threshold / 2 + merged_segs[-1].end += half_boundary + s.begin -= half_boundary + + merged_segs.append(s) + else: + merged_segs[-1].end = s.end + return [x for x in merged_segs if x.end - x.begin > min_segment_length] + + +class SegmentVadFunction(KaldiFunction): + """ + Multiprocessing function to generate segments from VAD output. + + See Also + -------- + :meth:`montreal_forced_aligner.segmenter.Segmenter.segment_vad` + Main function that calls this function in parallel + :meth:`montreal_forced_aligner.segmenter.Segmenter.segment_vad_arguments` + Job method for generating arguments for this function + :kaldi_utils:`segmentation.pl` + Kaldi utility + + Parameters + ---------- + args: :class:`~montreal_forced_aligner.segmenter.SegmentVadArguments` + Arguments for the function + """ + + progress_pattern = re.compile( + r"^LOG.*processed (?P\d+) utterances.*(?P\d+) had.*(?P\d+) were.*" + ) + + def __init__(self, args: SegmentVadArguments): + super().__init__(args) + self.vad_path = args.vad_path + self.segmentation_options = args.segmentation_options + + def _run(self) -> typing.Generator[typing.Tuple[int, float, float]]: + """Run the function""" + with mfa_open(self.log_path, "w") as log_file: + copy_proc = subprocess.Popen( + [ + thirdparty_binary("copy-vector"), + "--binary=false", + f"scp:{self.vad_path}", + "ark,t:-", + ], + stdout=subprocess.PIPE, + stderr=log_file, + env=os.environ, + ) + for utt_id, frames in read_feats(copy_proc): + initial_segments = get_initial_segmentation( + frames, self.segmentation_options["frame_shift"] + ) + + merged = merge_segments( + initial_segments, + self.segmentation_options["close_th"], + self.segmentation_options["large_chunk_size"], + self.segmentation_options["len_th"], + ) + yield utt_id, merged diff --git a/montreal_forced_aligner/vad/segmenter.py b/montreal_forced_aligner/vad/segmenter.py new file mode 100644 index 00000000..4b762e7e --- /dev/null +++ b/montreal_forced_aligner/vad/segmenter.py @@ -0,0 +1,412 @@ +""" +Segmenter +========= + +""" +from __future__ import annotations + +import logging +import os +import sys +import typing +from typing import Dict, List, Optional + +import sqlalchemy +import tqdm +from sqlalchemy.orm import joinedload, selectinload + +from montreal_forced_aligner.abc import FileExporterMixin, MetaDict, TopLevelMfaWorker +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpusMixin +from montreal_forced_aligner.corpus.features import VadConfigMixin +from montreal_forced_aligner.data import TextFileType, WorkflowType +from montreal_forced_aligner.db import CorpusWorkflow, File, Utterance +from montreal_forced_aligner.exceptions import KaldiProcessingError +from montreal_forced_aligner.helper import load_configuration +from montreal_forced_aligner.utils import log_kaldi_errors, run_kaldi_function +from montreal_forced_aligner.vad.multiprocessing import ( + FOUND_SPEECHBRAIN, + VAD, + SegmentVadArguments, + SegmentVadFunction, +) + +SegmentationType = List[Dict[str, float]] + +__all__ = ["Segmenter"] + +logger = logging.getLogger("mfa") + + +class Segmenter(VadConfigMixin, AcousticCorpusMixin, FileExporterMixin, TopLevelMfaWorker): + """ + Class for performing speaker classification, parameters are passed to + `speechbrain.pretrained.interfaces.VAD.get_speech_segments + `_ + + Parameters + ---------- + segment_padding: float + Size of padding on both ends of a segment + large_chunk_size: float + Size (in seconds) of the large chunks that are read sequentially + from the input audio file. + small_chunk_size: float + Size (in seconds) of the small chunks extracted from the large ones. + The audio signal is processed in parallel within the small chunks. + Note that large_chunk_size/small_chunk_size must be an integer. + overlap_small_chunk: bool + If True, it creates overlapped small chunks (with 50% overal). + The probabilities of the overlapped chunks are combined using + hamming windows. + apply_energy_VAD: bool + If True, a energy-based VAD is used on the detected speech segments. + The neural network VAD often creates longer segments and tends to + merge close segments together. The energy VAD post-processes can be + useful for having a fine-grained voice activity detection. + The energy thresholds is managed by activation_th and + deactivation_th (see below). + double_check: bool + If True, double checkis (using the neural VAD) that the candidate + speech segments actually contain speech. A threshold on the mean + posterior probabilities provided by the neural network is applied + based on the speech_th parameter (see below). + activation_th: float + Threshold of the neural posteriors above which starting a speech segment. + deactivation_th: float + Threshold of the neural posteriors below which ending a speech segment. + en_activation_th: float + A new speech segment is started it the energy is above activation_th. + This is active only if apply_energy_VAD is True. + en_deactivation_th: float + The segment is considered ended when the energy is <= deactivation_th. + This is active only if apply_energy_VAD is True. + speech_th: float + Threshold on the mean posterior probability within the candidate + speech segment. Below that threshold, the segment is re-assigned to + a non-speech region. This is active only if double_check is True. + close_th: float + If the distance between boundaries is smaller than close_th, the + segments will be merged. + len_th: float + If the length of the segment is smaller than len_th, the segments + will be merged. + """ + + def __init__( + self, + segment_padding: float = 0.01, + large_chunk_size: float = 30, + small_chunk_size: float = 0.05, + overlap_small_chunk: bool = False, + apply_energy_VAD: bool = False, + double_check: bool = True, + close_th: float = 0.250, + len_th: float = 0.250, + activation_th: float = 0.5, + deactivation_th: float = 0.25, + en_activation_th: float = 0.5, + en_deactivation_th: float = 0.0, + speech_th: float = 0.50, + cuda: bool = False, + speechbrain: bool = False, + **kwargs, + ): + if speechbrain and not FOUND_SPEECHBRAIN: + logger.error( + "Could not import speechbrain, please ensure it is installed via `pip install speechbrain`" + ) + sys.exit(1) + super().__init__(**kwargs) + self.large_chunk_size = large_chunk_size + self.small_chunk_size = small_chunk_size + self.overlap_small_chunk = overlap_small_chunk + self.apply_energy_VAD = apply_energy_VAD + self.double_check = double_check + self.close_th = close_th + self.len_th = len_th + self.activation_th = activation_th + self.deactivation_th = deactivation_th + self.en_activation_th = en_activation_th + self.en_deactivation_th = en_deactivation_th + self.speech_th = speech_th + self.cuda = cuda + self.speechbrain = speechbrain + self.segment_padding = segment_padding + + @classmethod + def parse_parameters( + cls, + config_path: Optional[str] = None, + args: Optional[Dict[str, typing.Any]] = None, + unknown_args: Optional[typing.Iterable[str]] = None, + ) -> MetaDict: + """ + Parse parameters for segmentation from a config path or command-line arguments + + Parameters + ---------- + config_path: str + Config path + args: dict[str, Any] + Parsed arguments + unknown_args: list[str] + Optional list of arguments that were not parsed + + Returns + ------- + dict[str, Any] + Configuration parameters + """ + global_params = {} + if config_path and os.path.exists(config_path): + data = load_configuration(config_path) + for k, v in data.items(): + if k == "features": + if "type" in v: + v["feature_type"] = v["type"] + del v["type"] + global_params.update(v) + else: + if v is None and k in cls.nullable_fields: + v = [] + global_params[k] = v + global_params.update(cls.parse_args(args, unknown_args)) + return global_params + + def segment_vad_arguments(self) -> List[SegmentVadArguments]: + """ + Generate Job arguments for :class:`~montreal_forced_aligner.segmenter.SegmentVadFunction` + + Returns + ------- + list[SegmentVadArguments] + Arguments for processing + """ + return [ + SegmentVadArguments( + j.id, + getattr(self, "db_string", ""), + os.path.join(self.working_log_directory, f"segment_vad.{j.id}.log"), + j.construct_path(self.split_directory, "vad", "scp"), + self.segmentation_options, + ) + for j in self.jobs + ] + + @property + def segmentation_options(self) -> MetaDict: + """Options for segmentation""" + return { + "large_chunk_size": self.large_chunk_size, + "frame_shift": self.frame_shift, + "small_chunk_size": self.small_chunk_size, + "overlap_small_chunk": self.overlap_small_chunk, + "apply_energy_VAD": self.apply_energy_VAD, + "double_check": self.double_check, + "activation_th": self.activation_th, + "deactivation_th": self.deactivation_th, + "en_activation_th": self.en_activation_th, + "en_deactivation_th": self.en_deactivation_th, + "speech_th": self.speech_th, + "close_th": self.close_th, + "len_th": self.len_th, + } + + def segment_vad_speechbrain(self) -> None: + """ + Run segmentation based off of VAD. + + See Also + -------- + :class:`~montreal_forced_aligner.segmenter.SegmentVadFunction` + Multiprocessing helper function for each job + segment_vad_arguments + Job method for generating arguments for helper function + """ + + old_utts = set() + new_utts = [] + kwargs = self.segmentation_options + kwargs.pop("frame_shift") + with tqdm.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() + if not utt_index: + utt_index = 0 + utt_index += 1 + files: List[File] = ( + session.query(File, Utterance) + .options(joinedload(File.sound_file)) + .join(Utterance.file) + ) + for f, u in files: + boundaries = self.vad_model.get_speech_segments( + f.sound_file.sound_file_path, **kwargs + ).numpy() + for i in range(boundaries.shape[0]): + old_utts.add(u.id) + begin, end = boundaries[i, :] + begin -= self.segment_padding + end += self.segment_padding + begin = max(0.0, begin) + end = max(f.sound_file.duration, end) + new_utts.append( + { + "id": utt_index, + "begin": begin, + "end": end, + "text": "speech", + "speaker_id": u.speaker_id, + "file_id": u.file_id, + "oovs": "", + "normalized_text": "", + "features": "", + "in_subset": False, + "ignored": False, + "channel": u.channel, + } + ) + utt_index += 1 + pbar.update(1) + session.query(Utterance).filter(Utterance.id.in_(old_utts)).delete() + session.bulk_insert_mappings( + Utterance, new_utts, return_defaults=False, render_nulls=True + ) + session.commit() + + def segment_vad_mfa(self) -> None: + """ + Run segmentation based off of VAD. + + See Also + -------- + :class:`~montreal_forced_aligner.segmenter.SegmentVadFunction` + Multiprocessing helper function for each job + segment_vad_arguments + Job method for generating arguments for helper function + """ + + arguments = self.segment_vad_arguments() + old_utts = set() + new_utts = [] + + with tqdm.tqdm( + total=self.num_utterances, disable=GLOBAL_CONFIG.quiet + ) as pbar, self.session() as session: + utterances = session.query( + Utterance.id, Utterance.channel, Utterance.speaker_id, Utterance.file_id + ) + utterance_cache = {} + for u_id, channel, speaker_id, file_id in utterances: + utterance_cache[u_id] = (channel, speaker_id, file_id) + for utt, segments in run_kaldi_function(SegmentVadFunction, arguments, pbar.update): + old_utts.add(utt) + channel, speaker_id, file_id = utterance_cache[utt] + for seg in segments: + new_utts.append( + { + "begin": seg.begin, + "end": seg.end, + "text": "speech", + "speaker_id": speaker_id, + "file_id": file_id, + "oovs": "", + "normalized_text": "", + "features": "", + "in_subset": False, + "ignored": False, + "channel": channel, + } + ) + session.query(Utterance).filter(Utterance.id.in_(old_utts)).delete() + session.bulk_insert_mappings( + Utterance, new_utts, return_defaults=False, render_nulls=True + ) + session.commit() + + def setup(self) -> None: + """Setup segmentation""" + super().setup() + self.create_new_current_workflow(WorkflowType.segmentation) + log_dir = os.path.join(self.working_directory, "log") + os.makedirs(log_dir, exist_ok=True) + try: + if self.speechbrain: + model_dir = os.path.join( + GLOBAL_CONFIG.current_profile.temporary_directory, "models", "VAD" + ) + os.makedirs(model_dir, exist_ok=True) + run_opts = None + if self.cuda: + run_opts = {"device": "cuda"} + self.vad_model = VAD.from_hparams( + source="speechbrain/vad-crdnn-libriparty", savedir=model_dir, run_opts=run_opts + ) + self.initialize_database() + self._load_corpus() + else: + self.load_corpus() + except Exception as e: + if isinstance(e, KaldiProcessingError): + log_kaldi_errors(e.error_logs) + e.update_log_file() + raise + + def segment(self) -> None: + """ + Performs VAD and segmentation into utterances + + Raises + ------ + :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` + If there were any errors in running Kaldi binaries + """ + self.setup() + self.create_new_current_workflow(WorkflowType.segmentation) + wf = self.current_workflow + if wf.done: + logger.info("Segmentation already done, skipping.") + return + try: + if not self.speechbrain: + self.compute_vad() + self.segment_vad_mfa() + else: + self.segment_vad_speechbrain() + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update( + {"done": True} + ) + session.commit() + except Exception as e: + with self.session() as session: + session.query(CorpusWorkflow).filter(CorpusWorkflow.id == wf.id).update( + {"dirty": True} + ) + session.commit() + if isinstance(e, KaldiProcessingError): + log_kaldi_errors(e.error_logs) + e.update_log_file() + raise + + def export_files(self, output_directory: str, output_format: Optional[str] = None) -> None: + """ + Export the results of segmentation as TextGrids + + Parameters + ---------- + output_directory: str + Directory to save segmentation TextGrids + """ + if output_format is None: + output_format = TextFileType.TEXTGRID.value + os.makedirs(output_directory, exist_ok=True) + with self.session() as session: + for f in session.query(File).options( + selectinload(File.utterances).joinedload(Utterance.speaker, innerjoin=True), + joinedload(File.sound_file, innerjoin=True), + joinedload(File.text_file), + ): + f.save(output_directory, output_format=output_format) diff --git a/montreal_forced_aligner/vad/vad_trainer.py b/montreal_forced_aligner/vad/vad_trainer.py new file mode 100644 index 00000000..e69de29b diff --git a/montreal_forced_aligner/validation/corpus_validator.py b/montreal_forced_aligner/validation/corpus_validator.py index b98955e3..207aad91 100644 --- a/montreal_forced_aligner/validation/corpus_validator.py +++ b/montreal_forced_aligner/validation/corpus_validator.py @@ -1,391 +1,44 @@ """ Validating corpora ================== - """ from __future__ import annotations -import multiprocessing as mp +import logging import os -import subprocess import time import typing from decimal import Decimal -from queue import Empty -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional import sqlalchemy -import tqdm -from sqlalchemy.orm import Session, joinedload, load_only, selectinload +from sqlalchemy.orm import joinedload from montreal_forced_aligner.acoustic_modeling.trainer import TrainableAligner -from montreal_forced_aligner.alignment import CorpusAligner, PretrainedAligner +from montreal_forced_aligner.alignment import PretrainedAligner from montreal_forced_aligner.alignment.multiprocessing import compile_information_func -from montreal_forced_aligner.data import MfaArguments -from montreal_forced_aligner.db import ( - Corpus, - Dictionary, - File, - SoundFile, - Speaker, - TextFile, - Utterance, -) +from montreal_forced_aligner.config import GLOBAL_CONFIG +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, - load_scp, mfa_open, ) -from montreal_forced_aligner.transcription.transcriber import TranscriberMixin -from montreal_forced_aligner.utils import ( - KaldiFunction, - KaldiProcessWorker, - Stopped, - log_kaldi_errors, - run_mp, - run_non_mp, - thirdparty_binary, -) +from montreal_forced_aligner.utils import log_kaldi_errors, run_mp, run_non_mp if TYPE_CHECKING: - from argparse import Namespace - from dataclasses import dataclass - from montreal_forced_aligner.abc import MetaDict -else: - from dataclassy import dataclass __all__ = ["TrainingValidator", "PretrainedValidator"] +logger = logging.getLogger("mfa") -@dataclass -class TestUtterancesArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.validation.corpus_validator.TestUtterancesFunction`""" - - feature_strings: Dict[str, str] - text_int_paths: Dict[str, str] - model_path: str - disambiguation_symbols_int_path: str - score_options: MetaDict - text_paths: Dict[str, str] - tree_path: str - utt2lm_paths: Dict[str, str] - order: int - method: str - - -@dataclass -class TrainSpeakerLmArguments(MfaArguments): - """Arguments for :class:`~montreal_forced_aligner.validation.corpus_validator.TrainSpeakerLmFunction`""" - - word_symbols_paths: Dict[str, str] - speaker_mapping: Dict[str, List[str]] - speaker_paths: Dict[str, str] - oov_word: str - order: int - method: str - target_num_ngrams: int - - -class TestUtterancesFunction(KaldiFunction): - """ - Multiprocessing function to test utterance transcriptions with utterance and speaker ngram models - - See Also - -------- - :kaldi_src:`compile-train-graphs-fsts` - Relevant Kaldi binary - :kaldi_src:`gmm-latgen-faster` - Relevant Kaldi binary - :kaldi_src:`lattice-oracle` - Relevant Kaldi binary - :openfst_src:`farcompilestrings` - Relevant OpenFst binary - :ngram_src:`ngramcount` - Relevant OpenGrm-Ngram binary - :ngram_src:`ngrammake` - Relevant OpenGrm-Ngram binary - :ngram_src:`ngramshrink` - Relevant OpenGrm-Ngram binary - - Parameters - ---------- - args: :class:`~montreal_forced_aligner.validation.corpus_validator.TestUtterancesArguments` - Arguments for the function - """ - - def __init__(self, args: TestUtterancesArguments): - super().__init__(args) - self.feature_strings = args.feature_strings - self.text_int_paths = args.text_int_paths - self.disambiguation_symbols_int_path = args.disambiguation_symbols_int_path - self.model_path = args.model_path - self.score_options = args.score_options - self.text_paths = args.text_paths - self.tree_path = args.tree_path - self.utt2lm_paths = args.utt2lm_paths - self.order = args.order - self.method = args.method - self.reversed_word_mapping = {} - self.word_symbols_paths = {} - self.lexicon_disambig_fst_paths = {} - - def _run(self) -> typing.Generator[typing.Tuple[int, str]]: - """Run the function""" - db_engine = sqlalchemy.create_engine(f"sqlite:///{self.db_path}?mode=ro&nolock=1") - with Session(db_engine) as session: - for dict_id in self.feature_strings.keys(): - d = ( - session.query(Dictionary) - .options( - selectinload(Dictionary.words), - load_only( - Dictionary.oov_word, - Dictionary.root_temp_directory, - ), - ) - .get(dict_id) - ) - self.oov_word = d.oov_word - self.word_symbols_paths[dict_id] = d.words_symbol_path - self.lexicon_disambig_fst_paths[dict_id] = d.lexicon_disambig_fst_path - - self.reversed_word_mapping[dict_id] = {} - for w in d.words: - self.reversed_word_mapping[dict_id][w.id] = w.word - with mfa_open(self.log_path, "w") as log_file: - for dict_id in self.feature_strings.keys(): - feature_string = self.feature_strings[dict_id] - text_int_path = self.text_int_paths[dict_id] - disambig_int_path = self.disambiguation_symbols_int_path - disambig_L_fst_path = self.lexicon_disambig_fst_paths[dict_id] - utt2lm_path = self.utt2lm_paths[dict_id] - text_path = self.text_paths[dict_id] - word_symbols_path = self.word_symbols_paths[dict_id] - - compile_proc = subprocess.Popen( - [ - thirdparty_binary("compile-train-graphs-fsts"), - f"--transition-scale={self.score_options['transition_scale']}", - f"--self-loop-scale={self.score_options['self_loop_scale']}", - f"--read-disambig-syms={disambig_int_path}", - "--batch_size=1", - self.tree_path, - self.model_path, - disambig_L_fst_path, - "ark:-", - "ark:-", - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - latgen_proc = subprocess.Popen( - [ - thirdparty_binary("gmm-latgen-faster"), - f"--acoustic-scale={self.score_options['acoustic_scale']}", - f"--beam={self.score_options['beam']}", - f"--max-active={self.score_options['max_active']}", - f"--lattice-beam={self.score_options['lattice_beam']}", - f"--word-symbol-table={word_symbols_path}", - "--allow-partial", - self.model_path, - "ark:-", - feature_string, - "ark:-", - ], - stderr=log_file, - stdin=compile_proc.stdout, - stdout=subprocess.PIPE, - env=os.environ, - ) - - oracle_proc = subprocess.Popen( - [ - thirdparty_binary("lattice-oracle"), - "ark:-", - f"ark,t:{text_int_path}", - "ark,t:-", - ], - stdin=latgen_proc.stdout, - stdout=subprocess.PIPE, - env=os.environ, - stderr=log_file, - ) - texts = load_scp(text_path) - fsts = load_scp(utt2lm_path) - temp_dir = os.path.dirname(self.log_path) - for utt, text in texts.items(): - if not text: - continue - mod_path = os.path.join(temp_dir, f"{utt}.mod") - far_proc = subprocess.Popen( - [ - thirdparty_binary("farcompilestrings"), - "--fst_type=compact", - f"--unknown_symbol={self.oov_word}", - f"--symbols={word_symbols_path}", - "--generate_keys=1", - "--keep_symbols", - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - count_proc = subprocess.Popen( - [thirdparty_binary("ngramcount"), f"--order={self.order}"], - stdin=far_proc.stdout, - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - with mfa_open(mod_path, "wb") as f: - make_proc = subprocess.Popen( - [thirdparty_binary("ngrammake"), f"--method={self.method}"], - stdin=count_proc.stdout, - stdout=f, - stderr=log_file, - env=os.environ, - ) - far_proc.stdin.write(" ".join(text).encode("utf8")) - far_proc.stdin.flush() - far_proc.stdin.close() - make_proc.communicate() - merge_proc = subprocess.Popen( - [ - thirdparty_binary("ngrammerge"), - "--normalize", - "--v=10", - mod_path, - fsts[utt], - ], - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - print_proc = subprocess.Popen( - [thirdparty_binary("fstprint"), "--numeric=true", "--v=10"], - stderr=log_file, - stdin=merge_proc.stdout, - stdout=subprocess.PIPE, - env=os.environ, - ) - # fst = far_proc.stdout.read() - fst = print_proc.communicate()[0] - compile_proc.stdin.write(f"{utt}\n".encode("utf8")) - compile_proc.stdin.write(fst) - compile_proc.stdin.write(b"\n") - compile_proc.stdin.flush() - output = oracle_proc.stdout.readline() - output = output.strip().decode("utf8").split(" ") - utterance = int(output[0].split("-")[-1]) - transcript = " ".join( - self.reversed_word_mapping[dict_id][int(x)] for x in output[1:] - ) - os.remove(mod_path) - yield utterance, transcript - - compile_proc.stdin.close() - oracle_proc.communicate() - self.check_call(oracle_proc) - - -class TrainSpeakerLmFunction(KaldiFunction): - """ - Multiprocessing function to training small language models for each speaker - - See Also - -------- - :openfst_src:`farcompilestrings` - Relevant OpenFst binary - :ngram_src:`ngramcount` - Relevant OpenGrm-Ngram binary - :ngram_src:`ngrammake` - Relevant OpenGrm-Ngram binary - :ngram_src:`ngramshrink` - Relevant OpenGrm-Ngram binary - - Parameters - ---------- - args: :class:`~montreal_forced_aligner.validation.corpus_validator.TrainSpeakerLmArguments` - Arguments for the function - """ - - def __init__(self, args: TrainSpeakerLmArguments): - super().__init__(args) - self.word_symbols_paths = args.word_symbols_paths - self.speaker_mapping = args.speaker_mapping - self.speaker_paths = args.speaker_paths - self.oov_word = args.oov_word - self.order = args.order - self.method = args.method - self.target_num_ngrams = args.target_num_ngrams - - def _run(self) -> typing.Generator[bool]: - """Run the function""" - with mfa_open(self.log_path, "w") as log_file: - - for dict_id, speakers in self.speaker_mapping.items(): - word_symbols_path = self.word_symbols_paths[dict_id] - for speaker in speakers: - training_path = self.speaker_paths[speaker] - base_path = os.path.splitext(training_path)[0] - mod_path = base_path + ".mod" - far_proc = subprocess.Popen( - [ - thirdparty_binary("farcompilestrings"), - "--fst_type=compact", - f"--unknown_symbol={self.oov_word}", - f"--symbols={word_symbols_path}", - "--keep_symbols", - training_path, - ], - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - count_proc = subprocess.Popen( - [thirdparty_binary("ngramcount"), f"--order={self.order}"], - stdin=far_proc.stdout, - stdout=subprocess.PIPE, - stderr=log_file, - ) - make_proc = subprocess.Popen( - [thirdparty_binary("ngrammake"), "--method=kneser_ney"], - stdin=count_proc.stdout, - stdout=subprocess.PIPE, - stderr=log_file, - env=os.environ, - ) - shrink_proc = subprocess.Popen( - [ - thirdparty_binary("ngramshrink"), - "--method=relative_entropy", - f"--target_number_of_ngrams={self.target_num_ngrams}", - "--shrink_opt=2", - "--theta=0.001", - "-", - mod_path, - ], - stdin=make_proc.stdout, - stderr=log_file, - env=os.environ, - ) - shrink_proc.communicate() - self.check_call(shrink_proc) - assert os.path.exists(mod_path) - os.remove(training_path) - yield os.path.exists(mod_path) - - -class ValidationMixin(CorpusAligner, TranscriberMixin): +class ValidationMixin: """ Mixin class for performing validation on a corpus @@ -395,6 +48,8 @@ class ValidationMixin(CorpusAligner, TranscriberMixin): Flag for whether feature generation and training/alignment should be skipped test_transcriptions: bool Flag for whether utterance transcriptions should be tested with a unigram language model + phone_alignment: bool + Flag for whether alignments should be compared to a phone-based system target_num_ngrams: int Target number of ngrams from speaker models to use @@ -414,166 +69,23 @@ def __init__( ignore_acoustics: bool = False, test_transcriptions: bool = False, target_num_ngrams: int = 100, - min_word_count: int = 10, order: int = 3, method: str = "kneser_ney", **kwargs, ): - kwargs["clean"] = True super().__init__(**kwargs) self.ignore_acoustics = ignore_acoustics self.test_transcriptions = test_transcriptions self.target_num_ngrams = target_num_ngrams - self.min_word_count = min_word_count self.order = order self.method = method - self.printer = TerminalPrinter(print_function=self.log_info) - - def output_utt_fsts(self) -> None: - """ - Write utterance FSTs - """ - - with self.session() as session: - for j in self.jobs: - if not j.has_data: - continue - for dict_id in j.dictionary_ids: - utterances = ( - session.query(Utterance.kaldi_id, Utterance.speaker_id) - .join(Utterance.speaker) - .join(Speaker.dictionary) - .filter(Speaker.job_id == j.name) - .filter(Speaker.dictionary_id == dict_id) - .order_by(Utterance.kaldi_id) - ) - - utt2fst_scp_path = os.path.join( - self.split_directory, f"utt2lm.{dict_id}.{j.name}.scp" - ) - with mfa_open(utt2fst_scp_path, "w") as f: - for u_id, s_id in utterances: - speaker_lm = os.path.join(self.working_directory, f"{s_id}.mod") - f.write(f"{u_id} {speaker_lm}\n") - - def train_speaker_lm_arguments( - self, - ) -> List[TrainSpeakerLmArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.validation.corpus_validator.TrainSpeakerLmFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.validation.corpus_validator.TrainSpeakerLmArguments`] - Arguments for processing - """ - arguments = [] - with self.session() as session: - for j in self.jobs: - if not j.has_data: - continue - speaker_mapping = {} - speaker_paths = {} - words_symbol_paths = {} - - speakers = ( - session.query(Speaker) - .options(joinedload(Speaker.dictionary, innerjoin=True)) - .filter(Speaker.job_id == j.name) - ) - for s in speakers: - dict_id = s.dictionary_id - if dict_id not in speaker_mapping: - speaker_mapping[dict_id] = [] - words_symbol_paths[dict_id] = s.dictionary.words_symbol_path - speaker_mapping[dict_id].append(s.id) - speaker_paths[s.id] = os.path.join(self.working_directory, f"{s.id}.txt") - arguments.append( - TrainSpeakerLmArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"train_lm.{j.name}.log"), - words_symbol_paths, - speaker_mapping, - speaker_paths, - self.oov_word, - self.order, - self.method, - self.target_num_ngrams, - ) - ) - return arguments - - def test_utterances_arguments(self) -> List[TestUtterancesArguments]: - """ - Generate Job arguments for :class:`~montreal_forced_aligner.validation.corpus_validator.TestUtterancesFunction` - - Returns - ------- - list[:class:`~montreal_forced_aligner.validation.corpus_validator.TestUtterancesArguments`] - Arguments for processing - """ - feat_strings = self.construct_feature_proc_strings() - - return [ - TestUtterancesArguments( - j.name, - getattr(self, "db_path", ""), - os.path.join(self.working_log_directory, f"test_utterances.{j.name}.log"), - feat_strings[j.name], - j.construct_path_dictionary(self.data_directory, "text", "int.scp"), - self.model_path, - self.disambiguation_symbols_int_path, - self.score_options, - j.construct_path_dictionary(self.data_directory, "text", "scp"), - self.tree_path, - j.construct_path_dictionary(self.data_directory, "utt2lm", "scp"), - self.order, - self.method, - ) - for j in self.jobs - if j.has_data - ] + self.printer = TerminalPrinter(print_function=logger.info) @property def working_log_directory(self) -> str: """Working log directory""" return os.path.join(self.working_directory, "log") - def setup(self) -> None: - """ - Set up the corpus and validator - - Raises - ------ - :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` - If there were any errors in running Kaldi binaries - """ - try: - self.initialize_database() - self.load_corpus() - self.write_lexicon_information() - if self.test_transcriptions: - self.write_lexicon_information(write_disambiguation=True) - if self.ignore_acoustics: - self.log_info("Skipping acoustic feature generation") - else: - self.generate_features() - self.calculate_oovs_found() - - if not self.ignore_acoustics and self.test_transcriptions: - self.initialize_utt_fsts() - else: - self.log_info("Skipping transcription testing") - except Exception as e: - if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) - raise - def analyze_setup(self) -> None: """ Analyzes the setup process and outputs info to the console @@ -586,13 +98,13 @@ def analyze_setup(self) -> None: total_duration = session.query(sqlalchemy.func.sum(Utterance.duration)).scalar() total_duration = Decimal(str(total_duration)).quantize(Decimal("0.001")) - self.log_debug(f"Duration calculation took {time.time() - begin}") + logger.debug(f"Duration calculation took {time.time() - begin:.3f} seconds") begin = time.time() ignored_count = len(self.no_transcription_files) ignored_count += len(self.textgrid_read_errors) ignored_count += len(self.decode_error_files) - self.log_debug(f"Ignored count calculation took {time.time() - begin}") + 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") @@ -659,7 +171,7 @@ def analyze_oovs(self) -> None: ) self.oovs_found.update(oovs) if self.oovs_found: - self.calculate_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 = [ @@ -848,12 +360,12 @@ def compile_information(self) -> None: :meth:`.AlignMixin.compile_information_arguments` Job method for generating arguments for the helper function """ - self.log_debug("Analyzing alignment information") + logger.debug("Analyzing alignment information") compile_info_begin = time.time() self.collect_alignments() jobs = self.compile_information_arguments() - if self.use_mp: + if GLOBAL_CONFIG.use_mp: alignment_info = run_mp( compile_information_func, jobs, self.working_log_directory, True ) @@ -881,7 +393,7 @@ def compile_information(self) -> None: self.printer.print_header("Alignment") if not avg_like_frames: - self.log_debug( + 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") @@ -893,7 +405,7 @@ def compile_information(self) -> None: else: self.printer.print_green_stat(0, "utterances were too short to be aligned") if beam_too_narrow_count: - self.log_debug( + logger.debug( f"There were {beam_too_narrow_count} utterances that could not be aligned with " f"the current beam settings." ) @@ -936,87 +448,8 @@ def compile_information(self) -> None: average_log_like = avg_like_sum / avg_like_frames if average_logdet_sum: average_log_like += average_logdet_sum / average_logdet_frames - self.log_debug(f"Average per frame likelihood for alignment: {average_log_like}") - self.log_debug(f"Compiling information took {time.time() - compile_info_begin}") - - def initialize_utt_fsts(self) -> None: - """ - Construct utterance FSTs - """ - self.log_info("Initializing for testing transcriptions...") - self.output_utt_fsts() - - @property - def score_options(self) -> MetaDict: - """Parameters for scoring transcript lattices""" - return { - "self_loop_scale": 0.1, - "transition_scale": 1.0, - "acoustic_scale": 0.1, - "beam": 15.0, - "lattice_beam": 8.0, - "max_active": 750, - } - - def train_speaker_lms(self) -> None: - """Train language models for each speaker based on their utterances""" - begin = time.time() - self.calculate_word_counts() - log_directory = self.working_log_directory - os.makedirs(log_directory, exist_ok=True) - self.log_info("Compiling per speaker biased language models...") - with self.session() as session: - speakers = session.query(Speaker).options( - selectinload(Speaker.utterances).load_only(Utterance.normalized_text) - ) - for s in speakers: - with mfa_open(os.path.join(self.working_directory, f"{s.id}.txt"), "w") as f: - for u in s.utterances: - text = [ - x if self.word_counts[x] >= self.min_word_count else self.oov_word - for x in u.normalized_text.split() - ] - - f.write(" ".join(text) + "\n") - arguments = self.train_speaker_lm_arguments() - with tqdm.tqdm(total=self.num_speakers, disable=getattr(self, "quiet", False)) as pbar: - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - - for i, args in enumerate(arguments): - function = TrainSpeakerLmFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - if isinstance(result, KaldiProcessingError): - error_dict[result.job_name] = result - continue - pbar.update(1) - if error_dict: - for v in error_dict.values(): - raise v - else: - self.log_debug("Not using multiprocessing...") - for args in arguments: - function = TrainSpeakerLmFunction(args) - for _ in function.run(): - pbar.update(1) - self.log_debug(f"Compiling speaker language models took {time.time() - begin}") + logger.debug(f"Average per frame likelihood for alignment: {average_log_like}") + logger.debug(f"Compiling information took {time.time() - compile_info_begin:.3f} seconds") def test_utterance_transcriptions(self) -> None: """ @@ -1028,73 +461,12 @@ def test_utterance_transcriptions(self) -> None: :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` If there were any errors in running Kaldi binaries """ - self.log_info("Checking utterance transcriptions...") + logger.info("Checking utterance transcriptions...") try: self.train_speaker_lms() - self.log_info("Decoding utterances (this will take some time)...") - - begin = time.time() - log_directory = self.working_log_directory - os.makedirs(log_directory, exist_ok=True) - arguments = self.test_utterances_arguments() - utterance_mapping = [] - with tqdm.tqdm( - total=self.num_utterances, disable=getattr(self, "quiet", False) - ) as pbar: - if self.use_mp: - error_dict = {} - return_queue = mp.Queue() - stopped = Stopped() - procs = [] - for i, args in enumerate(arguments): - function = TestUtterancesFunction(args) - p = KaldiProcessWorker(i, return_queue, function, stopped) - procs.append(p) - p.start() - while True: - try: - result = return_queue.get(timeout=1) - if stopped.stop_check(): - continue - except Empty: - for proc in procs: - if not proc.finished.stop_check(): - break - else: - break - continue - if isinstance(result, KaldiProcessingError): - error_dict[result.job_name] = result - continue - utterance, transcript = result - pbar.update(1) - if not utterance or not transcript: - continue - utterance_mapping.append( - {"id": utterance, "transcription_text": transcript} - ) - for p in procs: - p.join() - if error_dict: - for v in error_dict.values(): - raise v - else: - self.log_debug("Not using multiprocessing...") - for args in arguments: - function = TestUtterancesFunction(args) - for utterance, transcript in function.run(): - if not utterance or not transcript: - continue - utterance_mapping.append( - {"id": utterance, "transcription_text": transcript} - ) - pbar.update(1) - with self.session() as session: - session.bulk_update_mappings(Utterance, utterance_mapping) - self.log_debug(f"Decoding utterances took {time.time() - begin}") - self.log_info("Finished decoding utterances!") + self.transcribe(WorkflowType.per_speaker_transcription) self.printer.print_header("Test transcriptions") ser, wer, cer = self.compute_wer() @@ -1125,11 +497,8 @@ def test_utterance_transcriptions(self) -> None: except Exception as e: if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise @@ -1151,21 +520,20 @@ class TrainingValidator(TrainableAligner, ValidationMixin): """ def __init__(self, **kwargs): + training_configuration = kwargs.pop("training_configuration", None) super().__init__(**kwargs) self.training_configs = {} - self.add_config("monophone", {}) - - @property - def workflow_identifier(self) -> str: - """Identifier for validation""" - return "validate_training" + if training_configuration is None: + training_configuration = [("monophone", {})] + for k, v in training_configuration: + self.add_config(k, v) @classmethod def parse_parameters( cls, config_path: Optional[str] = None, - args: Optional[Namespace] = None, - unknown_args: Optional[List[str]] = None, + args: Optional[Dict[str, Any]] = None, + unknown_args: Optional[typing.Iterable[str]] = None, ) -> MetaDict: """ @@ -1175,10 +543,10 @@ def parse_parameters( ---------- config_path: str Config path - args: :class:`~argparse.Namespace` - Command-line arguments from argparse - unknown_args: list[str], optional - Extra command-line arguments + args: dict[str, Any] + Parsed arguments + unknown_args: list[str] + Optional list of arguments that were not parsed Returns ------- @@ -1227,59 +595,56 @@ def setup(self) -> None: :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` If there were any errors in running Kaldi binaries """ + self.check_previous_run() + if hasattr(self, "initialize_database"): + self.initialize_database() if self.initialized: return try: all_begin = time.time() - self.initialize_database() self.dictionary_setup() - self.log_debug(f"Loaded dictionary in {time.time() - all_begin}") + logger.debug(f"Loaded dictionary in {time.time() - all_begin:.3f} seconds") begin = time.time() self._load_corpus() - self.log_debug(f"Loaded corpus in {time.time() - begin}") + logger.debug(f"Loaded corpus in {time.time() - begin:.3f} seconds") + + begin = time.time() + self.initialize_jobs() + logger.debug(f"Initialized jobs in {time.time() - begin:.3f} seconds") + + self.normalize_text() - self.calculate_oovs_found() + self.save_oovs_found(self.output_directory) begin = time.time() self.write_lexicon_information() self.write_training_information() - self.log_debug(f"Wrote lexicon information in {time.time() - begin}") + if self.test_transcriptions: + self.write_lexicon_information(write_disambiguation=True) + logger.debug(f"Wrote lexicon information in {time.time() - begin:.3f} seconds") if self.ignore_acoustics: - self.log_info("Skipping acoustic feature generation") + logger.info("Skipping acoustic feature generation") else: - - begin = time.time() - self.initialize_jobs() - self.log_debug(f"Initialized jobs in {time.time() - begin}") - begin = time.time() self.create_corpus_split() - self.log_debug(f"Created corpus split directory in {time.time() - begin}") - if self.test_transcriptions: - begin = time.time() - self.write_lexicon_information(write_disambiguation=True) - self.log_debug(f"Wrote lexicon information in {time.time() - begin}") + logger.debug( + f"Created corpus split directory in {time.time() - begin:.3f} seconds" + ) begin = time.time() self.generate_features() - self.log_debug(f"Generated features in {time.time() - begin}") - if self.test_transcriptions: - begin = time.time() - self.initialize_utt_fsts() - self.log_debug(f"Initialized utterance FSTs in {time.time() - begin}") + logger.debug(f"Generated features in {time.time() - begin:.3f} seconds") begin = time.time() - self.calculate_oovs_found() - self.log_debug(f"Calculated OOVs in {time.time() - begin}") + self.save_oovs_found(self.output_directory) + logger.debug(f"Calculated OOVs in {time.time() - begin:.3f} seconds") + self.setup_trainers() self.initialized = True except Exception as e: if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise def validate(self) -> None: @@ -1287,10 +652,10 @@ def validate(self) -> None: Performs validation of the corpus """ begin = time.time() - self.log_debug(f"Setup took {time.time() - begin}") + logger.debug(f"Setup took {time.time() - begin:.3f} seconds") self.setup() self.analyze_setup() - self.log_debug(f"Setup took {time.time() - begin}") + logger.debug(f"Setup took {time.time() - begin:.3f} seconds") if self.ignore_acoustics: self.printer.print_info_lines("Skipping test alignments.") return @@ -1298,6 +663,7 @@ def validate(self) -> None: self.train() if self.test_transcriptions: self.test_utterance_transcriptions() + self.get_phone_confidences() class PretrainedValidator(PretrainedAligner, ValidationMixin): @@ -1316,11 +682,6 @@ class PretrainedValidator(PretrainedAligner, ValidationMixin): def __init__(self, **kwargs): super().__init__(**kwargs) - @property - def workflow_identifier(self) -> str: - """Identifier for validation""" - return "validate_pretrained" - def setup(self) -> None: """ Set up the corpus and validator @@ -1330,98 +691,63 @@ def setup(self) -> None: :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` If there were any errors in running Kaldi binaries """ + self.initialize_database() if self.initialized: return try: + self.setup_acoustic_model() self.dictionary_setup() self._load_corpus() + self.initialize_jobs() + self.normalize_text() - self.calculate_oovs_found() + self.save_oovs_found(self.output_directory) if self.ignore_acoustics: - self.log_info("Skipping acoustic feature generation") + logger.info("Skipping acoustic feature generation") else: self.write_lexicon_information() - self.initialize_jobs() + self.create_corpus_split() if self.test_transcriptions: self.write_lexicon_information(write_disambiguation=True) self.generate_features() - if self.test_transcriptions: - self.initialize_utt_fsts() - else: - self.log_info("Skipping transcription testing") self.acoustic_model.validate(self) - self.acoustic_model.export_model(self.working_directory) - import logging - - logger = logging.getLogger(self.identifier) - self.acoustic_model.log_details(logger) + self.acoustic_model.log_details() self.initialized = True - self.log_info("Finished initializing!") + logger.info("Finished initializing!") except Exception as e: if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) + log_kaldi_errors(e.error_logs) + e.update_log_file() raise - def align(self) -> None: - """ - Validate alignment - """ - done_path = os.path.join(self.working_directory, "done") - dirty_path = os.path.join(self.working_directory, "dirty") - if os.path.exists(done_path): - self.log_debug("Alignment already done, skipping.") - return - try: - log_dir = os.path.join(self.working_directory, "log") - os.makedirs(log_dir, exist_ok=True) - self.compile_train_graphs() - - self.log_debug("Performing first-pass alignment...") - self.speaker_independent = True - self.align_utterances() - if self.uses_speaker_adaptation: - self.log_debug("Calculating fMLLR for speaker adaptation...") - self.calc_fmllr() - - self.speaker_independent = False - self.log_debug("Performing second-pass alignment...") - self.align_utterances() - - except Exception as e: - with mfa_open(dirty_path, "w"): - pass - if isinstance(e, KaldiProcessingError): - import logging - - logger = logging.getLogger(self.identifier) - log_kaldi_errors(e.error_logs, logger) - e.update_log_file(logger) - raise - with mfa_open(done_path, "w"): - pass - def validate(self) -> None: """ Performs validation of the corpus """ + self.initialize_database() + self.create_new_current_workflow(WorkflowType.alignment) self.setup() self.analyze_setup() self.analyze_missing_phones() if self.ignore_acoustics: - self.log_info("Skipping test alignments.") + logger.info("Skipping test alignments.") return self.align() - self.alignment_done = True + self.collect_alignments() self.compile_information() + if self.phone_confidence: + self.get_phone_confidences() + + if self.use_phone_model: + self.create_new_current_workflow(WorkflowType.phone_transcription) + self.transcribe() + self.collect_alignments() if self.test_transcriptions: self.test_utterance_transcriptions() + self.collect_alignments() self.transcription_done = True with self.session() as session: session.query(Corpus).update({"transcription_done": True}) diff --git a/montreal_forced_aligner/validation/dictionary_validator.py b/montreal_forced_aligner/validation/dictionary_validator.py index c1350aba..525a380a 100644 --- a/montreal_forced_aligner/validation/dictionary_validator.py +++ b/montreal_forced_aligner/validation/dictionary_validator.py @@ -1,11 +1,16 @@ """Classes for validating dictionaries""" +import logging import os import shutil import typing +from montreal_forced_aligner.config import GLOBAL_CONFIG +from montreal_forced_aligner.data import WorkflowType from montreal_forced_aligner.g2p.generator import PyniniValidator from montreal_forced_aligner.g2p.trainer import PyniniTrainer +logger = logging.getLogger("mfa") + class DictionaryValidator(PyniniTrainer): """ @@ -42,11 +47,6 @@ def __init__( self.g2p_model_path = g2p_model_path self.g2p_threshold = g2p_threshold - @property - def workflow_identifier(self) -> str: - """Identifier for validation""" - return "validate_dictionary" - def setup(self) -> None: """Set up the dictionary validator""" if self.initialized: @@ -55,12 +55,15 @@ def setup(self) -> None: self.dictionary_setup() self.write_lexicon_information() if self.g2p_model_path is None: - self.log_info("Not using a pretrained G2P model, training from the dictionary...") + self.create_new_current_workflow(WorkflowType.train_g2p) + logger.info("Not using a pretrained G2P model, training from the dictionary...") self.initialize_training() self.train() self.g2p_model_path = os.path.join(self.working_log_directory, "g2p_model.zip") self.export_model(self.g2p_model_path) + self.create_new_current_workflow(WorkflowType.g2p) else: + self.create_new_current_workflow(WorkflowType.g2p) self.initialize_training() self.initialized = True @@ -79,10 +82,10 @@ def validate(self, output_path: typing.Optional[str] = None) -> None: g2p_model_path=self.g2p_model_path, word_list=list(self.g2p_training_dictionary.keys()), temporary_directory=os.path.join(self.working_directory, "validation"), - num_jobs=self.num_jobs, + num_jobs=GLOBAL_CONFIG.num_jobs, num_pronunciations=self.num_pronunciations, ) gen.evaluate_g2p_model(self.g2p_training_dictionary) if output_path is not None: shutil.copyfile(gen.evaluation_csv_path, output_path) - self.log_info(f"Wrote scores to {output_path}") + logger.info(f"Wrote scores to {output_path}") diff --git a/rtd_environment.yml b/rtd_environment.yml index 25684416..b2da9c24 100644 --- a/rtd_environment.yml +++ b/rtd_environment.yml @@ -7,21 +7,34 @@ dependencies: - tqdm - requests - colorama - - setuptools_scm - ansiwrap - pyyaml - praatio - dataclassy - - sqlalchemy>=1.4 - - pip + - sqlalchemy>=2.0 - pynini - - biopython + - pgvector + - pgvector-python + - postgresql + - hdbscan + - psycopg2 + - biopython=1.79 + - click + - setuptools_scm + - importlib_metadata - sphinx - numpydoc - sphinx-design - - sphinxcontrib-autoprogram + - sphinx-click + - sphinx-intl - pydata-sphinx-theme - - interrogate - - importlib_metadata + - myst-parser + - mock + - setuptools-scm + - numba + - kneed + - matplotlib + - seaborn - pip: - - sphinxemoji + - sphinx-needs + - sphinxcontrib-plantuml diff --git a/setup.cfg b/setup.cfg index dc2905ac..31bca5f5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,6 +10,7 @@ maintainer = Michael McAuliffe maintainer_email = michael.e.mcauliffe@gmail.com license = MIT license_file = LICENSE +license_files = LICENSE classifiers = Development Status :: 3 - Alpha License :: OSI Approved :: MIT License Operating System :: OS Independent @@ -20,6 +21,7 @@ classifiers = Development Status :: 3 - Alpha Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: Implementation :: CPython + Topic :: Multimedia :: Sound/Audio :: Speech Topic :: Scientific/Engineering Topic :: Text Processing :: Linguistic keywords = phonology @@ -27,21 +29,30 @@ keywords = phonology phonetics alignment segmentation -licence_file = LICENSE + transcription + g2p + language modeling [options] packages = find: install_requires = ansiwrap biopython + biopython<=1.79 + click + click colorama dataclassy + kneed librosa + matplotlib + numba numpy praatio>=5.0 pyyaml requests scikit-learn + seaborn sqlalchemy>=1.4 tqdm python_requires = >=3.8 @@ -52,13 +63,13 @@ exclude = tests [options.entry_points] console_scripts = - mfa = montreal_forced_aligner.command_line.mfa:main + mfa = montreal_forced_aligner.command_line.mfa:mfa_cli [options.extras_require] anchor = anchor-annotator - pyqt5 pyqtgraph + pyside6 dev = coverage coveralls @@ -66,19 +77,16 @@ dev = pytest pytest-mypy setuptools-scm - sphinx - sphinx-automodapi - sphinx-rtd-theme - sphinxemoji tomli tox tox-conda docs = interrogate + numpydoc + pydata-sphinx-theme sphinx - sphinx-automodapi - sphinx-rtd-theme - sphinxemoji + sphinx-click + sphinx-design testing = coverage coveralls diff --git a/tests/conftest.py b/tests/conftest.py index efb750a3..6455f632 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,12 +3,20 @@ import os import shutil +import mock import pytest import yaml +from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.helper import mfa_open +@pytest.fixture(autouse=True, scope="session") +def mock_settings_env_vars(): + with mock.patch.dict(os.environ, {"MFA_PROFILE": "test", "SQLALCHEMY_WARN_20": "true"}): + yield + + @pytest.fixture(scope="session") def test_dir(): base = os.path.dirname(os.path.abspath(__file__)) @@ -60,16 +68,52 @@ def generated_dir(test_dir): @pytest.fixture(scope="session") -def temp_dir(generated_dir): +def global_config(): + + GLOBAL_CONFIG.current_profile_name = "test" + GLOBAL_CONFIG.current_profile.clean = True + GLOBAL_CONFIG.current_profile.database_backend = "psycopg2" + GLOBAL_CONFIG.current_profile.database_port = 65432 + GLOBAL_CONFIG.current_profile.debug = True + GLOBAL_CONFIG.current_profile.verbose = True + GLOBAL_CONFIG.current_profile.num_jobs = 2 + GLOBAL_CONFIG.current_profile.use_mp = False + GLOBAL_CONFIG.save() + yield GLOBAL_CONFIG + + +@pytest.fixture(scope="session") +def temp_dir(generated_dir, global_config): + temp_dir = os.path.join(generated_dir, "temp") + global_config.current_profile.temporary_directory = temp_dir + global_config.save() + yield temp_dir + + +@pytest.fixture(scope="function") +def db_setup(temp_dir, global_config, request): + from montreal_forced_aligner.command_line.utils import ( + check_databases, + cleanup_databases, + remove_databases, + ) + + check_databases() + + def fin(): + cleanup_databases() + remove_databases() - return os.path.join(generated_dir, "temp") + yield True + request.addfinalizer(fin) @pytest.fixture(scope="session") def model_manager(): from montreal_forced_aligner.models import ModelManager - return ModelManager() + github_token = os.getenv("GITHUB_TOKEN", None) + return ModelManager(github_token) @pytest.fixture(scope="session") @@ -173,10 +217,16 @@ def english_uk_mfa_dictionary(model_manager): @pytest.fixture(scope="session") def english_ivector_model(model_manager): - return None - if not model_manager.has_local_model("ivector", "english_ivector"): - model_manager.download_model("ivector", "english_ivector") - return "english_ivector" + if not model_manager.has_local_model("ivector", "english_mfa"): + model_manager.download_model("ivector", "english_mfa") + return "english_mfa" + + +@pytest.fixture(scope="session") +def multilingual_ivector_model(model_manager): + if not model_manager.has_local_model("ivector", "multilingual_mfa"): + model_manager.download_model("ivector", "multilingual_mfa") + return "multilingual_mfa" @pytest.fixture(scope="session") @@ -223,9 +273,9 @@ def mono_align_model_path(output_model_dir): return os.path.join(output_model_dir, "mono_model.zip") -@pytest.fixture(scope="session") +@pytest.fixture() def basic_corpus_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "basic") + path = os.path.join(corpus_root_dir, "test_basic") os.makedirs(path, exist_ok=True) names = [("michael", ["acoustic_corpus"]), ("sickmichael", ["cold_corpus", "cold_corpus3"])] for s, files in names: @@ -248,9 +298,57 @@ def basic_corpus_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() +def combined_corpus_dir(corpus_root_dir, wav_dir, lab_dir): + path = os.path.join(corpus_root_dir, "test_combined") + os.makedirs(path, exist_ok=True) + names = [ + ("michael", ["acoustic_corpus.wav"]), + ("sickmichael", ["cold_corpus.wav", "cold_corpus3.wav"]), + ( + "speaker", + [ + "multilingual_ipa.flac", + "multilingual_ipa_2.flac", + "multilingual_ipa_3.flac", + "multilingual_ipa_4.flac", + "multilingual_ipa_5.flac", + ], + ), + ( + "speaker_two", + [ + "multilingual_ipa_us.flac", + "multilingual_ipa_us_2.flac", + "multilingual_ipa_us_3.flac", + "multilingual_ipa_us_4.flac", + "multilingual_ipa_us_5.flac", + ], + ), + ( + "speaker_three", + [ + "common_voice_en_22058264.mp3", + "common_voice_en_22058266.mp3", + "common_voice_en_22058267.mp3", + ], + ), + ] + for s, files in names: + s_dir = os.path.join(path, s) + os.makedirs(s_dir, exist_ok=True) + for name in files: + shutil.copyfile(os.path.join(wav_dir, name), os.path.join(s_dir, name)) + text_name = name.split(".")[0] + ".lab" + if not os.path.exists(os.path.join(lab_dir, text_name)): + text_name = name.split(".")[0] + ".txt" + shutil.copyfile(os.path.join(lab_dir, text_name), os.path.join(s_dir, text_name)) + return path + + +@pytest.fixture() def duplicated_name_corpus_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "basic") + path = os.path.join(corpus_root_dir, "test_duplicated") os.makedirs(path, exist_ok=True) names = [("michael", ["acoustic_corpus"]), ("sickmichael", ["cold_corpus", "cold_corpus3"])] for s, files in names: @@ -269,7 +367,7 @@ def duplicated_name_corpus_dir(corpus_root_dir, wav_dir, lab_dir): @pytest.fixture(scope="session") def basic_reference_dir(corpus_root_dir, wav_dir, textgrid_dir): - path = os.path.join(corpus_root_dir, "basic_reference") + path = os.path.join(corpus_root_dir, "test_basic_reference") os.makedirs(path, exist_ok=True) names = [("michael", ["acoustic_corpus"]), ("sickmichael", ["cold_corpus", "cold_corpus3"])] for s, files in names: @@ -283,9 +381,9 @@ def basic_reference_dir(corpus_root_dir, wav_dir, textgrid_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def xsampa_corpus_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "xsampa") + path = os.path.join(corpus_root_dir, "test_xsampa") os.makedirs(path, exist_ok=True) s_dir = os.path.join(path, "michael") @@ -297,9 +395,9 @@ def xsampa_corpus_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def basic_split_dir(corpus_root_dir, wav_dir, lab_dir, textgrid_dir): - path = os.path.join(corpus_root_dir, "split") + path = os.path.join(corpus_root_dir, "test_split") audio_path = os.path.join(path, "audio") text_path = os.path.join(path, "text") os.makedirs(path, exist_ok=True) @@ -343,9 +441,9 @@ def basic_split_dir(corpus_root_dir, wav_dir, lab_dir, textgrid_dir): return audio_path, text_path -@pytest.fixture(scope="session") +@pytest.fixture() def multilingual_ipa_corpus_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "multilingual") + path = os.path.join(corpus_root_dir, "test_multilingual") os.makedirs(path, exist_ok=True) names = [ ( @@ -382,9 +480,9 @@ def multilingual_ipa_corpus_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def multilingual_ipa_tg_corpus_dir(corpus_root_dir, wav_dir, textgrid_dir): - path = os.path.join(corpus_root_dir, "multilingual_tg") + path = os.path.join(corpus_root_dir, "test_multilingual_tg") os.makedirs(path, exist_ok=True) names = [ ( @@ -422,9 +520,9 @@ def multilingual_ipa_tg_corpus_dir(corpus_root_dir, wav_dir, textgrid_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def weird_words_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "weird_words") + path = os.path.join(corpus_root_dir, "test_weird_words") os.makedirs(path, exist_ok=True) name = "weird_words" shutil.copyfile( @@ -434,9 +532,9 @@ def weird_words_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def punctuated_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "punctuated") + path = os.path.join(corpus_root_dir, "test_punctuated") os.makedirs(path, exist_ok=True) name = "punctuated" shutil.copyfile( @@ -451,9 +549,36 @@ def punctuated_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() +def japanese_dir(corpus_root_dir, wav_dir, lab_dir): + path = os.path.join(corpus_root_dir, "test_japanese") + os.makedirs(path, exist_ok=True) + name = "japanese" + shutil.copyfile(os.path.join(lab_dir, name + ".lab"), os.path.join(path, name + ".lab")) + return path + + +@pytest.fixture() +def devanagari_dir(corpus_root_dir, wav_dir, lab_dir): + path = os.path.join(corpus_root_dir, "test_devanagari") + os.makedirs(path, exist_ok=True) + name = "devanagari" + shutil.copyfile(os.path.join(lab_dir, name + ".lab"), os.path.join(path, name + ".lab")) + return path + + +@pytest.fixture() +def french_clitics_dir(corpus_root_dir, wav_dir, lab_dir): + path = os.path.join(corpus_root_dir, "test_french_clitics") + os.makedirs(path, exist_ok=True) + name = "french_clitics" + shutil.copyfile(os.path.join(lab_dir, name + ".lab"), os.path.join(path, name + ".lab")) + return path + + +@pytest.fixture() def swedish_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "swedish") + path = os.path.join(corpus_root_dir, "test_swedish") os.makedirs(path, exist_ok=True) names = [ ( @@ -479,9 +604,9 @@ def swedish_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def basic_corpus_txt_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "basic_txt") + path = os.path.join(corpus_root_dir, "test_basic_txt") os.makedirs(path, exist_ok=True) names = [("michael", ["acoustic_corpus"]), ("sickmichael", ["cold_corpus", "cold_corpus3"])] for s, files in names: @@ -497,9 +622,9 @@ def basic_corpus_txt_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def extra_corpus_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "extra") + path = os.path.join(corpus_root_dir, "test_extra") os.makedirs(path, exist_ok=True) name = "cold_corpus3" shutil.copyfile(os.path.join(wav_dir, name + ".wav"), os.path.join(path, name + ".wav")) @@ -507,9 +632,9 @@ def extra_corpus_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def transcribe_corpus_24bit_dir(corpus_root_dir, wav_dir): - path = os.path.join(corpus_root_dir, "24bit") + path = os.path.join(corpus_root_dir, "test_24bit") os.makedirs(path, exist_ok=True) name = "cold_corpus_24bit" shutil.copyfile(os.path.join(wav_dir, name + ".wav"), os.path.join(path, name + ".wav")) @@ -518,9 +643,9 @@ def transcribe_corpus_24bit_dir(corpus_root_dir, wav_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def stereo_corpus_dir(corpus_root_dir, wav_dir, textgrid_dir): - path = os.path.join(corpus_root_dir, "stereo") + path = os.path.join(corpus_root_dir, "test_stereo") os.makedirs(path, exist_ok=True) name = "michaelandsickmichael" shutil.copyfile(os.path.join(wav_dir, name + ".wav"), os.path.join(path, name + ".wav")) @@ -530,9 +655,9 @@ def stereo_corpus_dir(corpus_root_dir, wav_dir, textgrid_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def mp3_corpus_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "cv_mp3") + path = os.path.join(corpus_root_dir, "test_cv_mp3") os.makedirs(path, exist_ok=True) names = ["common_voice_en_22058264", "common_voice_en_22058266", "common_voice_en_22058267"] for name in names: @@ -541,9 +666,9 @@ def mp3_corpus_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def opus_corpus_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "mls_opus") + path = os.path.join(corpus_root_dir, "test_mls_opus") os.makedirs(path, exist_ok=True) names = ["13697_11991_000000"] for name in names: @@ -552,9 +677,9 @@ def opus_corpus_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def stereo_corpus_short_tg_dir(corpus_root_dir, wav_dir, textgrid_dir): - path = os.path.join(corpus_root_dir, "stereo_short_tg") + path = os.path.join(corpus_root_dir, "test_stereo_short_tg") os.makedirs(path, exist_ok=True) name = "michaelandsickmichael" shutil.copyfile(os.path.join(wav_dir, name + ".wav"), os.path.join(path, name + ".wav")) @@ -565,9 +690,9 @@ def stereo_corpus_short_tg_dir(corpus_root_dir, wav_dir, textgrid_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def flac_corpus_dir(corpus_root_dir, wav_dir, lab_dir): - path = os.path.join(corpus_root_dir, "flac_corpus") + path = os.path.join(corpus_root_dir, "test_flac_corpus") os.makedirs(path, exist_ok=True) name = "61-70968-0000" shutil.copyfile(os.path.join(wav_dir, name + ".flac"), os.path.join(path, name + ".flac")) @@ -575,9 +700,9 @@ def flac_corpus_dir(corpus_root_dir, wav_dir, lab_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def flac_tg_corpus_dir(corpus_root_dir, wav_dir, textgrid_dir): - path = os.path.join(corpus_root_dir, "flac_tg_corpus") + path = os.path.join(corpus_root_dir, "test_flac_tg_corpus") os.makedirs(path, exist_ok=True) name = "61-70968-0000" shutil.copyfile(os.path.join(wav_dir, name + ".flac"), os.path.join(path, name + ".flac")) @@ -587,9 +712,9 @@ def flac_tg_corpus_dir(corpus_root_dir, wav_dir, textgrid_dir): return path -@pytest.fixture(scope="session") +@pytest.fixture() def shortsegments_corpus_dir(corpus_root_dir, wav_dir, textgrid_dir): - path = os.path.join(corpus_root_dir, "short_segments") + path = os.path.join(corpus_root_dir, "test_short_segments") os.makedirs(path, exist_ok=True) name = "short_segments" shutil.copyfile(os.path.join(wav_dir, "dummy.wav"), os.path.join(path, name + ".wav")) @@ -606,55 +731,75 @@ def dict_dir(test_dir): @pytest.fixture(scope="session") def abstract_dict_path(dict_dir): - return os.path.join(dict_dir, "abstract.txt") + return os.path.join(dict_dir, "test_abstract.txt") @pytest.fixture(scope="session") def basic_dict_path(dict_dir): - return os.path.join(dict_dir, "basic.txt") + return os.path.join(dict_dir, "test_basic.txt") @pytest.fixture(scope="session") def tabbed_dict_path(dict_dir): - return os.path.join(dict_dir, "tabbed_dictionary.txt") + return os.path.join(dict_dir, "test_tabbed_dictionary.txt") @pytest.fixture(scope="session") def extra_annotations_path(dict_dir): - return os.path.join(dict_dir, "extra_annotations.txt") + return os.path.join(dict_dir, "test_extra_annotations.txt") @pytest.fixture(scope="session") def frclitics_dict_path(dict_dir): - return os.path.join(dict_dir, "frclitics.txt") + return os.path.join(dict_dir, "test_frclitics.txt") + + +@pytest.fixture(scope="session") +def japanese_dict_path(dict_dir): + return os.path.join(dict_dir, "test_japanese.txt") + + +@pytest.fixture(scope="session") +def hindi_dict_path(dict_dir): + return os.path.join(dict_dir, "test_hindi.txt") @pytest.fixture(scope="session") def xsampa_dict_path(dict_dir): - return os.path.join(dict_dir, "xsampa.txt") + return os.path.join(dict_dir, "test_xsampa.txt") @pytest.fixture(scope="session") def mixed_dict_path(dict_dir): - return os.path.join(dict_dir, "mixed_format_dictionary.txt") + return os.path.join(dict_dir, "test_mixed_format_dictionary.txt") @pytest.fixture(scope="session") def vietnamese_dict_path(dict_dir): - return os.path.join(dict_dir, "vietnamese_ipa.txt") + return os.path.join(dict_dir, "test_vietnamese_ipa.txt") @pytest.fixture(scope="session") def acoustic_dict_path(dict_dir): - return os.path.join(dict_dir, "acoustic.txt") + return os.path.join(dict_dir, "test_acoustic.txt") + + +@pytest.fixture(scope="session") +def rules_path(config_directory): + return os.path.join(config_directory, "test_rules.yaml") + + +@pytest.fixture(scope="session") +def groups_path(config_directory): + return os.path.join(config_directory, "test_groups.yaml") @pytest.fixture(scope="session") def speaker_dictionary_path(basic_dict_path, acoustic_dict_path, generated_dir): data = {"default": acoustic_dict_path, "sickmichael": basic_dict_path} - speaker_dict_path = os.path.join(generated_dir, "basic_acoustic_dicts.yaml") + speaker_dict_path = os.path.join(generated_dir, "test_basic_acoustic_dicts.yaml") with mfa_open(speaker_dict_path, "w") as f: - yaml.safe_dump(data, f) + yaml.safe_dump(data, f, allow_unicode=True) return speaker_dict_path @@ -812,16 +957,20 @@ def sat_train_config_path(config_directory): def multispeaker_dictionary_config_path(generated_dir, basic_dict_path, english_dictionary): path = os.path.join(generated_dir, "multispeaker_dictionary.yaml") with mfa_open(path, "w") as f: - yaml.safe_dump({"default": english_dictionary, "michael": basic_dict_path}, f) + yaml.safe_dump( + {"default": english_dictionary, "michael": basic_dict_path}, f, allow_unicode=True + ) return path @pytest.fixture(scope="session") def mfa_speaker_dict_path(generated_dir, english_uk_mfa_dictionary, english_us_mfa_dictionary): - path = os.path.join(generated_dir, "multispeaker_mfa_dictionary.yaml") + path = os.path.join(generated_dir, "test_multispeaker_mfa_dictionary.yaml") with mfa_open(path, "w") as f: yaml.safe_dump( - {"default": english_us_mfa_dictionary, "speaker": english_uk_mfa_dictionary}, f + {"default": english_us_mfa_dictionary, "speaker": english_uk_mfa_dictionary}, + f, + allow_unicode=True, ) return path diff --git a/tests/data/am/acoustic_g2p_output_model.zip b/tests/data/am/acoustic_g2p_output_model.zip index c8a0de82aae0b37d3eb4ce7921e1e31ccc002cf9..0e07aa5e3216221a86238220ba4ad3a1f231d803 100644 GIT binary patch literal 539108 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⪙9?lok8hKwJC$swpA{;cWm01T5H@_pJ* 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�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 z zn4R~7IZH1>(1MfD_v|xTV)FwGZ03U=iNuEQb+LcF3@2^hFI2qV37?HVh?N?4a?BUa z#yYln_~E1D9M#TcobS7<;95!=&fa|u5@WXEbBB6?JIuk>N>|~4)ifO7v=EE06~-^q z?D4)U3qW5x5v1xCgQ4*g=shF_$9hF@7r!$8a{U*Wt1W|bvZnaV(T%XVNeP~By$V6y z(a`?nuO7KE3BOQTfVWxOz=wCnpnn8&J|^D;BmP1hT{{nNTY3Vgd@JN^^XZ1ip%ajC zTLVwE4a0H661X_35l&zG2+J46|Lu1Xm@F~EM{owb+_(T=_}BnX6i>snTV;Rs+jsc& zrU~9DOu>f~lOXHpcW__738uYvha8V2P;{RGO{Ot$N@E?U#Rh@2@)r~{9t0~+*g^mK zEO^bA3_EKipm9Y4eEPG7qhcNj>%U2H3hW#>*LI6f|aiDxG2{=^~F#p(X=&YZBOJAqrEP-6mKlcfmqA9#7O^0P4`S2686KWP^ zg3YE8$Y0or^g04@tgQnc(3;5+x_B2Bjz5D9m$TvQ$u;;*#e3KiX^9tpSH@C-&e(eK z5?pR;4~4fJ|N4W2uw{uaL~XQy5S<$6&^e0ZR#Kej?*&`M%YhxM0C&w$sQS7Sy1!I_ zY*j3jM+d;CF+sd%@gStc_`=bMFsP270jtaAfM`bqjCllt&L2A%{gsafp6tUN>soN( z9&eE4i{or`(BiZy-GJ24agMH!0PgWW!tqj=!CCJ^@aEQ9{A*n=F0l&3*|R)=|HuSB zv}^|_X4YHIj3>MC%Vs~U5VI2J{k6BxN`$~Er5)t!f8*&vNzf!TALoYD;^-tnyh}a- zPT76~Ti#XZh`kRkZ?@v}Q(f>?!53FdQNTGXPQtgZ1=v;lEqoEn`*+a%KlV3GdRzVz zzNUC#JX?KzA1IbgXYU96#P5d9VeO+wRBZa0sz=+-;L_wLblSmmPXepPU8g{07axo{=jzqJ1(cNYFwHo@c_v7n$56JqcV<(Msa5N*eAN-$0-BM*mm7 zW|81&ys_aI>Zm&e3GStEY6c&yBnZFwREm97Vz9iy4(M}^!IK4q@KDr86eac&&OYkK zqB7ILbIU`plQM?|dqN>${1Ym6&;jA_dQk3p27&Lwz%Vx(7=y(S=-!C<(x$`1#deV2 zL7=+sDm3~!!!9*h93wu4YI7E1jX!6BE1nE4O$i`)CKyiMw1z{&NB^Cj+<%}o<;*Kd(SBW3U+yaR2Y)5FBf+CV1i?s9*M9j5Bu{QzRW zk*a1k8BuIz{@O=I$v6sz!wT@*GC}sD<=3i0>o~UFskrL4%40U|#76eiO*@eM9>e~e zmIAEQ130kc9sL;*jx`n>fvx*CQ!URZa2|FiHl;6U!hQi*eOi|FjuN3Jx4x4c-P>gQ zl|fP@TR`5b+Ew0X43OUmaoE!1M1D)yLGRW>Bzv7Axwa#le2}On{oz{hRR(~&!kj47 zm2m4`oBXeQO~Vp1GEl9@J^exaKktP@=cb&$6OXt zlS{F5j1(sw31j}4cI>3Tnv*TEo)Z(Vfgj6SV`q*#<}aI%t%{yt;%R}k7CPd*SJi*@ zD*=JQ=O8RJ6F1##hx;B@_({-O+|#xRyJrSN$J|U<`oIt$*PekpJ-@=^C>|W=2EZ8& zFX*0Q1?XifJR1mRZ4>BDin zLsO!8>5n(@1l9gEvgjXQn7>m;8J+6LM(ib8CUg4#W`A_OwWd{Xr?a{`@o;U&a#mPs zjCv2rK+}}Z=tS2Jx}_wV1pErdDf@J=`TljZMu|&;eG5Rl{yOD%w}5pE1=%M@lTnd~ zIAnfSHDBMl6~${nqhwPB3=rmvA~P)H*-AG#Uoc5_-)FHxCl zWLL?kmQq&j7YXZB#zmjUDrPA@hN@*vT!~sgJmr=O-iynoNk5BFq~vX>NStm$(SBrAX!k1(#x zla@-ouX*I$>hpAagcfR89|_xElp`&AlD zG6krU7e6SZ8*}%4nh#o^?~;hm@0h##N2ywG06Kc^Dp{5xOSZf+M?EipqAA*XK!R?Q&*yF~o`ptDa)?&GAR`;In7aYH zF!Mz{$n%>%Oz}kpc(-&0)BMPWnHd^Oqt5u#9~B&8Q7FnPWXGVYuUp{t)KW6^l}r8C z+=Ln3S%m40hb{31$Yy*k9M63~gFPfs>wZ%@{k$O)*|#1R9&}(_hwmec1ykX(SvYO0 z%Vs{El|b@Eg-jUELT*m`kl9v6#3vJgPVohCJDr^wh3{jeZe@5WaBbOPK zlsw4RafgUWvj1wU{`nOMsj50Yh)u7l}1?Giv!Jd!7 zk2T7{rL_*#3NUCi^c2`{O+~&T@?iXZGZ=WjVVdNOA>4Z-Y7o%@B4UYXXefyH%7SC) zY!L2Ofnk$N=!`0c!9_aI;GqQ$s%Id4O&+q}cok(IoQCfc7f`uof*qogz<298+$OgK zTYLHAaK(LSzD^?CN_K#dk26ukHGAx6RAYW~?n)^5u{28@Iy?v{Ypc?_-cbVYy6v1|l87MZw5-erpApEDjL?)9HoDTzL_m(hSxQe%Ugh7STDm32i}5@YCUtdKpHFi zOhH$kOF-J9zh2@Gb(o##4ATxuE}T6?5%_Hhuy*8sx7#VHX_|pC$zotKJwIv1jS3Y(8yFx z2&&@)pJl2LJ~|6>zP~_S>sG;j0Y3a~L-xNL3ow6@!hgb`d{;ZKTK8Ru#W&j#o?PiQ z%X7!>TWk`Kw$xxGtDnY8S{z#7P<`6Roo7*VhqJJxo7dr5$1|OKhqGh0X0_qBH`TZN zr&>ldXjR)(p0Bo05x2B1Z?GIQ++n%mTbYIG@>I)fPogYiW8YMZ7{u|8UpKI9x%;?U z6ZTlHQi`)^Pq4%8Ip?Quspk|HBN*e?;IP zEE2AszUF3i=7>PeY`4PdUR8mbaS{LjfwR*P8HFU#7;{f+22#u%V}3EdXu;gO|2JpH zuVNh=aaSLE)LL>HZ`M`C+8n`8H)gX5xD2`fT!ZXe{O~5LCRqNyllk>Xm>egML99-i zCZvTy3*SaiF5ZT93JEN%c>t1JCpKfT0PAEmm4!L%qxW_cCnFj$2!02ccdJ?#eWHguU|phx!dXFsumjS9mjFD4L7g4o{Ak`Q)|&*knnc zY`TDi_%+xn!3eZymK#yAk%e~$>i(kjg{)Ih7EPWJ2&bybVAY0;G`25^_oZInqE>wk zJ1wKOYTu@MoTk;y)_BdsA;-_-*#|;6E8oh2j+89N_V88Q-!P6sODEXphuyJrPZd=o z&o5*8>sXb^;eK?eu!PxtaSnEnRf6E-*?8xJ%MfvYH+BBnOCRk}g@+dsh)UvJ*exFk zQo|wme!u}%HmiWF%(tVzegUetHX*%fe$3@#MaWYok=Cu@C!b&z`en)oboP8`Zn_8& z1%^=GEsHfz#lYw6gD~b032nBm^v;}kR<|&RzWZXyJMpN1b3H`>k6fy$YF&N6;%3~j zs%7Km?9-jDtaG3o$1}2;r4jseTJlF=pWmRXo?NBZ@6Evp5`t{wnRxvCavfSRITwzd zpT^!)l40kpm1d7P^Fi;uF#7s=8&W?MM2$3W(K^-yYjj_ui<=9fW55S?{pyA=p?i=P z=m#Tj4#5QjJFu|d4@TD6bk0dX_L%KrYEUCg-+P~gFM)3fbG6(7V(y^se_1mU>}#9Q%V>vP@u24L@h$?a3U6FAThE%D_KYTxa?Z z)}hY1+o6H#!)Rtcli_p?`-wFG5tc?rjz0t8zjk(QZ!Vr5sD&Gk6+v85C;p`%j?XTL z1=&Ig{J1a$0?t?dwg21jO4~MYJ9H5eBM2B|-h*i+9bh1s3k^MGuw2Lx(=C>G`_5q4 zl06T%2*yKWTR-p`0^w=iBAocVk<(jcja%nD;Jg|M!}0Z7Ilg@@sMl8q4*V8^?T5ZG z2I4Q#)17`CzoeP)x_T#UD=!1388LXLQyqS!ehnnIDkIadQmlPD9JA*q;q~%faO_4G zoLF`j&i|IhldJkr{=Pqu_bw6_c1q(H&&}}>d0o7E>Ib-cR12SESR9-gj+?SJtE@o0Piw!D%I_tUR}nVJbcdRH9Vbd^AU%2PNh{unNbCxDGO7q&Gg zz(8XV)GwKVGY(G0u2npk&i@vs3`^igf_?DW{ue0h$_9t~*RkgrQ+#^CX;>zqfa}CA z;f6EHoOdg4!}P&7xa&p_7Lhs)znf$@t?n%2`tmeNx+w|`22MyKRS$a_>SNSA2GYYH zQG{m<%+Ys+dyl5#+ZV5Z9q2)Pkeab$D2550f|_(2ot(Sjg}hL`&BI z9^rvs^-7o!Is;EQAsDR8u*?rZTp7FxJB|0la7G}^5?g>PmR-boyhWh2LXOiTBET^% zC3w8G3)_f(!Am#maH`i%<3tsA!mf>C`26n;xTN4Ds#@ZP=V=+>JCkxz%t1jMY5xe4 ze4_A?Asu|;@fVm-9mL05>tOTzRd{IREVx~u@NAJYPFu*2uf6Sq15^rMNqGXaE(5Y3 z2;mFdG@MZR9lqgwIGbGxwx)Y6$${kAvyf@wiUjAG~?ev*#Gc!%ESNl_RfFHr#r{*$xPd$1+}Jx6lsl- z&0^MQSAGbJe_TY?FHb~*Ek$U*@tsPU&QLTW>&%?|>lv^6s7emqzXJA`Cb;r^v*^>U zhsgoA-Q4IJE=jH6R_?kiNp{vt61%GwBx%8AA{rRXebHq}4{TC~Y{Loj;km2P^6AG3 zsH+0iWy$4gS?aT9E0I6f!VN9lOZGjOK>JO^(Glkx|H_5=?|$==mC+|C_u^tP7NBtGl{7%t$Pxf=buEb*9rK2?m{ctYe95G6;+uOLk#~sAd0%cd)Ws? z`%*wWI0Bf99Vo~8F!;x{p_pVH@M^S!gT-EO>#`~g>a?J+ouARL>vF(M0O%}w3L3iv zpxJ&Gyx#I1vi#-(*I5^YHtFG4Rp()8tum z`IKS)JabwTpjOqnQpH-d0gI?bLPDEcqBU0oj8a&(6&)u5OK&R zrqc>h%j9e38Y~Y9R_lXFiy1t3ZA9FQo6t_D!AjHn-v8?jG`_y_oNnu{9I>#~IB4`1 zK5D)W6PI}$he=uZSN|Efxx5^AA6tUc_|@@e^Kyu2c#ck9oWoIR_yq+wLvd@sW5_TN z$A+iE;mOap=!1Y5-glQDdyWLcWA}JiJwF1})GASkFTmi!cW|X1<0zxA@Tn&Yo;Hf( z=%M?lus;GKjwfJ)dD$>mwFL&Nqaa;X79MoA|GQGlkQS@`52PlPH^iIo)x+D7KHJh| z<_}ib`2yWF@{(0|oMUMKAe%IjGWM`&ynreiK=1Rz}qUv1_*iGTXfyh(gBaB=^7d=QDie_8CQ^`rlMPFj^`vGKhS=O_CR{o{S( z(@=EgNhX~783D_`=)ujBZU3&+T+i?RPo(BxeaDj5b%WL0QEg%9Bf*P!beSWzOWe|F z*YfHe>bh0GKgw8Es6K{kbN*Cimz7w2(Aj8l_I-|}^4C{9#T8x__afHuf_~QV93NFz z$$77+K6GA!*VWK$G4b|db^ggC7L!hYtLptYhiw~6;9ZnRwV1H8r>L#J5lqyc0 zSpX7An^BzhdDM8}6x!??i3&uQkw7T{2xotAt@72W-B2#lDYE!q?b=$R%JJMXm-F~= z13W3#hLb$RNvZpeNmT*t*AK;8o_xbM<*Z@EbSu{7;{(G)X_!&tg)1I3AmzSFu;6Zi zw$}jlxt@4}nFBu_IOF`Gm+)w29C)ob54QxvAw^FavIHiYZF{a%z4GHM-pTbTye+GZd9y|gc|#i{c;EQ!cy3mQd3Ppn%7;sp@JUZ>gD5kkE!GH6VFS#(;F+xlLF+k40U4#qcI?eEZ+`*Fh zFDWz%ud=wTFTpzQnS$u)LGsjm12xT`%fixR8oDx_6gV&tx_=uRwp{@CH|yb=_obv& zFN!Meo=bT5`RKPj-psDklJvOd9%#$2XADwon5OwA#7ZfSF?NYV6Ve~KJ&7|3s!AbA z8l@!5i186(6Cb(0(@P zPIAU>$r&KILya!HdmNC&5~`zS%r0B3fIj(JvhxNX(rcT4Q88UV4C^$gWPJk7Zkx(z zc1ttzmUGAohbBg_+lH)f${-1`7t#7-F6L*llb8+SuSp&|lNR(@GV5D+(<5rd+;guE zAp2*pD*rrjWNJM)=JD6=GQWZw$?c&yIz2Vle65G3`J^T;k&?TM;&ar9yl{g>&qxfr zlmUJ zbK2pXOaKx1><%fU9kgfvh7DTX-1zTi1fRZczPr1&qQtS3h)+Ar*fvL#i9lOsn!;uj zeCjye)%eZ)M9BjrVRDQr>uI6UqjrSO3#SD_l&qX{l1{E|LQ5Y-!qO$@aL5CG*7Arj zduzED7`PmyA>+erhkp*WN{nYU12@oR%Vt37ZviYak&1pdj3S=pY3RRIMO^otr#WKU zbjWujZ4K-~kwly>8xCc7fG$`AQ@kNk4Z8MCXvyy z+#}$FtgW9Sd%jjwzEB84jUN)bDMJ+_bEBbch5~q97qpO+n!_S5Z~C*zi7LdJV~KSo z>_eUiXYhm)ODqo~Gu=Gyx{c{X+&RPC#`GPdE}KWg)(DgPhSF4KN(7a!(nEP)PLVa+ z2?o5Z@~2lUiWIv=-u@DSor(^^g67d7TVZ;0tODIEQ6kY+h7hoP7mR1; zlYn2vWWmf*fz zgh7!CpraJQ&WM+WxiP8ahMgM~bBl)7{7sB~*jlp1H=1m4YB0a|L;%Fs2hwM`*;KAO zh+)0;A<1q4;%3ECrA>Dsy?7F?zm`dT3+HoezkEg?X5NDPnn@(ddoMh3EMNr!0o5yK zl4tv598%VmBQ+0Ed6 zL;-RlCv)OPJ*ZB*4SqLUh8`?e#3^2qth~`CCaHH4n{<38h|fO=>5{>;)m#TWZ7j*b zDK_k@MW^92|7~u6mo~UIxRbeI9Z0T66!?}%!ch8b^BX=bz|@)JSi2Qe<>*}Ui@yR~ zd(M$K=t0KqvUsu77}+)0ij0Dc89%=qG%Hb^&Isw_e!P5|8eVB3?4@N?I*Xv^N|dwr z*-31Czm9Y%{G|4HF+Hsm#;($gL)&G;Na@8S2*0NbrH0et=^IgOdfN}Cuk5CK=a$g6 z@4rZaYzwr`ZYAP|Mnk3?95jPHV@|nNyresp>8g>8cKO^MaXM zj0rjCufjd)1KU=LZySQsV@qIN|+))9}GrKZsYv5qdLb7Ni(HB>2J= znt?;nL{2eHF&{PW^%SFad#&i|+GAW_jx%>>hA0}*bV5Dr{prigUX0!Kc&f1d0liyO zMUuG5Wa^I`bU{{siNlQ&M(Rcu($B0wa>_!;I_Ls7${+~M`N?lF>HB4};Yca<8M3G1 zWrgs|oKO*$2lS^?HjJz?rG;x#NMzm^p|8rR)X^R6rE3GE)yIvoZ45F`NS=YU`|6mt zcAaE!@HMk8dmeD6Hls^-e9)eN!*qkvC;EO9L3iJLMK6>RP?)_0O*EWA+WJ*UR@faT zXJWm1*mP^MV^AFlFBPY$E6+jbf(1;R;!MU<(H1O6J78YaIg~OhoHpHQhxm_guy{o@ zYy3TrG&mffeGlX4%3o1n@W|8LaJ3zF&2T`U1+%H9QxBC9%HfW^D5mBb8T7*AaAFYA zLZ*6D)1{X0myVXI2@&6kBU`{mo{-}_6U+hIMc!!HU?$mEHwD$UE@HT+chiT@jc7$k3Gwt%poaFM zH1u3Mgqy3<)XC2o)7?`Uqh}FF?T$1pddWej&+?O%NptB!#+W?bU%(}+PB2SGcS5Co z5;|TvPL91^#_ZTzMVe)%K(=Z;I_m7k@j8|QUWLoq=`$`M-C-emtl}&?dh!KQZGKBN zqGr>cYn4pp#$qVCA&WZ;5S{*8xa!Q*ZcyAejxL^2Kwe6tsAF;#O1FB;Y~6p%{8>>E zcln(pvMbC3Ic)rdYX7dI4UHz;Eddg=aNs?W;~66V{-dR_Vl`xR?FlIVMUk4BaPY!M&AJ zDl#kF-gFYa535Pg%TSK?LHb??v9y1RW^+5(W%qRG zm)9a>>@K0%&-K{NmP15L;}Y1M`@%#mT~3Zj2GT1*&q%bRK3(?v7`>lTM zKX;IJT~DX$v=?Dn#i!hHR|@_|oT+}MG<$3BJy=|@4opqc(45V2q<_4NxihbVyY}J& zTB!5b`~*iDerj4WPuhX3KmaMXND4f zRQ?OP`ES2k3}523l@4MN*G!ZY!A zZWkz@p3kxCVz7N^B6fFmgyXFzv4mnCo-$Gj&)Cg4zEBf$J-^^Pp|YIhmwMRBB+-UuatB0pT@snuSpP3BCL=YkaO2GUb2A8cB zu+mNp4!rIMkFtBHa?v!H2=a%{(R!F0Hi}#X6`^vsBcw-0!qB%A_$)r#V)V;2j!|tO zCsZ&W>-}lK9aW9^Y6QU*S2ti~4;37u&Byt*PYA1OJisa&k3ng0Imm82iLE|{;CIP2 zI4e2}jEr7^!h1O^y44PuPJGk*o0|tEf@shWjfH9hc4-aeL zn75L^&ddkfj`JWYZ3d=gm*L0laai0O0n?&7U{!7y^fp#O$jA4b7mhkOuA&7>KXbvq z?mncbzr+2b&EW92xy83<;4SSYc;}-!+`l;=L^YP;RenmC75)R60e1LnNf;bmIt$-0 z>xb|eYa#BLFm!ihf*5}T?8w!Cd+yF~+4tEBP!?dNgn&) zVH37rG#gZ&y5prqHn_h1JLE1|i=$2(<2RC7P&(8J(-sQixYF;a)9gN!Y#7J#W~x}< zw-S~`9D$>6J3vzKH1zB~1x8+0AettOJ-QGWWMsoopB;!^+5jq762Z!Q8Qje90Q{BJh-J9_}1RQ;#d=}T3CfI8_a~0o~OZ>If(l^qQHHR5FV&0z>T8%&@6rq zAMDb=2X)rNQ+);e#-K9KnlUn}Z9jNAK$Ou>3J$9BTX)Y!B*-WJFmy$nhnhVU)Q z7JJm0a&ovLSh%wvn^>FTPaJ7Z1lQoJo3!xGX zp2G3m0oZu`G#rWz0k1{&80JgiGBYo%To(gpuV%y4v6Dll&@U*|WN84usPCw&}Uu*i~DDRh$p>-ZV zbM3)*qg1fJ=s7Gg(T5gp?tx5B6d=n2m??Maz;YAu;E)lP)iK1=Tp#1|5^em`U@ETm%Y?YMdf4}J0(ezrfYz~Q z*r2G3=evpGlMV*pf8h-rPIJYFyoNy0&jiBimxH^Q6s)p71ANj2aJZ@;ZFy-1I~JMY ztHn_e>NE`D&(1+t&sHmBW)35zgAtB+kXfa-99^i}Bn`5!iKxE=Q{-n6q-# zHymGc3xBl@;e4K$%sI7O3p10%uubMF?As-dcioG_yAEjM-ZQ1xuwn;JzJ3V`&c(ut z_*~f4YzS)k7MShzhJ~~FvC7;QIA&u4=UvM{=iE*Fwp{_g-?an=wZB5+_&AD*5rd-H za$pu}0^jv(QLf4Z2PmT+N6 zVhZlL+789aGkmt*R!&xQh(V@?9hUtbq z6L<08j(hlz6~*^|NpsphhJ)y?Y;0r_gH8M2b0igiV;|)*Jg%{lV-i0V>xBzqfs4v` z+U)ya^nM5?4?KgY`5{yn0B>tgt4$R&@2hAu!d?{=XO!15Yr;{7u zfSogVc^QDQ=r`2mp$pk_SfpPy87qXo0T=$K@E}(d?sh+b1v^i}r-KHtf7Jkz3k8U1B-_3I8|5#u%b14wiZbAkQchLIA9Ngt4i}Oyz!`@^8@cpwACq^y=r8%o{)%AH0Q5%E$ zo>st{{9NSysu&EdqT!*ze8|a2f&1sg@stb!yqE05ci%_Bnb(!@CtVo-Zf-_XVh-T6 z6$+sEDGNRh&w$&SCjoZWAaSWCR1z)&CdJ#pUFHkUy0VgU>`4b+;CBTd^jE;6eYZH$ zR}n{N{|xML^aJ=l&EZI?xWh+@0B~`M!E>a7agU?y&*zb54fDS3(fNpNVZsP4Vk^ z7rZW`6XHHpA>(QP_L<3^jQs!Pr0a;08y(;8M{UWTs`#U8IDsuU+4Y)=w9m7bIv!er zWyPdHPCFjbUthzM@~?x`*XIb%ETUWIzCo9ci_%#)1gOzy1KqT21=p@iom}5v19JS7 z7+jozgr-NCPiYS$q6SHffb@IrAz5i^HqU_T!S9D=FVW)e4Xfaa*-WLJa zMVQ?gH;GyGW#sWG7gcRg{MUQ^{=46q2?7wTAi~)-s|M>9oWU`Z?%~1&I|%Vk28ARU z9J4qGPPk3M!SPq{$l@=s&rKXg_Fu;OObzP%x*a5QB9U%pJS?3*7v0S0K;B1>V*w)( zNa0FDK`oa_o7o62*Uf^NF6xlY(Zo^dPhm;M7nHAZ8f3S$BDs-uF!vrmn0?m)mw`6) zc~T3yp>rJ0PXxi1#-4vCmRj!bFba#@cp)Ekc}CURyyOoWJR4(uUPYQAub2CsxBcV_ zUY6cU-otI1cug6BylJq47mBTTR_{!CiF#H%+x=^K=aTO5n%Wog_;RoD9{NT6%OCKM z7W7QinL+L<<^i7s^Xlxd`ELt#Ca*u5iI#O`-aiOps{D)@xtBrA)@*gg>4UKy&l_d+re;G?Se)9{fx!XXY}%`;}Y4_Xm-QHR3=ik zjhvh_pB_GOg{%7gI$E(bhOW`gV=kyJ<<>^bCeC;G;70oqlHxTNz9w8GXjTK|ev?Jl zlLJVAQzpDG(}y(mv)qQz+{)}Wan$431czc`iLpmMUbixd{rbm&J+(Ig!X%QZ`GH5& zXO$2=xQ9!dyMRi+vZJ&o6m+ZxpkzIZI8_R`>wqgLyYWz|VNzv*wj-CYr^#`%0pfW( ziRwP}VOB)fFz3e_$oqYN&~*(X_>`tj%f=qk*4swh*nMf-X4Fo$3H@eLRNd*9`$|Nj zxrte@^d;e27S1euD?{Jp3c!Y@I2dvi#7OcQ-7B{MmH5nL6UODScI`TLm7kbJwc$rJ z^m{Iz<1xf|A1H@mk%ypt<}yC0y@OqKOqcGm5P}NFF`6-b1*BBYBX2yuQcFi$L_2HH zQxHOTIdO2!Uy*h5uZ2OrYouwVBi%e%m{{yhAuHTBQ_Hn}h%tMDv_s696Hipo9~m=q zrH(1YsYsR$6wYF@1a8tpF{YR*H=(PuuCN*l2Dsa#b-`06kgZx9&K{DuLY{6fBu=fy zq#))P9Bt)*zREh}E`63{=XTKvwOF*(U7b0vh|s;Z=_sUbHCNaOVCriif4r0Fg%97! zOR4WOuPxh0xp^Mh9FPNpx4 z#*+=-%y~%AT#9#}OKWIYkj;F%~n!I zX=2%E=i$ZFKSrItH{hbBem3M>z971x*2rWR4|2^?RIg=wM9bNnpxssbZ|s3-r_NQK^I6KdF*yP2{P{*i zqt?^RcsnT9FJ?kgsu+WxGl|tMd3sUmC-YFxkXTMNrt*#-(UW%`Y`u{Z(q7R@dL47A zs+Jt0Lcm=!6iPl{I$E))M2(1iSKzk7BW|hNEmR(#ijvbFnstse& zyydgl(UwJ38zuF@<>3?@m$8x6KbFDXxf)9|%j`+fwsB?xt>D_gVB-{@tKhpBitj24|`O7GIp`wc{B%gB^fdcz;jg`5Zo;Div(?&7o zD@g0q2i(-7ri@N{Cu0~OMcp6$Bqe7mN#L*z`ob?xjN9B%O2!H_K(m;B?*OVM^cvbG zFGe?(rqiXf#Nq1Avut^{3LJ5`jd#>vCyRwK8@*kf=FM1!c=IYyS+z9$nD30_YclAE z7;X42YQzey`os(v<`a=7OA@s)pOzn<0v1*xRC$O)2T%m!#_S=U>o3sn$rWgpWQxC z$5aKBL(T5_U^Zb7-+yf-V#k-`D5;|)u;&@H>c=uyb>4CdGZur+Cuwv2R6#1*ponBY z44BJg^)u=AbKzO}Nwj*o0$Mw8lev7#52+QJK$=H2(Rj2JZ7QD&ZNrO6!QOe~apY%o zB_WRZ`-(BEyXVm760gzRqjGf8rb&#WnX37=GxEr}yQOla3!jC54v#9_J4ZwCPWJ4U zJ@|G?R#kqw41Mm9OJ7P_<1}VJ8v)Dk%ey={lRSmJxql^!Ufx1wt^)|zc)+Rv9cDn$ z8?|nZqi-jtqmFO2)T&37#GP45T9n*r=B7Q+eM^{W)X+w6*2ZxI=e#6a!bI4114-tn zhB&obLo2s-lyFl%mvE;(xkiT`eWWFA1$5ShQ?#~U7QTOtrnPr_=uFc|uzb5MuDT!0 zuHjg*JFFt<@b;55hW@7h4Gi2{8wmX^*3^Ed6phvqBTWG+biyqf7HJBjc|m7MG!0?u z%b$|N6NA8aqP}wbw{WPx9*dGWEP1Lb2&XL6$Zy#g@Hz372rq0zy%X}Nv|SS>o@byZ zUJ=A(>SCmCEI@_kekOt|%~05NKN7k64V3xCBF$_Qv|so$=%3A`K?S~Ocfu8JcK8tY zVPF-I^IkNO%B ztFiVjs5bY&+M{uVQ*xS}n-UC=VnG_^GSCR$De94{MqSng0TVnxl9x<{mD@$p#O`xw zxS-s8SazI9`x0Ul6iRx+T&PKQ8@JtnlA>x^+G#n1whv^X;d%35?8QqGME*bS-aMMB zxNRRd&$C2HsYr%I$ap^YeM}9ciAITplteR0DD#kH%1n}oWK5jBw{w)CG)pClQc9CV zY5euPYki;JTF-jlcRlO*^Zk3Tb@thxz3=-M)xSTl0serePk$&8v9mA;tza)yNyXy`p-?2>X$**0}(E#Zr3-?JH5AjXuqv4L%Ut6gT2x6#wF*%-n@_uz6QAy;SlNBdF!Tgzn_xp|#RX?ZFIiT-6ksLVg939*wu0 zUfQ1Aljo!8JLid8nppEH>rYmDvWltpo5`%IFm+m8dm5R3eux-2sahX4SAs2qW-Y6{ zyIaa9*>qGy1arVLjZ|4TTfN%2iv46`5xhDbLW@b>r#f!kAf6vy(iePn=mf1e`cJMP zIvB4d_Jz>&ZFw@I1MCV;|>kbUiC` zMz8B!kAj; zF><00Ngn#Qu;#Xt%j)5D-{F}Y@|QP#zZj0Z>TpP_+CcSBG_!8B8#C!$A~552I4t^* zO*@!H(p#N*5OnJgle6kKIqH56mQ8b^lsaterX3U(M_?pWZTm zYB*5)N*Lo;zbUK8V%m2+4UTRIXI9j@Vc?%IG)TTjzc-MkqHQk|4^1&+pw_G@J>TMa%EM%?dc^!*sGk$lJ&MoM0Jc+V-&z;c4eke`aK` zg4DLq>%O?t18&Dj#^`be=_qx7UNCe&6K@V|V*TqL{97!671P1E<|gj&OeKkiXFy#= z73_jc(dwxQ$TUBJqgV7nscV!RxHUw=mB#_ioxtcvE)>L>f>qxWkm@WW23yY(b!{;$ zQP>7DFc)m5KZcY#arjm_0#`?EVfchS`teubSo3E zbCbu77{2ihg)Cp-{qGX&u;Os^mk?qf zLNB($mn5XhR8i=%6h;{eW8L{rFh~9+WXhJK!_5NJ=!wD1&+BnOqZ=mo6~Vjp1X-gF zxYlqtW-myEnA8iv+B}QB=A0s?zGw!mVPiDhR|$N3Q*g`1lW<3965PC4aBf5aR30Y7 zK%g&nF53+2mhhlR&i1F%AJ0KMlWP3+ ztOREZOS6B(DWk8{PE^vZh2nrHEO!b5?hI92eMk+**Xw}ltr56q{sr=HB|uf5AVza- zpeybW3irSJi%ADy!{&O}sUZcfIm_VS5f_l#r;Oa^p0L6~6y&1LgV?w^dfjM)(9<{I z!`Zj+)Vv$4?tTN|s4XaFG!;)&in8Acsf4K0Dv zkHWY#!V?xZ^3bqO6s4jy@b|z8aDN&G@oEc^-^2$zIwN4!c1O6L6bjzH*N9H|8n)1h z^^iaP5bTIm0z765Yh)5&wd4t?IHHQ$^S{H|f^NuLk_qC@W8_3rEzs$FxKaHFQR`BI zMO%tsc3C#=y0(&}X8wVVZv`Ryy9j$4xrTR~>#)ZCHLRa?0q?F~4yVJ5;m*=GWV-WW z+}@!|ejUyLua#54s#_kTExdph(8kR>1VHs$AB;7>CfZvzL%dfiWZKEY1C3_F))dDL z&!Qp!&1IssLL5?Rs$gI8IymJr2}PxcphR~uoSU}@v?c1H!)Ydj^`?TkyB(YEJdKKi zA5r`45X`tzf+}hkp(?!%i$Vin@?$>UtIUUFpFTM9ya*nMOW~Y_La26P99~axgOz?Z zp!ei4)c@69LVIsQPK+a_OJ9MImARl|Qv-%gTIl#f5kKs9gB>?UL4F1wteG_kg9o<4 z-To9{ds@O#RtntFr@&Cx3od_Yg|l~Mpz)?LOgQ%tqt(%DhZUmi@+C4DVAGE`ExJ)W zYrS<UZ&%^c2s`$Fh z7x#Qu!Kq8@Vf0rtD1UqmXU0yWHKT{YxAvp*`#9M4Wj18E-U2bp8dP|59$Mm(pv$fR zmWI3qGTj9#Hw2=2aS}3Ly)fX7IJ@gGAC~i#L1NHD*jT+ArWM-R6lT5CgXqW|z&=bvNQWtelwE`SI)CfZau@JTp9wQVVn9>R3KHHMfsjH6{Cw*T zB~5c+tS28{No&FtIYGQw^a!*J#9@ze7$(nMiPvQB5&sfn3}D|NZM6<(B24mV#{oJSvE<&{asJHg*BwAuNjKdqab{Y z3zlEhVUENF;`iPJA_u}DGjxC?F7&{@DWagSRSHW2?!lY0fHNQp1jB^z)AHAEdcTLmlKd^5)0j>x1pq<8lD=@!Rfy)z=L1sVPpAu zaO;l;&hjI`xAPWUsFKI&9(mx_oeWjhe=$xx4+d0{;0av`!rJEedpHWp`!g_f(MO0E z3}=7Xbr4psI)mHl)sg+G78_^VvPs2ZoR~5N+P-^WfbR-;VEPKCoy#MKO`LErWF8oT z5lPUDgN@6j(W_xUJm6#CkE{>GX@^1XGi3Y3VENR3=-zKQ24_N8r5!M-OPu$b6Fy&B(29+Zl6FZ^%z=6 z3$WjpH^JlDM;OL=1&5eC(i`Z42lsym#da&y+ZzcLZuM~CSPkU)_L7tVz=L}&@TXZ4 z7N{h{v@0ti^)GIJdawj{G=#w@@x&d0vq9fV4~q?&p=x&=JUMz0O3E$*f0{eIso{sV z0wLU~G#$*!BcN93E#U?40k!%VwxC1>J3+sgeL1>^t#!+ty=u)=wwj|6dxO;;G`&>9 zZrRiWc2ONrkUIfPp$*>JW{%u=B{ZMshngQevEh9pI{FsiP5V!f9hD3x)#Tu5WB`bk zR)KQINlbLmMZK?zAUmrJ-fme5{EBfHrr-c3OQgWq*%=z54*{ci0qo0k&}xw^zPS>C z4<#j$tt@~7ORuA{{aqAw|A@vfQgN)e82RO-t$oXt@oLiptlwpZbL&lzyQdz5&pd+r zOUj{t)khGuPe!M+r%;bRhIMj*xW2X*vy3&-e)f5G$}~&-?s^eYx67lp0D|Ni9UQCw zLqgvDBmsV&Ff8B;6T`3Jr-wAmh#3do&YcjnY5_`L`w1WTqoG*&8Ci3C2=t=U;gI@6 zcKz{swt3ZQ6pCcC_h%`f;hDMYj8`YvnRm{k0G|vNs(a&!FJoXEdmcTuoxzj>f@NkB zaPLqtR*T)nDVlMpr<9I5IxTE%!#MP^K8w6IW&G683k5@8q4BclR!f!HaIOoz&>2-GlEV+F+}=5mN=b-~qN^$Ra)DEmp=YGv~AGR+wU%sxCY%?u8pRXCd@=0<_%` z`Fk(Ufqaq)48~pl%j=&*s^)xHqB{?--8=(#RWAQK-|+sCST!TfgR3bo&a-}yeXRKW?Ehv$t(Lh+I-GuS9Oo2JJ-yD&zaM<1Zz;yJBF{xrY|=|z6)U1n zt&QgJ`505{)Xq@$j|Mo7JqE0XWq&vVDJjiko<1-j5l?RFv~&DseC4E%lmZ6n!0nb$ zI;z8pN?!G|nc40@YZc!liv<^vopSxG177=Bm6d}ec2Lw>Q2jEKxu}H-_e*7_R}seb zYY+7)7f?2P7w(YVO@@_{TJ%S|VSJE};HsCz$V-u_V0pvoUMuEdlO`i_bu+4&i@@pW zezf{kaVladko>{()IP^B$msh@G}U=zvl%~xzOrBwqdM^+J=WjrC*AU7d4`*UvlVyn3ZU$F3|GDatl*kS@ckSg-2pfs=j zW^KE@o?a@D1Vc}>;0i28_TFevwZG2(?$g?WpFc9&9tANcc51a;{B;d0;sTix!({xS zDb$jc=fD(uJ1|n3i>O&0j-c~?jQ(V!Mj1?Ph3M$(#O-PxWug0;$WPxwH{an=;dkD! z^cU#RJ%#4wil-PD6s^YjwEDLkKbEB^(R^Yk(GDXEULY2#Ybd#;bp zwED|9d_`%;kWQ=6`+umxW2bTd*R|}0CpWi*{%$AxwRW`#GPjvsXY;J?Ij%*Qw_$kA zoniH?*~64c>|-Rqr!{{~P+^{{%%R)jOt11ODxmZ}fREop;a*MzS>OMbGK^b5MUgm2 z{L7S1R+UpV={r~v#Ras_5+kByuRx2bd?PL?=Q%4lFQ|i!5ges0QDmX{8mMhMPOsXm zOg2hhq^icHsS944{&n8`pLMf$aT)Q7P5^I#NJ>5>@nOFR z!zT?T6J@~Xt3$-Ew~~2_-?20-Ye8iHB!Nx|a8f-35m!yg`X@z%b}fRR+Lu83c?{vx zHv;jOQ7~g-A(**2!SThW5a&4uBrOKXuiqJPUn2yjzH|bqFUp|4%mSt~)I#cUOQ?yC z1r=3cSdtS%J~?Y(^W-nAzcLT~1)SjJ$x3j$@DBJw2H;nP0j|3L83c^Wp&{%AdvmiZ z)@RwEBI_)S9QcOTljBfXn2vW&WuQWP89b0*hYr)7;P>hXQZsvm3_U*wD~+=7-JS$| zuKELdhmzs(IaSP>VSqbc+=f*NwzR z|IC*AtKNTfve4Dn0<#yZW5HKR%rBpSiS2H%>B2%(`DKKXW-rl1(iPp<8=?A#1UCNg z!RtTtAwK>btcd##I`Oq6c{K~dGYeqD#Bs3qItPny8$)FMMksJO2ZezZ7#(2*8K#;z z)nPUq750JD3nefk{s9s2UJQp>hVb#&S@`X61%lgU|01O|2uNRmotyO8i)EwGYFv>m zuw@u;#oU9Z)pa(v^x&1JqU@9}Cvhaj4wItl;ciy*)`RUfk-$HM2=a#%<2g6pfxz}R0J_q*1>s0hOLqCQAGbQWZHl)$=GHn?&6Gr*2W z__Lb_5r>uVfItA;OVUJZjg_c=ump_9&%+!81(u-s^6r8a+#KAFY?*f4O3~e zS+$Vhc7W(6CQiw%@(*N%*a=H@#F*QrCQ2wv!S%v0f zY}c+btZ+)h|2I(!mdQAFWfSJ>m;ZChwxeM?R$tj^?Kw1x=YGdoFRb#%jn(&n{v!y% zsoU7XRRidM_A zJf(OjpD^PX=x(N5NhJyR8B0nOV(ASv>meh1Ds7V;&9Qv5*lPDYWk`Lqn3`Z8fZ0CJ zh?wwx7GtbOH@(@#bcj@gX3P${;Ljwa1v=3c-S=VNsc*FDsf)1tt`-O!`%K9%RwIhd zuj!D-D)c5*9eRy@KmGg!VHQl1?8BDlmQlmCXFbP)9Zdy4+-KN{gVD=xVTh z^0gp#WSlVDH?ZD|o}&gz?vdcLUF7?!d(d`%fJoKsCvATMsTX%-sbDXFky+EIb=6;} zW!kpn9KSxZZb*g^J{`@mUMh!@cB`qkx!uHdT@oX#WJ53QRi?|hwbYEogJ_9u^p{Ol zoEsKAGE~<~U)wBCzb`v&b(^2CiiJffEup#8rKx+GBl%1rZPb90{W%v_@xOzl{#;g* z;%)Ncoj5r-$C{I-FGO;C^r%DTZB)(4re?<>b*g^0KP9p7GvVltQAxs!LDO?Hqz#)< zDq=lkoskUvv`~W2>k@Av1Ln^m^CWqQ)kpqR+G)dS^ z7RKh$v3s-_apf9%=ezmz6DJL{*tnD)k(!SF7r7+NO@f-TdV!)Ce0hG7dJ--NDTYpT*4Ij^X6!C4p&v(1+!Q7pEhnm3 z+J+_HJ<7^h0%1QkoNBOF6wDJhGq|&q&(~qYtrJ9XR|Md&TKkMe# zpLQ7g&;Y^5Y(Y%Y9Q_PT@#?TJky};-4xw)F=Y|a25}OHPTXpevLnRcxTML6-LQ&FkDEib6Tqg!vO)?-=Rt_XYcS6B!7EG@jhuh=E;I-}^ytMiVGEXhx zac(g9YRAFqFTaVoSs2m%r3C3g{e-tv2x>L1!@aG~$r%q-h_Zf843d`M3GWN&jmc0{ z^Z=rH^T23DHu+&JI@#$o53+)_aVRE~Y&s_fNgvZl zrEe}=>Yok^*9hb4&?qu}st-}9h$JVxy`gueBI!2UP2L~5Nsd>YfH}3VSn5JkNl7gs zY^QW^b5nwfh#?p$MrPUBsnsva{ zIfbO$9V8OVZNdF{I4nwj4r!InxUxl9GKKZx2N76-$ zQGW?wpYR6$&u_`@`2!?TNC-^kn?a-CQE(TJCjz-0B#xyB=N}A{iv$ zym2^hZ)O4Gs~pQ)x9TFd_45cXH&%#OZGE3Ne23<`Ic~ElIGn~k-lxXn*;eq};}+Q1 zr_JPf_y4rHGS|T7Tk#H?(8vbPH?3}-PUAEi?UWzZNnVaN9ph>?U(%#_=e7rOxAUjI^Haa9edcjeu`59zUSawUoGyj zQRCQceoZA_v7~0`v+3_H{!G_)3B1#gO)ZS*qEubtIL{;}=ndhY>2>$AK)KG0_UxZQ zb9k-PSIc&=wMZuy$LvV4Y&6&z+e0!R9|;xv$cpumBj*}sapE2af?CvE;OpwMVqA6T z#}%2hPevFs>wyU!V)=?LaIZx7BVTBV@wqKCzFWXeM{_)*6i>_V+sV@PO@OZtrc(N+ z{*X7TY)EqZZSuOLiry8$p-Xn8(p>Xc`j12g)$dVCB041~eUnj240C8bxg4r_ES20F zQ38V#v6O+vMr!Ss!z`6Y0bt&452?XZN!0^3sHml}M#A<`EuxaJXl5qm`in!nCQrk~ z#u9F2z$?z$juN_k>m{nqI~-nT%QBDu>V(iTDR#viF)TS53MPlLi1Mf9;LSga++JEs z32olgwY@`;G_ zCvx7amb{E!3QAXgkpzzbYH&P)`tX`XADSzGSM74(x?&NuJdvkFOV3h4yEk$ErySc& zy;|&itqS5#tKwI~SD-Vi30#dzaFYJpf0tL_=DuLq_%e~)Il3By(iB1S5D$Vn*N`7K z%VEjlLh#Xi2#Fm#!0!B6^3v1-7x9GPabF`0nvH;Fd;D>aeyFm~pdK!kxC9(Yd4Os4e9HQ61g*f4hFuf=bE6p0<_TlLyzrqK6 zqMyQok`VY&?1X8r9idU76lMs{z*+1Nvcn-9jz7{NyL~Gmx%?$j**FDsPA`KEaf9%) zRu~UCeTsTedwCCtrZ_;*OmSQpTF7qLCkUHg2;-udWn|MOX_&n* z2?7$^;8cMN?l0d8x0+ZanEi_6M_NOb<9R5Xszcyp5&sB`4raf;@O4gfY?Zc5W7 zrAO$TFh6osC6OFc5~L*r-mvb;{37dRI&rqO56=2_(K6%Uc}}N@BsjYTk%%$@xHI}Ma=0JV2UyVyu-cwH5 zTS5NSagy#QL)Te}5aps;a zA{i>JO}4_dMCfTWX?-9;{drbRxt1nTd?B}3Ju5Gf`4V~LQA#w-pJq+n%~vM-)CHP3 zqUVTY{vO&O=`0Cx5vFq@KT%!p_AtXX;>@DfU$jl+6WDh(iJEscliYBSA*r9`Y41=i zP!~$#B>0`Ar3##3v0*NK{X7Hu!7(8As0s$uGr(9}3MF$6u)2bta2`7)v1Dzz^zqxL zp!rP|d}Aj-XqO$GC_NKS3=h%=9H!uO;}s+_uaX>HG?RY#Fr1q1DnY-YuW^dECeVi* zPmm!OZ8$ujLsu{}=?{nE>GiU)^o{c;X|Iis=%SUvOitx#P_HLUmq---)XbKWlo|k* z%~i@WScLgG-I<(DT1mZI_nh(HZb#}b{~_btJ)~!Z2tx0URE~VtrL|vSjO|=PKs<#%pll$sIJ#Gs5ln?O@0{3E-*%$+{GY_rF$xT>lbK7o@@9+(sZH z3Bb8s4aUMBSw4DhaBQ;uFI&3?u5QPnakQB%me~UXQ|6%?PXn6dJt1fJAsC6wB+FMy zp=YZia0tRW!8PE}MuU3O6Oxx_0KYV@KzQ*0d0IIPBL^60xts}4Y}OKoJ5%uHr9q;m zXAGLf&tUy|b3AryBmADshM}k{WH?&^in&VA{gemdd97eSyAhTZwG)%kV=!5k2;sx0 zh)+>GtU1pGtpT0Cig_w57MO;~sgIwD_NqXJJ$%4jS(vc$!qnV?y z9*rUJ$}-?P>JAA9YRQ7CYAE@=3RGQYgMVNMjA{s?-K_PX7%&|wMXf<|tvN2U--2oh zA;<}L1jF%7Th>GMePKx1gmu^2vejB|d zHM`fs@0SErGENe!%{=hi!~=)#S|D|{2om5fEYFPr#eOsJ4Lk@5KfaO7un0)7FoDai zIv_hH2yMMHp;6KmEJNP^JKgvn{Pbp^-G9Qwy;Qn*U71cTn!!9?8NZ%w>i!hcXI{+n zGOV?EVrR#jN8hrZ>M6zjy?dGUV&ezA4oKu43_ioNJNd-dwbgG@D2c8%$BBSZ9f(Y>xVvn_>f{|__qe_Ts|c0haI;mZce*d$JTGt!RG8vH|Wi5(y<1-N=cjg6| zxVxLm+Pt0?Tw6i%t`tx~@0UWIo-OSX=t*3)J*ZHvF(SXRn*@9w{vXrh1`FL_gs~aDG)`IW%j^WSq)u6m#Cw6Z8$bKPp3Jv&L$yO5?%sL$fx6*3C zy{d+Mh!=s+X6IO62l(K;#xAm^@e)3276oH40pHalWZH$jB&stMDzDX({MiX`Q8k6Q zx|@Qz{2e%uv)dnSM?qc5%^DhqVLU7X;C~O zhKuU+*(Y7^;qG@+nGg3B85vat>=`%*%ZwZ;kB?lcp9tilK%M6jPN?c$_j%XIM2@N+NG*(xcx?tsIv;Y(74F z4R!Up0D454lk9dr>QMa-;^&w~6kV06;_nEShH6wp{7lXbr%Vn^6aOE5qRS!_Pw382}InIE<&Q$Q6G(aIsFQDI!f`j{}f44&(oU`CR>5x+K^}K-eQ0p!4 z8h9G>4Q;y|q`1WL6wf7fhmHA=F|UyxwibW%gL{5ui}hN`e%^^66V?}gMDRS4?QC4e zZdo5W zwf{fqklp#6)Ye^9R&ghdb1Jvln`Jdq*R)Tg>6FYy~F1-t;0 zp#rMCX%#hP-wQ8!Vs$)|_=z7uf=W$Kx8GUciyMKAVVNoBk-fp%#-(s1$+OMJru zjzRfOT9khlm6O)Xd8sY}IfiWF6g$jPiHRdsx%x!hQ>0mJ@DZ_kEJ)Yie@^Y&z|Z=W z&G$b#4y&XsBKp^dS;fwCuzz$bWDD(uoOLsCmg-H?QT~81xsxovk6#Rk95%`sctgk;_EqA3JmZC_sA4G}t<-58oB6p;_%Z9DQgFIq&)Y-CElX zLjOsvJ(L$~6>1=@QbFz3TP>V*-@QPi$ zu^Y=;thSH-Uv?ap{d*Df!z{R5_H3@iinZJ|?G9Xh{dwH7qdMHaFXmi*zkOWSDswJJ z)|@+DwS=4H58MD7Q?BN5C$4Mt>VK)xfBagV+#rjtb7p<+a%MFwv|#x!PGQ|j3SjyE zI>Cx}+rv_}9Ar)QIkOIhIo*~yY+_*qS$BE24_absyYmrb}m$(!0Kb_mw~j<(wR=>lg#UK_0zRzh(? zWI;nj*J_>a5IK_no(u%V(-(pm!c#vF+b`sifF(dJ586Qu-#$-f+HNAR$7X|(h9zfN zP9UXo{33O^!G-hmd=+_>R7%R|63*i3bK!2&6l!hUK4N|n;enwDN?j7C4dZ3#yNPXd z|BOM{v12K-_SZU)ifbo31`d#TgBWJ{4M7}UzXV@?I!hhoMN|E$7ePrQo1XS9j*5O^ zMY)R`GsA_-;8!WmTx5w*6AMELe|f#tHBg2)Z%v3uH{mEbJslA8HrtJ*(%Sy4AY=K~CGm16Y)kOGQm7=cttw zu=Z6R=OiR2lFmkFD$95?EyQ!7X55j7ObeuZ?zpp)UWwqBy*1R)eX1>apC#D6XQUV_ z!KIw;Mjd94KoXI*-A3mX-i64FY*M=gnXebmlga1l^!Cf5v`m~Z+~pgA_;rfJvG_eL zOUaV3qlzdtaGmgR_Rt>ha)`dh6!?>q$~m@M7`DuxO&6Zf2Q_ae*73UoWa;D6WcC_C zN@uPPYszwpW3Q%z-$KJFZy7=0KiAs)L{N^sKj%2B%P*PUH(XBNMg_Xzoh;`}w>IwK zT!!a4n<&4O5|ZwzLcLfs9bX^UAw9bj=mCEZVmV8M)_NO9-&v}_l;>7j=m^F)Cw%5m zgVZZ(Fh`xLiCIBge&rDU_l9I+-!;n1ejm&0P#x#tiw+W7d4vRC8X!qN_oz9{ndZu= z>fmY}#4z)Yf{=X)5#1a~1Qq~weV!tF`nuhe#@D?}&lXd9zTpX4W~vg?aAY}stiqJ& z7fLabE@kjxcNwILwUXJ=@5ua&O6u{pT4L?{j*2rrO}*zApz<~fQK3pttS-D1fvW~N z9DTk?>hv*w#^Uixc>P`;Y7dyQ_`e20&*S-Yu)+d*=A)@#_iPdCyVxA)5eB+Jt&QdR zTAi#L7onc={iJf++UbklGdZc&Td?ej8f==OONWL@GGz}Nh)U2AlFm`4yH8uJioZ%bn3mSwhz*)>77{7PL&ECJH;Pq?&qV znWThtG&{c*n>5wwch5J%17ADZ=$#OWQsYC_?-pcD&T=Z@NCHdRVjm8hJ*AkADUcZP z90V+yIW_zvR&|q^lw>dd8TxWiDfIJMaS94;^mB9GJWb< z^8AbgEtMuu3q21e-hP9W;Mo!4;U!GfEG}lv&QGRU9k!Ie`z4k_V+?0p(UJ<;{Dit` zSxH7+_ri((%hU_yI?8-g9&9llZwjut$Z9N$qr0@3xbYH(lhA^dd0QTE;pie4I4dxUnpj&7-3ov&q%CKh00g#9(uD z0cD)8L|v#fvx@&z$LY|YMpF#rOes~Rg7Cj9&4Wd5zshiK~q)VSox>a@bOSibNGgC@;Yh{ zOo}AZ*KS2q(JQQJ?_ObgavlxwBL_U)!q6oqi&+-sWb7NEAq zk2AA&R*_|yO+q#y@q%_1#2Z@>g*TU|p+|#I zxBp%9sl*51mj8h0X557=og9*|iXuh2Tbo^aj#`b(*$&J*8#+JIhX~HhpuD$tS@J&* zq3Rbr;CK!@&e?5<$QO9PJtMJtHPC{Uf{>;b-n=7qTpF_HhCq_(4?$L)-J@sdodt7F zb-R}lzj0aGxvrO5`s)*6GSVsY+sj#Vk2MgjzyF~^+ngC%QbSjN;7~6+M5%cX6d89< zFM4#>cUF@9Yfh2l6Jp_Uk+OAjqnYJbC;`daR(rM;qpp+CNYv!!zi32k^!2b%d?-g$R#IqX54w(%^lVB+47@g!bot5~CRpE14T0rV|Z|&a1-qE06*g%P9R4ic&KhMYJt0b9)6VRUB{dATHuXxa{v$Rj;Od2b2f4S#_P z#uH?}f;Z^1*O0;`y?EfjRa`#!8)l{0utQ32v*|bn7YR$@uJjZ%Qn7)98_wXjD`I%l zh6^iO1=&s0gwags4b*q5;O(Zj&{)y|g<>nPAmJEpNWX%S7uVwbn?0bPe+r)%W&dXgzk0x z7`wj?`-Xpj$kgX>fudr&-xF6y>W2=;Rx=ZXaf(0 zP?T1-1(QJ`482Svqb|Wtyd%e6W49C?iq7Nf>pSqB!3i94{{riE#c=t7N_e?r4z3hD z3cnI-VG2hZG90a8ZMF<<%8rA{fbGD!QG_8OC&6tb7Q9yu5=qlh*dO@`T+_P2_S+;R zxo(FYD-1w6GYI_ZV#zYicJQ>8gIBMNfnSJ%5#dCbV`_u5ddiVRN5ILN3e@qA#NK@o zz$q+&z5P=$q1FQH?%f9=eqXG0Tg2Xz8-S9vXW^^VdMwgd0bTj=MC#Zy7%MJ-s8uPD zU^f-2nvcV|pYd=$aw?2$QUYE3NLc$>1DaQx!r04v*gR!6MD0rhS)G$0pe73QEM~x< za2(9Ks0WW{h+t8!HO%vNg@^Z6LyF51_B7>3n6_mYO5=*zU1th0eDpntI_|-J;lp^( zWj!7^m577+_IO0%FfP^EfU)!_1XSg~(5b!P^mz&TK6HgJ?`#N?lEsZ)y=WY`37<=Q zLHfHid{a0BZfuCh?Yqu_K~N7l_~jnt4M(AXTQVfx)rGfPZNc_x95RPXL21Sk2%ePu zt8=nIKcR_S;rs%H+Pk<;@;ydBP-d4>=g_k?25aI=FyhQQcAV;K7!rMoTjoB(Z8N_D zt92z7FG$7HClzp)Hw&*1eSsxfDIg^$fCqQ!U@3nmsMKrY{kUz<%`S1J8$GHtzwnzTQZFiYY?=+%dSgqn_m6 znFh_HdHCpZ3gjP>hi}t+VZQWX@bgR}FFfUO*OZsUJK+P&*3)L&c?{vf_&hX-H)k*Q z?nPJ61a|dqeN1Y8gS&gZForb~#rsX5ZfG~`U?!2J~Z`A9+U!_S!hXq>PXDB#bO8}R+o=hZ0INmjukxg<dD1)K1^3Dhnt5-K$*7_raRkUhUP*z zq%H(ca(iIsEgqN-rGwcKGYC48NBkej;k<{bkhy6G$T?q!S*P}5_fQuJpT&nc$LpZ_ zqZG_Uj{^SRRd+XuNmyhUPtqbc{I>Y1USvYYP z(70_e&gs4jHm+r_YR7x+1f|R8A*ajQbbXbAR^e;H~{v-aGBa1CN9P!xbZ0tYL?vLKm!6Tfhtni+bkhMG?Yf+)Cs@#EUkBv=Qhg*5v}HA zaI~vKpKd0Q>F6m+(`87%_9MpN{X|d}`h?Up@4-*;1xQ@o2O7SMaLKhS*3|5TdeQn_ z^^Yo5Y5Tre^=ilBS-Yd3!1Uk*?!~vtT&c9>+`Bi7(Kh4fkdXHkdIecT`RZa?d-)v- zU+RX2E-NtG(qAEsw#_uIP>5=z%TnK*ERMriDA9Q*f%d06!My}84zdg4XwOlgJ6g{Y z=ZeEbyq^sh~i#2 zE5kieI?SCJdj>z-y_0K^@q;)Xo=eTIpQg%h%{V)g8qqJSL}C+TNGvO3IIrJXuH4y%@{!VieXN&-t?I z9Vc5f5GCr~<{VFu;xrF>(3+1Uh~1jb98VVfQ}4^x?I<607aeB>wjSUzM%r}c%Mq?c zuU!4ICuT^#CxYH7*Z~u`Yq%Rnh3Xd`m%?BDHlog+UFgL%Cs^=7pC0DrqYd*t;oFfM zvO{SD65<`C(zEv0om{3y-@5C9!`=yGt9T0|&h zeQ}R@;d_~5$2F#f;y-E~RI`!#q+HmUW&3X<3AQ5!GBiC)6HlOwY*|5(M$=yIB6c2ljiDh}U_kV%MMxu<~m@{=l7#*HtFrn%7Bi`J56~IqHNJ z&!5C=6@+kx;RxKhbscJ+1j4FW@i_KI6LgEMgBv|TU~g@P_w0~?D6v)Gs-*|T`W;|> zYc6bmkpXf`?I7lxF4W&y1`#LYL4A7%R4j4F3DQpy3ElmVC!<5B0!t!cN?OX%QY?8;Eym{=zLAdtvs_da!e7 zgsk38xUkC`hSeirbMG7Y87d6=2hKyXbO5-HRie$~SK;{5(=g5J6I$UL@dq~n?7mPI zHxw)d$2|{0?0W-boQ?+dT3t}uSqPQxzTnzDgrXIB;4Nnf`Q5&d;VF#`9$jPYf4K&4 z`ZIp^47cK_Iv3p6+J)601_HnQI=qSf$Zxi&W0nE*Td^kLG@QsC8+c z@+NpK`xoA%nl1Kdt$R3HxT+Q zE^gOkaVyQ`@a?v3T#jB2H*Wg}T%*!pCW7RB03GQp<;J3A1Q>j3Scgmf-~S zhftSoKR9LmKRLxe+tKUA0${$jg>(7k6nM7r4tjfJ9lXAuOqTx$A%~xblfjEF$obQ9 z5*#Bz&q`Qv@}Gv+j$9RlE5^!9dzBPic|9LB^y$H;#^=_a*5ce9Vu7H^y+H$I8bEHB z7+LqLj_wx;CG690txYFSfTM2r>h3nRpgZnbTtD|b8ej69*$wBdmme0O^^*2P^oIvs zcH{%p!(MtXEq**#;oDHH$nGW02s= zM)YpT1Rc6=EAPa_K zCvkn#H{s>pT3iEq8Rz6Ez_9Z}V)v$wv`J2+m;Kj3@AEihE~(1h_o9iajsIdgoP?M{ z8AC|%c|*h2?y5WLYmHXN$dV)L(qK-DAV}qCF%FYz$tKkR5`A_utPlQ7LgWEwJHYTR2>J4RYL% z0^{Thr9&bxd1(-yc`gK<{PG)~hz@|?^R2jN-#0v9x($vk<727rZa^!mVXay+uJa-FtS=f4F~$WWSr9iag+E^^0P~s#@I0#xuk?l>IdvZFZwQ3f z zA3*K04E}at5oA44#HQ7cVQXbScuYzI$>D?WwemZ%mWu^bqiT37ERT-`U#l<$!>v;y2)VEJ{Oke$AWux4pdeSpkd!1tn!9>+-Da9 zwO2D?`SdK7Uf~J}SH4(yZe)_`e&%6Jy zg*tSzG!;tgT;TE#KZu#DgUz>ohlIjxykTSe-;u_D#d*Zfx8b$t*YkI8Gy11k^S?@G zTu4Ah78lSZ?WX@ZLP%=I3EE%YNR4Nd!75otu0tipmMjJC(}WJri+9|*-HF|hW0MKG zH#NOy9cq$wndbHLh@0>+a!#=K2b zxJ!(7UOht=+up06-t1gI>YL5J*`ZnQ$U#{CWfRwrI|UjdTc{)V1#4yWG}aM$TaepP z!#(x$Bjfn^4f*{13K86Kk9IwuL^7-ofP+sb!;dbgq2T-lgW&@w{8(=oy0m-X$v`tm_sz!7J(NLjs1mlW zki}Mw*7)4z1bA`32+~F#LdJ@3xP8_hSex?^4BTg8&87m-Ei8rAsd`Y*c@B>rG6ExjW!$Px!aMHHh@K*E}3cB$MB9dF-qv}V< zsLF$GK{1@M{4wsGe*r6;vBCSImGBhV0r<2{0Qclx00#+oxFL8RKX4ty>M}2|V`n|< z%N=i)rSN9<`~f4D_}wFL>54T@{@BIJ?l;C9hXa^jd>K#r+6gag&*9$vx3Q9+Bo63} z$K6J?IC@Kcjj-{Pa^X84mEoBQ$ne!Svskk-Hrq_OD{CVod4}Dw@CZLO zO2NjbEP~xvF3JlDUd$6b9L9h5NRV&t6vh{J`o)_fkjPuRaS3l}*IFB?dFN~t8ywmH zi@m^v{xo9EusbhA@5ete@V`o;UVlNBQKJ76J)!^Vh0S8y{>b8&WZL!wI)65%n_d|s zdMtypZqam-cSx4b%QHoDPOhd$rm7(Wkw)}$BoO_Y-_5+3_6%L+ws4xSVY;v{ z9t!@kl(`)=0WDSULv0H`QSSaTXprms&%X2j=-1}~mMHa&FS^q<6WaW)A=9KF^!=wJ zs>)me{L6Pxvxg>1Sf>SM4!6;2%^qZJI2j@ar=p(6;gGdrIxHA90xKb5d_wIPsK%(^ z^YsbHE_pTNhD#vDxERzBSA#AnO~C4_1>tDWYxL$-C|KI@vQ_g%$Not@5$D9+Ow<7gKs<0 zlTZ;{wOkPk?q(w&fm>L^XCBCJCGwQwm@4c>^QW2ABeX6h!w&e#PYH!B)tNI2jg zAys(cu?L%Hg~H~!sei|}|1Xw{>tL zK3$Q?{kT32PaB;Fb&--dqC)|mEN-GNRFBi!wp%zN2Q^?bH;(w`yMj)6By5#%C&oYK zaz(4w!?by|wA)INTXeOq&dV_x4c!Z&x%>*w$J_Fx*i0GD+1e1#%qXsQ(|t5~(-&$! zbtfJ05Tc#F5pd_qY3`vF-KcGb2K#sJ2fXt&2l=N3*8ANOuQ#{cSf5;$45y^_ka-Jt za=k)}XiiLkwOnQttPZfFALP~P2mia|oYq}3l&-_!YR%-x&AktmY%V%Go{CEs7@73DJwC0a|#q--HnaOyV^wt9y;jfTnM$RfgcI-?5*Lh8aF{bn?yU1{gk3{qKm zoftWZknKa6+@m^tqI98DT5smY?8d!o2yc00x1utV7E^*srPnwyga*slXbP15f-nZdF*Ot=gz|% z`z%SOS4R=Q;Ca>oVpEd?f#lqjOtgf}Bg2ydAbl|(jg47AQ$Z-*B3ni3lVcdIi|M2~ zXam>yYCa8|>C9T4HiBof85~d-QUC7LX711NjrF;2=5xN}B+;7()^n#Z+nG3OPa1D6 z$8DGOvEn)#n&~8owhe^QqEi#F_m%5(pL!7_8D4{n<1Cc0iPD)D?{U5qOrmrlM&DDe zQNM#JNH}AXOC=5-F*Pd{_e&1X=w zT#??=c0i?VrKqQTDcp3O1#6G0kz*5*&`?o4+Afz`+n5+bZzsn6v(L#t=1szeA-w$k zG~DuaJ*$fTh5)96b@G-8E7GY2TVD!*2(J(Lg`_oW&EQuo=YJdjC`gB;JH2p@@N_I8 zriCxtFUNPhvmn{u3d;+ILru^l$XQ+thb1RqYOIeX4$Z*XrJ|UQ0KRFy0AH9K3Xi7% z-lEtBNk?x$iBAs1ZQBA#C%$6QqHz#yo`(J41C$6G;lp{);nme|SXZJBrYNqLc;*&y z96G50iXw~9mDo(EU3LJDDVW0g8Pzb;YY#|&u7^U_F{oIw55y3H=56O8&NB(pcL-sd zM0s4&V+-Lju25?>5%S+hgT|llEepO58qb2EF-wI7b@?!JXb88|g~6TT5pY%3W<{j< z;IALUal7#)=yR+98ZUtl{HlTu_7l`+DabNcOu@&_89^D-iB$q@@sYFEEHXnE@AYWL z@soxiYakS_-4KXlKTN|BWyTPp84oP${WxrL8WiZ9gVyogP%gzlMOzHCnvB3$q#4fJ z8w;X?190M-AXZM1##uLifOy$^Ja)Sr4rS`I7JsXQ9UtuR35juhS-cXLP7A@NGU52T zej_|zT8(oT{DenhZqOr8g;zEuV&&R+=n9>Pv(j&ah15y>!Qw9Ni=Tw=4cKC#m3y#+ zs4w;*k)UbO4hr?PaM67k-o%}Z^S8U$8o}QaS7$Xl+7BfnvYH_m-peAUt)@-i^T9X2{U-UD-sr~{Df0$UxRGa zHwdjqaFXW05o;A}ymb-Y5_}pW49~$tG8I?s5yJD_R$y&IH!vEs!@8RaL9H(quj$(P zcbK@?pJ~*$JBBB+e-ZDksT8lqC5U%$A@F3u;Ga0&Ur$Qa++#)uozWt%wMdWWjPk7} zq7RQm{-t&k`>$E5pf5wKuT8>hPnmMVXJ54b;pbcbeQ=oe{j#GoZR23#CvDs)_>Qwb zAe2mU_l3&HB)U*X7GII7V9eL7qo=%5Ij$`e2(3S6X;}ZA(}F9Bib^}u&d4JIj}$?F z&JcY=oX|jtB*YY05~bzO>&hc$ke<;5bUSGdNnqV40jU`^d7^_>6%SY_L?WO2W26_U z>nDQU#SPXbE1ol2(pTxE6RWJ7D@Ra_L1aR6_ zh*#dO-9t7*Pf@(y6~FgOL#NvG)$3<43aT-F%a9Jr4R~95%GKEO-fiLQ7`&hRwAji7dX*;^gzsfB1 z>1Cz`#aT4mwny3V?~$#DGWkH?p_&RMI5jy5S&mHM2-GQ(>)aQpCnJXJ=-7frc>(0< ztoLM1pE~FLjSpn?;WOyE)O6;9*nJe;u?#s!$U{I$Cc4K=Mw36L&^eaZL4NciI$?W* zGvV0YDSbGDjv03owd^_cwYf9VFDNFS8J*0-+nQ)F_ifdsYHP&&mH{Id zIZ&P{#of0_4OitRQ~8KqG{qzYy5)J~$x9XLcfS}^r^Q>}&36O2FDcgPyuiA1t4Hd> zcC+b{pVQH2^=LYU^@?$5Y$YDKw_x$OE?iuuPp`DDB+nN=CD+fNqMt?-=(N_;gs-t3 z4M|=iFXx>i4<>k_ZGtuEA>6FHVz94AiZ$uIe*+}c$ z9aQ8q4Kl5?;7-9_;JrD7)TL*`a6~1ldTgrk#%?#Kvbt~Y)3>bc54(?wiak{W2>VF;ucOToKYbTpg?y?^A zF0dR8FO7hbdA4x$Xcmg)T|y_TXMw=lUr6U?1ze8P0CMyQEZNP1W|k=|oUH}yqxaGE z^NJAo?hrV&zee4=tYFEPBJjN`4oxoGpk4D82nFl2jP7aU*C83W>c=&l&Ao!J4VU4c zz4LJme>c2e8H`6xDzF^4UBxA-iNF&R#rMfq^y0xjI2KwD+B=ewD)R!Y>vqAw+&0KI z?1IZ<#!wO?iY>zy!-230kesj=zQ=q+rm@wKa5EWZ@veezxGON-2_P^n8@l`qU}>cz zjEv8Qf#!{n+Ij)ROC!PW>jiZ9rvTiXlY*RlUm?p~im2|f5}b@?A){bzxZc=CH$k7l)cgTaj?Mt-4^diDZD$rdyiq^$-qp+~^NC-xeu7EJObc%wu<2h88{{`*+ z+Jjn7JV9C3pFl{X5UeLD!f`Vpe7pZKoX{4=#ovr^LeDU~Iqr=%+Z@C0+a5s0n?6vR z^%y;!5e1{Od5kJ62h>;v=x&53vYpL>*@d}`hsZwgIGl(c^ESiFPlwRvsp~;2G8Y7! z^3aOB4KV9z4CHWxU_ZYcY1iaJgKrdsSL}jio@?=;tM_2mcSHCvM;baKmVxVdE}Y!S zL+eEXu=4VBc(x}5&%N%!Ds8dH8~P{VnLF9I^1yQ><)@E5XOu%v{}|NBZv&@3X}q)2 z2(i9Dhb_0$!0UJ<%3N>?7OcLEN|y}6CnE){s-6qJD;1z`+ziIurow#{Q^>3kfg{C> zA$YbRR!<9ubpI95-&ch;?3xCf#!waL6Gm zT75X!F@o!|Odi(A9AYATA95Tk9+5OJQxc|q3Z&b&a5hKF(&yf>%+0r=&}!-ZZ?h15 zkW0af+T+kR1!uf%!E*HVcOffw@-;LgZWriZQDd#vy94`AZp7Dr|HR)PHp1@4I$#Ty zq0>uU;F{Mg9DLCLS`(}BbfeGcr>r~3l?%cW=_GJD{uH&}c7}~@f_UqrcsRaD3d_5e zf_PgY{#q{#pKaJM{-qTyJ#!2aI@%!g`B%tjS_Y9+23G8Pj%u?n!Tgi4f5%esm!-Yy z;ym71p9k+=_I%!d-=n#Q)6l4{I_gN2{+B5J!&blj$pe(P_6*lSzJSR(5XwDzRE)mb z7DGQpzW_$#LY=t0Bja~r1sTx_L_xI@T&1ok5)>3npA3E{uTyo&#;^>gN_j6T+rF8e zR%xmAiR>XupT}|*xY}}rQ-FbQ4d}{@r{qc6I`Z|$Mn)t%lDbkwsV)2gHXCK5WTrE9A6!Yws{4p8I7F_W zmZcxdu5#zezG5CEnsJ?$I#ZR(5@fdI4zXVOlF?YIOf-^b;x`rDFd;()*|iJ6#n@9+ zePt01?#o5G!C8p)NSe$%w1Q|2Z$tSF>6~z9P3{s!lX{b9PpCgl12yF+?y#~Hm6Y+I z7O$jW(#wo)r?!SL z3BD>Qq=$=iKYJpp(!FH2<8$kOw?~up;}S|R@4~BJ{E^qgVYstw974|3z^4PT*r0e2 zDo$mh!>>JYvX&OMJzk9Y&W9l1TL3Gq34)zVCGfOxKX6;;hys-Ef{U@lAO08$N@2Z7 zclm6{Fh2|PTO)xREDnWQPmzz#Su|_TduY~~fK|o(p=W6d`uvBxNbGokCMSvm#P%Z5 zTT`KJ=qEDq8bk+ImqYk$LG(Ow8RNC+KFluP3BTvbL1d0BwrxCz(zV~?JLd%;`MnEd zmZU#1s><5vW_^XpLZf*_m}{1i!ikipsR z7I0rs3s!G4f-9%)BJrh>=$ZW<>}Vy8_mmW(y-NF_de{jco2`a_9`c8EaSouS-hou6 zw}SFLE$q;80y4e_;iSEFR9s8bFC2oy;O_1OcXzj70fKvQcXxMp2o@m0-I)P`211bF z0|W`~4tL0T&vVZE-tXS;kGp!U->T`|UEN((T~*Cy@7)6AsBv5_>tl65wn^Ol=*u2hqswz4_Y+`u0DuGFV)F#8qj!X4Oa)?wDdxYdBnho<7%b;IU2u7r7JZR7c ztFKBFen0_R%-V=}Iq%)2r-#pTN|Z)}-4oW&-of+6Sx)rwV_{%6+~ZY8%(x}3lt`AG z{Mc->iaw&GM%)LJegT(*SDzbKJo-C^n^J1jjKpZs2sxQ(99_0Ov|PKIN^IcCR;8Q| zvN~|Bc&e2B2iq-(B~g(atdKsS%bNX$n#`|V46{d%vD=E7;;s>15e?WZNk-Ks!C2?ev^b=N#tMg;vt8_(TPTPn1C zT?S|rn_zN#BIvvFGoXMwU+e>g zYLbx=(c*pz0&9y>s8s%?Q@JVj?S&E)9^q>|?Vga>vnu0J;ae;FW5iQk4GNJh*gHFO z1g|w7{3rqFrGU?2RjOlRHCvBs$&Z1}gBoVwz!1HIpY7CQk{JDH$O$VGO4~NKm|T;0 zV)rwC`zL++<(BJwx^=+pw1nf0U62Rn2zP3-yDOVKg(rUk3#)wg!{^2Hf&B3lWx*u* zSeUq4!{{s5gd)$|^wMU91ZD&kYD7HNlu_yV+VE1oq(`@I06;uQ<{P;l2DWh#q+i%0qJ?Uu>LX(Yp29wJi%G zW(KarsWrm#d6bi%b_rTbYtZrGLzNod0H=H4LKjhWxw_Pm*Sc>A_M&?%5{wwdmW(0? zQi=d_t~ZV4?@3;r9;q!H&A59DozTm@dfuf9F#5(oue;o#Kl!ji zkvlX(`(PwQm+Q)mmaFi2zN_cku~&2}@y*A5(fUc+-Wo?;&(ZSFQ^92ChiMaW4XC+6 zD!xzIV=jKH&Z9_GTA;#ARg1(Ar0bDY9VaV^IE{2q2gdgfTDNO?Z?0=w9vaZPPsxh3 zH(kfI-QVO|ja{0ggB0pp+vgUzIuW?}#;rH9-!L@k))fc$26ixgd)}7Fji6((OZ~m) zaj(pCwqk>9FR?mlr^9~{RXbsCAv#}Y&L7Cy{5iWT$C0cz`dG~gxDBjddx-4X?}~xL zE2w&l$ZC?&bojA>F+e8KLvl)np9n{sAQ$^0wfEN?DG}3W!WJ)2EL-SK;C6!V>-(k6 zO`*Hn`R96lT9MV^C;Ok4y&BKg&(uHBuTqS1?BdNx52V6BACxz3+RY3p%ss>ZIO3YP zQ&r>qp3va$&J~n3s^k{mds;z1SE|a(X1x~QhQpwC0Xg{$_~KMhjHXl>Lb!`9zlz>VfJYXAsE95 z^7hdH=QU!D1}vf8&l^?L!p=Ff$X&BxK$%{+{PL-5#tLpY+ozs0#x$|uaqb~Gma;s+ zcDtv*yPR>7dfeoLBX*WLCZRpYL`_1bI*-OH7^!u<#ud!Z=nY)C!4Tm+Wkv?m}~_d8fclDA@LG9J%rB79li1inVW2_95^qEl`#El6$EPm5ImgQo7;!+Mwpn|6t5=9&I3&_* z&JyUmB3V##MN{C<^t@IecmB-t?Me0diPmNj)%h8bh+xini_Y7lp>=5Pe3G8&ZH5e`*@H9IevNuCc5^9mz`?bT`!)%efiOOGuv<-nAx0d zS2d}65-{;4Aa6jWc>yT+^CyUu6E;KN2ljoB38&GM1Mcx&8|i)^yA5U_;pwjB&288AtG{kB2+6qrQk%bDu;^IF~zG{h1dc3SKE0<>|>c>zmOfgCIiG%E}3dNP>?<*HJxho$RD1E>cIA%SDgg&Iq z6p>vpQGrT?*?f+)-huXL+28r0!Y9%~g$F%KTlr(#lQNO7H-CW<$3pON0!_c8O~3_| zk-FsA0I5A3$@yhjlP;<~qx5w!!7Y^w|Z>H$1keLwyP&s};FDr@y83B2{_}$VBAJ!N6qC`)Z>{I>sljz#7#+t;wQ(C^J5mBd~QO}83 zWuv(A9r~?v&qMxMvK?^vRg7$FxpHU5zG;S=Sa`d)f+&f~Plq3E(E7Nzo@cWUPfvA= zFcEmCV&+pi5ait+$m14mL4HP$mfmS!9uaXgSC~02Q!DFUr}Udnok8DN7_%-_#hZh!#n8tnv&7np&4c={P@ynnw?((yl;Az^f-KFTOVjyQ8Eg^cef2 zzPkqDtR>1(bO61hEn#Hi+ZY8>hvEPOp2}TYt{s0p0V{E7=+?(R3-fkQloHpH%Ft=u za>-L10v3ghjNOdytgQNC0vu_mdRA(Kn@oE=Ur5t2g+q8&R>S6o=g3#7KSa5slxA=e zVXX1yR}vDfClruUPu_@KxB-S-0+`FSz4KTs&o(F8}fFT|)t{28>oxyj6bipxnB zx2v2R!(T3WWFYb#sURO%&eRhGH%Xqud8+I{AuQ!9GR|SI$5eiB^Q(&|I#EZD>4kHa zO_j<1No&RLTN4>Qk^>u8-vQ&f3+AUZE>is_#A@~Wx`O=p_?Mfz*ehXIUy?JO53jNd zC#UO3_DUHTGGR!Rb`~nfLYy8PCoM0P%Dp?;CJ;L$J!$f3V<(^MM68oS^Em@jB0Dyl ztoL<)^GdbP_Gh1%Zcx)6J29{8(izW3%l?=oCswIy(sP`f-Z8diAWZQ_<^RmI6XsZF zu`P$_O0JQ~~Vv0%^w!&x3HR0BDM7l|{P|E66)^F!gG+;dVaHA6IxJwZe$Wu_Te-mz%p z6Raax7*zXZc9Tjz_~zX=zXv`uL0eEQmV@1NUn>03W=CfK&CQ;9msJM&5G#(;#|*S> z8_j|FwZ@aC!fKO^Ca#5FpODUbaQaKHUL9PGqV>oImXX7oyMxAE}%^5-4CAf_2`(l~(DK|3bFh`uf z1wdOX2i1y4EsbdHc8xZg~ABBV%?=JFlfQBCgDqdau2OxCxLhk<^*h%_D|7(Dzva($ZKsb>SV zvln+Gb-+r%rA^`&7Z~R#?Zi#!IWxc?q~-yk0(CWDo$Mb zL7Cc5LCag(3v0Ioq#fmb$HcQ8Et_6i{PiO+TX$5^kmL2L8Sv zTYmei_b9?nN-Fqaq$_ceIWA%n!uUfQm{Ws!UQs(&6oC{_Z9nv1?K6MtudC#U>R^(H zUZYyjXoflunWeA^AOf-01)+$-Pdm*BpnGJYRiVF{<>WFxg8sm4sD}K@hDf|d0+VzL zZ(y&&+1MZuHQ*=<9#MIwK3bF2!$EC`s+qw_tQ93v8Dr3D;<|ol<#K&^+`bK*jVh

?{8!*(`=f&BUTwSRw19TPhVCbPN8T%WhjRC#bGE$hIqXxAczcL>)EB4Jb#wnfua zP}+OuOHgj?EMdnR-jpZNfhew-jA9h7b3w)3tIVVm}MY!h<*QxV>Iwr9??=mz3EUkPu-`NoRX@L<-?*zqTxJ4|IxG1dS;_2HmJZG_3yVY`mj3e3UX)~Q>d z_`_fidd(zp&_Vr1ZzIQpqV{5~4louFvoV6y=*=U^F$G(dZ`Q5y|GyIJ;+KajI=PhhRiYmFZ-jc()9DVc4&x(E^+z_mp&h%yPk~`oq&Lo zC*zHTbsa$KqIO$bg|R%AMVPL-03lQ+or&4iPx>m5PXRYvC1mw?Z65uIx=N~B(Ip9i zMUJm8PPUZ*kqrq-v~3maCBJLd*#*WOY!$U}(MJ`L#gMH>{jUI4fK^FV%yj&b6XoV( z)I}L{5t?O8Qdg?2+|vbR!+ZlF@ta0*`(flW#kRuBZMuzZ1Hih9aHS@zC2nZMm4b_v zqKz@xb2E8>JI`{zg*D`n1DAsgfbe`%X!S7K$5?6Z-K<^hlI{`guHo08tUO+moEE^m zH9>Z&?BIFmnw|K`+=}3|LZ~tqOZ=m(C}QQHbPrfnEbw`Xy<;UOzTFY7eab@+>s2lIxuFs0mBb&yGT{1FtUwUvZcg=!FNj_EU89vqqfUL(5l- ziToyc|KUxy4}15@Rceyf+6ZhJFY)_N+n~Qf=CVwc0k=%#o4zZ2`9|%q0eyre zeygJJ+BcvG%?*k$G2U}%{l5vS8Kb|){~w83Qe)_k5X+s1v68`zV+ zoh=t2bY#y;6>>@UIC+HXZCa}!ajMiiIo2sVJL zSe2hXDDO0lQ)D82k(?`*qRyc*qSFw;^69Ne^1+mmPl_LxtwJA@0fYz!QVg*m9PBAT z{(2;(eSYz&$e1wC-C*qTXJ_Q5X|;qmS%dY2fpEfL=P}59m|&x;Edc>#h#c$jvz$qi zt+EDFjqlE>4uK`LK&13j3Pklbkt>XB@YD32y&?>_%k0k<& z^Ey#Sc~(G^|7)+v`bWlVb8`-oOwB4$p#0XC-hgnB5;{drucU>y8c`A(Ay@T&5%ZokBIQh|sj^1S zke)*iD75O=(-cOwdJ_+%!P(a_KM=rIDGVjL{t4z`RlLc0LX=rmHTSME7(Q0OXz?3fm^k_zI|u=n4P;dY{!XAy&pK&e>^nEs{$U~6 zF4qP|m6oy4OUH{7y%CrsLb;&%{*bm}JA4SSzZ>M#@vo^(&!JZM+L?F&@` zRhC?xPd7h%>5C^?j8A}~c`u7DZ^*koUH0vLwAD4#5@NpNB%RZ{0oN@%EV#8|B(SbU zdp@;689m~PKk9q)y%R$ImOYD*6I$+dnj)an&`4OGiDXt*XPbmK8^iU@9!j@et<7v7 z01QBK+JEAw&(MXt?g*in2qbJ%VvJaWJVEqvC{xE%kFkd>AV-4Li>h5Lz%HN>O8s#IjXa zd}b)36UIFF{LXfbAZ@UvMmdd|pGrne3XxGhY0r>VTpkG@{VfJI5KerBkzNj64Sj@C zUK1VrEnBiw7B?#^rRnRpY^LV9YErPU;#~zAm-+XzPgne&^Pi80HQ!C-kZE)m_>}v0 z-0Qxd*TdLwOHukQlt4T2>t^o**H!Rqg4(4pL|lwrL#l}PjJ{&D!l>_;kev-rg^1($ zf;u9yv;4X+-M^Hdd2NV#GY(#L?Z{@F_2|CY@ppVy@hkJdaD_)s!MTlggM3hptdUg9 zsFqG)1I?(k9WS<2JTQnYZq3i9IC zUAQyL?>;UzHH-wu53f~QtxP=55ZZ4);Iwzh6*6xPVg9O<5MujHtXIGSI`X`B`VOi# zF7kn~t+6@XsEykw*mQpQ&VkYD;1gYvAH@=cUF){92G3y=_a__5R_b8B1f$@F)2c1EOY~8qFBAZG z3J)xGxnCBn7jgMAKHg>K+8{io>MYp-?V3LC*o~x1V%Y+(XsqMl&Nk~@n(~D~iNa4z z{8r_$J))D~NuWkjZC;!PG^(+QGA1eMS)Y=sqW(rV9}N_ zpLY*~Th)qBT>9Vg@5UGqlAnPMSm}{J^HT6Pe1S26R3GC6hE~CaDtwcfu)K>{t{TS9BFjf(Wzq)-zF`F>1B9EcB=^nXA zx1jOC1ZVEtpCy-c-&EM8Dt^UZ2TCMQL%b|)-)$p;H>uZ`8RZn_?&F&TmgnS=u)(xB zLi2HH<#x9d1(c9TPER1qC&aE)&-W+$*t$IrIgxBkUSYmY8_>MO!UgY7y-)L^$M%zE zQrCZ1!R>XB5mQluzv1%sgi}3CtNDE-x#0|}D=mdQ7v&qJzULXY;7Y=?{gHQ~3s;hk zS$Uf$x3sZZqe885v+*aJmOCSv@Ssnf`0HN0MUEVo%>}9AY`5d`4+ptr0ZE@{`U%D# zLJu1W2mLnZr|VVlo*rs{83hQlJilIBMd;sI`lxo_@3!Hpm9>kq@HBc)Dt1$zURp~T z)P=3m_s#?_NpmkbQStk4pR`Aoe{W|^w0PPplkjVzmsV7ZT z&l~IY{+e%UI8to0SNJXLS2{;+M32oFjDYoK{?Dv^Eo2eS#}}Jc?MR zz+q8pw2jjs`L9jYryf2PB~}~C%WphGZiotp&MVSKzTQ~-)zKox8X_mvrY6``Asrbm z#EK+JssU%R(x|CR8uaKGzbX&S;DlQz<)aL_UP5mCqKu=+Mdk%h>{5}1=y5QjYX-hz z4G@8*j|#ba+y~31<9R#=;Mw%HQTZn$Wp<*R{;Yyd^3uo?Vm${DJAqmfN!Vm;PuE6f zpTM?_K34r(+dq26dI$2dZ|v5<&v8$k%C!39-b@Z>i1G;CS}=p%$j)x(Bwzac(N+w0 zVAN5!xo)h`N!oaF?VpnR-kd0hM>V`#9_%{g^8iwH^i>yhxQdTE#>`$U4k!sw+5~kx zLJsQp!o=%Cb&;HoKWOe8t>Tnt+hAETcXM{*jp;-IJS?BZM7qJSIo!Rb_x+p6AqXUik$&71qo2S7yG5xy_${pC_QbzkYV;cWr9e z^64CQKF?H4iD<*o@6e{;^bRJA+)Ncy=f*`nr^85T&ZIqf8iBKoXPdrK8Q)MCCE0&W z@6Nx5aLRj(LIPThHCK&Nh5)e2~eNvYA)6{_86;HDHFRzbWZ9obnKO0ZSL!=vJ5f z>TLpXi%HUK${=Px{IX$b&;iQ_mmr6M%lI!@33TgDRRjsEn89ysJa&s&qv`2lJPSvY z?!PnYq%^&9|LE;?)Xr}c7&k2L+XvLTMbNIlDc?fhW-|ZiFvG%?vKvu1M`OK)7u4!Q zSDe&}(5``@FzLu2{jK~?%wi2{xlJTwep(1sg&1$Mz4==>sUpdDWAz|$-_&B~_lyB7 z_0$EAzv>*kNcd^m^U|zJL&L`2)D)bOBwSg9?AhBlOxPjgPV#Kn|3DV|7OBN1RF7wh zM8$S=sE9C;mGIW%Q4NbAY=^}4WC&WmVRnbSMRu%c0z}xg(*@_+;I-RzeBB%R6!-&S z0%h=t_mNT$W%Iyim1rR$pTA1HW#I=;CG)a}!sk_s;StsSUj_1K3a0jU($IePjzWpe z&Fio{N=7)2`)xfkb!{e%`OR;)euaUfBIF2W$0$vA?2U#ic)IvYO+GqOnzZV2_d>B; zCAW^Hv1H~9a_8B#SqlCrG7OXz=NW50Lw zZ1_&-+_S3qdDmP-FOQ*)Q-|q90`&`^Em0glfz)6|#w|-oXZEXH~>DN~#@WDapef#_veyYC+2&CGCdj zO_37B(@?lKK=ky3cN1ny&l-9-!U=w7>^!q}O}v#a)@lD8?71X2XJ4C>2)mItYVEIR zGpTvMr||4fCq%(Q|M?W<%y5r7qd;en-OrK^_It(MpE0y5Pcj0WtC#R3lXsI{Sb*t< zKFTYb57ZjzmSe((0!V==Zu{ zn)x*wX3@fsBnV=sThOgd$G2&)7-@)sH3E&n<;vKf0@e7^9ZOb$U$p}IHBrpnCn?-Mde1F>Kyn%hLB?5UW7xZav(hBGIJLg5r z`I?z6&OVkcoN@aMc$aNbrX}wvy?4XFH??pw8)fL5Exj;3LMhNz-80(UJr&|~GMTY& z(#UyM?3b~45OTR&PbJ|x_O9NiiC0{S%HlpRUf*=z%j->>Iq7^1M@q2A_tqxcH-t-; zXymhNrwe85f?BZW&-9$TU}s4J^pDskhLrg7R0*S4jqcAI&+NMUw;%5B^druqP5I3$S&@7P zd*GC+=fI9gR%|XR2q_GO;x>|08MkR1MqI-ij##$hca=mU@0S4vKBjzaGep(iJHL+~N$mv_8AO>!>WnaR_ykPlzWZCNOb^iX@7?(A1TfdC0rRvx zvYwsbGoFNCRvLFcMm6Cgsvd+TKQUJ>OubfMMNMWYkA}S7jG|p{EE;$uZJxR3%3)`r zr~)dK`L-tQBo~vIO)=e8?*8hhFF32$iq6m=BQ(Q;u=3`Kqh@|J22oO7Cuuf}s~O!Y zj#=9F>a~NBx~jyi;E72GJnCw0B&*e)@z9y&4}-QWrQV(lyOj(4cbdOa7$k0YITbJq zNw%I?u5FLpZr9xM^K0sh$~UXQ!9#Wq>#n5ffz%^MXY}UGdBbdIcD6eY&Rh5^=^ofU z3i+=X3*ehn!u=CS$_w{J6Dv(0eF^^;xZtkeOXq5qxbBioA!iaS@?U#hYEDHKHS*R#W zL((-9s`B!$2os|4dTl@WuAdi(Tg-M-H}-@H0wk}?Cuwy|oZtZ@xJ5S_G;xci!GbNa zW=-X+L$KG9+38pg>DdXlYjCHXp|mbgd)8L0L7^OYCkHhoUu)HdI+-p`tR~#kaz37W zdK?xWOokyX60X2tm?%Dl+{4Of_qF=IQbo#r=Dao<*HO?CJlNyi^H#&vLP0Gb^1IHm z9O`I5ulc~(x;er}xz&rch$B@w_(Xekw}L;^NCB54wKhNg7Rk~Og*eO+>w*6&iR~t^ zTg)grQSw)O)rFrwIaB@v!Bdv0${R@mev%zNHXDZ5W){$CrU&FU9=J{3LZ9y!bjO?J z4^8iTh3Gpju*uUP|fuuH%B$R zjxVk~KtwMQ3?bf~Ic&MmjpLdB zez}1E`@uE?p7r&IjFpmST(P-4UHx5@-`b6pQ*?-?hXZB+qQti|w!O36>bjCU<(X-<+7mArfE*3*YO7nL`H;dQt+UJ zC*m%5x7Sj6zQa;cuSTuCDYk%mTIVexgH63 zX%^F%nIAQ$eOM;QbZ&OCL!Reo&BG|{MrD*#Dvf(S=SOGwI+UESyiQbA-xaV-m?`fX zvR4+Vba%(bEluN)9>c0zu0?J#ml5x^rDC>u8@9}=(8hmD=qArsqO42f;bUoPou^NAcia+VsYh#q7JuW9zIEQ1V0td)H}vFe?4z}zC#*qRbDy@m$CY*9S3#r z$Jq29#J2aYxHk;>;rH!~yL{JluGCH(nzzUOof!%`&GpE*pt!;J4n)quzpW1*>JU)M zH0ut$aWp*yK01EH`syh%aQ$Hx-RDB!X3p9G>qO9-ZBOhF5$wtjF z+wNP0pN(bHwj&B9Bv%-;mhnU77THd^#<9@3Le8cBXoeWVu%@b1U}HNxF6Pw`^|9g2 zF_}OoUrmIOP>oZ~JBqJ7*|03bc|{FU30?E%Y;Ku=MO-4}rHiPPU93t;&07}2mpD~x$=R|>4i3h` znW8}6X$|`h_qADGNg5B>^JKYFljIKeN6q#`CM>q)yRW2QRMo=@ecK6WYMEs^L$A2B zs8S1Qr%YLK=r@2x_vDX;g}T28qf;;&+0LJoC(UF;j+ibiJh4wsZJU|#?)To*w6|sV zjD4~)OK2H*2&QLl@x%rAY3}=nsk?>~=9tiIRvvYbk&Lns3rlHaQh;i&S$}RFN8ztghv{$4<9)`RDF_EjD%@uYQ-aR4IjyiVlm&o ze;~PdmQ20rhms6lRtyvm&Q3MIjlVT->tJWl-9_4T9}^b8 z?kD)Tq)4L5Tc-8>18RZgqtT|c3SfVCjP=KPfCinQlgs;M{zQ#M{qNrLm3j2aw|Wxv zzdR$RQ+R^bj1=lmWw_THZ(+yYwIPORqK8>JFV@EKtge> zV(dHG+qODb@d?Z|n^%DEJHiU%rzUT%);>9~iWI`!1?2N#e+(+@`VzFeB)J7%G*vfm zLUYAdo|aj|=>|xgPPbE}k$C{w&rWrUR%3?ma9=;+D?7m3^}jCjT3}mGbP(sA`mo8q zOeadR)&eq};WXGfUJ1D=BZ;50G9I&e=Vp+T2tRYQ5jUk;)O82{L+A?}L90gP_c?f^ zyHlNM0fOAj?2W?`46k{TXpG!tinhDg9}k&gct&BB4&&&Fvw1W49Q;OiYqk0g%Oj1K zML-I+l}5<}Cc~C-HM-&Z@r1@sRu#l0Ni6Mx$6m34@!D2>uQXamhPl$@dT?SDqSRw;oCl#NsHIDlrlHaE-lx{B}Q=wQI7%{Vr6KMX5F)ntoRB=W;M5ZrNo(_TcW zc@~pO2#5P1GDxgSXj9}SC8{|Vi%9r~-yvS&5t4JMStljcP8kh!ww($?W>Rcbj?3dL^46=B+lpK$yj`rhiV>99Of}?7BmCxou^! zW##tyX(3{8dpY8qleQWp!J{YlK~Kq-~BB9D!k&0R!XPB`=54ucT%gtLxuC$6nNBVkR?3O@lnuK41Qmfr0ok+XgEM4$# zz!6@}mO5{o#^f*2<$ha|D}`2b*2nlafEZP4&i;Zb7ac9s>QNId{u;(etzY$3LfYxW zRcAU{q}7aH853^Wq#{@(Ge3@%y~oH!9I@EtaO3azp^x}4n&4lww03&(SM+vJ75o0C|xaWO35uuBI7ow2qNPJWS}Ab)qs;EfEV-vDJE#3 z3Th5Ff(~K>mX)CeB{>#utQ5tGu<|9Ce`dXm{SW<6RC6Xp9D*Oshrrz}{APnE zdb>avg9@X9l9rs7!jk-p^myx0Ks0e{zcbPQ0DXs#*^qM>4?-ll45f;+l9-ajxR`!y zn=<7JLs)W@1_l;-Dmo|nJmvO#&t9nx4TOJlU5>DJ_}Kpc);;|Ht$P`}PF!)GRK!=B zmH{UwK0-|ByNVW&`2(k?D*^3Gn;DGjQ%Mf5+#(e=);33-!Sa^|cxMC7? z01OgXG9BP`wV;yq{}_0 z&oA~MnOQtGU1phEQx(#tTB3UyCdD!hzk_*{D(C9&l)kAb;2P)zfnldW2%NkEp!K~#8rUZ4o8sTA3H{fsd;S@4yZ>J_MMG7` zi_Gs#7SMnJ_@pu5VhET70`AQg$bdleHn@$(|H$+e#QY%^yam}7{OJD}E^!#7?k`KX zLCw(O#%PuMdkVmR36bJo!>ZZQQbSfh^=nuC96?tL**Oie*O~eZ=#$N|%(BK#DakeGoy zoAKfW*Z`3xd;p*sn-JU!-~!FqA+TxQmhiILgy9n}K;rNT3_!K-1wbcKFF;N3CARGq zJAre!RD>(Rqy=9l4x4AERDu?9HJ`R~%|^p-cC)&#j03PyBT-D$-=h)m$Z4sza}}40 zurOG}lw|aSt+m66LZ!9;`bCkfBK5+KeNBH6E=7*bZJ2IVm0f6Yf1 z#{__DRvF2M;LKJcTEdu&D#wREP3BBTsWlCS2v2oKbtJ`AO&Q;B3b;gvr$Chfi0d=| zX~Yi0zP6l?xfc``iXn-PN0p=XcF34L!Y3*e9iA$JoFzjJp893vEJv2Zm=~_>FwX1{ zXFiM1Jfud(4qzvBPW)R!VEqJF{>fi_nPcrUTRgw1A}EHazUwb?n&6uXQBITzW4%=5 zxPJWgf4*k+f9YrTi-%M!FRTrT9oe~2DnbiTjjzpKW2!S)A7ciIO9@)~+RD6eu{D%` zwMnkp4GK#|e@9_Sp^ih61_a@YMfX>l`i4_DVHhI22?b0E|)3uFz#20_25O9Q=vLWZiGJO^Ii8lrpaC_e1BsSsu5h47)U6SBje z21!9Cjt%+{H66tewGD+w0I?%NN>u3|)Zd{HE67zO<7AY?g~(s5;J?N%@fJJBGlK96 z+l|bM$coLPOE$p)GX5oLh|T{c=}fKIKjL{rpFwxU_yjSEzgo*_xrs@Lg1A8XQjLuL z_{*D$ECTdG5>#=*hIgqo_!Q?WGJuN+y`jnJ6qe3cK7Mot9EPt3^C3$~q$T|D#UB>3?tc?rV6? zA4kg=p~0RP1Lgu$nDC(hnb^B9A&|!cM58=P`uZnBXm=}o2`HZ+(ISC`yY^qfrFQ5a z=U9)UvwAM`0~GywQgh#^Hkq7gDN zPILyPCGxyrIg>>=FhGj)0pJko1Vl{m+J7Dl9)Pq7$;K#Nh{81P^C(-6loh3U+(;rHJb$P4B{prd-x4Ie@BKoiQqNR*{lqkbjLhPf2d z%sS2LERo#1J{ZcKGMl%UoR1Cr(tB;Y?8f4A1|^uC~c@zy>bG zk?6dTfR}SqDFsP9jvVZ0m&uch6(>6Dv!~pyjXA+^kZizaH58Kib>ihBzO+?8H?*VW zTJdlAZ33eD+tQB$UV>r{;>b%-g!)&26#o}Zp*t({l6AVW<%NQY0c1lbCNF>iAnSjD zba2Z{ZsG+14zu|IfUKrB(tzG5@Fu=U0t6=0!5&ecqV}R9sX6j&H{?K?AUCP1|Jvfi zj4ah>aW6hl17il`0}CWzQJXTF3P98zjUY=|PFW6#6CQu!-%Ga~MLJ62=&YEm1c(oy z97z7d*D~Ga#iah!x?G4Q-{TDc^U zL*SsV4jPCGC`<#RzAWGdGs0-#L=j&k9IWvIGx#F$FF*w(!JtCVq-2pCmst+?(x(K5 zJBAE7i?n`3R(uYw6Vs1r1F#c0<59km%1rn_kC=npZ3%JSyJ2lw zlz&pdU*Cme|H+`9i2iYGx9|2d~%}`KL8JSY|w=x9fQcVmg$NH(B9_ z;m7e2vm-%-ON9}nUm#Y$pI1o8R7)|a5>~@41~Yg}Qc zTE$w;tp6&5R zQ9V&mQCyiV2r3WeF2jj!WSSSIHGxx@tWz)dq7|;cptwf0Rm5`z%`Q_K|mlY5NSg| zsuPmrFmHH)vZMBCT=K@8S%%y7Cm+QfB*pb3tuy6vsfi=`3@Hy|oQO*ZdV-h8|I{v# z6DRUvId@Af;(%YeZZGG$5dFn%k@`OHS>_IIS@3?OpX*dtD3ZWqL@)4$1Oe_MVXcIWRmR z|I5VR{maB18%F=w6E2(y76E1WKTeuIV(e=j+6K(u=c;*>f4Cha{Ihvj{=lC-*C z9L+QqumJxG@WIH zI?8WKyWt>JRRWF4B0Da%9B-|ym7IIP^rBuZc6}Tm1W1q%LBE}%bA1q%LBE}%b zAN$PILYBp8k@@JUY?*Z>6AeYNruP^e()n?(|MS_=WxS z883b7vTADb7^c^qc|f}xRU5KKEiO|M{jcY**|YYi)ArDhTgt~*&s|^GeY3qJ7uOSW zb*sHet7iJ}jj38eDihq!u7nQ}JsmYemOnmRGtRXp$8OJa=I&*F)0b&lp!%-*`+0uP zHGJj`)$j_+yLOf2_E~wJ>grnVdQtg;_FU5b(%xQBrl{-l-1(~HkK*=Q(zCdJURcJtuhVV!=e-><=TGO`pZutO%SvzE`B-q? zzN3x1+H_~xf7`iAqR+#AHt)B}&049&-A{$-bekl*`0sz!xm;D;{!6Oj&SZRS6h9-3 z>*+6kPB?$28Mha-pa1sTi@Ju+^WN+H(;4;czg`Q{OPVij|G4s9^KOoO>W=#_{;Qw) z^MBXoOOT()7N~EAoGkTziprPv{wnS5;kgIR$7=8CS@LWDmo6>sRk^=@FZTbR$mh?5 z?P+9w&vu+SZT~j6iv4uL2>niaN|GtH6HdB-f|=ad-2+{;+Hf`FLJ6dyIYm3`M`w{kuLVC@8-) zaISr9aMwHTcg?t--I;TJO>npU|IU2==RW!8<)2&BYq8?y7PZg(-_J@jx43m({eS1} zuCFmlk}vG`lBlq2Qq(naez^9yKDGE(@xy`k+2{J`mjW{LYAB2K z;?{EgnmypwMKe^`*RNBrtn{+m^8$-}ycV=pNmSgmyO(PfwC=Xkm-5Ds3@`L`*!^19 zsiuBu_JaX!_5Hs-mA7Yng6_OO@5Q^c#ocZ^x3ssrevdNVZ#6zQ-hXwzrgzSB{!FsCd=z%B zqRyEAtflR7zQ1_4J$JjG()Jg%-kq-Ndfz$UgLA&7aK1j;ea=hGsPWT1UH3nA-utm` zVZV#)7k@SHos>!D?V&rGEc*A)N4o!M{)S_9?3*4KuHWzd zxGMkmgyQmrt?{4A7d5vu*Wv$bPhoS6-yax{L*w@e1?6+E9?P3BB2IVa3;UWXU!$A- z+g*e7u3wMMyL>!Z=1aRD*NpSIc0un=-HU$XHJza)UyhW2hgH z+HLJOcl`6+^}QqK>xVn(xgWQ?%K5V>|NUOrbKSzOzw1X~YZa#A)+p&(J$5cM&$IZI z8>;Ki*@D(`zBl81p6I;2pyx~ltyK~gcb<}-%Qg2`yuBnU$=@0kcO9cP4p%Sqdtdvg zLo@Y@sXr8XeVrf0?W-si_O(;oJ`2;`?z1G<&{cyYPUYS18Y>_BFFj;YKfLLwmrbs& zw)w%L_doN2edU-MI{i)Tz1e4B`nhiCt?;y$%NwQN@BO&?h23sES1@F}?keo>VM@|- zuWomJ{@{FE-$_qlw|B{U&g@U`_`G}$c8DkBt**6HcfO7)$#r)9C}^#cs<2Mi*H-yo z%Q)qH9sO(FV|ihl;&kV8m%=*l$39&)&-cpXWQ@dmFTSvATY# z{-=MZP+VtG=gqI8It$CXW-9JW&imAD$)C<#(BG>z-ue62`<<@kD(Cx>&Y$1Kq@KqdoE0t0zQiC z{%={gpnogl{F-vU=kNUQ=&3E#ci;W)o~(O6&g+`HAGeo8g*|6EdoCp}P){uM`K$B$ znd|#<#hv$V~&r>aVgN*Y`eLvjwe{|901(mxbk( z_VjPfjemhYAto+zR%C3<(AdYqr-_^T`3~*xAM88K-`d#xzid?SI zCRBUDG3=8p$B>{ij@A{XI^NEUc6_vbmg82%5J#D`2*<615sqHX6vr(8VU9h|2RJ;N zt#SBFi*R&koas3INgv19Hers$ltf2RaJoZ%d5mMvvojrgDn>dgl#6#99v0)Uc+YXX zc73A5Z^4s}ec??U6TEyJYYqoGrY%_K`0`R~$C&kV9nVDdckq=7juEfyar7It+7UOP zo1?;o6^>?y5>EaYe%bM0&~``Jas3=S8>BfpU+m`a*_!HD)HBA>v`v!3=RiA$GX4|C zZ^6qQgL`zqMDwUP4b1N(Dg&yj$ zLy4ceL)qBBg<@Y5qO{(Ws4Q%|L)o-4QJK6iL|IYgppt8eR+755P<#hvE7kp5Cm7MsMO2qs`Wvu8K-C~DwqGJnXQdA3Nb7Ey>(vRNCtcRkNpF={F9bqv_@K*oP|T4}L+rE=nD zyV8DOwsJ8vTY0IYD20D`OuL`|Qv{ zsmrZO*7igteacGZW8o=7`}g(_9_Q;H8rnO^xK=}dzgFfWuvvD0?@e#I*@EZJJQ4op zA)90TFV?-xs?WLjt!>o&oqB`iPwTxlyyG4BWV*U3WQf)3o8GH)u+`R2@1-YoZe)9_ zf|sr5gcMu5TGhNCUj9$}PmzJ%_86{)crx1tw$A=rKrL&}qkFB##?`cS8`Q#@F}kLA z_P(F(TJ&XWui1yy3-cGKJ&&BT-`F$2nz*LE9yBu~@2z#my&t%}*xRdKBQ5gpxmx2@ z&uS|*e&Xq`-1PT8nqW)*KJ3KT`#PUU>6xrmz8R_gbIR*_&i1FR+w1PMZv7z9rq27^ zI%e2S>#6i}*4kw&=s$Zn)J{E?*oLn-Z9R0WzE67FmDN7XKUE2^_Hf3V+v{H8tS zqdHpDw}WkOC3f<0-&oGNx>>At@L!F)pX%hUS+8cRKE7FM%RL*sH;-1;kDvTZ-FkGi zwM=~vTVr*xc4O*pYs0NG^{Yy>w)m5Z+R_7O^wC{E*K;dBuHE{shwXCh*Q{U7s-!J! zwpBY>J6CTvs)^OJr>CulZL#)2=1O(vP>a^Xcc3=0kH2Vj%*tuL=C zs}8=}(st$SBicVKgS9cy9jyzaRqM3QKUwR&HOIEK!(6>xV7B$ZOM`6tS6NQHGVTS< z<8US0;T02o`mqdq-mytm&)+85Mt$z7o;*6y=KI=qt%KIh8hmEHt<90i-Vc6%!M3+X zrY#}hH*fW&iQX?a-(-8F#tYW?t9$K@W_0l$bmM|r_0~t~=kuy-x8iHr$KQH$r;_fr1S3@o5)Ko2F+-9xQ=5*VL1#Nxi z@@3isHb3iukU;BOZ_m5cH^@v#S*0%E6 z^4=A#>bD8DW6vD3*T1;Xc5v|(>(PKwy{gvl>g~T|zII~n->ef`9#LQG5~SVcS@y4o zz3$zkw?#XURom9e{)RPk?`8G!>W6Gi{mlyr~Abm-Tt9-!Il~i=u2v z9`m%QFIsC;m_09;+L;^YZBe~f<*vC_ORKkKz4q0aTyNjMbx|vv++lmER;ukm zkE8ZpO(*GIe8t{a~|tq4_$kZL=(G`~3da$De!5_E_VC*1v@X z*{(Nr*Oe*>whJA@eDvu0))$no>>YYHv8}A#K%4i^!5S}r$ojyOJ+$a{y{tjsRIwe5 zy{3Bg{mS~=lND^U>UZ*fxpp1fA!}W2#Fa2rUwmF2-l3Z9>BU{NQBQgKtiRRKc5#NT z<_vgV%h@$XZ!>S7*0N!w;`ZEJpA~CY``nJ0rpNwxS*?Cyi7oWSW_s)RtNO1AJ@u0< z*Z1yzq>R49)1iGmZlKSI@aNT#j9;}12cxyn4gu=p*ArFNxvE;br>FJTPg|(1x>Zzt zJgRH2Kf6)s(qxD=XR%#ba`Y+d$;@5WAG@8gH=XKfYxKyZyaub!S|`}vQ^&n*wH*)j z(jHuvtA73YF6(PgOwoGOYV!nyH!IqD+kM@M ztJl`pUzyEp*Y{NMd3DGZd(VAiUtAWl*mivMZq#O4Q=UG89K@*=*j|h-*GqH_Ju>;@L;wz zXnzAGc2cbO@+X;3pLJb(e>vf*7MHh7&9JAQsB+WN`~5Ead>(DwNFV=WAD<`6wb1ik zyJr8@x+b^5t*y2in?(N)GslqBmJwXxM=t7gl%HA)+pJVNVGecAu9X&(Mqy>A@1 zB9e&6$|$SsQPy)`_oJvZG>9bX8>ys>QfbJ@${yKC17#G-e(w7y$}U1uiWbr$l}h~l z0l)v?oa zZVi7&F7A=1(HVhgv#nn3dYuBCeu$s4jQwc2Ql$oGHbe)BDo`}HGd zqrfZLFd{_LZf=2=5_2YR*Ra_xWqHQZYm%{Z-HqP-lp@wk)&moriSFkG5VHs&D<^$If0lwtAR$;imt@Wl63D7nS%YC zZeIcVDboamahiMSbhPy@<}uDv-=a?xrq#Z;sdi{8{i9w zL_samMB%nB=iJ^N#J#YNcWPj!4=hD}cr!RH(q=SjgP>M34qkq3 ztiAKf6q*@l*qK{KWuz}6tu3jHT7HW8Ge#DjjS>dl3~}0bHik^X9LA$yF4L$M#T-~0 z%ekB;ip)GB7@mX0v^HH3eHQ3K^kom2D_*9{y>3z4`)l!~9ES9)e~Pxq3e(+uK>a=+ zKpJ=%{4T#pSi;*O;KLfu2C;LjS3ChUvt1sd8zNcf-_715g)6n@ey*75_MzoRk|-nn zp1A2-)K)Z}p`3pXv6jhR_<$CkXzP|l3t&uxxT`*rb`Os zMUz=pCCw1@;w&1SevTA%d7=DOI62f`j@mET(`}L|G|&7cfkI$aMlU7rIL6dg;$*d# zr!c8pqzqvL2U*pZVkjkx(Bdt^G~~#4I9F(ayQ(jc#M8^D)xbqo+A>8D{HMuG)tWL6 zS87OIuQL~p2mQC=(7|_C=p)JV=65S>U|*ga$_Pu~ zMA10(_d+3^ZuF$y>lTxOH_PE*WCGpeEXcX@uLd>my9+MaA?V7nx-3~-BR$`hoym%1y9^JxNNhUG|LGj2} zGn6zQZEnCvY)3iA(lzOc zTy1j8Ih&JJznTnu-UWytkoBPSw82_2qLU@Fzh(Q^v}G*H8Zd~I}Df=;crl38IoTcCJNGWr{A~bB8WL?b~-z)L6ob)2QTZFK8l})se_e zO_H3vEx@eyT1*w%!{|xQc_j9;lkA22jBfY}#)kJb6(%b1AZrxW%fBO9{kLgiY)k9-z?!wQ{#!fO9~1VzY3Bk9vp2%n+EccRQZP})8#C{C>b{*lZX+rX%QcN z@Mi~7s>~%Gg*Q<`tT*H8t;0E8oyqdon*)=wi$G;iiq`DA1Zt-*plYeTwbzCt$@~w! zQ2gyHakP4ixW2AJrt(54?do^)_wD0kN+TL1E;f_ZzrS%hlT%6MrY`cyBNI9PIS*&8 zKT@L~1)P?8SY7(UCrvc4OkRZKphJd5nj;y&Y(0HFFsPw5288bP0{JSO!`M!*PYHBobm$_OS zYnKJO<*Mj!i~B6Aj@4HEkVHGSbReg68*=hMJ;yV+mF1@`f_BOs#Si07)9H6MG}&V< zwGnzkRAttIE{#Ed1cO*hGeXF)T9LVOa~)b8rUmuGEgaK69^}%jOcshWoSn8_HMK$y z(D;Zdt1WpE+EEjZ*wwdCj;bqQ=AB&J!StU!z^#o@` zAe-i2zQ{2ZF=YNT{XmQl9YG_Vt*qbL(ddk&E%Vh)3*0kR9oaJ za+H*1-dl>-naBqbkx4N+kcY>_eO2w$N}8&)$Zzjx9%UT$Q#_a9NY zdK*3YwE)Vc%7}scIg*vV5<0WvsMq(Koc|o}lI@EoQSS{kl6OA><;IJUr6`>^e~U!N z=u+x?LLRQ_JtU%Q!fV1U3&|$u7}VYtfm3s?&UOR)x~1XUna^u~vW~aJ!J&nxm=U*W zklDzG`~35nNTst(YRM}q9krY88Cr&%u{M!^5{=G%jfA@)9_A}vvLU;2Gt6oG$T>gj z#6=F)Al^NXoRXbDcN(qHo25%o(U*SH7R4B{%eut;sm(6Vvy?le-K>;sRtjV7Ss{;1 zl*Nf%rBZE2wIh0)%b*TxWfa=rWZwN>BJ~!lV15)|VtN;(!49i%#;n7lF4!cPIl>)J zcJBzJH;bpJL2oH>iP%jZ#c!r7mc*lq&2F5^wU=27&H8Dqk1Aadw*cagji94O>7>zK zlr@NM(a#^m>EbPl@cw`my{9QfrRL>=hI>kl@4?Nq|AP(+(&iwy(d=?gWJm=j9l#v)|FYzigz|<7+ru>ecmvq~eu{p~`_8#kF9B~%)69)^ z{cydHJd<_pASdClL~UwvA;@Rjnuqugu(IpF);fG@ATeGF#P|D7v$;MO$$!sHQN=%Q z!n;J6qj9JR1XZVL|4zVDH(oL;3@%XgpCa9_FqipvW(5iATwgclAj)id--Vyoih+lL zJ>07h(D8qY>sar z=v@H`ab6Cp1@l-AZpn=2%g@X#a`615M0);T4BeTvhq?8%6>Z#fncmJ!AZi^Ms3QF+ z8XO`d%IFf#XqTt2RQpj`O(dzfd6wP@eoG9;&FLK(`FxG6RaZkIdsA4-Eob252p8PnH-=JPa??q-8)V(% zQ2J)GK2^ZHC|vnIl7G1qZF3T<+o}4DhNPL$B|dX#!D&7@`9MdVi~MS3dTt4sIqpoVvNn8Z&@Y68QO(LAS0 zV(#?DZ2JLq&Q{YEpwvFf5mL)wy&ByHdq)R|%VsVp*c(F)+h(1Bw+J1TzQuYiu?kzu z>(U=@WzaO2EcH1a!4VeaWkSa~NmbB#=IUT3xtIXpWxWr!$!6lxL6vrMuO>G zr(IBE^Nn0{6y>~swGLhSBnfnTHff#LX>P5wU?e*>~Zz zT%`KvyO882N8EFo7rhlHvDt4YkZ-mp3)pmv&E*Yi4X40^GLl;#{ z-$<`#G$8XU3z?h0B9OGqahNlIj2^5kfx8Y~_*L?z+CKggs8!0LafAnO(_{27Oq8@= zGC?td9uObdS`&~ogf3jxLY~K#fpc9C>8cQhMX^9HdS7OVv_=A%SrzAOx_SdStkCq-tx7Ul zcxgKtzPiF(@qH&z5Q`*l(!bNs{vvQ~Wd>7M@(b~v44`+CR@CL}cK{XcV%lT7nECl~ zFUcRE^eay~b<0@|^1>Iv&!q%L4~fvOt2?P)XBbH`xFj7d@}38;h9U@Zs9;&NoguzF z=_K~|bt1ht2Yv*sU(J^Tw`Zaex9=n%FvkF$xj8T5hPpJg-Fu2p4`^!=A zvJA#&tU8HE%r`PFw~{LEsXz;^Xwtn_=EUFqJlXo^IlZeghvpV*z|71B^zq7N)blI= zWxO7!wVsfr_fv19pRt>uYBUG|~t-5g5>T6mDVzY9&U`bgv|Z3&n9R$5s!g!u2- z(1lwZ@wVh)bbifoR{5@tb*sEJn4c9g)RwVfJfvsn+%r$9;FBmwIQ)vh9C2#&RD&*l z|DyKM*<~PBwFGdM0g)NaVzsyLsy*?7hw8EOh{E|&^5%FIns&d#itW6?d2`>Iby=*R zrOBK{X~w;^W+yDrTX{29-swPRkI^P9M}c#DeYiVyt(P9}#I+LEB_rpoRCU z=$pm*9QVJ$l=oLEh;Er-g(sVV{P}pMUhf@gYq>?adXnh8b%|u;7C#KwH6xk-Zr}^Q z45?hxYx4BiT=XJUruJuRJAx-QNP}6$x^g0)E`4J~hE11Hi&-apo|-|To@Bx9d0}M3 z1|wwbQA2k=4yHmaVzqCmG-LMo2HJm$&05CS$%%TA0>WH>$wC%C_;w!USXFL;d%OQ5 zQzjLhglHbd#xe;4l`2WWJ()VObsL~$n*q8av!J%J><}p{QDdF%K1L<`ec-K`C2PwH z0VvtA9(J_wFu{v`$jIKqup`F-ne=wD@(&Wu@`Ljr%k&~`NcHFFJB_o%4u_EuUkBC| zw>UUpxSkB`NTpL-_MsDR<Zub)k0(Xajep7`&cc#=g5C6oXtyLA0xwO zD>-p}4IG_!(&#GB_Su}9kVfz2tl-XEa`tf&hbzI7WisNy8Z#7N>3Uv=;=bn`-4@x}CObB)4p*vMUu*#cE`j-f6S zXuKCGMIWZ__aYFVaTo|T3V^RoG`)~D%qg`=CUQc%Y2BnXZ7545YxiG5fzw^!TE|70 z88ftYPa@5EcMbI_GOW8LG02CWt;tz2pV_G?$Vzs#r`eXvpnkPBhxa*#VP-$NQ6osI zUS}d+`6sn9>prp$O^*|=G(B>TyPkx&-XZ7K))DQg)y$B+6Rla=!MR!;gl0Snpyqo$ z<7F$(_#}=(_PPpsd*f0Xt2_d|o*UR|%ANELW6s#+8iTT@2obojgWi{4z+A~QXWDAG zu-23yiY^~O7qTo_rrlcfO6WRzTVyLSE?GyfiU!cSp#b`RsQ?cA90cVep=49hBDl6Y z8{LthoM%%j(DN5bwe5Vx^wyzbSfcuyx(mOrJK?jA3fNIpP>-0A5?h>Jy`JHCM%3wi z$iE-l{G1NwEvb8++RemYUdi_AiJ)1=4G9J}S{r%}X+NEd+8Xv4+ z$!bq3sLag>%G{(8*`j2-O)+_Oa*Av`kBFHnFJ8N(02&ixAYfh^((t%P6vrFT8C6Yl zjlo;+WlEZMI$lP4y^o1SZzzo)l&Ew2RRKBGm`Uo{f`<Z;2w!p}VeR4;ml zacs+F&K<9S>xxG7GFgVYy?GE% zG13`fh*zRAx#1nkx_wxRL|&O_!2 zQN~PA^iG(=}EF~5&&1eM?&;TE$G=fK(;(;Ls}^sG|C|talgL= z!6_N^#Bv;8zBTykeo2N{M`RAb6S{0x_lUQPImEgn5BCj;XS(~yxqOj>_>FH`%-+T;CtxG_I9072bZ{`@U`bJLK`l9foGblpGVRq&rxZ9;PyxIDfSx~+!6&E4GrX23J)%Sy9(WK*?}&U@=?~=cc{eoH*0!y2vl0~ z$jX)xB>bN#aJ^Z=i4rWYt?_wb7- zeT@b`6=kv={E?5a4jIq!X8L%wD35y#+IajD<9tF3a$0hr>#h=-e$dBx7uQIQ+1k{l zubEYMR1>YT3PhqIE6MQF8^kc+AE~NVWQCs8M%K00Iljk>$@o@nA`u$G$$w|XvGsX@ zB1iQ}NU0t}Wj3Jj?EeT$=^foA$pt1{Np#SgpvC9PQAmh1StziQIFIVn(1Hn)@K(2O zpZm_bVXi2A?zskY{=i=*&C`%(Ei>di5NV>n#LMYS|8F=jAdE6}ea)TAjX)+dnBh*j zi%u0jAnSI7qB9m#HI`;~sexrL-J`d-`s>L2y5m=lQ307G^0-c!GuBwdc^jz*b<-Sk zy*-EEfqe{N1g^1?q+Znum>Z&}%hg%VX3;GEFgMmh342uh>IRa35zf>pTh($d@1Pxb zWsp<)4dVND38Tuhgeo73q%F{dbYQvu8K~`gMILp{VXQmXkkh51v{~#pJn$I+sdgzkPj(SJ(^Q~_ z*<)y7h$JiQK_3ctNg{vmFJ(Qg7b4MbjOmR8Ua-B+Prc-Mm=CY_(Bk0%M)k`SQ~tXE z3Mw|#>5Q8*11p}>G8qYC&~^{zAC*HMO(F2@Ukv9MzXrV8wi?FcBbZI!%i!;~qeKfn zAg<@PIe%zAb##7B3Mwf)C{$(kx@Mw}B5B0mZwvhtH-x^|_nQ9T1`zsOg>+KQL0=}0 z^Y}|9QeT#hdNygXTJKJLl(J-`xm{aMLgCHs#4lJ8&+)^IW9juQBTW)Gt| zX;(A2aMEmB3qPFtyN%xSUxi-J{SQ`tiev%FQ6MvMPJKSU3qUp zH}AtkaA3zX{;$WV>A)%cRwf-XFN6?b(}zSs{4(5I-GUsOztFC-0doELN_6PgYZAM_ z5nDYBq{9MHl-}zm?c4mwzNJbm|H%llu-p>0PMsn=&(*+q&la+yV#%yn7m}&N?Z{q0 z2|YOdgOw55zgU0LvuBGuC@dQ-Y_hg30YX1d>RdmWuihSXLC7IJ>tC}i>1`N zn824Lblc)IsZx}oqop^HcittG(d5ADd&mb~-!Gxy#BvfiT!q$p^PtZ0FGTHG7U^3m zOE;WUB>MXXN#=_qY*8P3^nCmu^FXkb2_d8KMaqrQn0do`pOS`xi=^3Od}2&V&sHLq zmkb9EZ>9+rXJAE`G;G^<31q*mfh+@ka!T$jjhz1jKCDTnc%_Q@bR(&q*Km?{n6fxp zuKnyEc}E%5LQi@(p^%tbu;6+9IAY(?cY^_GBS%(=*69`v+Y*?n-oi@sfp~R>AM)?Wl&!7}kE<0Y&dFkj8)4q4xyF zkK#n>?i2i!^TZwQC==G_Bp1#ar)pG~e1l`f+^Dr22dbdH7#DTqqiRK7bpOFgsBFn( z+_e6{riV2Qdq)o0{KOiG?VjbdbvAR1m$9{=2#zp0vqgpD&T9VW5y|{|tl+D1}p;oJKZZGoX$K zF2T|#HKb!>AdPL>LXUYC6M=2&WS~cgW}O@(Uv|gQa%~aV5PAU>D|T}HE=j>>YgI;} zpS$kt{c%$DgbyFII>Fc%KP9*O2Wn=%{YJmIm*e7{Z=v`NV9WXy^vOz58m?o99=$n1 zl(HAHHncCI=O230`p9^y{p<~yPHy33=iCCJ3##N>%vzfL;VcdD{8o<*<@j*tJ54J1xVyCa) zTK7kG5Bo&fg1TY{p}OZ+Lzy9NVH)U=QP&i+zfLu`8|HfRQtL5YT$B3>FPY(GmTq&v zfA$y?&cP5k>SO>R+E(3P0}O1hneF8BedTz6h&4YA^3V9Jh*rizBc-S zS@9~==MhG)%~=jB$~36vu0=TiEDuxUmCV`xB$=6+W##1sHcV51I&BP#VFXPQiRb|< zvTNmeN@Pu#qY^La5-$a+s6NW_>fcr;Irb2_U!!Ehlag7NE~Z71ndpjD1^oPO34NG{ ziUg^^y^s_#u;D0r*ZGs2JM|rmBTGTr?L4c``7)Z@`W%t(t7=7V<&*6dd8kGN(SI*S zh()F|iTmPa$&ci&XOU$>{YxL7s7wA-T;;0=w0-MXZ=|AD+sNt#y z<3^8Dud_w;R&5omzqN}#T_0Whv`L+bDGf%8W}2zYhl40+nI!u8)EH*)I+(kgn{;^} zL4)PokpBAvG3?-HE}xr%b|XRDa?u=bj~9jSL%ek>9yHOz3q48h6PY^a872BF+J)&l zcYtIZxWLI?5=rAo4_%iP2g}4(vPQf@93S zUN{L&ZnYx=sb5KDjvs4twtC%t-$;6V)Ev|WFTis9-8l33oH~24qvU!14b;(ENh?*8 znAFz`nJ&=9mO2gayxV}83H*#qeb=%a_ovhS%14Q2PZe6PAWO^%KNL<4kwd!{GdZ(Q zfBv*SoqJZ56E6^k^75aP8o_jwv{s6GS(l)zljn))x_pj}?E;QlMmX#D0s&4|b}PxP zQ31aSSG3F{6bS}>VAffGCLZ@g!0=Hqs5A>(xPB?1To+uJhZYCu6ZJ&q<3KM{(`L^o zJ-30qej)Vh=8weq%v|_&&<~B=imE%XBc14%1k-MvZ)m$)rz^qF1tn;K=mJbdj5=8-AqXo?0VVtjPjOv=l4at+A_?qr_bA+%5`eE?oIpQimt(IMRTyRehL&J| zd|CcCtnvDQTpX0~ZneX(p|}ph?K;5s1TVg!8;_msov&2eY$~sSp7%nPGI2 zJqai5u7mrY{#o~$597M8U{tmOVvnDPPhs=%LdQf{Z2t(?ENq4zwHo~Pttp(+P6s|7 z0esER2tN&b1zTNGu+kJS+&>;2Pl#+Wuf-^PZ--u*E8BM{}hHG(hiZ-N2c2o7U! zz)v?5Eih_;fA^llm|ZW@-Lwu;j}F1!!#c3l)EIVG=c6S%TOpPw4=TJD!*td|q-)uV zf^A=-v^pC&MLLkVb|@^Yk%l|o>Zs)54rnsXfxJ=|u>Il$6L}_}(6kvx{F1@bcFo|m zQw#6tyb96HV<7II3^Df;;YVBnXn)OwY4=>%QRoUcm6w9rg*(8zt^;NmVZ89&A<(o~ z0H?;*z}I=2;6FbMv~^@ar8Ey#YU{wUZ3+3->A^Pdhrv&3+qW zxzeSW>+>q8*tr1j`h$g98tzp-|)! zJUFfjyGqpI$e#<)vN{{uPaFX43sGoS%v_MX{t<0q3c*=g5D$I5jn+Lf0^gxONGj1C ze=APFDH}fEzs?RgwU5Bdf>eAmO&-kSn?bZk0uQgW!5jZ6W2Wa57K*r!5B%oE1%g@l zNRBIZ4$;D4Ef;X6*hx4R@eIz<3TWonn%!AV{N5%AJX)>c_wVg+a_2$Ve9j2&2A4pn z;u>f>o(-8>qhOEFR`{wM2amrA!NB{=Q2NXsxaX~gug6MGv4H*It291V`wMF3RiIrf zy>Wr9CAzwE1;kG+gxw(vQCiUfcp@EzOAp=vuNDiia@YdxO^LvcPJ#39Hp3eKm9UlH z1^#^xf~`dopj+|{3ERkHfu38q`Oz)7c0?V!HJ3o2^hPjVeI2b5U4*OKwZLNU70{TY z1A88tL5jRS+kW^P2nnsnTLXP?ggHO--_C==_loR~5r5H{MFlYFWPr-rbaAF@4iqg_ z$1A3l>$yOak~Z&kxPsXDAj_QUkoW_YqV5It^_fqhoy5OBE( zs<_@mOvxl9;bI){>H|2p^`YM9x6nhQK2-Q$J31}<53KJ!gCNgzusjzFe^%K*SDG=D zuXzkRjc&k9#BG@DJp=aI&QPMh5l)P60pCe2c;&wm^-cxA;T zzZ>!=bRfg#BItPWV)|n_)P(gT)1qg{cnv@J8&o6q=Pw}eS_Ae@vcWja5X84zK%e0p zSh#EioppHx0%P)UXp#>n8~fpxW@V`J+;QylAs3r;DdJbU6^QSY7|h(73+J8@xUyaX z2E(#JJ9$3{sW@U~egiC;bpyG03c<*d&2ZhQ8Tpq*L1^oIaIL=w@sCu1domX;%$0<^ z-fhTNIs=kJ6Ht_mJ}fnx3;j~IP@%mUVgsj<#EM3c2M&1GsDeXgAnw9oS5B3Qw;5u+epvIR`{N@6s(8_xdG#r^*K;cnz!T(I;MTvmw&+dD3J zD%coq9#+L`{se%)uOs-+d`EDzYz6UBW8gBlhqGp+VP$zgoQ{^m!T|u~D!nl6D+I=B zAvn0>3X~n6?H_Vl5b)?OuHDEB0cj%eYr|FeSab{yeToH-ge1J_b~H?+is12BXS`V5 z5^wG+z#GFqfM2-+6wfj$!TBmAjPhXL%3Dw_b{pPm+2I}wJIL=<#c!3R;Mt$2$o9TA zq$l4*o-Sc%Qu{ni9611nkFDWqyFNU9cO2HLt;Bn4Ho>9%HMrYR4R4qJ4lQM~bGp6> zo+$9(Uu+A!CM*SReOZbXXESr`j|&`R12nUjVhv3lY(}`iA|Mb*au@V$48ikcv*1(v zZKOE=5gf^|1A$6W{KKvregC!-jZeM@8TJ)av7rg_irrvBH4&7H>d>-(yofJp7#67* zz+uOKXt*R6gdd-PLFP7EvR4CA(_X{hZeQSPivSlNOUSRd1YUt0^xH=q4(W*F$?Q|G zZ>=AUzB&bpQ!k*tW(ZtFFJqO1f%vPx3XV!}f~fzZVDHj6eAU$ulio?_zMz8DIY*&Q z%oA(O{Q==!L+~mx3F?glp)D>RoW*NEv3ow4=v)VS>@FP7ru-HZuXXY5Rk$kRsmlJrK~$cHmmJALf=AKoIwLFq1h7!N<8VkBS|{df!23 z_-;Z_dkHjLkichH!kFS!_{!83e7&XyOWgOwqxoCGGu|H7?yScw+4Z2m`w&R{0l4a9 zjQReZhbJ%d;EK-$h$w4AM?~I08h06V;;7)*gTW#Poh zRNjt$FA;-b>=3YI6p6GoJW|$Wd$)Jp$#Wslev*LF-M@k)>V|DC2lrPs?&(C*9D}c7UC${4MKwJVXAr~Jj%L&Z%W?=H-o=upXg3dC=Pb#5^EOX4<@=ed^^Sxf2zy4-9UV zqG?V(s&Em;Nj<@^`l&EJVOx!!d2fVpURAKX6NN-NSfJ5y2l)-g!mh|nJeunS8}cv0 z5YKg_ZoUGWEpoz}6>IVRgj|#wbPgq)jDpkJAvipgfzfnctn79G_AF}x2h$X27OH@{ z>z`pVX(8B3Yyej41Z0HC0AI~Fu=Qwy<4sqv&ap%ku}=&aKK=9Jo~siS`n)zBgyam;5nfq4Am z&~lHR(BHKR|5Y}HH^(LMy)7ni)pb7vF3g2CSxe|wxeLp6w&9*dcVU1zhU{l&E!|)M z5>wxiYT6fg{rW1Ze$xVOLUpjBy8s=qN;%P9HeF=k;29c0U z{-IJOQ}E90f_dKMU@_(bt3n2$^o9jI@V5Z#m(n;tE)DJ$DuP?AGw_OM!zLSoenlUI zv{-%Ua2`f8I}O3J{XKZ*@50+wzkrhQ$FR${6e}PxcH)V>0`$3v7HAGXNb1K!=r5LzID<0gc#+@opmDXND=Z$8*R)em0J4uSp9axWm-pIHE$j8#^=GSnms6d zwJL<1iNq-q&k*@&2P(5&S;q7x{2T5>5(xkjVF}34|0I@ty#VL`%|L%6S0R;e?I7f` zAH7uKf@Oy;g3vlH?9)1dB=^tyEYpTo{160lrLQPQ{4|{3BZQ9&uEZCwFJ>ni#$#5k zCM)*ez)TA!3G;Vo~;bA`W|rM&~8wj_1>KI|H1o|BABrk z!1{{!VEdoho+kYYYHQi>MN0~+Nrb?*w%PApumrz-_6zRbRKdUYcEY?97a(P&I~da- zD5|r9ug(gvkOeg{SjC_JAjX#Isu_UWl#|_g=%hi!QQXF zQ1QwH&*5H%)t>2rpCcC@%$!1}9`M7M^=^y!%b*1Y&GoF zzJ?4!WU!%;3+}s+4uhgR@LNh4^ooKY>q!<$SOYw&*=C2x~cok{#Iai;IW-aMp>u z23e4?Totxf_d^{wFBb9m0?9A);azVau%9HO+Sbprcj*cgEn5ihT}FZT_Z`&l(+bip zkD$#$7yr%ZLa%N%k8g5IFSREW+f0eodiBhxbTGeZQT6nCtR_J z2ffi=;61YnRNi$%(Tg*nVR#4fl~UlI^B&yPDh1yw{ovBb7N}h63AQhzpigNLj!Kz? zCD#!2#YbSfX?+l$a~ZB(`vPAZW58_s1sJ?+$5JM2aA}wecU}j>UFCWBjq+T4tfdme z=}T;(YfG_uY!r6iQGf-c#_+_^3OHWP!fqqQ@P5{7uiQI`Cq8E2rxSxX$V43<<0^;q z%m0J0gfFnTPY%!fcm|K{djK60JE8Sv%B(Bu;6}+lu&OGD7^(Fz5^IEihv$KrVJEof z^n!o?3hY?7701aqL$hZY4%M=RthdeZQ@8_m&7DU3UKq~$<7&vQ3qfONFXAW9yP?_o zCUCjQV`;UY=!MX0*j2g#H&$_j<#JuPG|q<`xb?AON-jQiode5fK0#caGFFM2L|R%0 zVJ7G;+$-Rqs%#7V-_kBfoO2yo3VTub#_h0MJsmd46~gZU1bPa!NNK$*r1qMCa-B2q z^FM>u$$03Nt^(E5R`96$IoL$i%;sVpj^ZTYqv;BmztIG9u~*>GXW~$@G8<>DiNTcz zY_MI3739uW$4v)HafJINh+2OOXz6)ure_2@68m855*IkI?JZ)>H-pzVRPnl7sh~RK z1l#_s21Q{LD3tk&qTJKLNq^SAb{3!=mbK8cUl#W54TZE-t0Br|K8&yG$J*l^&=Dwy zP4?V``}6FfT7e(l3#Wi^@l|*j<_MZ%EWGSq06Kd(0_Tpp;ne#3=*5mnl={#Fo;{m` zHXUdK>k?C-1v((LRv35wyb41f^` z{&Zg(&v<7-qGb{I*Z0DyRS)4*mItVHoyGcYQkdNw273gP!Ift*&RHojn@cIUKVgl- z^l!q$i8#2o{1a@ynG6c`H(|BFN!Y*31HOE+gtxbzpfj0~aN2qXegCryDuj+=K^rIh zMqdH)EqbxLP!fEbNdXmPfyIWJ;dxCD{Qffl9s1D_pDho@*9PH6pg&k0_JX?p*=~DF z0l)V7i#|TS2@(y~u>D9f1k5)CuhqgJy_g`4b(dk6A0Nc_eFp=P0!WU2jzqL0VBwx| zq`k2LoD_oKsnG-E$zBd~!e@Q0U^&RiDFB;m4#eo2L0{MmOx3J|kCi8}`?Ywu;l>NK zcV7Tb6kHs+&n!AeJ5%$&#OWxOy7Yf*mrTKmK>;48+1f{ z75qI@j=n#*1Ak{!;qSE#Fz2KaEQ|^Q7Rci3XFo90bo_2sp+X9QownMezr;>h)aQ|7!v4TILA?VI1@+Q5#H)6M^ef3%Xd= z1fok37v%p zywb)5+yivsz?;`l&}s|LzZb%E?ly?qpAHooYM{tN;jEwxocfmxcfX`U;YkkYv48GjoTgy;B7i{v32G+3`MZ9c(D{#QY(Vc z*>3+ocHTQEimiJaC5mJvO3t7lNmNjnz4puq2m*qL7{Cmoq96to1&NZgWKk56prS|) z(>=_9NDx6$QIaG@5k-=U>3V**-gB$o`=0N2zEgE?efO*T$FA<#-CfmtJ!`M^JgfKY zWQRQ9^l--_!OO9rLLx+Qi-CW0F-X>PLE4ZimS9Z)qw*v4EBAp+uQi;K`v%u}9;4Ev zFUU0WCt82KA5y~P!QNT`Rv)(nv?T?;+)Ra&_uU{T`WhZ8rURF)IKCCJ2&s`>FxQoa z6A!+C&r1P+aCreQCEL(BX~4a^Zs0Xh!*Gn|k2gIVhZlN>V1-H`Z2QcEcaGk}JI~l- zkGdKVRoV@9t9jw{tH)6MKnb6`>VUFss-VbL14pHKg5~~5sMr0?`4#LyY-tCc@N$Gj zg;!AVx0fhCF&mzi_`uQQTXEl^o!F>vG4+J8AJyvWF>1U50r6@}f$K^#-ro2KZylDT zsT|9O8}@QI*1j8r`CPHHf;RT}@(#LJEWuIX7Wj@2FMb>!3bwRrm{at@*kB_*G?NE& zr+M%P1AQ!A9|4s!gHXKXH}-30AAPK$+_t5p-vPv#0`E^D-b(;30iqz2ASshppT9+@P7Mmt^F|% zu7w#v@wo=%LPo-(dsiUFaS$abEr*A}miX8+EkL3VAkp_SptTF3^Ry~Hf7}H+SKk13 z;1>{}ya%C|Es*(|pL#OD3eHPs!JE|8c zY9cFs8f@C|gOa{~BMj}l2Q{1DL8V6w{w`t$$JL7vLo*mLm+gknxfif$lre61NI@Nv z<&fu~gF7cI;9%TF2i*pt(B0ieG*p;`@^eGJ&==-fcnfEU`hNH ze2g%MqA>txsjZ;9`_OMauZIn4qao+payYv)7xvWlfl2}&e($^sS~vJWo7pM!y5}?g zdc+h2>KZ9)=Evb+J|Jp>HXi*X0Xz3%usv1){)V$CtxO0?2UfuWM=^Z$R~ok8FaXAj zs^R$Icr)`4~kaN$nl+sGg%0}h%9 zf=j~>a37HXj}r|jOE4BaHZ*}VZ-oH=*2u0R@o=%p5#9}R!&A4T5Lfg8?Q=Q}POIpk z>3;#7S}?4#-Uj}+mVuIJI~qGF4Q4;TBfn#w;6EacvwVE8)9`wz;19-4VSPABTm!%M z=!9QZ4Y;@9G6)QR2fZcxU{?JhY;%}~jq{6O>i!iF^kN_zReqQfD?vwrpsSi{qzd-JdT-3(xB2fEu8Q7gUjq4AD!i1nH zHiK#?k$wu_4a-pbs^7Zz7ah9_B;t}pMD^4w#&LHNG3(tPEbjLSms~7>Hroym?dHMU zH{DXmZyNgxK!|{=^_X#<^Y8?-Qc~C2D5&4z|YzN>As%O zmXr#dhA*+}%~p^I$$?wntl=j2JqWtm2xc^Au(GrSCM>|8zAM0`@dI#IUJhqXb|Lo( zF6?9@4n8#vz>IgsoL26DE+nIrO;YeZZvxFfYl5p$63}?$4!o$3gOl!8;Y4UOQ25%R z@Z>>wopJ$COfOP=y8-&_M$o~_RFDpn!U2ZYfX65R93yK$SD^wpHKV||D-ML~rKl(6 znD{^)9Un6uLE`+b)TpggTsh2-r$*PpNSGXacHjo>UAZtiZjOVV(y)r7D;(O3sM&jp zz}|8_?r(Vq+j-xBOkFkb7z`k_p%IuZ62?R03eY|H5*+Ep(3cYdLQ_4^^=k_}@rVQ8 zGn{zbV=r|6i~~j92uM6{4QoF*f=IXo{`KMuDn2ibH#-SZpP$R7wtMWLMqMd}gsa!^ z@eSHkYhKC&DBX$kWd_0L z<6)cuepq;!94!03A9y`^!M4#F z?=<-ZH!JutS4A}5>b4kX9~nU<0q4QB>pa!tce!75aySN!RL>> z1(EA!SorA~EIIuMhIF-{e9a2Xx!D&sADf47yWHWy!$HU%7sQu71_F=&7_6$h4ce#e zAku*kZcchZ;>{cIVyXkCmvG`C4_8>VPY~4G51`&(+rfmfkt%TN5j>E+4qf+rz-ayn z-iqU4=!_svJhB8TLpNgg*>9*ecL-{~41&^l0rZ9G;8xFWq|}}TQYEQi9ohvh*|Tt* zIgPy+oq(>KeTepL9=Yu$pkKxe3I@WVJiZcXUiJsEHKE}4T@{aM|+|ReLrNkL;DC`YaB`148R?l$;f&->$&N?u+9KWjpa#BMPp6mWoAp%Hag{ zz4*Yv5b)Zoiu3)cI3-mBKg#C9-D@ehbwUZ>6r%xsx)OdOJ#aHca4BLpxCmtfr&cX| zS-%&Q4Z>koVmSy1SHXTqUNCn7CzkBTt#7v@mYaWk&k;R@FiO|W#|d!X+-fCGc4K>x=U z9DL&)#OgFa+l3;?7n}aAl~!Trv`n=9z$B^)*$7#lCD6Uki58T321}GEKxo?~tQxTs zw!_`*QT9z6ev1G7xTu<$WOOnYgLTYoVC-<iiQI?d^167y zeHFaZ;Ku092t3-|v}pUi}5S|DDil?E=$uEUxjU2u6| z0(^`hq$;e97wwLK&MIL%9y$eg$2cpf7ISaj3fkK3kQ2X&>Zzqc zRo@p3iW^L@Gy5#A`F0eGT^6QBcm(6~W#X7G-k2&C8BN`1y#+)JFsvENL7p2&pwJ=< z9J(B_TDA@>`B(?s-ua+;AP@Mg;$Wb>5*B!Uh2tGMFtI%pcrOP-Ncj|Md$koBnrb0# zgBN&Z1%c4!6EOFA5^3#a!0o59u=lYJm3G6Jdi%5k^zOM1(p-D7M5YQ(|Eh`23nO6S zTP+~2aj00Kfz@x$W2#;Tc-h~ES1V+2wTK#yzgGbR7N&T8vOLbycnyK26=u$96g0Zq4vun$g0@;U3X^IBC1eX@ zejDKWrUWehS&c^KanZ0bAB{e%N!`)c37oz<)bN@Zys$+D?_|7$$33o?zcmZzj_-n7 zJZ13l_72>pbO%RVyN~r8!+!I}O0dr3!rOj2< z(;q?J02P)Vs06!xO!y&o6{5cFg#N1(JbH6K4&vmXwl6otFK8^Am|Tq!?D;q`#fX~cSBBj82%#a44ZSl!EBu(xQXY()fNhV z&~1!Q*koePy%A7$u?Y_89)O6-80;TC3=wh|Pk1)rsR`gc9tGRzc`$fu5vV+S0&gZ)!H?TKP@zx^ z3%7}b?@|>k_)!Rg)|-K(s~VV|I}Fe0ZjffY1GbEgp=O3S%wD?+wm4ge5EMT{#9?Z5Yg0Qa?)mG*%l=~K8aWNAx&&q;~BhRqgnH^YX*G=$OxCL3g zgK+1=Zt9lI^N?xDfqlO!;{Crh@Qz_7G^I{JO|T+RC@zp^-vVD*4WJx$2nsg6g6lVp zaevSgFrwTA?E*G5EZYRyeDd(-SOu6y>B7n(dnj*vjusc?qvCf7uuE$lY(C-vl{PHk zy{?gd>DWiL@fez zb{fd$o!GoL8rr|lL9Ij)b#L`_B9+0d|<=R)LYo4yA_--sl#R7d%*ve zjrz-K0lI{sdngf(3iagzcvK?|PhR^8Mn2o1=*D4WGV2Xy`v2En z#(%M^F&761huW*VY^UZc|F)CwA6GPQP@t?d6#b{&Yq|cl3fE}IyZV3@8*JHSMa#E- zgrhz{vHM5L^C^|32N>;r443YR0RNCmP}>PKzniO!x0pVC?!x z@&qM8+WY{hZ*(G1p(OFn*@aT>T81dDc?8uYkO+wjMi~c8$g6}qv8ccn9s6jBAAK}r zR9Te~bcJC=J$Q{sPz}XBiopa~B90zExl4#)dGg;@H~(qZZkHzP_EQsExi#aIduF)h zV?P!-Jc{?P;K4o@Td^yBKXv;3daM}s4k8|SV7hKOh?nZ&rp=w;kRF1mzA{+U-x%{h zZiB%tS?uqihSg~KVE*9)lzJLsf!KW56Q&Q3!YuIlv*Yk&G8q5J7={bkOssXEpPJNO z4}5*spxm(q7CO|zEz2vQ%@GDCztW*SJQ*&y|3KeA`eW}`PN*#O2DX|op~efG#P>5p z&|$u3FuFAuhPFB3mW*f^F{t})4BI5rV24=*uKm6X`W?0L#CIv+3Xp=ZwHvYWmR^`s zbHGWLQ^Dg)CkSdO!K*q|{5QS7bn{i#l;per;Qb!@DxiOr@jv$dRh!Gnr_Ju*SlrmOoY z4r?aK6X}AabnbrSYm$igofC;d;ZnwC$#yPyn(Cp;(tw&QsG=uJFB&BPz$M8Gyl1l=}nMT^R!nPV9%N&RU#q^Il; zXw6A7JyC*C5v;6>Qf)@DMQUWe4jW}GUVs+qhM`|}YbdvzBf&D-fN;9tfXeJQkV)!0 zDABulNd1VjkYIibwU_&VbH<)Jl{ZvMUla?SoZCkEEAKB$vVkMRA>eesAB~xiNOevP zo{P*;-W)SSRMsB!ZLttmu%SWm`2}z@Qwf2V1In=z0prh)K<>61Wh}r6x#%9i{=d#b z<5E`$(tnPgehfec#x&%`vk`J-2(;Sc74qv!N7bLdA-cXA?AN%53MNL8LzpEvsCpo; zP1lhQJqeW&cIZQp2`YGFgsc;9qQ|=s#pk39TTiO}u){1B7Z+JiFZ!_}_hy0u8|Iy3A5eWau>pB00&I_F);%a>~*j%1M z+PyaQlk_4~x+a1-xrTykF3RFh7ZPy-TMGa1OM)-;U&!bPPcU=oAab$-ekb^ zYB{2k1`;gg#-L~4Z`A#=3`ERL zrl|8+0g;%XR6FtXIfdst53=rNF}UrtV6;_HfAgw8-}}?yXtd#O1=tmH zL1B754v64~xaXcY*p(Y!GzrFPhbOStl1Qi!%*Ph?Wgw(94A~;u*e*m7mcLkx%eLRb z^*vNv6L1ym&D>#$&SO-cw+zOojc~@Z-2gH*C}aLGoI~l5yPyn?<}{*hRUx1h{0xN8 ze1v;}Tj14e4UqCNhqb48@gchjaBJbm^5fD#ziat-GWH*15lY|pPf!cL-b3i9n4lif zrAVjQgXqlLTX*n6Nd2=2E9UY~H;C~ac4+x|EvD4KA+s%;>`;B(X7n_C1^LMF3Pr;% zo>{o47nGA!;p&}aa+&=+16R!KP zJt-c&95sVJpoDxCV&cLgqfMS+$g0Mo)Hn92Txc#=P$sfR8|dD0mln7>KMT=M|y zONik|?xNVMbRFDEWx>@bRiLTG!W45c*2r!`$6rdpM9WnuQc(ueoM?~<62(a`D{+PK zSvdST2V_oJgT0Ux29EF8&~qLxetZfC^SppsrH;LNxv75>wIw$W-~R`wsrM|W7nax2 z48Q5KBqH{*X}dY;;_H;xxfvd23G>F#56uPAmrXCGnb|R^rk4U)3E9omU$LqUC2q&* zfeT6Y?_}EGrk~Hc*sIKr=nSNJN)*wrr}DE8eF&mIJW9~}-|S=SWTnw;*QByOZzk#b z7CGeFOT%pL^;G(Vn<{N@Um<;(W=vmnL;0fK>Syzad14K z{jc2qCTg^fl!kwR+UN>iCi*zV`0>t;lHEB-amp-X)(5U;Z%WptEPbcXenVeEY*+q8 zBQyAz*HaV8d&7y8?GL6|kGe>fQ>z*+R6?BnSkRRW&dx^XOk(R4?%YA+e7}ez?s3%J zMwe;tKR#l4PN`DY7w58;a+k7Jy4l0e*PZ0bvA0aa9BUfC*9^;JAd;o^QjN{&^Nzad zb|!V(iF+*fPuE$`#QIr(h1%CZ3E1|d6_xBWrSP4yqHqbt;6JQ&Ewh^P$5DDa4gNS% zf|lcix1~^el@0&0sPt^We;repTG&@U34OI_!+USGP*y&x{_~zcn(-a&KhNjhEQBrT z4hH%C(*)^Jv~*)J+RdmE>U8CD zc0P{)E#&O|`W;bO`fJ`REZ6HdNUlqK^x$hn%rCu0biWax zm8x>kuMQ70pHxcHW6pf6=P>1?DX!1{OVs{5U;m$J>J`QR?LGgq?QfvQ^?MLg-2v=t zKT_ELcP>7@^(l&Wl>CqAasA5*JLC?~WLGBr2sI~p+Y){*I<$pJro0+{u?yTNH~8G3np0TJ<;8%lNw5dN?CA>HW<#QR_cu#0?s;iR~lPyT4np~csMz52t ze^D%lb*q1og5xs8r&BJ#g<_Cnm;j7jy$EkkOT&DNI`Ozy0w3HsfDY+DL0^Y^k!QsL zFh2MM1f)E`-Y*=yrJ`$muDzfHIwg}1p0ZGR{RShiVTuse{Xw}EI7Ngs<}t!g{Ej~x zzY}llE71yd8A31Z=wEX2m$FjS?4t?Tt#}z#tFj1RpL~WNHP>Kac0TTTIS0oNy}?n9 zMR@6+%TRw%AB!H=px$u_#BJkcp!+KTm)jo4eSR4*&V35bPUb=BR(%|c<#1N046eDe z4sUR~i5~VffxhJ$OzH4~CH=Cn_k0dywcUjw4=j0ul(-XYCO0XN^Pce0^+&?O!DK?A9O+5lM1I}Jfpyl8GR%lY z%!g;&8Jtd;qW7TCEN&IxFdy2jw!EM|~;@~x6fM*pjW`YTm zEffYbMv%0w`$V~A8g5ECM>q4xJz&-*Hbc2)B}Zr-ltuf_Co-xeH&Gs&T_N&MA0s~J z9br5_Df^dP+~$ZU_}h=cWmP5AB0GtAZ`gvfYZN7A0Uti#eh>AjbffCR9r%8K6}r)C z1D!7o(dTFKARyd}IthEqCh@wY%;q)y}`nG5dC_-L!tclo{VPzq$vF{H6`^b+utx zPs|36#nqZ8nwo-=SS|O#EwwGt46|>l8m6`(S!O>@rq}7_me<-AP--mXwM>%_1e%f3 zMP_9>19eC8ifRXCSJfHQ?9Du{RaG}}el|O`(Mb;6KYvF#?|z{{Ek5x7 zxd~5)-VDiinHYQ7Z$7xmoK0{tx-?9%-~Oo)htvNjq@f_>+dKCqSNg+SU+TAnMA7+ z=vY0*4k=QU!0C9(!5SXcPp#Ve)vsgFj13#g!|Pb?0;|!~tQuVw9P0aaL@kI6Rj#Q)lv~P1m1QgcW6h5H zU)E^W9%6oYCG$IuSD}#3!MbJDFVOq>#musyALxedL*jmv7P0?JI4<|ILXp%i=#O_a zWo(!QD3F1+^7UlnvJhCzxeg>hJtokbgXr|_9d)WX3(3P_`bh9in;BM*B7#EV2&Pye zqmnBKMJ6vNm%Q&leeQ9n0N0@Vr{C5c4tz_w)-8wBzgiPBlV)aXGujyeGfRl$B8Ye} z^O`iS*?{8BZWB^3t|9gUJrq@Sfyqbx#&{u+WcDn~)Qo*8l2WoOl)M}C4tl1#h*M_| z5g%l#NRGsus|a zy-x_SohMNbr#k6g8cFQ&VNOk_F|58iqYVe{uE6h5YddKN4|4vsRw zGj0O2r_~@oayi)?sZA+TJp!+ZB2r(^g(xsOO&BapBH`6I<;3btB6)D;-!smixMyDb z1U<3aVC$-Uq@z3!*EDh{Tpxde;O*5Ay^DcrvTjqTt&_04EgD!+W;pL_I@EZ)2f3a1 zk&x_L5ULqNf;auJ@YwD$Q`vT;>vJ7_ zV>hDrB|j)*JKQm6EC#zsZZJM!ifZJpBbQV<%w=Q%d*fr2Tpk9yuI0kx_73n%-jAFG zf+6GQd-UefJbJ!}3(hsgqq2Z3FladnKH_E&+hz>c8t}3a7GGN4sFIBL*HTlfrYSxrwD(2WP}bM z_r}kzDM1gV1zvo8K#3;9@Y(C9u(h=yzBd|!)~sts!Oarr#A;>WvC#!_VT>Neg`m%= z4e=Q7#J(g^NP8F9oy-iNDLU{{M{q|HX{`cu{w8VyJH-D< z&68WinNIuii}tdO z1u>H^MdRl?5}7-z_7Y~fENpr+6XnlDfxtdZ(o!)O0`^!CO(sJGb?_I84lS$mmB<2) zEkO*OH7iiNEk?fjErejHALSXhG~$evuU%DMLHT)Kl+nB~nBm21Y!yiQHyxPni-Y1wHU+6eEFBcUHcaw|Ga+6-nQOfNgYjpFD5#=$TJ|lU?xuztj7VT;c zARLsxF^m?A5YdwAlPl-VBQo?~^L{Po_;OgR+jE7Togr|!%_0p+>pk05Ce4UcdlsMu(isqRma&!mytW3XOg1NZkQD&4HC)1hY)i@9!3snq3JPhQ+eNG z#GcMXV%5`V%EeVwR4dv?(OJG8F0|zlCpKo06<-VBuXxt^Gal3)xr|ht1AWHBBcLGN2CO}!k7(Z`518b-T zc(T11J}rI#t5wf|gXU%EFq?< zKf0q}SJFOsJro2R<*Z>|Egi~wb0Ctl9=Ko6fPSeJ)w-eur``3(n`vvYTXh1Ke?Y}c z3}o=|hjjc(UlVs}sN(S+Nxc7Q1N=Iqhb_PJft6MuEZe3Cpqifn5z-dMst91F`$?jkb$uta?_E7UY#i_IQ+px3=2?d>+_>E>0 z-eQ*o_sa+HsYBVIIwMUjl|hhJD}&wWTrj)E4|e&t!p1MWVEMca+T;fi)@DQMAuTN3 zs(`OZIN~RS9&~@7Mnyj*z&f@Bd8FroTc|u<-~0_Wjx5F#r;33))gAg=!(mia8uTr+ z@WjUje`jUI9}%(f$s6`Ri2eK51>qNp>!~yIz4fh&9GQ>%YwE48yd!7WY0TF1t*lKI z#`V&hd0Fp_wo|L4ch}1u(7@wY@<{x=kc==qL{Q7#!_HeQd}56!bFx&Ov=3QCz7kzX zkL4MoTHQ@2pD5|EWnB7b@ngH-!aW-zy!R1lzc7c?jg+llu4zW*4yX`^hHWXmGh)zD z-UO-YkI4rocF=f^mQ%M3oBw4!>%_g!cyYA~)t?zn&GfRuf5cm{+`B-ui2PUV-MwAx zPpw__b1ChLb_=8qlz>H*Djag*{4b51zNHuZh2BA~_(FW9`2qgd{pU0S|J2Zd>0yu} zcN4k)sD?6Ceo&4Wz%@f*@ZpOg+84ovPb<%&MOLfu-1D7(K67wl>hG}Qg};aKGkX^+ zFU*LQMoVFRUFOBg(UWEE$z8?Du0PKbHL?4*eDKE;`d5Z1Tb~b5+!XFpd6WNa$jH`V@pRPa@Gwey#$^U99gO8 zif$93UXV)`Z3zSAQ97BGYEQ_@TA_$mFO;aw4-cehAY7)5-d(YQq$ zKJjsqpI;~-4bf4QP`e9h92AG8w@<>`(+Q|qFOj07vxl;&u@p6$8aFUTrUsDXu?4r<@^`h@@#mUyViv*+kCi>!T zVg|eK!^mwNU@rZ@+)fHGxw@SQ7UM4QslS#O{W=TxflAJcZ9>^YtBG7EOHirZ4jYp{ zF;=Fj68J&{ac0t;814=SyO>kt>6PEmNop_BUnoZ|uv`X5^lJ&O;%Z95a&E?>I}^mT zZ9GFqCI!}Rx(m&g!-U}_dB*p%?u4oQV#4A;8}gpWHA_7uLF#!dA=QSnP;in0!QYrd z9v^tXH1aNF7S2mB3rna-w(bo+_i+Li6QC){qCXUlwU;M2_MsIJI)jTmQDVQiz_^}fmcU>x_oK^ z=X!Rb)7h3GQb-D1AOL2PFfQDH zpx>8?76$ht;-xC+@X$f5vAIbC~@!j|!JufwXD#}{JB!|#A!fdU^nPJ>=a157_MgQug@=xE0+d`-9paz`(K zER6$a%1*;vX{HcWZ~JHbVt*nW9LdFa8wEZ!A3|e4#6ddc zHSQiX!-J<-aI7~R_0|NzQ6?Yo?b(mt7N0_#dzK<1cn}@Cvl-aV8*pW-Bi!#XLpSS` zVUC*@9JKYI!CMF?8FPYt!fmj#tB1MzdKBnf1k`pqa2k%IRbMKQmDLZ#pp-z~y=35T zzX3BBI6%B4AMHZfcxgfh{wi*Zci+{(GxCWzu~-G>RSI!_;ScDXP{O+hl5tvOCuGo8 z;FGh(uzN0?awJg;xNCMH2ksz9?0p3{JyO7O_ZG1K#DfDm_d(Z-@8EhN32wD&L5Wfq z%&Y4{&p;C>xJp6fnIY7D`!?7|K0?T31K6)thV#F^qKprc5I-3PAK6uCvD+}>`WA;Y z_N0RQ{UT~~rXBV0p(c2|jfQ{t7s3sQgLIKdobDJ2r9+0e(!d|qs;lFlJr^*?I}V(l zvK3Y&T0pAVX%OBV2cJ3mph0*X$0QfP^Wzba((??qh;ZPLX-;^dHWbIRdtAR>Y@P6o9~kG4#!AA>4M~ho5?%re0&y z@d?4*kaETlc5jP@ys1wRUfKyn>1nX;Dg(cat+4JF8(yXk!rFnWz%#^){nE!lz_%T_ z`AA@CmlV8KLLC#?L!c^_3ZTR5W0i$yHU zLBLKB^fx?*J$+s9mCFOB12bXVRUG)&+k*egL$JCa7yF8yplXV(#l*o-e7RT$pZgY! z{MPbg*+@}5zEv3SaC5=b7+>5w_W}gtPr&`LK+0$(2UYaPWz;K#An%s~ZkZ3j-2xNP z_0*AS6_)|ciWBJ5Vm(N1szkZ81wbYRLu!2kST~+QAMV+J-MdNfi;%|qcXq(to0j0Z ztPWY%4xkX3QpCT!7PVOz!HLgND0$;8yl4D1e*J_K2NpcU`AuB-Z0`ZQvhX`th8yFz z+Rp(kD+56(8Ei8;2o1_tp}Xc3Uj8r!WKCP(h>ZbGmtKTFB?rS@NgZr_;uwB`rcoT{ zFzgUj#QS*{!RwuCA@>wFNQmk~?iN>ge|k4;-O~@IGVSo>Od`mde+K1aSymz-=_$Rg8vtJJq8+vR=Q>Rd-cT5G-z`F`g8LN|{cQ8u+ za-MqqTroPHWCKH8r-<)KQ^@is9|#+fgw*E4B>koaK3CWe#wMI(Q%VS#x=Wc9JE%z} z+`f#X57fb{$v$MXNdm^X4uVf*3aK(%MRL{UG9vY);BImXp@%<{(DNL=x(lG)5ut<% zt(`)4))0FIo5*?pS&}%Z`Ij0)#B2@H9w#oGV#kTFGA>`G@|QoR*{yrU>JjH>eUzx9*?oOb&um-DcK%_pF49Mw{`Bgm zbv3V3XseQi*p8R-*j$u@w1)9ix}D}P_OF*j{gvm-S>8v4=#n05=!~==`hz#XmfYyb z{-vWzU;FS7d+e1wt8sT2d$$=Ey>?L$OWnqj{=acm@Q<;4qb>g@dR}`xgxOHG9vew? zP}c_tGcSlXqc6H~Xhcnx^|K(2>ertP<(EsCwg;!lS?(P2sv<8KF|G0LJ2fPKjV>e> zZ-Y8bibn-R-Ls5w~g~iU_c42;-6s%7A=KCk{nRw zY5~a$l%V1I01?l*4)|Bh5&cPOgq7$8!tdP?!Xv+u2)3E}_y6Dg`QFsW$KaHF6kOGL zP7Uo%0^fU@sCegXh&il7-FbZ(wOU*Y)<25`?(X&2Bjhf6ykP{S=47#j01Z3qR)D`h z9k1Ee1#%ogV9p_e4VwV7MPgCG6Dzo>6$hKSsMzA_2WU^2LTdXXAY;)!oI&$|Yx0-D z{LoW4wv`P_PltkTT{tW@-v{T{tpQ%V0k)(ag&s=n-;I#xH}?IL?2unHR#wmNh`Cj-l7KmzkxBV@R-oPRhoF*T*L*)q5CShk&Mal$*3bv%*WS z@R_3|7?zq5h8a5u=3!$(k=ajtxoA#!%$$OWxVy-?H0-KZ`q?4MBmYZ^cF0U@KI;%={wLPc!7xw-83d29& zJ2~?bGKgM>6*akW%B@Zmd;Ts;nz(>#twNA@a~aw+=ZBwun!v5vp?G9d2hy&8NXZI$ zg2!%Afcr~1I&7JY$PQsBys`;c*E!%;c@jKJN<=0-x^RlV9-eztz!QlKI2u0&S{4jA z)};&Ld*$IGV=Z**T!KeqSD?K&2a=A?!RrTdpxSi--oN{;rbQhEd1XI{vhdCfDOA4MTVlG%4M`pPEAna0niM5aR zV!@s9pvW+W2t}E{htlO;Wjh=t9N0tj47=9b>66vPte;FYq zR_(&RdtLDX;e4=8`ig5CImH0pG!9 zuMBR!$%_lLt#QJ0M@Wr&f!QBs@PC3s03i@uieG#2@Jj?KvJ`#bXAH?sVYdS^&%K!Xa`_?C;j87Z%`u!bV(l zILQYmT5SdR>&up4NWp$bg|}eR)d=|yCXB6b>es?Pgtw0rwm^!;h_?5eF#Xx8T|Sydxi?0rYX*}-&C)+d27wz0z*wus@` z|8_0d|Ele8peFcx5Vsi&cG;0TtYu>B*hBas>tmf0JHJ<%CGswkbDROtNQW{G{k=pOyX~SW}Y49_w{U z^S3harKg<8aTO&yjq({Ua-I=Tr6C0t{M=P`$Zon1|$a>Pwqln8IGy!OJF_IhuQ$T-|sDY<4{((dQHR^iflW>e6YH z(yCTlQeliruj`R#vip(64RKg8%gYqcksz}Ite_#-i`R_OH_{u3fOb97*wP02+4GbRzhq*8%qk+zy$W&Kdn3l@FAO%%9tv2#CdO+zkWpJ8 zO88VvZBWi*9{ncAyld4}FT45%h&ojX-PY9OF~O;0m8_-T?- z{DI^TT`gqcZ$K{ViveQzG$FqF1Cf)TLUaYhQ#4g1P{I=Px+uTZXh)u{Nj? zyB66KN#E;;s8_R$Vv|Y&=jxL0O)4mYrisL9o8?5V-V9^%qBG%?Udv$QA4j5lPk^`U zH|lfFY}j`-6v6Wqqm-o6fke(GQKDsTH8iUJA{(e52-#`|B~(rg<))gEt8_JB zXL})~v+X70u+V|Juv_bhvBlR>jlL=(bDk1&flZX{Q$nN-Go3h=lYkbEzNM(RNYaKs z^VP4?cuLqv4KX2O5~RF1>n|EyWS-imO6~0$WeQGGfRHF56(v2ea{B@@7?EF)_sY>1yI`tq3riHPO0X-UOF33!S}Fg%o7I)QB(6qIe#B03X}iN&0myis1?g zBC`7oJoMur{bOT@v&XoQ`OharVu~H|Xs{*k$*&}4En*m|=2=krt_ocZ&c@?q9pv2% zPgEx3Nj^H>1VQ;FOfK8|6n&HnPVHK#^UMfCgJTn{wEw{nVvQ1&HwuWS_FKuJ&wCmC zEqg&}jUC{l{fMi|8oFovz(n;aF?=Tr^~*mb^7qLwCXIuUyigB=V=4~Ca7UsGx7dW} z*UQAvXc1vIGeucBc8*M~@22D(7^3hV&8Db4yG^j&Oi*`%8Eh7ef`xjyRF)+KkDK?hHP)_q+jpI;a`Uadmv%rfzK@-xz&`@kpgJkO;|cQr^_md+E<$b? zmLqn#5)`ft%H+~D3Z#$S7UBfMfbB_K5CudGC@LA0D4+s@1Q8TLKtw=#tMY?8$TNv6jQ2wIqXc@tG2QSMg(l$1fo-PPIZR z(Paf+s-SS20=W>^1Gf7mX!EcE`=lAY`z-IY^s=ATd#P&4)R3 zu-Y*q^DKsGzpp`}&KD7`lQ_30HIWEJh&$}PS;3sv(_&b=4EFNPWpL?1K09nEK}#o` zhcWeQaINxs<_=W~6Fyqv0y|rF+ne1?1s#OfxGUKgohk59zn7WTZO@(UD1#NB{mF7C zC73ed7I|~~H8fdn1pzv2dblwA{-z{T)mB1R ztv6vW&r)KXjvXe)(*|jYy=rvdDsf66?;5kmdo>fIJ(p*>`5sy3CC2Xf^^{&QRvy+= z2~d1k$;NbDm~cULh_l|ktlIM0bLKyrS=V-z-=Sn z$h@u%$jcE&|1ZzLkITTU#2N_w&D6--T^(?zbPV^RfZVtdGKz ztj(afXA|~+-AJic(Kxy+mzwpc6^4$QQR0(=aLlqV7?CbdIeoXrs3*cy`Rb406=Z`# zZGm`BAP4S5QMnS+?WRP4xdR$?^7^aV*`#K ztl`3?Lm(a<3Biw#KuGFs(Cv4Hfv`-NoEr+&!l@wZyAjxWWq7Sv4)j+ae6A!$Wk<$h zM{qm_bjN|=1xHGz;xXi`+KLZ{pW`Cqk7##H9_gdApr^PG($5+oYqc33xvs+EoOR%J z(G_369EYWf3qfGJBP@}qhUVZYcxT>IoNFeCuPQf!eaIHjeNzBR4yL#>qmr16e?hc6 zvcXO6G>j}yhmvhP5c~cWGDGwsCo2dv!j40@#uV70a1>Nk&f(A&56UCehr0c07S7MQ z3!gsSgPMRQEKUqZ;g{;TM!gIoEOx?~QxdqKMIQaXXQJK3JlJn?5mFLQf#rziZyCf< zE#VoghIaVb>JB#a2ACI=O%h(W5|4l!G+%HBwG*~NQq6bL!5M`xxg^k-zXIsrHlX!f zA&{O8khA(a5Mcx88hk+pF9)JtK@&XmYKN6h>QqU_b>#f)hM8N<(Io#lhFo+)*<^F{ zwb8-I!_Oi4*)^!E6GGLb?WiT10--N2LG8FmJm;c`QT^pmop2w-Fd02ZqtP}k8ukfS z!`>&__-U#xw9mT%b7Yla{^1xf&+&k!J7zH6tq$Ves>7-^E|}&y25*nZVd=`NL@6{M zD4zhhpP~m}`<}p>G18=bmIFGc8&fwAZ6^`pX&AOKhNP}ti-DzqB$*0%$VYNljWTVFMB%U)M1eavqwN5CuJXtI0vfpS(pZr83dhntQMh}t8a7@o z!2ASjY}zM*>lZuWP_YHZkKBX@B8p(>C5*p3Js@+pFqUk&0C_8f@wxm_a3r?iuy6uC zsh5EVPlTb!x)2n{g+R|5E-^{o27O5Z_))C_xW{BcXnF~})!YLU4b8FP(`?LaTuLo& zJ%EjBo2Y?` z{RPUlAA+*pSZWtv%V9>yZ2Y`+6LI`734gRrgn|4Gz%#x_!gdUi@}6>3GH*o%SqFG0 zoCKxUZo#d%d!%W|30CMd!_BMv$+q=x$R?@7MDlzjeHuTGilWOz z2~>4j1&+UM;Sv{wTS<~Et|K& zQI}9i+X^Uh{}5C?X+b61^C5mRKE4-5I_!Ok z1GSzJxRb`1Y|a3=%XjJdf@!pU{A4o6%bD?(1xBX(EHlG%HuvZ!ZDw_ZB2f&uO;(zD z5ZC_AkUJjfYSaer>>nKc_c3%=i#8ne*~dt)t)u7dlLt|M9Wp$#k85<|Gb7#R${V@u zjrnkc^|yY*eV3}wlbpAb{WMb@ZV6Nq-TsH5Q~Zohd~}1kKQ#e|P18Be1HY}1l_~Du zl0%o)jgYe1T9VykN-zI>jJU3R$>}hxVP36IwhIdvh1piOxI|r(YdANNh=+}VF`|>W zq0?$fY|0_-lrh(cKzTlA%)CgF&3nZC!Q3NDhu3qiNLP^Jc?ERyTp>Dv9^$n)JjT76jKtqXKHWs9!=jJkz{o2f7 zr%-ar+?Tm`<>L9bp*x_^$CO^L^^+N2A;HdmFq_?JrNc;!NhRu>Jm| zi#XzeQ_|QzyA~#P4uHYaaQHSl1>{E_f!#?0pKIGdSmQNBeI5(KE7pS9nO3-IVhk~k zO1R%b6^ef2Tvd?WUS20tf2VOu>}>O2-i{BkMRw`M46@E-lgo8XrIyVw!chSq8AL7m8N=6A{re6M-IuR|MgUx5&oejbJQMpdx>@_E>x z(*)ah$YG?AIEd^q`lk`d)n*R=Nsp1Sv2uL<6j$ECNHYi1gC`yAs}52duAXB*eAz>_ ztna{I^YvS#4UY4ZghKdQGZP%v+{?s|(mnjlg&ORX>EoICatHW>=cI9w&QyL%rw%p* zB(uTwNAZd3FG|R<0yP~TdHE-Y-_~Ic{KE+3AF}wm zt@}?hYm0|wIJTUf?^yX#yk$6*<(J=F&cC3#w8eEGy=CjcVg8(nKlm$GR65j~1USlD zY;;g4$ai>J`pltT-wDp{NV?-V2Wl!h#i@tX-DL&y?=3~$*yW%l; zNcQr>&8%8JHco75e{jTMd=`(tY^;FO{>kz!ZV6dVZ-aGPv#V8Fvr0Hlr!Shf_{~*s zEmoS=8V*NWJh!T}&;=PSaqe$g@~#Lv?v^Kxw$JF6B0;wnZC5XTgDtj%gl}~S^G|g+ zHr=|#W`l&2|I9}%6Ap+tZ8djt>>Y`8{24p&|4BuXV71tv*9TA6{a@!gq#gh3UgGwi z|6@F10!{tbeefQM|6{!JqVGRNP2{%_26KfjxO=#_*jV|tzJGV)Z`SgU8Dy*@iF;5_ zgvU4%AC>rj>2Hbr*Q#(nPi}osIl>4!L{lb(YB;QF&sI&g?$M5a_$SdaPxll#ST+uEkBxm^m!bv zHS%F2-5-<9H)81~xfoitM~8VnC6+BUIt`^g=a@y(r^yUG8OT_*4Z@1j80CB&u8u~u z{l<%1SxcAwWcrG6?7UyrJe87@@cHBvk|&VF)k`j>H}-b112!dSw5uC3tLKBVR|-FK z`WQ-nwK-$5a2L#7AkXl4Ghh$Dg9%t%442+>h(3gnfoF>ut2I_+;Ne+XfAAIw-BV`k zVQI}xT&qr;7glk0E<44%5gFo^15zXT6+;(L?mX02}f^uZ=A6JIS5ft<0J9 zEs4o(xu!ZGb$B2T)=l7FKl6>m27F*oF;G10QI#iD(u z!>Hse(lsi|xC|X-ln0*C5}u~)HQq61yQVg3l~1H!*A_4#g+)wryCwb9sgr@8^~{Uo z9whaw3M6|^Ci(0Ujwlgliv#a6W!zFm%21b{yMHXazQ~1ZS4QZZ!BX0&xrXeXl+4zT z9L6>0g<)sI$Ch8}518kbhaK`Jp2cIW0$@8T#ed}-L9YKc$N8>Q=O%Y|DZbR_{ z_|z=#Sj;FnN!H#oLg9#&;K;@#qtE4o`CpD%7Svg)dRyy`|$9ac-+&v3MXGV zil2tf5&OlEV`vUpK2rftZzY9^zo2+g7EImt2~NfBhmCRVRKhng{MFq-Zn7zGb@E4Y za9=H%$h!?WIfW2*W)Z~CRfB?VHRS!~0@KoR$cjimi0li2uB+W-{x}EFboK}T^&%@Vqk0I5?Iyv0MDvl2LZ=w z;GC4D954ssB!}Ug^F>fFnhXQtUqD$m51xCL13$37o1n&B6wFS%Ko1=s448Bjj~HHXV2ZR+?ezufasEuoD&znx z{;Wpbne`sTcF9pQRCTbkRf}qVJD)0ZeTQR@IwG+tgS-BpAhrKKyb3-6t3yPoNwhf6Ur>JL6_6XI6(BNw)QO;deE6V-@XX5 z@42H}rwDar+cY%M*@Ai96HzQo9Jj_i`X{XSkDWzxg2aDv66}M}K`a|`z_xZpo_28! zPti(*ignxC0Z0zcm-1y*`=a(On#nXTr2+mS_#R`2K7RPN{OT5Q!c zf72xULWNq+*vQjdOE^XMj}a!cOCxnlqq+jmVDyLS!zDaa`;LbLkhrDzmI6`tpLvt$Nwp6>EoXKC#XdU0lzohnRybnk)L@y zggRuU!wZ;tjonbM$cTK>qGnM?r~s)9vN&WV>N#1kn~Wwx;`^^KtxS|z@OeGE;p}s^ zV_OgX*rS7Gf(--!LT6Sr^i&@*W~#Wr_??&@l+4X{O_+5X^`@&tN}^I@RY9Vcw7#LB0w zVBtPWR(y=a!L0Fwt8fT4g0wJ&a)D=7TDaUqA6Lf_=r)qZ!kb(;*vwk-=5^Pjs!V@*s0j`3zdD zokoQo9k3%VVw7o&2<$B1$tG0G@FUo%yeY>hepmGe=6r1ywRz=1j^eZ=Sp2+;$#YF? ze(9>mK0R{>cEp`w=Q~r(GleK(`|COKO&EGt=47gM3z&QR2Shwv10 z$$DlA=ZSv}J;7ifjhP=vQeg>mrmvHnHrUNh^u5D1E6AsHKJWjx7P34;6ZHf;GN|0;ikr!qf zc=pKx%p47+dQNH~e*Fa|Ry{aTMIB_KgsA*d3kVx3fXAn%;^mw~NG>yh{)mlG`fLLx zr{=@(lYEdJo)7k6dl8R)CAa7(YJx!{q>lzm)^gdSL&(96t1|>j`w)NcGxGNY<)YsW>kuI@0}hq;hHiOj#mJi zRxPqgfdT5G9rKl2flfik=~w2pT>G)kjI89$#>`M4p9`!R+lE9&^>q%wF!rE~($U{a6UHSwUC+@}ZymmD;cSvL8x)MR0Nd z8SVgBbEhVo(vt_*FuJd$nCRkTY{Zi>xWm+v?Fl`@3KI{up{t77{WF;bK@rSZc#-S! z{VY3_W#7CdE(#<{^x=rzar$+(1ao*;l3|TT=-V#J^xM-?uw>%`B5tqA39AjD{fAq) zLQ-dFo!!bru-2UJ$U94XI^^x=H06`l+ae?>GMR+8ba1UgL}`yP>QFm*F0E{w%I*6Y z!4;NB|9cmRzt*+pL^e*Z(jx;YV<@A=bvXFpDy#}ShqvV?;=@IYpzhXXf@%ke`I|D5 zl34(oUaUl=orYMwG#1{qjRT3AT6iv^3zU~WOpG+d`f)~(e_k3ooZT?*OgXtK{*CYk zcLKdEm>78KLqvNMoDp${#z+a=v}_nMwlzZNg9bR1Vh+ijy-?ma9^WUNC7GK0A%5am zXi5y@xsG6PE&E=5P9d5~{*5`BGVV8&!E5OK``4bl0yT>m}Mq<5kH z+~e@gAr#(J2*ZmO7V=s;$XUDBfD zcK=_z{f2W6kWCM){`;P}o3x{O<|!-L(x2}9DSk82iF<(OGdG1i3+rb_mR)BD?_2}@ z%~`Ckv^}0%6>Tr+d5QR5QG}+F7`xCPav(7|1jW8-b0SB)uu;y8+g7FwifbQowm)19 z^FzNd*EO2SudfN@{(2jbZsjrALMO0zP6)H@MG)9@m@phqU9SJZdF1nqcbpxcKQcZy zrZB}zM>vK7%Q;!^SJICkO0d+Y3R@P3NpTc#?PwW-!hyLr)y? zhE6p<+OYl?IdAR;HG#fF%tVZy!A5dA3d-qm{uWSuHl9wJDhj44VYHH15jSIh0bPA- z)Naa~@$e;g<=-o{{#sY>L~ANCry9b2BdKx2N5HDH5p!0a;-u@mgK?iMa1*x=;#QYo zO;8u8iu==4Up;nF~CeIRyp7J3`+k2jF~9_5Dzb%!!g`iPQJ98K^`p&`>aAxzfux)!n(;M zlO%XDy@4F46(dvStvMNMr@)!j8%Uw^RrqfDmV}7kg}{~xpmL{*_+IEBsiccMJ~9{V zXGq|K+Pm;5{tkH>9Yz+<+zVpXCt%wgcQT8z0;5IFFh=quxia*YDB}S#y~-1epKtpo zw*QYT+Ps!}{;_rK{DUjr{)XlK6Tsyyi>ByAN~L3dnHo?!L?oTd{&1 z{?N-g6ryKe@HUpd=sN{;T>8kuqv5n!$8NIlU=#hot)C$c2EgkK#`ooc%s_-G@8p%4 zys#}r*tZ~sSCF-bjct9z3}5Z%2F`qqQ|3uQ(P@3GuXP3?7Z)Z%Hju2HD#dg=-em5F zHqZ;OjK22UBT$~#NsElTN7tzCBUkP?GYjpTxrT$e9GTwv&>UMxzuuciW{RqlBc^p+ zNf&Xh+qzW@SJHz#8_=cgALxN>UphVOh3MZo;$OHcT$%*Vf|($#@sxP`_JCE;I~ZJ- zfbmsx>u z+AAXRtAW(cyaK5YWpLH_ETa1U9vE+qgw=JIvAe_*y99zU*vtj0T@)$(y+6Tvo-YiU z1f%?#4j6mh2{Hp^kvtIu-lQ13T4oHpQqO^JX8;^~V+0KXuff1@7u@)84>X$Bg4bz; z6!A|m=G13MO;SXM$%(KtGaA_W)ubeJHFWA70jW#{Smsm#^_ejfr5L zSr45-1@O+C;Hej5ap{WbkkMiRr-JM8$MRm>YX1Pem)(bzqld9d%LaGkT4LC+E~J}m zz^k4Yp>)0$My7osFQN}Zk?!J;(MQmV!%>#!RoN0qpOQ0rPob6Ltn`Y$s`%S0b5~;Ce;T5-PjR>ws%6~cvaiX> zSJtF?<9@pGix#Y+gqf{pxZH;+&5+}##+mDyPB$6Z&^g;*b66P}rttC*C);c;v-t55 z&Z2G~Vy>0Rxmi0*Ye+_uZ#$lmr{XK%202J}M=mDc_qQ>2(|V!VY!2&Qv=IUsePFX0 zu6+7USUOzGX*6ynxl9}s-yULqhRf22RIiXb0^*GOvF)5e2^Y@Q^=|e9Q z&YzIfpd<4=$ovX@x@u(NzwJAJX5|o^U&crAMlNomRA8Ng0M1(0NnY73A-tt^WW@%= z@ZB7kp6iBTLeEKX0~fM?$iTja0eH&I1GSNr5F8i}!pSFyk5oKJj!A~k&ibIyF9bcA zy-?k{4je)&z*kokJu?T%=Jn@5YAgX^e@~bhRS8uC8z5tm815543S+&rq1oRS4rE<} zzUM~JW;-3kziU9qsX4ejrifGv%*Ff8W2mY{>(E*K5M189fm%Fg9$a57MXfC|gR}ZO z!NcDR&(9S?!Ps2RY+f@6X7oV)h3lN$)5)N4UmT6s=pldICWxr3f%}`IAS`1VG;Zo6 z(%O4*?U5tI)lUJH#%_fCdxyb%a4IOZpC^Ti4Ke^fe&3^qcYi6G-@jT>5 z6f4w-+7CZ6qtEYO+A`7qTAk0nDpc-04r`iS$S$jOWO9xj0L{82=A_d!QtEBR^5(X1 zHcWaPd;_4{s(Y}x3iCCm5$K3oZ3GbIA{d+dkQCg<7#ewf+>!ntD`9Kf7cU%$)Uo(f8 zg?kb0U|V|p@?>tq=yvA3NC@2eE)KiS)Y6~6Cox(z>)<8FfFW`->7xE-^5WDmU7Z(8 zzE92LE)sSC*)JygA?F0lM)q)fSn&k znuVkand(9dI63_*c{IU}WavpS=8ke)^KDVg%dAAWtC-Zh`_y6bZd@Tq+^jRRX(|C0YNnD!M~>Iz4!}n|rCZiZ%$jNLF2b zOiUi1Ceead=n&VRl)}CfOuV@P`=cy@@#dZa8QEg?maY;UzsG~DH&2+i%ZK2Oz(H^= znniY&Q;dGl31U#!Myli7z-PgE+ONYLM#mS?LN`Jfnc0n`+|(a3V^YYJgJlpA-$9RL z>vFd_zTym45yn3s^=If=X)3gV#xHvQ#fPl+1TS_m#B;MBC2&?HrNX;ayCE@C4z1}D z&gatHHHg7GR;WLIPXk^fI^d-iX z9HQy^nY6_PKO*;xOMl9{Kwb*prQ^n`k^Sac%>5EEa$lU{j+n-C-U;lbzdV^k9ie%!Orn{xOgZB z!i#Od_OKZi1~X*Wn0XK!5CQq&Ibe-r!1L|{nAScG+IHH3p_?>#4U|ENq$&)Z{X(jT z3_!`559O>45oAPQbGh*x1tA=6vDxz*aNHsPr%Dig4*&ht;uivjDyl$c5|^Nm%s$0oeM@L__xu+;ry<#N2%b3d14z{%bA1D(HdhrL9o& zS{_{PPC}0l#o!fvA4X0ZfT8Vo7I(KJ=NFfuH&@csDZwdP`|oQ5*~+9$N4sRT9EX#h`)q1>puRajL)k zPkj+>ZPWiGZ_>n%W%Oo6Qi_|uvSD6Q{O{i{J>g^C| zmQiC`_%NU`3wJ{o$OU#&OF9m~o!xUlsqPw;T9=CR&Xi*6h79<5Wj;)+sfN-p3950% zSd`zE1%;KL;j_XfD7U---|aN3;;Q;?Ap8h#Mo&F~^PzA$sXM1Z1WL|uFFZ2< zms37m72|I5_<~Y1pHAVHyeXousWsD!#!Jw4>u648YM1?o`YrSqM@zUI=R<30KIGI! zsc}=}Q^?ZN6aTggQ`6;mf&ww%&>u!bSFM581+R$nzz#C)XEy13dy;H0d`-^wRgyVd z1K{cUWR88HJ2dTC3tF3G$*Yx05W7TzSZ%sSl1Fz#z;BpRnHl&364y%y#`Di{GAz{{*!J^1ct71^NP#Krq^rf9qp3z)&)^qlVdrY>&LHibyut+ z)}Bf9gZF;TGc-2RJ9J9u8MpEXo$mT?b4ZQ2D+RR9dh$epi$=+~fypTB;R^Hb#KZA(1)N>u1EAtpCFgm~H8N^$1MgIPL5G}x(Bi2O zBIgei6j?I$DGvg=gkY_TC@hcaC%Z3Sg=+!7t@i41uzscleBn-pS2r~wUu`esCTM`h zMmGrbR)Z&6M&Pzv2RPfe{!`TcSJ(b0>e~OJ_CF1^KTgu_JFUi#5NYBM-`~&AT)OXX z2>vl+0{xPFFP0`^TNaR$K@0x-v*PXBO5uB{z5Tuv6UM|mlc{-oku%hl%lL@}GRsSO z^poquuwlh??w*(_(5NfKoHVZ>M{}QZ4%CFv&r3=`cjh!}}cI%;oXXFuu z;aZ|X-)G||m!EdBDSFr&R8jW^YGHHclB$%u59v7y#iL49Ek zx^J+=7hM$Z%+j&!^OK&8+-)zCH(dc=Rwq$&J}YsY)}}GL&+fv9J)UrQuQW)A2~*F8 zlt`V~b8_3enlA6Ovj66hF}x?l9GK?BIeJ=vQTw%gA~<{JSgknNH%UY^vjqx7xuydrE@T zj(yCv_TEko^Y1jrO}Ioy{yf!uc_@(S{gA`>M@;9rrJAv8CQRWSu+?BC)*HjXkQ%e+ z@EMF>&_R2L_cEo23)u6~>CBuXEoAo7Zd!YJCesny4^5-y^!#8cde)W8Bs1ENew{jt zJ~Qh+G2N3w@^WJ69mA>QScWg>Qiv?~+uOs8x~Vcf<<1bEWr~L}jsp9SF9930(T2^1@ow`#;^rLSFon=6_XfhgWCH)}WukYO zg6ZB>L}^(vJibqZ!Ge9icfWbGQr1vWwyM)p%egaX|ZH9jJUvftlZ9;l;I; z@MW}@%=$6`%I%IoYxn`Yd`Jc)=CF9`K>&O$2*A3>kFjo%7cSaShc=Hyu|T4mx=MGz z!ZouXO`w<@w%>;P&F)Z-=Lw?akTP8K=c9KfpKMOQ1C5n4u{vued2v$;bP}$T!4FEr zslx(BosOaU=DYZG!!MY(BoE>(J)x|!2+nN~hM!7euu$PBYF|qvJ0w-XnfTrh z1Jf=)0BO&2kkB*%=~oXqom=ZTRzI4^u^W>K@A@Sodm<3Jz8~iNY%?HMxuZlpPKVeZ zN61X9CEo7>$ppPku;|BLQssD&bewa8AIAN}{pLf`HT0dx9jPJJiKS5c^AI_1B8lAh zjU2 ziIu$r8+l*27!g1eyY=x_>_QS7qyPu9y`kV`3Oqbm3I(s{fu`gv$l$#rEPn)6bSwt5 ztn;8IvjW~QN^r_%Hz?`Mhp;!&P&aWrJ}GE{f^YuFK1}|nj6PN){y$;#bJf(ja$TpW zYxZkNba`EKeqAFuZZ?s!tZe+s9B* zwG7#^`U2-|c#%WFVPOz2sUf$kmO&SaxdSpEZmYb9AU= z*(rb4E-u=v3!!mOa5F^VpU+`D6EC%Y`g5Kx|Li|y^lnSL{*#K-*Pg2U+4hzcb?`3l zE0N)~M+#FHi;7#bz&&Ldmij&m#4b3qlEfI7a(>bW zKAX{9iW8_aO_HqpE*q3AH)mY?)$rUpF6Y`!Q)bDmD}RsP{`K5m4@IcNA!(e}`J6g# zD@nbJ=E0p{X{TawQ%Xb7oN6*xg~Cgp$iDWiFi>|FWj~(9u3`gF{Zxe?KfB`uX&Eec z*h4A$w!$_`VQTAHSCk10#cbnrOzf0IpFI=6f0809wtfbl+d6!fy#c2LyoWCb&GF4+ z0+To<)UudN^!+4{5)q>K!)__+xUNEZ&NuM%6#OTsN&XgN_0<#nY^}q5o#Tu6n~s0u zpLLRI70?ago3@wmZ{D57*Y`{Pn=bzGExl4TBGpyG5tT9^vm35)j+B1nSbj?7xOaZ! zm^~=rC|~&aFMHrg{@3dHjwt03Vap@MvH66b=J1k>BDdhx5YJ4_h>emW^AuousGF}?5Pm^j87xcp%u+5eq1IyMAyUdv|VJN zARd-^UT&I1ouPfguG25|_fmd!=is~1H@47Di}Bi@OeSSN0E;y>uwvT_$V$6H2S&|- zxOa2ui!VRGS$jvA`er@6f6FjCgKI%VVk+F^eLwH4a*b;glt32rFR>4L;zGh2-jg*M z=FnlewAs;5lH{y62U(41`oX(z^t&QL3$EVRoPFV*y=InGGaQ^v^LKCIZVA`rjv27v zD5r?P2MJfwyQZ|cY{oi=SuUcyTz(KP4%x%%%a*{5$;)V0(Fn?d+LngQ1d(b`xjT%apoq*cJ5^;aK6X*c#WJN zD}0ciW-!w1vo@VGL!gX4(QgkOnc}qeaAU}!8 z)px){%ZF%9_jf=9YdF(1VuA0e0yfnj>DycSneS>GTAyy?7->%=@wMvgp4oj6XZMBk z@t_vjSrrH;`hZC^Xry1tY@***h0rT2*MQ*A54%0%WtchlU)ZlpzRPiQPJ#;wm)g8&(*I%G^ zoi$k@9&U?bZbX867c_d5v0{Y^P8^%XIrHccJ?C^jNH(Z369*MQ$+(r))rzHugKO=6 ztlUiB&k6^>U6mlW`3-&LhX={k$YW>a_tB*r^f+pxS>!}XE~DrY4bv{kfr!X5M#}#V zeP-bzO85LCR_$R7%2~+p&KfH~T*+lv@^&L{ZQFK`jLKwXbd#C4i~_4O^bKC6g%P3C znJ8WRo_sKvMl_ucF)`J8%-pDXOkjHqN!_Z?{+M^44zyEawzU1?>nSuRyWy2i${DO++3*Iu!boKY_;>IMf`4EFxn~pjw+nB)4J>dQM74eAOiz)|R zz!4JxdzV-(QVU1e3*9G~LEab`zl*xdD&eY=;t{sXoGHAF)6`viwg5PBGK=rOHW=+2amf7*3^Faca z2I---cr_;aq`;a~WgJnrM{4PHxNj^CCnjgYB_DfgdC*;mIG~A-t=nLrT?QP^XM@nq zn?N6y!GcNx;70;_nQ#P->F#NhL zwTZg)+hdN$z_m?KucAoB?z4jKsZOYPH-sD)6QpL$Y6LyaSWuoFh<>w2;ZDp)xLq;{ zo`0T!y_TDSJEk7>X(Kr0d;%WpYoW%fP;jwUp<0s104_cTSsOjkEu?J?(vBpN{F5>L%2Dg;>6ACU`dcLHjpJglWqmElL7CM+0G! zl^dM04uM<0BH+h2O%ONA0pyfGnv_2LD69bcpe(R_s)>!jYrA_Iz#NmUw30T~l2p!iC!7LJojw{8m_2P3-ndXn# zzxI<~OJ7o(#ZSPeZewW9?uQ?xwP0=b1GEh_vFdg|I5w$LqvhG)T1|E(UX{%cvo-8x_t6;PLzIaNJA<6(_}mNKJ1bbYZYoS0r-8Hg z^+V3NSrEfZ2c6nHIHzEU7RwAl>4hmQnIZ`771N;dXaPuUJq`S38v35@2kAAbaCn4^ z?k&3^2LvHbq6V4*>tN!EQu0DB7-l+aV7sRiJiqG+#-RtH$bSG@Z*fVIV=_E4jD-dI zbHG-{0%LtzfA1Zm;B`z9uO`-$WW8eIQ@a;ZUhaU8pHoPuQy1x04Tpz0!H|BB4;Bsr zXg*YiCqq7v*BQ?VTpuO!pMF90oL%T0|+P}iUB1V5ETRiMvUZ~Gm?>vpdbnYduG_62q-89 z1W^GA1{6^YnD~57)p>8#Q|Em5srR2-=hpdSYVSQWd)4ml)!o1D>D}!uh{FP_fxR~z z%lm{wl;u|NU3D2$6fiU{>j14vS(1SgS@3>kVFjun$SFl+wEuF0gU>3Uxjzm36K+77;3x>`tb)Tr z6l9P0!Gf1u_>pTg)+>pGR1+y|R`VHFrIo?2a=^nmIoS1g&mTKuh2Kr-gH5XdUN6y! z_>HW9yY?DLiEEpge^`!HUV7k|9UGwTAP2l%lZ+>}f5YEDwZK>ZLi{FpEqRXj6f8fS ziSg|be692cd?=nv%K52d(|1OAB$$W%CAkcL@NmRr>O4I5$1XhWO9#aRQuy^=W2_k+ z0=a#HU^W;5tFKN$?*2aLyN+;2iXQ=0Vu{8@oC34w6 zLH+-mrT(9orT&YSB9oeQ;8hnVy?$s+K21OKd2m z69kNq{|;Al#ZeevZ<3{U=ZjKh(I=^zaPy{W;|KK62?@e)ItYciCzw9o5J<)QG0<@N z8gXN92x`iefC%q=R)YCO!c~rw5(^PCJzq1?gz_oYcL5ICe!8Nm{`N{_8zc7zqXkjZ zvq?l*oCeaqHkXnuT=U;fc<}wGjZ!7Hz!^uHnDP!JB)9S7o3}nfO{hFLOew?pP4!@; zh2eQu5`1QI$z8@psC3N*^m^ z(a7KsI?F2rVr7XasPZLJA6bGTM7=$!*0y$!2l=A)_S6zGYuu%-^DDuK$o62mE#XUn|9TI|{Qa|J<;n_ZZ3bBB%NB z>2xCXrA4#or>UBzB=o2s2)W{+dN(u1&P)K~)*iH+F zZ=gSlN6|IPcZl!OrnHTPFqK7Yq1=ww&~h^BNMr9Uc=Y5K#IUD{+1_~2%Dw}~_?=<* z$tT2EWH;q0Uka-XGoN6}V*;K&;=}o;>p)=R3J`WLhFYa)NV83VIJfVhVR;L3 z7goXoadpf;p$_#r2jS-M2hbT>i#JYlmn%_6rah--PSVj)2bWH;Ag<4mnXexP4CpI1eAi+Ex>gym=dV zI~71&F$Z>XYl2881*~S83LvhCvqyizN}e9L^0*X~zgpll4NY8eViqJ*ci{@LS(vr$ zhJ349aPjd6t4qfJ#M6lWVY9~W8*H}9MRpzWkp0v%gk58l&n|l$#m?T4#4cLl#&#*l zWxq;a+QMn&$u4+$i2dUOi(N7v%D#T!@ZW6RU#nT=hY{5;2%`KVFToMeNlZT~AS{~; z2=RPgV&vCWVxjalBIXE-_z@jOSggB3OzWf&UFsxl0CfNjE#p zayQF)3@|a`_LRZ(bquGYI#H}Y$_RKCd>-CXMET*GeA__okZ{EvIOpEiZ zWw@MLXphphs6EYxwcNXdwQe~OpOs>fNrf0$-`Yuy!4$RTl^El=zJW3+%5HM-mZBrQ z<(XuKG^)_|D=l>`809P&1J~Z4)UtU_aQL1Y%pWy}SFe!SmK~)KGf+Tp%k*UY86UcH zHl21{cN3l3>_A^`_=<$|Zos++)u^{qkj!UpQ3HW?5Slqk9pB5}EV_CXtmV6d6t9xB zn*CYyA*%^|=9xfzHHmLqY@;9U?14-}6y|`(};%WzN1-ZzfmvrZqOx6 z4BQ(MfL(PertOtY^p~#fh%GRWrr76!V?u(~OUhupuU^_F&psK9wcnXA8GiOrSwgPkOcHGwMk@rfv4;QVM>{ zi1F$ysw5%}oeR=|Ol(R&xvPo{-{(@+3&N;nqS4gu#~V#^&P7l&lLOS9Q5UN1%S*~) zxffOGQHX-mHlSx0GFfsr3n_Wq-_Rmz#RMGEZceBDX(B71dAPO=qN6X;!%i)DR__?D zALehq>@STXZj(%3S{mKm`;1n&vjncz+k@+V7g!bW4%zEnhL&zuM21R&o%a*g^l%%p zNvwskoO<+Hu*Otc+ncKTo`m?47EoSRZ&VPz%Zo+8D!LvLbE!!?`jG-Y+BsBw{8hk+GbP5N{Xx<{2Q1Dr}^M9j;1+| z@&MDHi9sa=zJ6|H6-~`uC30Vr;gh?<+~{keb_TCvnOiHOQX>x9+ro+FI8aD%+E-wS z!qxO$$x&0sbA8}({~6JjdjXyg-X#`aUPNgs3efhJdPwWRJXEfv0vT2*NI|NVQuPU^ zYM3l4LShY!1gMeEbS^S;iQCN0*fsQ2`aYURh>KnoqK3%Vh9)0r_~R6Di3&$L z=AGyc-*)Op;%lny?Eos$lLM2q13;~-rHoa!!WSui+G)ioYvM^094pv^W=%Ax{r>SR zllD%tR|%)U@61s>>7T{Pn!9TrrBK%V5wu1}*)yk2c8bqBS3FYSM}l zVtBMUS^K49QT5VSXnNm5*6Q_<)XjJ&)X+af^Rjk8p2<9V)vzYzYwkx2EXX4j4QLFPXWJ@X0h2h22{6ihY^K(C% zq@K6qn9Uog-N*Rw4%d!ehB%eaIRUn<#<22M z7}BP7sq2!DSTB0Nu@=~s6Ndu5S?8|&LU)T4&^6kXo|ka|-5%~EwCf_t((W(JbVR(o8rq?>1?^#zf!^Hx=kfJs%vOp9NuS!f7jx00<^n=zVSxnpYzL zO&M2FSN(3NjS)_VUPcs8n$=2T|7x4$cY~EICPCIAw zLfh4Jdh{LIA+iDSCX5pg+%dfzrP1oo#1J+29UPJkrN=$zF^i}kwB1{Sj!(bS(3o6 zmZ!Mys-dB6a%kz}e#&il0Uf+(9;@5Al=U|h{x96c{beI*hpoxL1M1{@@kZP<{17B` ze6Z{e39Nm^ABUSA#Jy(0ILGM{8nfcXMUGv-cVZOIZ7;&vmIk<(R>OsriEw%EZ5Vp{ z01n|DctKwS=u5i8HVb2{8+8tj#3pdm#yghU9vF+WkFvVEY%~Sye&y?;FDrF_ySES_>No%p-eFAHu!DzaeKwCS12y!b3R< z*yq|?qJ(=IUyG5(TT@Sh{m}xbvehN`8wGs=4K3mwlK$BeTne6DXr z+1eU#uk8mBPOFe-?j6L*2g2|t8*$9a?}xZ|9Qf148+f;31=eb)#bvkj@X1?61eaYm z@Qb#=?SLV;cK!ht(;3F~a}{7E!4DoyXQ0t<1p1l?eB5CT7H#6kM>|U)Ip-b(9<7G) z{va628$#W`bs<{{g4I-+}Ia zOSo8Y8%#U<(BUz8{ChYOOdP+1rdc4oc*+C2y;I?QyUU-5kPR$_tytDa17A3p0LC}N zLE^+*SkLtU^+a3-JwbVRtG5$!VxnM$i40u06b1tudXf6w&7f*z0K8oi*f6{gcQvmd zKQ|jgj)ejlId&Q;H`PNYC5r7Q{zRc6zwlt5B3AAC23jYy@xdMhmpYH*{OV5h(=G$> z(kHFU9 zUnuh1Rphd34#q#;p?$W>Fu-FCxl5wJ@_PsrTnT_`ZbkBd+G{co-@<8QfnJT<^F>oQv1FE6qXBJw&!V*6)kilA?Imq{Cu7i-+8?;(a z3%Itr;L2ON@J@S*cr+$Q9=p06`14dDV&i3K4c`Q#4#SXjRs$m`aj<-E4Rt@ukiOjs zh^uQtD|B|i$u=8c|B?6HHgD+J#z2nDRa|s|!cIBHWb{}S%(gxU=RyJ8A3g*z`<)@p zcs0p;kAqA&G6!+oI}D~R9iVcm6mhDHeqto36hIG>M$1IJrHs(A~BzSX#S@ojX_%^kY)T`;VB6 zmJ29i=fo&1>0t&XeV6cUe}C-qP90aKC1b%CbI8yVDV%8KjU}E&fs&9nNX=w|+>1{T ztzQcUoIT*tWr(kfB*7=uo8TRL48$0BoE&Wj^vqqDL)t(t+5wBKnz2AO1xrWLK~V_f*t}Q((#QQ_ zrc@SdX?+6G?hY6*Uj;=4IdJRS640=lAcKzc!xc}rbcn{lwVbNS%er5!3|8f*B ze^iHC-o%g*S+C)7;R`UFJc)~ZAHs1pPYmmR!Y#H5ICnjS=^Z)P*_H?AHCkf1$71*y znh(buPT(U!*)Y&%0{TyGK)>`F(2dy!+(+wy_tF6nnk$4Qyy}7VCJ{;_QgN7REYdpa z0p72Y!J=m&@Nw)0f2@dC9_1znGOxql2Sp&$ECG8C7X7imBBZdFHOXExhrFzU$la39 zz;n+n%$Co`nK!=Sa8F;X87`02JB!f~&LI5MJ_uW0oP(!?G_k>VPHZ%F2lKN-KwCc= zPu@L_`M)p4nJYZ;ox{iA{B$W?+F1ZiUlYJ>-c!8Lj}M!@o`%h<%3;M8f7lW@2S@La z#co6_+*|quUT&&`r8QD`-hwxv)uaqAeELv(QH&gZG>0sZts_GQ`AFjjmtjQdIX=(+ zjAQqAVfSa&c*T=cDA(;ldf`iO*5E93#jLRwzkD$oweXQJ6nI<+L2nZ{)+f-b#bQDJSfi{~2vuB}rDYYN4&w3Fxf% zpd;`cFMe(gxv}PWN%;cUZZUyc-&W(-`70niHwkVQ`9Q^+B-opw3$ijtAbRanDBV{D zDaXs8$I=x0_HKn6n--IT%Ny}PYC2Xf2nC+y6khx6&;F_KfxCPn*lH;aHU*qmHtzx$ zf0@CTMr`n*FOR`bwiBY0Eg?DF8PE9qhKbA8;H=OKH)du~rTS%X7~1g%!?eJX6ASRB zYpP(i(+0A0HK2rSf<^}#z8~*^JgqlSw#NoI;?&@*(Lvx$_kbUYTJZB$3CubrLH^|V zf0CpAtFui>M_7x@iuM*)V(7n^Npp7DWpuf?2|dtF{=fG`TJk}Jje;U@VTX-bcJ3ZX zPtK#y51eAQ=EMR`3owmdj?^{XI)XG7X%;`vjn@T9!TAR+^cKx4Aoksu)>0Ov>1{i} zuh$nm?A)6QHNT=Nbv;zpug&OouK+_nFM1|@4r|^Gb(Z(NBv!!hZi+ednRvHc5i-2? zQfGD3sLsnSU?R>1JD%T1S@oWX2{obhhBvTYpB1C`uD52twAs?^x;5I&G-fZ{8n0)5 z4BTg?++{)x^4Aq4c4NM#MQOiEqVU zqSx({p=8@e@pEDhT_t;MmHnYeTA)e{b=<+! zD*t7H3R>!@`K}K@MckGCvidDLVBSYh^ep^)RmlI-6X_8q75L(DL(Uugu|62Py}Jp< z?;^0_GiA)bf`R(7ZEzrnfr0{{eb8NHt=|K2%hf}$09M4XsAaBca|oRnX+5RpY4*Q)bu#87V?wd^qTRdrBQfH z%Ng$(KZzxO>O*tr5`4-`7@~T9!*SLQJh!hMn+vG?u}<9BglNadq+{{AIuQtcyn*}{ zGz`rAaVUSwL2j|Khtz;xWRb)C7z$pTz?fCh(){@6hv3Cf*L!q(lk zSZGr-e3!n6mJLS32LD)m-69OcPIrTGfG56c&5QMR90E>bNgSb_3?8LdfF6$m_o5%b zRTcrRUQ7N-uK%y6c5zpm{+;P2YHxO%m+QJU&kvt(w&>Aywg!g~Nhj}Tm0%wHEjGJml@hCO`oKnve%t>(`9>=vP! z2sW$cpjmwS8S}HBf19m%xzX&HxfdXclTg}IGG+7p-bL(FdA(*l>=ZNY;UCO!qEpLa z=LUALyix1_&vk9bfdl`Ju9*#56X*1o(g_pURG`&x(+kTE)5Gh-siV232ratD>X=(b z^pUSwHecuB0^a#l(115uArWG_vF99^^=}4`n{$}d&lyN*oq5yv(I#Tc++-x+;>hHt z^iyN!l;}^JD=F?D0hEK{Br#BC2Y0_Vz-t&lS0>q}Rlfu%4ILYbe~u!(=~X*Dog|8< zUJ9_bsd+Y8c&`SlOXL4-k8e5M2Lz902o(DbpcR%Mh)bruXnH^#bJFX8^>zy!Gc|)` zm3v6u>It;i^`O*c^O2p(2L!yW@N0h{E+t<=czQVMxTyu=c@=1ER|b4+tp|~N+R*<{ z1Uv@E&{lC0@>>nTBv>1sGCkm|k%NZQj=+aQ%aB@7Hwygz3=Qds;2BXTcr9-M>$s0X zB3C`sS=RhhUCZB{{_jZbi^2n{!ypXvZ_=ezU)aLW<^rn4=PEHL+!wNxuTdr&-hh}H zH?2%pfu55ey`8LSv}oVP9JQCFwuCj(U5nVnr?Z?O?m0~Gy*<%HUX&mr&k3OOmBqxD zmDQ-|DT|e$YK~&!)aZ6&d&>R0J;g{B5+^?uvSeZovZlV%l!*R3G{=4v&2gQ>(iM)R z3}zNmjxjDQ@hkFw&&mGlclU3OLHfTl;f&rT^z2gbJam9g~7F_ z)X~WEOt8Ml2a@_qD1S{I=-db7n(hk0dv6lypT_>6Zw}P_K?OVqYEjf7E0nmc0_iCw zAiL$fkjyiRqFgzk#}sOxhxn!D*d z8s3SaZI8h}mD>OMwf|{;jq48?zAhc?eAl4=qOYbH2L}w!&i>!~YQC<_qK|Y6F?oZ^ z%-P6;^t@*sIIl2`;aJ59QnEJW8nZ;GdJ%&Yw7^;%iKvXrGb?-?qP&Zk@F zJ5c&xOsMv6EIp}Ab;d?Pn)pp>TTmoKnTtZ~&Jv3J)K3Tu+0dJR=+j%Tzi&vn zx{3~Z{F7xamO?RgZ_%a3PjD__E|T=Gg>ADd(c&o%Y{n-}tq*!lac~0tieCX0Pi;f- zT(yLuLp1BCk&@|`r5A`Ym$}p&AvsFA)fwFkiJ@*kUjhy;KhZXulqR3iPU4%L7)&Tu zP@By7(EP-U)FZyWM*V^Jtce*X(;8ncq^*1hjV-y4UROK(-ID){ukq&Ti0w?FmujY1 z_(>UX)Zc)Mi6q{Yo{rkB+i>;eaCByWCd};Sz@~?vLHm0GbzFNx3~;6(*_}~fk)Z-e z(?AiE(L>atXkLjM?=TDuk;7w019aA}BSRl(l(iuk6lFub3x z1b54Lq+|ac?(Lg{M@u!alB79UMCXH0OdO2+pF&zQD-ER{mx0g~cGd9!4 z>Pjf^%F(*x2T_P+ED_dF0oST}2($eIAi3`j z`n4bkDp|5{ZA2Z!T{WTo#t2%PeHm%i^Wjy-=fPu77__PTLd|VU2snQkCPnsu(ibHt zKm?Fd#-Jzh9W8!~z*?3Ac5+{Uhb;epVzd6DMJ-zR--&ylK3~9I!DN|>Yu2-Otdn6& z{Yo$|`m(o~Uo)shq&dFjP97LfAUdThV*FY#CRNTln`$2L2xYL+AtL+?FmiLDdc!PBzD zY*GDdFgWFf)t0ER1}&4QttW1RTaO5M{m5ZV|2V2Onydd?RA zl4mr+|KnU3awiX*?jiYP6wJ!wrObYa1mKVLga2{gW-B=}*$2Y-!{WOjo+6Jqc%sH)Y8V~Y4`u*depw>U}?)m#%?o(7tazvxnmyI zvRMNva}w~I#0t1|V(&lAR{SqM_@9Okg#M6G_Bx6!uqlOYqoKp*-w@mq$qrz@+L_t1 zBhH`w+@ziDBv;73C#TLnryJBF_c-fs68>ui!7eM}qeKXi)1N}{8-x&Z{(KhdY9kH> z2qS@jVq(eD6k_3-C&XXx6~<=%%eqma|KzGB|4O=a=^c=$^~Dk^c$#G$Yhc;REA)>( zNvt&B!kCyfp}nSanB2B*rk&wpZcP=_NBfK5+=?WMzgQO3Wi{zHx5S7p$wA_*atbU6 z5W~{C1>h>EPajdZj?SFqBp78AN?DzonH1Sa&+1vz+e&pl1X;$upv;MF(iGN?d!vt3-fuH zX2*+^&hG=wa{DoTy2_Sed@s`eUdNdIN2OW(UEit49c6St!7O3!yBRVqyI5DtoatVp zClt>zGo;%4mSRPGqrx6`QrX+?qO@P3sE#|0)hf|R*^AS#d&U`A`e-m`M}>)I2RB-4 zr5tiZx9RV_7g@hY6IuIW4e8*NSlpi}2ZziHQ0=E!W<+@(o+>!Q+!zT)xs$EPc(xB+ zZs1^Am(9azvAT5CfdN`hR*~5gL{T$>@suK`8qpD$WLllNlGv>ui{b;asBO+~sQASO zO$RFG(^q>OX~FZ(bWFH9`gC_Sl8Omr)w*3^`KW$nol6s^z5EpE0$)eg%14H*5Q8RS zQqK&H*p*RN?)pHbSs1JyYGwyNyvK5zIEMGju$!ZQYmheNJ^IVB1ibQYAPOUjo9!@ia(?UJ0o z;-vyqGWQrYoR~!CiLW7dz7N4q2P5F@_iaph8w=7tsZ8eCz#0;7o~RewF%JdT-M1ftnywW#X57W-&-%Q+g^FdGDzQ!~D9CU8{0eY{-O-g4V1vY*u zq9skWn;V6vnFq-{q{$K^vzAlhv=SABhaO+1YlJm0$Kxnw^P~A@TQxo)?#2e@hw?ac zcdC)$LS{Wboy~g{vCcWr zxr8)RXqOA82ToHzZfJo{qca-iEob52t<)p%WcBjwLdIuTpj(BBlsQi*ZEf^{lIl}H z8+Z87=ju|i-hMTD?S77Cqrgo#NAoT7aK3MInciVW;i1a=qQInuWnnx(C^=11A>!zi(7|F9R!&Ub(q(ceK8F4)d`^2mYG(Lcg! zUg}T3x|a%W6@B=uX$KsV>0#o8k29-7RyR9-sbtvnU2bf_;lb?ye=6HCDFf;_uuJYP(w%#fQXZ^g9lBMDWY>og+mkQ|omxzX9=}8Vd;#=Km06Qo_XUR5 zOn|ZE9CY8cj{Z1Wi1|`F>DQNKLG+O{%qHmLwZ5XXt6Wrb=+Jd^x(=h`HRs@kPQ7W3 zr6`^D&6bcH9z!?pgtI&ZTv;n8#nH`EwGGHZ9o=5Jh0;(mtmiJe$|`BeB-G3uXutd= zprFi;oPS-1iO3Gt)z~mXpEDV0*zP2@{P8x1T6?KO-tFk-NkO`7o+y)r10l0$8oK1a zz~YnhX@{hb)T{0^=48%C+Bf<>edl@%v%@Hek*v3*Pj)V)gd5(nf_-D@NzOrPxpE0B zaM30@_fQiWjR~bjq_vs`e+nQ~Pa!05Jrqv5jZ-yQi_p{pE*7g|2{r1VKoC)O5GoLi zZde62O(m+**JOB`(i2Y;xrKJrrq}VPVW|jnij_*OpEM>6)VR@aVXFE1_UCkP3kSJB z*$xZzPm+Nz`sf2`v)JJFDja`8glxIa&9H4&G~e(og7Jk$H2H|6mn0hC(*v7X>6zI` zT|%GU1!kZvVSp?e1({AgPA0Fkq;YWBcG}{e3?11KLG4zHpb}qcqS!4rkzGv;pw*v< zPv;+@TUGqj3AgnuR);k`?W+NA6dI|K_m;HvNpC7$UL4U*^XZe!9sE3nBop?10fpi| zT6f?Oy+12}UOy?$EOHK^TGxDMZY}m_9tK&`$Gl_F7vC9LJNyP(A;_XQIe(+txNueh zVu*qZiiCZkFA5X6-x$74pK^0pOJ#AnP-1cwriX3g=^YWzsQ1MO3FigEsOqZ)B&I4; zmWNkT=M&eX7hXrv;TTo)lDK}hy@$9bhx491MV$y*wuV4xrpm1@SsbWx4sCE6+FR97qme}+YJbb zi~vtJcdTp~j5kH<&G=+C|0EK0;*!{r(IBNyLWrHSk+=mlPq|;zu zO)``%9|47KN4(aikK}E-0z+F=AV#o;e7)Th5A_lFRG|97fTm^FS@!5TB}a#(IJi zu-H`_2eZv^NJj`fjave9`s*R&;5@7t5QTqR%_kp+dVyY&5LjH>4$eQ;!rd!|Fpq}= zr|%Rbhi~@ab-5RT$GQfe=*flr#7_A5q8z+OnqX(gAy7(GH?y~YjPrRMu+>62oc_WN zb`Mx%C&~TbjL+hPlk{6(lVF1?M$skU0GjBz8FAej#TFG+qq4 z>M3BjW)ivgN5NtRYapHlLaRV0lzG&^(64UzQa&Hf?X|{+p_?J6ZUY&ibp^%+GVqJh zAUM|`PgZR(AUTwF;rx|)Fto-VhI4+yWpPd%6|x`i3-|!+S3z)0vkk&M*22U0iEwCl zBiN6Q!^ZMIdug6Mgn#A7U3?Po?sgeC?B>Hm=nnLst_AT;o1xQaA-w*)5Ii;e!C%Z7 zs2_{)+mp(`c~cA)eX)W2ycS?SwG&deUIAC5Avn4f$dudS_!>?_9jis~OXa;-EPe~# z#BGHeKIdcJJKsR#P(GONNW&l07r~YN(b#j-FX($*2uWOB5O(r2955J!m0Js;T;qnmf!%34rL^OM&a@C<>+c@o4cY$oz%DDOd)AH@Lzq?-cUzb%w;xrC`wN z2(eEbp|E-d5QEPF5|qHmUJYv*aN)gNHps{PIQIHz4Id|@@!YYeaM1M=%-h@x_945` zk$vhUBf$xS{a2vmx(t4PW)}+lcn^Z7N{Fp%nm~bCf{IHV@YszbkSHYqGix?O?#id| z`jI-TY{l? zG-%|@W6fQ0;Fws6C-0E(v!VbFT?J6o@**yYB{B{9xI}Wrv?ZB7c8226*#qq!WA;>8js^utT z7%UHK<(}Ze3m2d_LKOI(lt44rB1kS@f+ZZ3v1)cQ@JNK>^3x1hW;sGlTo`QY5GC)~y zKLUG;EztQN88Dh9kF{E*;i{+tZhFN7rt>tQ_eKM_RENXqloXIKGXnP`O87;f5bVVT zFwi0ncT6H+>A5RNv}q&Egqc7YsR|S33jZYEDe%WlJKxA>f0QU;dzQ4bJ!f;+zn;dj z-4_A-^!FOJSw{mqIVXpGYES;(qWHhQSs!hV>>UG%K>I*qK1cr zsD=vM@}060@{SNRj;OdT7xwtZz}B`T6VpT5ni2v-Zzah>scF_fTE@FRXPvv413JjeGjE} zPyE2fvA(F~&|!M6qb*ty8c**Esia50=uvsTKCF|=wW05t7VA;^0{WGZHax%chIKuT z3wvlQ)6tRESY~fDdYPLAIWwV@R>2KoL+3AQneaI4qR&Z6Pf!7oBet|t|3lQRQBP4^ z*U+jF1-!Fx2ja;xLdGtSp?6CteN4Csaf5*9NhD*f!74bBOT;SM5!mAk>b4i6rOXqW-j)uqI2;e4 zqEsF9=0_c&J~0=yG`{6Q@D_ono@ZPAhBg=j-gHKpw&OEqp@hwh4dq6hiBaMui@bTwy0^Ep!~QCHBE zS}--z9LACv2|}zoJ=SaUZ%v|9c&af$)IOS~IC82@Hy+#sGx9o!k$wOxo2Ds=k~J(D>H_6;EEl15?MPlZ zp7OusODRnH(^0?tsLKmhq9=>^(80xpl;y95gpXNSV>?o2_HWgMn$X7xWr@&!BL`W2 z3Bt5rKqPCW&r23Zaxwk+#xll_HxRXcA7X9bQ#5_)V2e2ORq2n@&B&{GIg|Wx8a%$! z_{Yz3x;#S!ozF?9r%$hj;qmWiaqlsxvyG&$5Ms==sl{;FL5}o#7lhoTjp+SE6TG-s z%wl^M&<$Gm>HUv4L00+`w0}eabuZJUYL4!wdatFK3N)!xwsF<0{uvRluGL`8m@cC- zcsGOH%~T{(@rXKR9*e#&tzZd8hSO)AJ|nl3-L%P~7-TgRM-;XO5hpUtaj=paSR|G+ z1<9My6N5IUP&tf<^%Z9zMVd@kibtb$P?Hht<*XgTZKcn(X) zk)%Gn5U1B!TGN}B|B2AdCFm8KX|yUph{FCINMwU5tH=INWT3QwZr3SC!ao-?Y|VW9 zeeg81FMlD^{NXr^X7``~LqNR0^CmNlMg29J;qbJaXf6wtTZ^l*YR7h^qRNL$&6 zqbnkxP(|b};!|HTA&?bsNljWjvBL~1W19@L0NxrZqKw}RB_ zT}IT2)*9-{8-!kRRKdGdM^SR=NmLdp)bu7i9Q(kps(82u*Nfr&O5P!S$VRA zGGS#Rub#z}x8XZ#S=BTlDCke`&C5ZKuaxP9^S_~kmyS_}6; zo9L^M45gg+3hkXtM#f)Onnn&!p|ZQJEF}do)7sPRf1g0`FTcXgy9Vc6R7HA64g#;2 zIV}5m5fl$ABUy1a5z7~VbUaisPlph+s>H!go4dsFNhS1~ItSU$Hi3x&2OLWgfYY}* z!Tzoia!>h*_C5bVG+4zTZ4W+3mss!$ee=*#w@g4VS2BDd;YD_6#Lo*Va;Yvv+thy!*cTR`H0l{UUE+&Nuohhi&drd4V zC_&rYUjpB^QdIM715A`=gL{z$mLT##dVW2Ykk&(w!?RF%xd#}SRig8$E-&1rdQTR6FR8YTmAgibN5Zm>xzuL&v~ZKmqj$KLwql{b<3H&&0krJJIpXC?xeh z2hDDgfZcw=P#;%;+~&SOD`f8x*Y}IU3eiWXsy7$O>Fq%eKAA(JOB;&bD1mf_Y4jkI z8}#3HBFT$8P+Peo_Un>>wskupmZt>A3EE?mor1{drZ94vX+xnNb})3b4$iHaMf%1r zL}OJxI{Q@tS~(#8bQ>xE?*@z6i2P5^!k;2M(3;!!u@TAh)d({W>lI z>jpXDVT%^r3Xy~nPa#kUUIv`zvheBY9$?M(py7>2;o$e5$e=e9+RIviN6!Il*;^r* zs{p<&)Pd-cI=FmP7(~Ct!|Eg3;H709WWLGJ1W$U!h1ysxXl-W74U+hh+sYb-%t2e+WHlm+n7V*qKn$-*(mMZhG; z!8vL#6zp_E&tl!6stSSVGCLS{>43(?rueAyVmxhXgWLUWU^HVc7E72#anwEZ4v!(L zki(d#s1x6pOoqp0YoM&-D(Z^A2UPt7I7^s<)%GBqv(ghD<=w(le~|a}O>a<(g9te* z@r!uyYKq8JVbH_&Dp=9}0nS%mfR6>U5N~%KeXlfx`m$hLotuYrCV1hwb2W?~4u zBwTgngwS=BsQO1eI!vyCR=EtgBN7NTN)Hjgi3Fw4;*c^4R z^~neAb90bT<0L#S8biwZVqp45=Y8fSp!@khh}!FuD1P@Z^k(b;{2#{7JemsV>-z|i zF&T=CrD!lk$h~`?D>R5Ep#dpUng>HOQZkFonP-v)6>-nGD4Ix$28jkm8Z;1%Z@+h~ z_xa;l&wAJM|2co0z4q|^oO9ORPTRl2!jWn`u}l>g&QAcN&^r+2poB+jta0IFKU|q) z1`-x27?LGKG#=&?gcY zy#i(&cYJjz0;6vA;L~bX6etGb%gX{Zvr8sJE-9FLSR0!~bx=JfmdrMqP5j@N;70pq zJW&!4YbJ$)LSi`{Sk#G{X||~HkBonXq=4*3ZImcl51U38;=DJr(do)s6dAk&`xiff zOGiq9R*kE&fetQt&n34vk$Sk0aIC9sXuD$`9{0u$I$qfg_L{0gbwezOf^ipxLbCs zlI_EDpon{ydsFiq1jQfY%O&u+{xcNtLRGTWEeA2~+>d9lAj!`nYJCnj_2pOo+&t;( zg%wjk_trG7b%8j&;;TUK4aV~`i|4|u#>33pT~l!NQzhotap?2*d4d%?#RYxYNATd} zMAj)fh*us>6l5wpv-^H$*n{a`YOU!A8lG@uyOXY4nQ%AI)?P=LzjYRsIMYbwL(AF2 zx#J1%{tXy=JDmD%?1O7L8hC15CcD(1$d@vG$}O6Z#0qs4L9%lVJ26HBsnU2TnIyv3 z8hXM#sW423DtNUo8cwlvsWa@fwyt37ok*-G=ps@!Gtl8zB|pH>P(UwCM59i9=KLfR zC(aD|$NgK`$oOD({G$kS9n;U<(t3ow^c2Oi(*ky&>?i-c!3}Oi!x=D3QYVKS4{<%D zr-Ru_Dg33k0hZ{VVAHHL*$G1#xL6*}MuuYWwwX15q$_kAG!1oF10N{Lm@{x*R~kmh>pYN{9m;2) zde7Z{_B*%oUKGDh%H8NO&53v-d6kXuByb99h5;c@}Ws#(3=dY{oS#w&kAtdL7=#=&&UpRFSJx#UHku zOt;%{+2*!E{v|Ut#xB|7-b=dVW33YHvxo-M(Yx$c?=x6`>objc_657%-i8SZ!%XAN z9eO&UiK{eXPXkKrnCaBzDC{$hWpEX6?GpzOv$uwxoj2ggOn?0GVl(&cug#q1jn>eR z8wR#=*11Zs83ElM%Xn{mC|Qy4RN20u=c!c#f3 zF-UjVR^Io5;HEcHrl^f;-7r zH})Pbov%s`J@^e01IBpLB8wO+xe@cNglBrO1*_JrAWtp&v1j8?tX}>Mozly}LSL86 zaS8>az(?4YRu5Y;-62VwhsAr;(0bE-lqu99yZs{Z#4jWCQ8q?;)fRgMx@hIF1dR-q zpr+$h%-en!4oqK&ErJwW>tl{PM!m4*ULCyJ9F1NlKft)fdAK+u0(HDMqL<2e^w=AP zJq3TEgi`=T8c4o8yM=3JoPavHk8n{`7g==>TERn@;TjKOMp3Z4D;d7tFU4o?v~c@8 zJJhcYMN2nD^sVg0BX{fJy^1ehS0Bb-?K~W8>%^ZC`q)0|hI?0@M{Zmc%DV*OE*EKh z@T3%fd40#sR)ySvcXL6mD7HOajhMB}-mTCFAcUVe0W9Y)aC@k6$~$ z^dp111+lPDGZVjYZ{d_Q4k~tvVdd*Npw$(JuB-gvV8=nJg_YJT+b`_R3+~8jK zyM<%@lEH7U0%kuF!u^U8#6VaRckP$LBVNIk@ov3^;YIq50B7utarStg!(dIM6{-6g~G&!I>{IBaj& zj<4knuXO2U|W_pr^rWCX;b*?0<77Xz(wL?)|N-f1(vpzaz4~K5Olx`iJ^)^$Px?4L7|fHt5a|s-LvR zq#^63U%km$gNEk9r80cw2d&~Xx(lHVB;h0uGbUv}ZVP$3g3cI2DmtXSg<1SCQ-joqiZ&$Id z{H~fx*LQBIsW16hT%X?lqQ31}LcPSZbM>BEQ|jfow4TgfTMrL5{kO{d zpOXm3yy7IB%-{$~MsXlQz!_AJ;SoTK{$oU!>C zoT$XFoQL5V9HT$mIpd-P9F(l!JecCg36_oG?2(n`9w4IJI*BNbv;pNjeqGK9Zau?s zm2Kl#>=kf)geo|3n$p~R|MszeYigw1IDI=SINnDyIG;;JxP7MtoImn@oP==|oP~0J zoG0V`IGPjMIIUT)INKEcINrUcT<7>q&i|wi4E|r1(-HM0-acvq!>GN2{gs)ptZfNg zxG{*ER!*X#<=TStjsq-zR2)mjMlhOr3f)At`SzS{qW%0Kd$(Z)(Xc&@js^Ps5&Lx9 zaHjzjqGTaZsDtx@cMP9wQf4!i2jSb-6mU5E9?~`+p|hKV*i*+Tu*LH-41U$;ABfe0 zEnd?&eUh25*Km|x9D9i0GCmhHJ@wh>*=_vB+jD5udLXYmr;xGfW4NTWgC9QmSyonnmk>MPqNi8Z2**2J=!gIKvs_Cu~|yozsLd!unIK zx6gWv`I15lE!Wa1Qzcofw=Em;SP%6FPO(PGb|z@E5!f9t6R4b-Kw=c9vL)H3|M>m@ zasQdZ#ID9r&zpv9!8IAdNZATn8GVzvY+Hm6oa>09`T(~nPZwn#%*D$0=NR_=-sVIP(X z35=g);LJ5v^jY$D;##v&aH&d(Wf?DJ*8_6uJjUMQugo~kVr9LEYp|2Z2`CuazmQH4VYuu>+;Ux0MBv(+h(jT8+j$@V|259fD z#b9oyjOi8LD77(@>lrQv1v`xSbH{jc6J|%!2kWMzgPXCSRqPx+wa|u*zMIZwY24wr zKI%oa(I9AP-$oZ+Z{>)#4bYTFCJ-~a4#Vv``D;xsGgtqGbc;waU7b40Qi{Y04eX;+ z&TSWrT(1=@w`iol!)15{@23lPrp?6Tj+s2SflJVQRT1qQYMEyGeN2c96ili~Wx->g z(CiscnaB9KOm1Tot1PQwa~te&{iZ1_I3Wobe>ZGy+R2Po9VXY7X7CIC#Z!|fpKaAk z!vn_4Xz=qAE2oF6vFVyJZt0ST-k1)W{@qw`t40imy$3mW&Ipl=TxT}V-h+0Qa3gTIEf>oNsac_oVWDh1W7yju$kU7Qlj5z{6Ee_>W`++A`WC_}~tK zcgsSazQI`7trwNw1|4lQ2TqZ;n&1p|DGNADos%@)L0 zodCnSTzG#umQJ+2Nq249%3UTW#)@i$;F0-wdSsp1#D^c11lm zQ!NlYyU!y>Je4**)(4{^CpywngoAxNdT2f$-Y6)L^Ogm0S@{=DQw?BSTrG+3hxJ(M zy#<$zoz1`dSx#V|6OMmB9$@6r4>~pZ1Qr^MP}ABm{E`s|`f|A%|FX?AY|Y7Khh*&8 zK-!hs`Hc&3Ys(C3zTy)emx$-S9HKQ*^F`UxMWJxi)1!9BA6nZv8p2g+b>M%RMDJADl0QbM9=(7r1;*X3Ixk2@a-s2uxDc>SQH+c~xmP zOru3iP;#dbF=>Us{fifD2|Gz7%q`iJ*1uqBtj9WiF5=0Lw_rqn3N6$2!B7uLlJS=- zD7m+eK1@sIyo=4D$#0CHT&;#(u69I~H4Fq1YPEUy*3+ah5|m?phtuZOTl?89nA5*d z8Wd#{xd+OwvcsWSFzs3fKl&%b&#$~#{p-8zSlDgKAAYTn0oP8A~^HFo&f?+|n8 z{mRyUTt@Qu$rB}$N|OD~1CN9?W1Vj#D%r50_17kn#3 z2`fw=cb&Eve}$5*A%en>!>FD+j!8R3u&)iVf@5|jjC<=98W-iz`W3P)FEo_0jN|CC zL59qUy8;Cl^Y|fddT9Mt1QkMR*cW9H!OW^s{xiu8Hs!4?uGEqT?M-pCbdfo0c>Nyl zKJ{dShs>GbmX&PYY!&)3v5o4c^-#(FU1+(xlfU125`UAv1YNFjfcnX-qMRR5Y_Cu% z)tTst^4D^iMTIrm2CpT8(l&M@szKlxyW`F(q_*NZePrP_a@Vv4tu=u+#U91B{3P^H-3M< zI);5FOwz`OrGL$)v122VC#KHbaVi6T-?N}MkDQ^e9Qyb%@m+L!YXCUDx>Q?fF5nEz zbwQ6Y(T7dA7^{6!%Be1MXv~aaeTWU#=1nW>OQ+&^^B?xW)-VnD=W~qi@Py z-!Pyb1cTgJz8Beti4D*$mnOI{&mZ(Q7Qm=+H+0YV!kzQuCMypZhSQ0fthIbH*M#aa zyZjjReG$(#&GLm%?_zr7u^gM*qm7RQB4}%yOA{k6; z5H*X#+b7|}x=SkYt}Pd|#+V7}cL@oyewvXV8|#_+8hN6o5=3w68w1r6W(!}QKp7o1 zbei{)_52>f6Nh6V{8}XxJeT7in;1gV5)5#&-3$Ju{fb~CIumgF8@|Q?dGy$?&cs8P zv&|w2A^Z7KNNpx`*`X^`bZY^(!*&hR6|Ug^xYS0+P|95!wUO>N4`H|GD8Ymf zS^m&<6MnkUDyVGcg=qhQ-jbwY~q0^;!T#r!H&D6 zdrUlrO)iA8fNu7#b}Wu96lVpNHvH?2`t17t{+h{`pxp4)p|6HQzaClPg@S^}vih&@4xvRR_3J*GZ8M9|~La#OS=gRuEY` z7hifU<|l_A;(uy+!%f_M3tG=9aC0rf=+RHwD5|FynYz2MsY%%I@vICc0PRy`ZlB*ChAXu<;loij9s}mPpD=>7gXGuo(M11}hW*BKn z#(O`&+Uc!K-`s@;haO{jTO`>acN0HwwIVaM8w+_O?oh9kjyo!^K>wB1;L=q zZ>}}S4xwT@W%;2^S86-@#c<}z?>P6PHG5Pxv2Kg`Dmt&EhdDeEBb~R}>GpGpg6G2u zZ21C5GLn*mD={UD6kRY?u+Z(vg&j%N-Iwrp7ZAAeMg=d1k;;%kh|<}M>! z@U?dhw|dMDD0U11E2o#-c1{jc(Y^@3XSefzZ@a}kdv_PyikSqxK7XlGMHJP4;0QvR z+ga%YH#(YDNNt8Aape7~x;i$VEI!Faa}#9}Z$H8=U3g7vjKVQxV4}d=FBlzaqlk67 z0T{1{sWr8>;NIi9Go9vEj#l7Xdaq^%`|D&YSa7f&*AI8FYFk56?3zHlog*}(4q7>k;f{nzq$KV)%RwFI5!(csh9SaV-yHV*z)zqkG6Fke+YD%B&Vqm9negS9As(D=hG6MYGk_7pL*&KlY(dt7?B2pP{_5-??$z zbM)Qf2ViVHmm7Fmo+Yff$Mvzu;_^m6a+g~lpj(7fKN##Dzvh=D2<0h(ccUMK zM27P_zRzIe`cH%Op6~qL!)^Spe?)Nhr4v@4r=J1wovB3iuiC z9@f5;iDXBP-x9Rz#h{HJ|Y@C}S zOFerNhLs6>8665MgTBM|xyInx8;(goV)*glW7rwB$yQ{K|3Aig&6yz_hqZd`tkGxz ziVg1&Ja^X;R7-zge>U~tGAju-aG{9qJ2naf%TBQ4hU?fe9z`viLFO>zho$%P*|Yu; zen+_qS*voJChU8FdcP3oH5X9xRDLbzdMI8ri)TB#a!~H|BI>#*hz10j(AhJG_{ocH zxMs53ab4lpT9e~vK$ACxT{MyK}gGPb*wx<-kVc*Vaw^9xwQ` zd!s=6!6ah1y$ILIxp6-X7ZF>VaGcNk4K`_aS#?S=8+;c?wR|7bakpO35t#c%zix0uybbDBOAzjrJE?{6OG>g zII}ueg(YaqGTACwy14W#^;vKnqjKG_RC_kcD(=BzxgroL^TEi^>tM6&_gai}f#K=f z>G+5g`sdK&T6sq<2&E>`WA&PJK=nM_yCZ@>=hWaH!@Kx<+jZP!@SP>d&aMmEzm=5C zwi7gLtzuHiZ?dt6hHC%c{(I5&Ysp(GT!A6)KBdI zJ+|4FnbdBi!ky}@Tx=S8<}}g`YqEiVBN8q04p7_MmYDoblbzR!gPr<{V4_;bT{n9_ zH#LS&&w3bOVw2sxC$7321J6RL^1=c?y}2mRn`v_AH$B>iX_%(5>2wuVbh*Yr5AE)xjS24Q0Y=`sgyAfPjIY9}J#oU0Vir4UZ=IxqU;gwb2dTY5ej$DP4_lxKx ztv@vC(U783^4=}HDR^Rg!I~qbNM0Qr z6fB%pC-Bd(Au)2!Y{97uykmk=?3ozPTPJWAMEUvSk!&Swf2u-_=U6kt@kywA^*+^U zOW^aBKU4LdVnP+BLFOG1RxJ{V&u*@x8=FJ%`@S*Edb0$K-yjEX6$5DPtEv2s$q0K+ zrh@eT44N^ZPefnq(F|Wdkoh}-M8tVApVM<#h~gq<@<^BYK9Rz%UEZsxN`GS5wd%;JuC-nLxA6DX2g85Fym>J8%fRZ#2X%OaUm(Ijrvpewikt~{KVS$C@ zGK9$mQcvq(j`HaYb|oZ}2Dy%MWqH?VL(*b)>*;6i7fW5n9)x49bv3Nnlg6xURM_s! z^Hih$GnW^ZL*rGg+40l!xe2Q2c)U9bB}R5*fyrJbzQ9#*_W3Wi^5T0s#m18Mp6sWu z208rc!rGkqv&J*dp7SWT#g{XDSqHPqP3Q@WM9?onIyWSSJGUa5dF2Ly@b~q!FF%I9 zA3nuWUbNDyt5!or)hBLOv?d<K#KbWxGZ>cb|NQ_wqEMp&v51@7Wc;<9> z0laZYMFm{VbXOW-h1FG9=gfiAEHC|-Lg+nA?Oq5uf2(i#iz*C3L zv5f`B*keBrPA>4ENHj>5qVoK9V_)aD|_PPQX;_5IBFk*2_V z%659_@df6@abaySF@pKG2O-GWioM#>gJpZSdQbs!wqJ+OnFuMgo*o&m1> z5(n=@w}C-R1T(C*1TpD{bl2JIoKCOh?C04U1>It8pGt6WpCb2^b{d@bT|~c} z9f67k=jeyE(d_3JJ(fH9K2!!?1I_fAU@@d5@U{!bp4-RaSeiDQuX+#<&uL;mwuHl< zrE}R=mwfuT$e2hTwId38>gT{Ah<3;7W~j78!mI9P)iic`#7MrXaj3;Rb&fR4TJm7 zZjgGp0YaO^@cLGN4h@{eEQB@*=(6R4cV!?D-s;9mdoHk}_8*C%9%bZX1xz|UUBF+V z1M<|K&aq zceqN8Zhz%IUaLoD^wbF+aQIByzE1G)mnC~Udj(M`Q4|C|>B7x!r(n;5be0=9nH^=x zbl=J+Y}~i){AEk#v+CvV@z2Z;XgzKla*}dz+#eCLZRjI*I;GH8tIKP3pXwq;Nx_D- zC6KAnj)qkdn7=;)R$7O{v8(DR+HAmHjVYzhwwbi|-3n-1nZlx*Kk@|{qufUQSTK#L zgV5sV*ziG9U~*MW@VHf;&9s>Tdj3JAW12m8&n-*n=nx|-&nV&gr+aDd?J9x&jpwwX zPK#~hoMfG!HE`O`C|sOai@K2?Sf<)k%zh<7R@EGb8wNtrwd#1JB1qtL zK9)b_=tBB->_Wjp>9pFCPaEj&RvBi$A{1-$r1=?=hXg``pG+bwfIso=ajw=#EVd_= z!s(}cnh@0h!jc{=!C)!u%8~*-2P^hfx{oU^7tho^&G5dsHLg1Mg9h|ovAQht7!*4` z@lVwbfx+W;`r~grzqsNZoumAV@4Yw?`b@0gm#QB3>G?SrRDHZwHCYCfuISP34K|z) zb4s}v4Q;?7qL!Z#n8n?u{)+!(WjFt{tqSe9V+G1)*)*WL01we8^SgDM*a_dHSRt`H5&Bz1TzCqyR_PSLSnxBBW&y* zg|ZCpwVzhp4j zLILyj)??6;NjPZKfj#OR)aEAQPB@K@r8{xc#?yq@?IB0x zEr!v{YY6}1YAmb?0*UN@IVffe*4sznvioh|0rg~7@nLRbLpjpa5Ukqm0ncZLaF3S^ zaCc?2bBD@~qbF`c4XbF}VVVdQew#73I11Ml)!{C!UYxjQCVAPEgpKph!^tr%*z8@2 zKa}>N^7OShq(2|G5D5%yOG4uc4LlZl94+h5v2(G;U>6{e-#dLq)_&&G0#)j zAD4)faW{S!C4nVnMAzO2KDSn(*`t>@GBp)8ydA+c&IY7qbO@f`j=-h=cs1(WWmFu! z2ZuNRfz>Asu-i=v_nq2=Wz&+NFV_Kg&u)P()`!b0PNMkRcldIuFcyT(K!vh;oFtcz z+t%;GNiBZ(Al4nJd?{EJHKF?)8|VobMR}uU$kDhD<=W#gGcJrQkUNBKvMFRsmIg6t z>w{UJBQYs?2oFg`L-?^f#IPopeArt6rJ{S$!AzD6{^P*y?d3S+wF^ARJXFrH#ho`h zFxIvUvqYDZe$QGsGFc2QE+2z%9buBO+lD0H)W)uWCh$1W4_>}KxIgE_zlzR7?zZD- zGgJjeM}EQMJCpEr&SVlcWiNWgT0vitHhQJaBmD}K$x#1Q?Ec-3Zg)=+wKU3=U($_9 zx|hM`y)uz>&nC+xkSut722|%25o#92Rof$u;>~s33sqOpctsp;{b+>`*M?!gQxUnu znMM}0RDr*dEx9xM4`d`%;fIvTIBsz>ntQ0D%j0s)HB3U~Lnl!}I}9^ARj~ZcCr}oS z#;?=7(C+dxIA2X6e7+on^N;h!b-EC-acaEmj8tCgrHMp`ACHIAtjOM~KWMq2lNdOw zk+kt9#6_Nvn44EIwn>9)<}?Vr18wAHd@zV(hntFXB2r#+itUPmzAt`QdYQ_iSN1{l4PAi)A`Fgu-+<%KlaL%4gx?j?_#)i_x2eS7NnB1=ZXzW6 zY$R@tJxexUv&Dc*k0Gp#hfy2Ppd&RRi9chBP=h5|6`+G=tt@SCaOLidB@9sp$ z^<12g=8Gc_8X-343P$HI!f^}E;?<%^{F0)E(;OX9M}~r1U=-$-oyOP8CSh-y9j-2X z1amqQ@B?=cm+wA;k@^+5=Q+XV_9LjA`-u35>X7Xx_Ye}9j)xyy;kJJ2!P>xTxFhlx zm(BTsx5FASy?Yyu+02K|)1q*rZ3505+Xqo`X{e)p5qG>T$M!X0cwQv~zYC8?X&VNP ziouw(z6UlHRbhKaEdII9!%nFV(5Uc7wR`tKbcsFoC02pdkt}rQ{|3K3vyl3mp;z-D zaC$t^=tKmxESia&NF!Jk;?Fz%^%KsQH-I8hQ{w+X9;aEoB@Gg>;2T+nU)OhHOn@oo zdCy1j)K1LVIt2eY-@LJ*aQ6cTlMXJ$8ymtPI6WUGDqaSOpEBrLdlCL#$i^Z5Z=!H= z06Y)9#(NV!A>Q7A&P`fm`H{`!AZdfFBM}&-uZeecPT_`arC8@ANh~8~;~L8n^lwzg zsIoxRdlU-^91|RO%M5%=wvu(5Pl9}n8qTy&19Od!+&kVm*jKz5E7lA{)-DxtQvMvg zQn`(pLY7$DSAtJ(tK#l$AMpLKF1F|cnAR^unI=698Cr`K2aC~4{}|4{_K$}KTH(av zKKv}CjMtMUp_I53UOqAn-#^#JBfcw;J58R5y+c&2l_itzm!Xy2W%!gl2=zFYtja1Q z(*^IawSN+b#g@T0p(uP?98P}RD#F6MZm6rKh%d9!!QZ?U_)oWCYIiYM)HcH3x9iB; z6BlsH6kQyMl|!9$6I6`44}o9Kp|ry@ykXpl<#m%$$H56rhrO^F-++sGH1d~Ol7*Jx z5c1|eG<_M1a}QsKcb!imWH1ctO<9QNQ~~$k09;8~hJhR{{3Jx7(B>GK?feH$Ca;3G_i}mP%2yJ{-_sC!dOlTYezu+KdN*Eorg8p#^6Dtj-PZyrS=uEdkue;=UN9zR}nrv%x)J(E;yjX>3n zZ^(_?!aPkM4{nR56O2>MSFU{i~7pN*f1M4e(cc z3GU*0!9CwfXkhNRJ0TmMT;brx?pFMmp$}g7xyY@!2)5rhgG+}7cuu?wv4I{qIVc*I zhV4VO*wgqV+ZZEn@zF!G1(MeoV6$m2JpDEW7kR3n+R`HMiT6gGclPj6$q!B94?;+p zGOs|OPfQZ#@vI-0;p4%F7b@oD1Qy&q4`k-|;u_H(l< z%<$%?&9HHyG!}1MK^6+F!s|y*;%U(lq#he_QYnL%#iqDC+z*{{>T}O-rUf;37me5VZNi`v6fUK?;@7@F8zdM zm3^40=!}QBtw_03aOaGdzz@$v)2mP5Pmd@{7OSJHr~{d5`UICMN`qGQc{ote48B4u zFz$N^N`KFWi!TYtG=rFSl^5 zPsXSEs-z|21=LJy28V{T*qryTE@`gEp|_V&>6R~^>T3Y;GX=19*)b>_GX?FP zN-^%s{)RT?)rOhR{V0IKp& z}zcCtU-!V>5PY|A649;c(b22ouAez-oIx*lBhY!?R{VYv*CG{u+wo_v+x? zhGoRUt`Z)lE8*qDAgqe_hr8cYa4>cSu6q6)W(?_reP|*4NKuAYE-O&Qc`PiOa2pCM zLg3SORZ^H2Nm^^?@PfKDiRR&Dgec8Gy@I{axfC!UDh6k@wV?C3JY3)y05dAz;LhDE z;nDMZWX@0lWErKwi8qJQ+ASSp+r){2&^>I`lS9=xmmvF38qW2~Ls#)2va~Z1rQ&Zu z_)1OuB|8u63sUi+y#od`)xe7<|LWI%S8Q|X#fwVwaJPa7zIW5X!~I_Pr{xgg6>5`r z1;ga}o-bI}n~qYNbHO^I0u^=CuqN6GuUyW>lZ_8yhQ2L^JjlR5j*+l4X&oBYE&|mg zd%U(L8SAIc!#LLxR2(0Gud=VA&h>iuzWF6uRT$#pBtzt{nS(D(QgEVaF7%1Ypn=I1 z-1|csCkuDL$AmV7;92DF;4>(Z-2{&}&Oi~_WVm zNW4QLo|}9DQ)I-^K++H$ymatwcOF=jSs|}?B5v#d7rWQ^m}B=9zRewr(+iz2^X>sW z7+wbvXXCLXrW5M4UqkrsHn1{pgi9Ja*w^Y#j&!!-+v!3)rMlC&RaJ{D5MPL1Kg@~s zt!wz?q#9A^I!sQq2jWg#fLF>3@l2BxJos@4dmO_schyA9-Byb^PJ7VJ*_(u2yoia1 zazNQU9S_2U|J#wOMm`svV;*9^+(G;@)*97y&qF}I1a4o@4(5CEV9;I&KgYKNda2@> zg}ZS^l_;J#KMhywOW}gCv&hbkr9@J`irn9_gPb@KMARG_;AZb6QZ(*8j9H}jPve{7 zsjj0a=DrChtAB?6AA@MIyAaUz06Wj{(C*hkB+e#S^x!*KZWw?%ov&y( z=P+L17LUmeN_g{y08X41LirX&G*&GK-snx}(H#I8?1yK!r(m>&4C+p~45ddugWcCz zXe2Tgd6%aV1H)P9)%ObRt*>CHl@reB@FvZ#kPI|x5?hZ&cq6`_TX#+w+8{y7A`!T1zB!jCeu~ORT$PQQNRv!V1p^1fxe~Fka^! zLR-&#JXh{ZUhS_0iN%4i_LvjeR1LzYUJTAScO4I%u*A9vQ*qJ9cQ{Zwh!LE0>{952 zj+XaWr6U2BJI7&OXBQqSnur#QuEX>RBBb0`i6kmN0g3nW@P1uB%BM=he}$< zduME~euIV{_M{}R8}fF^;CM(t^KDM3rDBEAy>Gy%SC~{R_{Z}V30Ov5@a~;=Xld91 zSL4s)?f3dP!=?%!`N-k88DC-Irz|`^x)02*XyBZ@DmXsxB6cUqk!=qR!E;2K>>W`c z!r8-E)%YAWA1A^ui6#&xN@&?f;asISbSmF_nEB=y<7G6pvoVdc8_u=7$F{(kC>8V77}SfLQ>VIMBidI~m0Q}E5f zRoKSWC7wNNiD|nvc__A(M832Fq10JWU-K7E{@zJcI!cJ$H9Jy+$59V+@cO3-C||XL zY!?XQQjL4C#62Dl?pp^z+b&_-zE}8NK?3J`w858?@;HBv0Txt@~ybx zfDqYZmW!(ne}OyU30T_p9@%zPe6qF&L{{H}d3MrdC~yq=w4O!h^)paPLIk5*=3!9l z1^oSX4^g?9fIFIdAnJ56SYR+cgu- zTeXPXiW{?eaK%n zhlCldC27~rf$kw`9B(H>j?WANbpM34o)2;OeK^F2EhFz$mSBUhD#D3mRF*NrE4N*V zoBcUZur%@si)Bv2bpMgFTi{ZCuIreZnKwI7uRRytlO-+YP`m2Uf2lwIJmlwc&s~S<; z*hkD{ZOOJji^$?tuB6?62D*BzA@a$4aCg!?B3E98q8Ehl|8V!FVL3%%+i7kv-TBl9IjB+yX@ z{V!d`h|m2|hrIx+P8s|?#s;5{FF_ZBD=0t65Z^`H!M2V=#3uGO)bBqF%S_c!-qsdF zCJn+e!i|s=HAl8d&x#MYoNAr0C?&9HZ33Dm#0#;uLTp!ua7JJTM+ihj-Db-o)s zXS(965!ayiPccL$d!mX}5(c`z!0K8bw3_yXn4D}$TDV>J7asom0^nQ}ncX>(Y!6YubA=;t!9z>j>pmJ6&wdJH#;(Rw^DJ&j%o$w;pTyHq`>7f(x4Z{i;&z~IvmQ=O_rS7$ zIRD@HyH-I)Mn>hT{MEam9amll-;~{yXg79EQLH0sq^*A{74D ztm$=ULurD}(dr(B0qn~Ylw8kfm-wBmk*w(~C;P36nSaea!NSVJQ9B_N!(PcsmQ`ny zP>2+cb{CQN+LI&|hBF2C#~0B+y-qOlZem|--_m&LK;}DtnegFw75P{-gei|LWBuuE zT$ZjOpEVfg=T05KO*3J&XEZRY{2VLm0YYHnUjU zC6PQ&ss2$phHiegTN19PE6M$GN3tT-84X<-v$^iT^dk}{g3JaiJ-89soW zlyxQNSC($`GW@+`~wz656aW>DUw0U`~~Qa{<5%*;bccyVY6QNJGo zx9@q9b>SE2%nT($V%DH_=38#2#GHQb_|8nfb&*Hw3gP;!(yDo;hNyAw9hYVOR1jLe z2`6-u==rvxg33;LHqO^l^24&l8>bVlGBrU|GT-@^W+r!x3^ge%cq%Rf=bmCn<|RPAX$o`bH%8k2;gl zzYH1YCS$_sVAi>5Hy?9)pm6Z<5;DIw8hhU8;4}BNtl;Ee_Frc6|Fd8257iLAcX=u9 zBMBqot*T__y*$!?rH1&+@BZRyL7A{$M&gZIy2XaEnIu9m5XVhfLxO$n#jDEhlEVrH z;s=dJM9K6ZdGc-&IkLE%c*{N~9~MQEFZ(me39YlF?l>XtYCQ1?EW)s()5V9cN0Zfo z8Kha_K*B6ih=rvQ@e?X=e!Vl1U*|=5n{C8jHJg|zAHlPK?+}CjGU8iW)=&DSQAZQ@8{guVF2)5+r2 z+Y}3CIiYZhC%uI&#A>x6N%OZQw*y>Bp5-fin{}8ZsP853wmrc0y*`AKj3pl1!pO;x zR=g>%PO2|lBVT`8l9LN-iDFuRvh?~2EIkxQPJ0#O(?gvYrZOET{~iO?56iJVOdgL$ z93=DD2U0BSNL($pl7>U>Q21^Q-u`rc7Rt(S%9ZFC3+^d3judo5Ts zcLb4L)P_S6dazq@2T9%9j?yA6a{AIr5_#QKeE(6i(;OR9((=VkJl#)+bTulA2mdt? zFIzi~?5jcb%^-nd{R{;U}BdG zxf->Hd^&pz^ZLKU=v;M@$yNV%(RfGSlhqZ6msPL(5>*|2b!zqh7mfd}k-)0Z07z+Y z1^I`r|Nm&5smUClJ>1N8Oi>d5wWBn9sXh+-xI@TaoKIf)$clw+A|}5wo#iKAXVuz2 zX|6oa`K5lxHLEW|bI35<|7w=V_MHjcTWo?iZ+dc?1vjBsu!P7@?r?w1RNB}0XVuD% z@uF>`4~eWbrc;~2YRp!qm*1wNz*=?NY2jL1`gwFEZyH^|UwS!{bFQxAMz!Sf8>j0~ z?nifJecA!X>%SuSq<~jM6+G}v?;V=DeU%W-izVSY>a>|!yq=q`X+C1cL|2{RM1-2KXBeP06XUu z@(wfC&{ZK`^o~gch26^3N>HXIBi>Ov&;Rz>QWxDQ5$PiM)0FL3pZ57nu3V2vyDpcx&@T ztn1u}*85A)dd?7>{MZ8Z-Nfj%VIv+5%SV38UR=@n6XNE^;?%_f;$^8x#M|Hz*|}a z-;`g+j5vmUY>f#s_d&sHGG>~6BRY0{Nus49S+TqTr@VOx-v(J>!rjx@vQ-byNbh5@ zml=NirigaC_3_z_foOOC0KU2uh(X8PakQ@^b_H({AB{XGo;ce`?4O{ES@rudrQV*L zYe*y7m-b=0!E~ZNdM|m(O(6lJ1IXt-eZ=1*ui}%D6dQIT8Rg*-#~7MB$8rBPZB!NkZj7R^3vr324z{2)arRS$H))+ zA2TKc7gym=z17&hj^JCm8%vi*!^U%maNQ>hbjwvGZ}W!X*){dJ&%%(vsuSq2F%A#8 zwL_jV#Z|hG|2t^>AGz#}5_d^;e-ubh%7p%x-CX{obbMAcj4J*Yq^OJjwu>8E%VyJl=8`MV+TL(S57-ie%GnFdMnca4X86 zz9jW9WBmfmo-rJ%Z)~H>9$$mRdBgFWe-V9MyO^mDv4o+oA4A>wjqtlp9lj3NkhTv$ z%Qj6PF35h4BV4dLX_%8M=+1b-f@egKP}h9?a`mUMciVn$eAO6~ob2V_3nK*Qwb6L! zR2Ei0EMoV^X|kZeBdnnQjwmzY8hSfQX~nM5%uW6b(;N^-f9Q=szseO97uoWzmQ$+Q zH}8fa&yT~NEyJKg1DH`rH22rRo~>2tg?H=rup?V?_~SM~{OF{?SYMMNppNXS~yJI22IvF$TdobC#AE(gWN%Ivw zdBvM||4G9fR#hqwtGCtBI@RZ3`C}Ekyd!0pJ6TMROxEYU*>6JKrzY%GCW`|>4mZQmYb5uXzOWFg@iIbi@#00G}a$?dXyxdw( z(o*xtqm93jwVxpJ8#a;#wv{-(-$k~`bVE}~4f@YCCDDp=h{cF7B310c-j$VTGvhOs z&Ly~XdSu$>=rPQNz}pB)z+Ug%1<3WzlKW@t;h@ zKC^LIY8kpY??A5+J8?#_J(ho$VunF4E{wTOs=nCapae5V?a7+Z?U+Sk4n&KMJ{>21 zx(jd)-%fI_Z6{|QrVxjLP4Hc}7~d3*C&OmB;G3NeB#(Pb)aPzMp9nKj_WdWm{;VZF zI7pL(_LswoO9M#n)*~qQaVDm93%K%YAo&!#k|;U%pwsXK?9D$wS_f;90Tx*(8%D?# ztJ~lfSAs?SII^Ms0a<3}ApUQl$bV$pGf$WMpJZnDdY&PWdv6@i{B5fKM-rGj_b$Hsh&GM&Z%?5Qm0V;bxw~Cl!%Kq@lHdAoUHD0 z4z8Xy#@KP;wLy-pVP#SkRCH2Znc!KL>^5 zmIZsbafX8+zVHV|Z`e*I8k57iEbM7yz@q z+t6cx4mP#q;aG#u+@FOxSaH2SQQa~C#Tz}uk6)=^o#;HNURp*DhW>(ODI=g`Llv5= zng{k}qsa7c9ELyt$PKkmgF|bI_F(x((d9~}b+dLEYTn2vs; zDTLdz7YFw_3x-;6!Qq`eF6g`hiqYEGW;zrn1lJ);UrZivD1?!fL;gFA{U2?acf#m@ zGS-%qq&Nnc_i-wlVCyu^$6~VRiiI@R#HYHu|Iq4LpJJ=sZ{3vsXi%)a-z_V)vuKd6 zFCTcNp|6SKoV`BPeH9j0%kS29{FoZ&)b%D;3i>J4yTc0{EtY4w}qsphg|r$_BmtDAjns;!(;s@q2Nsje9LSUg<*YPC(GUiD>i(h*fc9R0kb ztKTU0s}6r>`hWXa(+iqb{Xg*9LkF?s^6dteQ{p0dzkHMU$;vE=#y~@JXtu7>GvrxW-28(6tZ0_!dP#IEVcGq>W^RAWCfj5q*~OBG4z zrn_)!|0R0Ps)V-Z5p+$7ql>Pm(UUR~Zr%Qa=w-i@=?=Lf%1VDlzZd?-`U#g=gPRvD znhuPZAET$Y>*E=h#Qz%o{HNV!H;d)*!RD5OMs3wc=8bC?cJF1oM_K zjLJKU+B4P=O$P(y0@85Lo>DU4)dZqB#-3Pqr{YcDzTyG4hB)YSKTc2^(-4 z?eY#`s9XVwSbmuB@`o{LtQ~P1rb9N^P9mR&x{@`IFJsT`5aQ4pN#+G+ke<@TM16cM z+A8hD7^}y)xZLQ!lP`C|zh(@K_#vI}e2+9b?x(azzexH!O|g1MZ;^D#rsL9=-cqSi zt#-Bj?(msGSB^K3R zGE$`Wg?pungSS*iDIbyge~Fh)yL(XD8FE_s_Hml@*7tB}ub~PdGygqrnPalZ%;bXT-gG6-@83Nat&>Fqhv$kW{;NY_ zO0?)W4H7+_p~UrToh7RJE%|@#D*AWdozM%S{o|AWxBEIy_+Py=_V#Sa+Pq>`)jLg6 z6YI*n`cDA7<|?t@lB=2ZeHh%Y{pmgtJ_Qkjh~!O+!z*r!iqNfM)Sqz`?5bD`m#C2 zYhn6bDbF`))4Io%Rrlh?f$bmyrrVNW;;RufGibFiqufBaQ6DRuGQTJ?ygZS`1>}Rw z!2o!0Z773wDD_*u1eVFoHgzq4BF}XKz~7 z9qr|GyUzh?dG;5r|DH$xw8(LOk2bO8B0D%+V~Lm6YvR+%5wz#)C%DgT;%@{RGXK;} zepBsD>a@5L3?;$bt4)t_$vb!Us=J08Sg9x^>oB3{&%elQv4v2syoo7n*g z*CwFZh$+ldrv=bDi&U;aOo+ z8e)i^|H5c5?gY>er@{BP!0kF^E)D%|k2`eraba93QE!H-yDpwWI5$#q%)*zwY?Z}9 zt6mFN9aMy{$n31J)aE=rY^?bd7dnD;drK7FhH1e{wXcld6q3*@{Z=os=%3#-?#&nvq9UM z2x;NYyusWmE@$67tU4VcD9rqZl^6ZdKj#pZ$b4i?3TN5rfbz;3of^6dCXs+6=P-C; z6>Xe*6ZJCX_^sxq!s*~Fk#W@--lEKvJK%hc-n*?(<^D~HKHg^o{^O0gs@aeDBcYKT z;Y3_?_+`${bu3sNj)zm@ep4fj2)I42h>NTVfmJlz@(v&?t(bq^%lE8f6+33#<%y(WIN-kadyjV5Y~TX2KH~6@3RS5TA;*U-@FKa z`uow4yeLsp@@}~QB^JN?yaT^ynW$x`PegOXIKAf*w{le>UHKso#;hoXbK2{u~-15el z-gxF!n;DbzUBQ)R#bJ=G6-}Fx!}dHI&kSlC z;M7VtcH3_Od`i&f=imPA@bkk6mNP<=3;5x{W?9<6^g%}*93JV@C7V4t!>I=$KhIGZ zu5=mBs?EWl#o-tmF<)reGL{_n{>1DJ?IaczYS?#;21aT2C8f*EN%Y|mx_Fo^l^tUz z$Qf?Mm6vX!*2l)m)8Xm#cwiK_c4h|*r?aWW(h!XNVMazw;7BS-Wk0{Yshl716?|Q$ z@M~vSk*TXUfl2UbCeGQ553^x+7Yvp`4>G%}Bux}{RtnY(ga z-DyewMNq$2 z#0B#Gpu0YRg;uO%Caw->c{+mbjeiP$qfMCW%>lSleKyR`?MKO^l`OIK8I*|gxo7$9 zqM39oxZUQz2cOw$kzyh-jR`-4l;$A(bb0-B8ny>L) zFSpRzuU0JK!c+R9ZZfw%ZX_0*0_f`!O;oayXu8U1VVV(TIuXt=RG3Rv-Kt?`OPxsM z#8;I|B%bW*v>OhJfi-kyk1eNCeVw&`K>GIV26!>{3g_A<7Dq*orA?b=V&tWEQ1!n@ zd*^p^J>$zEWy?i6^lc?ScUnI6>z4wEmkv1atrMhhnCDuj6-en6P| zEy{6sX!vA1+Rvtjd-U!k4RJrgtmjwL?|05J;rak_CZLo3+Hn(?c+VAHA6FDCwBM0l z4;eB-rx1tb-sClQ`O*0PvAFn@4(6*bEH+06I+d;IRXu;^c2$M_ZWIx*^+B+TT3eNUy}EMa z@Cz*R=~>ub(h4U$rjYBqkhWjE#=654*gnmdFn|A0mQbCDRbMJOkIg&5rnw!wR*tNS zT;R)>B^E(g%tO9rx^>n4RrdHIZY0dnTf}uWO@SKQRPNHGVU)R*zzNeKRR2#B*PwHb zu6du225B>CwCrncPhlbDm9#Os`yhKZMGIr!9l(xlT5M(ZcFCW@1@Lu7f2x`X)Mn>& zYTIZ5*+Yh~z|&p)wfcn^;Cc$$|GeVcwOk;ew}B3KIttONZ}2`Jj{-ALhM2jBISedh z{OyzcU-xWqFIhsX*Xvj9eslqHox(@Brl`)|w=_a^{{f_-k1N!KAH>)`kGU(=s%Wt&p4HBr z#p@^T#g@VA@mpj)9~d(X@7BNJCdE&oN8bM8Zx~ln^L$M@_IxF6)Uc;9&rL{Y>HF1&r0$o3XQi9ceppbu_DXrIR{&RJkpa2T8Tc{R2U zKZrhY2SHap6<1#!j1z;!RC1#jzAX;K4BQ72_`d0_(_sYt*WKR*_zS6cPBbri^s zAA@?P9u80L0nV3SfcrecVT;iMYN#(yA-AHi3Y#?qSo z>gey=!)$NJTmyg5>OkD-ZV5J;O6VcV;kIlkr}-Djy=e zn?3`xvT6mp<{bX&pi%TDQ{Y|kY!^yPOh$_?`~KNpXFB4*O!9HWqAd< z^M@7ANZJYsx6|3M+jr@oy()NI_8S*n@eQh+mJpen(bT<12RCn3rHVfUc$NN7Z>wg* z0q0AQeAbf&CdFZRT?$t=S{}}A9!Oe0c2QWZ$ubR<>D<%L;oHy*{@845%(&bi`z7hY z_DjEEXDC4VM+N39nvT=WTWE0Q8)|hpi_d(x3OLz9?)JQBF7~?tx__5LtCY*|>4XlA z&#}hgOU|&Cn1e8I@?o00F^0A*EfP7LRlv*xYItPZ4tn6UFIZPv@dxJQL8iX}JQ-p@ zTR%tAzSoYzOT*x*chSCV*pG>1i~W97jC#(zCJrI0YCdd-*D}mLz7yK~ta!bQMMBSp z0c_9%CHnSgH`n%aDAo@tMXlKtEOpi)bWXVkXO~@pkF)o(!dde$NlyVZyzf)9>Rp&p zHV+_u5Y)Apuo}??*iv?$Q?@FC-%lH0>;1P?RtsD~ws02Y*B_-%y>CJ3y%ZYrK@R+a z61dg5H5ETQuF$S?j#!sz&d3QFVe*gXOxv-5{hHN@3&*M8&0$g0NF*n$cAo|t-C|g< z9f4DM*)X#;ov*B2iW=jPJoLK(OK+=!?Tgpk_pWVtELXxUU6RgzR0X1V>v6Dsug`V8 zJxeWA%-L!?Z;+AMg0J*mSE*(zg3&=$cIcZ0EbJPOSHnA?C@2kEN^QCLhA1%3Gvf9| zU1ER7W}}K+EcHnbV(WcAiiD60wCCYmq@Vu(s7V{IDMbi%dc2`3-s0Y)7U06rs`(OHYNYUbWwlOo3WCa>9^AG+1Bib#u;AI zTLx9Ra%O*{nu|(^V;3$pLjSYPkQ89Rr1xJsC?to0zheSgF^%9hoX(#g1<^^x3@Pm*-C{;V-(1GyA1L> zf+HR4B8Bpwrjk6Vju3o)lqAEVjXRKi2v6Ij398&RRu?~${hpPJjT<^q$shRA;0k)NZ96WCJB}Lit!&xgVQg%Ef1&S|MQpQNpwPV25jqvm!Ql`6fx7Cl z5SfXnv^)W4ELuZVAJ3O;vQDSzOKu6Hw>E?Sm~wP6FqaH@J&{~9zKT;{JCixFV~9YkN{?_>x`pNJ;@_W)?zAtr}bX;~4bhFXGmw4TPhoM>5IO6+)U;4yFWL zqU+U-*+jVm;P_+?Szhe~JwsnG)wXh4vA-{?tuhjxNr%vYpJ8-!PYoZrdWNX>Vh*1^ zu^Uok{&8i?V{Gv;qF3^Bsr{`Nw5X+;uHuifP_2Th5RXrl7P7~x^bdch&konZHizYGj)2x`6pEhl9y~>@fd{tE|!*HShD_B<5SD4(ULbOA6v2`cU(~izSxJ&f#)ps2x zFEqpBy+usp3gZv9MdJ=5Rr=~$IL=!VfcxS;(#fM*dBYfaG;bY5=UVKbC$F_~S9Z*% z$If-rrm7s##^iHcdiERo*J=Y8Hw4q=NsgTHUp1=jUqpw_b7H@4`ErU=_tJ>n1wI$`~BSc7+%Iac$f(h&qDAoUVvjOp8x!<_(^>P}7kr)cVmG*qGZ2*;OZK|C57xWxwm3oU1XYzl?#P&TQ(X z=0(fKY~`9%r8IPEH9yQhLh#`QE-!ovyWQ7?WsZ)7Lj|i?%c2p2-_S%{I68_}pTA9y zUY&+lY9o-IH6t=g1L$nYQGQC(TPQJ!rCawM2Ms&r*?GMv71 zgzG=?B=c=?l}tWWE-5)_BqWZD5SGs^C%H#+h>rL#+w%NAG%KB@gX>0+vA=kheW91u z)GQQ6kBMW2hKy@l)C_yy&xQBqd${G(mP2H?CoEa#&zZ;A(=&@Tu(MK)b?$w`|KQ8$ z=#;rt2EWJC7g_<-C?^75X_;_gOEu_{<|D8(NJFHsmSe`Zdz2%n}*>a29zH-^+ zUG&`WQ4px@M6!}+;p?j9LeB^vR*_M{w#_wVjg1EcZ`rl%iMPNMKRL02@YQV3+@rL> z`yrpucM0|+tl-C;b7ONnN5es@tChFvAChlna&V$32F-MevG&^pyf-|Jex5oXWTaCC zwE$VJeO(%q{VAtaGbygUq)UXAw`j-rlT~Ijufnn+YTTlOdl{D!#jlT9D!6Wv zWxAb_LV;os9)C87J(_fZX}p}mx~6>MhwM}mQj?6SMPD`YY={d}dO3@>-n)qfW7pBx z`RC~--8OFM`AC-S;!WL3_J{@t>?1WhrsAN#hp4lqKUDoX$+ul9!4N8o%l>F$)|=l| zlPou~6}s$pz)$x0Lk3nU)Wc~NHF#5Rhp!jqf&SK5Vda?$37!s@ zcx*m{;kJhEw@;6lQG$O*{n-OB`OB-=Q?I#@FJCLo}*oG(f#)@UWo@4zjfAOkU zUXW#TTXE`$!Kgi=fE{xw<1VegMF%A*Fy|*t)F!zGB!{!OPlNA5_=PL9R5*;2S17Ws zl!cgdrxbk#CE*IYINa2#%)FH2*n_+BSm*g1&yTENAzqVN;kY2yw0#R6&M$;OPC|a_ zJfOE;ykWnyG`Ux1W;DYz3f)DqsNWuj_2v($&a08)*fdqzf>n{w=ngBCyN<=$C8+do~r9!a9!Ya=ad;0R*Wq{CO)W^%R%A=2%>G^DDlV<;> zLG~Os_;(ZJSM+d)bKY=qjuSvJFOe^^+D2D)++hcNCa^ybe$d0yj|mayec5ZHSmDI; zRy;P=8Qx38tZnste#z}QSWz6!>HpY40wKG#>|bI+A@$m-Iq)H$KI}C8AnxGO z?{4N+-+je5{@g?7CF#PTm(Sqd&l;+-QAlPBE z=#tM0mN;+<>#KPH7x^!OSij4BIV)nx%_{6=ZJ@C7_gm~UzKd-s4udP<&e-f4iF!`& zu)-)$^}162x;gc;(xQvf6X=uQ z+H7-!JLKJSVyP`7@MZ9{e__N+Q7t{oYkUjm_O&tkZI=wlO-vIl{Z_`Qm2T$L?#I*U z6YF3`mN&muO^h<01(5trmdSPkrMU41B|=^C?~`C{SgHa()BY6-QT$nxf2xuS&$rpOqvB z+b;5{lb65|J$pPeEu5?2hvLg3P2qw@gy66In-)xMqlaFq3LjJ~z-48jNYQm4i#sKS zQkf6Xc62zk_>XTiYieLiQwUW$a)O(@_&8fId9~oIbY9p!#uXiP-gCd!ZlzNn_F?Q- z2(wY%gP-Tg5`$V_ynbsmZn?m*w6q~K<`jicog&dQZZHdhHLPFR z6x=)YuxMG30v&hgHEpXqNvC!#WzJoh>`#lpo42{c`-W^T0cJpKxV6w|;ZIMTx{1<^ z68^7+wh%YP1}_a!N0aBpu)TA!5VqAy=uN!LewNN<+b``C3ZukqfI>61|JBB9#}{D4 z*?VmB<>&arK#VW9g|Mq%mx8Z?Syjl~7#-meH;u47;BPy|-P^M)Dfw5@XiK;h>=Ac9OH$H36Jf4rHDa3xq2(igDtY zN$?~+9p8S+pnb;cNiMJk_GRt|`ZvXl?ui`0d_r#sO|!e$lqb1-#gMO@kG&gju}BV| z&IzC|zD;7%D?FbWyAb0-WV1V0`s2rp&&+h8SSV24F1bH9 zROpmH!76raBvmKgqK{W8!+F5u^-_h3ZXF`cUBS+;xyCtv9mmwSnxI+#H=@-J;cREE zIm?@xNSl&V_)#(&h)j48Rc=(qfZa7{n4iZkznqR?CWY*IuPH0Pc94b{2ji;LDeQ@F z3SGHx2GhN-jymS&;Bi_KochBv&ZmP4H{@W)g^{#60b$v4b(m%OlBeIWC~ zS%Q9a3{(7Yh3zkD0_vwIID}b3$)S&6y26z%c=3t(4p1Y@8meff=45zUZ-MV5bMV?T z#IK2ySmsX~*lIX|?Ruz4z6-JFJYW_3RP&v_7heUO5s1euliBKG1wpRy7_;|GrRVMp z5`x3GvHFxOurJJKbI(Sy5gRF%{#0XE2BtENQ;iP8v*xi?DGO+O*~+4a1sdI(p#r-Sz} zp2OM4WKxx;CFUsLVSn{3-;2D@g7yX!vdL( zWd#f`@qyOfV{j5QGBkm(SQKi_2Vx3UM3J-N{kcjX$rJ%;fc8W+*FRq?ncbQr{aDhJ7qauQOSM$fqw z|KqmF4&R)LVddy_c%_|%eR?dY$zXGO*XAHMgzaQE{+hDlc_-k0ODWg5YAUJj2!~Ut;=c>jW*m1ynr3tHSC2Rv5L; z1RBz};`IgY%=hI;;pNL%@^(%$Z175lXPI%-^XgmnMYNh<|J9#8xcWnX7t74!I%i$~zqkPy~BMh0|jHq#S#Hba5Sa(e2*J8qg>9Nf-HhX1nq=s(wW zD5lOK2i@jCL;WG*k#G_WUb&!TRvH;H;2hlBI+gTRg`uUzc0AB^4BwT6;kDZZV0u#- z&&>A2DYnb8@WfcWROW>}6{q1&U?D78UdS27SfWznZXEcJOD}wt$6tF^U{LH2(EFH; zw=8aBwxEGOd>&!Mduz2^6&L@S@)$#Pvm(eoh;&J#fHjJ{*3`S_HifOW{YT z2{sy;VzTuvI2rW?%*VZk<&SjG#oHBSd^UpB86DJ~*$k`wTjA@u9K1YXH+U3zfu^c4 zewn!tWsgq6yMIkF2J|s!-((E>5rmgC6i{{bIn-;~3GX6g@#yI+l$#Vo`Y9E2Wi?ab z$Px*+v@IS+W(>f^UE9&==V0zkup&NhJ^;%*G|^IN4qoyW!KA~0n79ERu2RGak~84l zF$imeYT@PFZ`?>lC3Le~3X@6(!%4LOEOaP>-KR(6i_q_I`Bed=nakqjvRs(ASPmuI zkD%K}XZ+-I3GSNb!u9Pj*th8?tWWyJJu6Zr%Ysi4|Ad1m{Sk#P20R6ok|*$S+cL7= zO#$$B;2h}%W8Sey+_vsG z5$ewnlR`CO6|)FbE`;NgfA$;>Jc13okKz4`-QaP`27~HPVsiUm*kBolL5JVLqKXl? z^?@or{kk5@zb?emAFHsz>M6#m#$(Hk51?wb6v?t`@bkD2Hveoc{1SyXlwae4xu*DL z>Pi?s(hZ;38j#FG4?*YkWjNeD4#G|tkWwhY@AiwJV1N?X8Fu5D5#gvirH)Irorc-5 zr?{wnhwyGi0(aa@2^PQl!iAS#hrORA+#7FYd|wyG{nV`IWZubxNzG+WW>5;?5o1&z ztcmtkV^Dg}4R4qS!humT_-pAxSkqkz-nloyvu^_=zRU#O4j;(F)2L5I;=)o6QbR?U zn~;vbMZrLa%3#L}6)fKx0yCAy;2-}=SYr?al?}0Q z|8E;84N^eeJU@Vc<6uq2SoD2T23ZTP!^p23*qw>_SkGZpJv>|-cDA?O^3e*yVVT-m3@##w(TsqPU{|vUu>*5E9^C!v%&MhdFEA;qehuR60Kp1C=`3`_7t1qAA=S+FmxF4-t7f8 z$J6l41%b=WZQxqV%i&zC0hgt83nVkzFlq5QNG-e!wB-@JH;BfHByG$;YKTYI>Y>Id zYf_ikjZo)7RJP2+PpM0wS#$(m8Wmyqo^tTWPX?zAZ7|c)53}=H;ZycZ=-z3J^{dGS_YyzixHos;T<{;zQ@;n!gG2GwKOe6+-wv6n z8{tdY7a09l9ivLKA>gnIzC8vwK^TCd@NhWTmVrYn!@+0YBHX^w5N8Jmp`FS+{2h=6 z<|+j^Z`=qnjtzxZ)hck%eLA+`oytw+zLDhTm|Vfjib$ ztR)*2-DJhv#WFC@vNSVuU`~Gq7AK!KFTEBJw+iRV@ z&wls&JkMu%&pywKMfzJvrF;rhCp;%B?@#n1XExTW$iwH+9DEe2Y38^+wCc$&*d;iS zQo19}DmQ_L>|yqy<{J%O985#g^l04<1EwwIj4KiT`0cJubEXW$=hJtHj1A~}=r0=8 zZvh>aal8=Z-u8*mIJ6$iw$_rm=sBG%&VuWc=SW+UhcyaEv8kKk zUg!_{EKWlH=4uk3c|!}Nf6}u{BKT?CqYQowbNy=xw|$@RVG9t_vJIhe^Rbvr(Haqk zS6G4;06{>$zfm_WRG$vZVk3CD?#Cy;p(tJ$jDusfU|^;~|Gu84z|_OkF?vqp@dV2t7QSO49;7@XtF2D?<;$LM;R{<2tDRzAW|)l!nLDjnKF=9XEa` z;fTF1PD%&j;Ey{r{DUi&sn0_5=PzVa^9r=;5A`3ghd8TcFe)jbDW|6)_eCz5DzD>O zwT@#*dM5X7iyf7Quc7AoDU@?7new)1kk{|2)a_o+x$1AitU20vG|!StyY-M8a;%Xv z%+#m!Q90bgUs^OYTNTM>UEFHx0D7dThkF6_baTxu^1IVY>zAicp?wIg%Wk6>$E%#v z{eN8D@O3mYb~=^zSEKW0!DP8~2U1^ZlT?Ti9`4;oV@DpMmf%Y6kV8H?8}-=w-*35I zSN7nq)j9U}>;Oy4o;Vf&63%H5VYg2J6Z5;b{R+r6KcYs{|Peazih8~QnrCN~^ceS{R zW|{4yEpJ|Pl?t!O^_(pgULOSOYilX@*k@WgA%n91YH|(k@?<+ahkMbX$z9D3q;-pD z;_5D0q+Kki~ZF7M;(a;63%4)F5>FN)Oma~_Op6g5*9@olC>ml>qVxg zYLW0YjrLAggM~~c?J&ruSK@qn+Q*0;eDD?yDP!5BsSeCA@f6~FDp7hm29S{0^ zLy-M^#MkLzYw}(0t-lAcc;lS$rK0DwvlqqHJWtq6J2YXj->|Mkfk(} z49i1lvM`Efz1xG!v%jHN-}i7xz75-N4{aK0{!95X6bIM4&-H@H#D}`pALl}EZ3DGgHXm;i`dX*-}l9bvU+veuzndTj4u;C3bkK;7h47o8xAG<>-AlJsjmnAL)r^twl%`Fa(Q#d~M?@;T^eb;TxkP1X^oi~-yR8WENX`RDrR zG|3|O1bdv1vp{g<0358>VEcQpcG~daWU<(mj2m6yZRd;${oip8!m}Q4I01($wotsr zL~6XS5t?Ju$g#H!4CM1rE-YrL3m%|dcPKKX>|kzC(W7hhu=a63@|kRbQ4i}m=d<;2 zx*@{9#{;+JnaEz;>vw{o0`##|=v{I8^UyM{V#hhWZx9Qe(c zPgC0W(p}GgxDc>}di|AyVa)*CReeGSUtb_q+wsVkSxc4aV`$mT5E?2spH{2u!pO-L zA#ST-vbvhKsJPI?q@iRy+XgJ_5!Y+hQ?xeRLBr`JXg0i|3I1j9^jjzT+^GZV@@HT7 zr{GW9ZgdavW!q1B(8ovP@x3CAMV-!t=A?ciht7+X(UHKq4f?`xg*H3V2k^W-8)LhU zV)>a^%)Gc07Y$1Aci%7;Zt|X)MrN_kCEDz%S_(phP57B%#@shv0z1`$_YYm!(nsvLw)jG~O z_3$cZ;YM%E!m(S9L)j%Y(O~n{Y~4<8XoeQSKH7|3Hh9KnRr!coHV3kX!}~DR(}?XU znU1@$i5TZ%iMQoe&E%h z94?20F)m^y9-R6`A-fC^I699k?`dLL_$B;PamUWKGolwQOKH%6379NeiI*BWtnE?> z0&*5$lJi__sHsKjq9UrxUQbt54ImUt)5mwKX#UwApIzojtCu98SGXw_O!`aR)9UGQ zwg@}CZj#BrG}7$B751%|x@8y9$L3>n`_3>#guf;~;c1WFZKTwLv*@;76D|LtgP|s) zVHxm)^NAgg{p*$?Xpj;+*OuG4Y#}AwNkKQMp)c1IDQ^;Yna-(rgb};Fkk0!a&%V?<0LHxgm zh;|u*70L7IN|7JjJCDHcyem#$sG`Xh(yZG~8u1ewF{b}bI%nmLCy&DM;lM{)Kfe=Z za|Pp3(W?k!Xv<5n3ZJo%+uu#P${>@C6=3=U$YV}5UAonQ@}Lxq;9k(b zRj=t?iI^p|@84q<=V zk5K35UGV;N0=egH(55aWYI_wV>NP!^?({F9z^pM8_{fQs*v>K)e@JP>I| zQ>gL&Qq+20WY4?XsN#1$71?}%?6nUhcdnR@mlk5ixmbL9Q9NY*t4?ci}J!7ynABEG6qu|x_fOfSf zBPM+#x=*a;mQ+2&nbanl-7^O>Hu1PWx14;w$|1e(3a$7cU`F&A?78)jZD1};+~y2_ zV|`?G-JgmPxqb|0Z`5G;z`L))#eCh%D_o>J3c2(?Iu^Cs^>R|iTDR|$L){#+4 zOlxBZD>!`^jkaH?_vWdvQnkQN&KC2PG!T^3Mt6d4(1@H9=z2bped>y4OO~`U)tF1H zc-BynRAL@gs~qEEdi2Xrt30IJA4b{Og9vf?31`J;G*aJ%5?{>4k*Ij`RXRhN?L8JO896pvnYZQ+`X1fSeueDIw9t#RE|3{-Ho1kyY7pgb&Ld_R7geWb- zv7{P09IFGZ+*_plMgvhX@94epaT3{_p!2oX)OFGtp)=HRciaLpjhqj4x&Amdwv5XA zD6x+_R^nW`2}Oyf!2R?$diG`?*C89qDW|@tjW-2O{>dEb>pqm0J{e7=ztd?@{&2dI zokCxO9Z9{uo=%#C(AA_?nmO_^4JgVW#ZSj+hK>!DhRz`0U=!LcqfZ(gauk$40~gI6 zkVtnll&8*yRA_I+OXN-*5QSzIB--_1T!lTO1^#o9;Mrp^Yoh%cFVY3aE9LBGd{W($2_2GU=}m zpPvh;&bAuTZyQnV-V1I&)3IpvIaqW@!{%%fH!vg;?a94aUrqyeqmSTy|2Oc|9z&b! zZepeDH+nTEhs2vNlA3iRC7FrnYNZq#SE$ZDs`f`<_!gAZ&0<~N4m5mxIEl{M;?l#3 zXgWO-{bNtzL64cYT62_Yw-->@4Kd>6WtjJy9hBdL+d&%^(5f_59M<{GB{zqY*DC{Z zNG>F2)jO1t{1TrobVKKe7t48J1g$=&pxbs1N49)MoZV{7ZHq^N?I)bb>V?bp*U>wq zl787*ATCM~!}sRnCd;SkQ9kf7{sOr3fBeh=~CHdvD)btMe-uV>6&xB!BqyjdqjKVLg5NPzSz{UOwY@GEknz?%;f^5&w z7G4?_vaWd3(TP0ceJCq2fb7IYP#n6AE~Z_;h@~o6Cd9*S-)r*845p4>_em|%3zd^+ zU=-(ol9F)t&OZzE;3iJ|wMWN`dNi4J;q3Pib~Y#iwQt<;A$u^Xn_Z{fBeQU&#MpVu zpX01?=1FF@d?&m%bdgU|6#H}J6h427q~H(p*bld>P`&*GS7Va!Y3f_`$)^%^x2Gn(U5k$MmM*q;dbtFM6Fzl**XIl3+#>dC0o$;t((Hswei`_j+K7h zP6y8K#Ut;hJzBC2Q~dp*y2gpx)U-(D&Nq@1Y(*huN+>&Rhgl0{@o``ps^b(z)_u#V z*sG*`u`{A5kOw#@-u(qE_W3Tn5mAku0;h!dQEgurUBLcJZ0bLXdyyW(iHEu`LQ!2e_d-M_dJx)&S}6%az?z6HjAqtN0v4CkH{ zQN8paEP6W}=lUgJs!TF%uquaV>sR7s7PAVkq0Drvvgn50QYNZXXMa6bvK(s#w#5A} z3R8dLwOJN>V$mgfI!~5a9OaltfH{kdoPjbYDW)NeA<;K|jQZ7!9jG;Alcn#Xxx^l^ zr55a6jwiZ5W-twjCd)cB0KM1r&mTZj3XN7;mh zcFl1`g2PsteN=%(tJ;xuNgEX27m%IU4j+yylgqDY@|xOCbCct!cJol^O?1QC?y;D* zwI6=|*i2Jwx~S11g~ptCN3-j1QlF2~%=VuV+U9B?-pw4(hxb8zW+p-#@~LjgMGE&$ zf#KsacKqc!`ZVDN({a{f7YdwMo_R96t^1vde2UquMebOA{{-Ev1jY~S#Xgr^r+B+$ zRQWYSYj`SNsb*r~4}k`5xkOh!y&%;%Wi;#=1iS2D9Br*)Q_Hr|WsP~5`q}_v7xm=d zP9tzm<1nG23rV{!L#|mL`;HBT=e`E$47^A?r4J!F?iwY0*TUKSUYO%E1-eRk?B!v3 zbkhn9G93b!u0EJ;IgCA<@EZzK)tHipGTizV;Q7*pn0W9W%y&#DgNHXrdGjokm}k?> zz!Ve>cuKX)B5-ZHK5HDYmkI`Cpe^>E|Wex3)j45k)QdB0=0h99(_GJtu>o!4jkmF^#|ao!b)0|uSVkabLiWPUZg&L zDnpQHa%6tZasO-+UA2tvvpxo5)J8m6{Psx zqGlcK|4DZi<^R0qp=*0_%88DeHtDAqPcMH|v+c>>nq41n)JQ!WeeqSS?!~da_SH;} z%f1*@x$a_o%#Q!+zw@7OIIF~QX`2snchv&9hDY7prfb)@2UdaHqsy^e*?vR1^57a* z5a-MN-s#Kbe7nZ|-@1t?|L?a>HkQnBK5Su+n6f5z>v)cYhNy}fwLPmflH~=r_&m|L zLA|KU&Pb?oc9g7~cYwQ_-%j-zeMA#?pTkfWbKz?L*`hj0sPOXDbuxY5TijBz6xOd| zxaM2_l7d5X*z~3i!ls%4$+$J%h*EDCZ=S9v)GXIw8Rm}#&*$>2@A`Pb*;7fVHZeha z%oyQDodzX-vliN>HVS+18VT{biq0LHC6fI0!8p6PR**VvE0Rl0ksO|>SpCgYTcjFf zE*UbqN}@Mcq596RiamR+7)+@f{z< zo1VFfPy2g9+oqQ=wWWg=xXy4He7;J^$O~gba+XM3}hSRvbp{)77Ic7cS4WrIEXsUQ4zWU4v#^alK)tsBclTvLg zE{o+X%^lgbaWClUIFYca;R|0O43oeuN35!JoqvB?L;O15ONbpAE6i%q5sr!@as9NR zF!4nJ{_1P+@9!O~%KEGB{Ll5gWXbaLuva&#UM`~ohYYo9HP)y4c%%bA@?l&Iv!CCnS!7DLZNSTGCf|er_r(TUGg?`)X~#I;;Z&4(SXZ4mMK68K{isBK`oPcPE1#3eB$N|= z*%;&Ph3n#B+r9`%K9@Mk94}mNJjCA)_2LKhdBbm%ZmxWMVJsJ2>&Q>@RU&P6kbd`n zO%wFT@H-xq()T?Qkwr$aq-gUgHepGdB>jp83!30ped&IQ#Mpg~=o#yctO2n?ZvQmN zz}|IG5=UTCLxWiE+g$|C(WL0En|SK|obEMqID4-JuHzIS-Mk#3&D~<_S0Cy2cr)@` z+sFscNfdWai|6{f7z%fz^l{|fLjJz}DPc)dju3cWPWW09A!IJJ<6HA;Da$j9Y_n{I z?@E@Eij`r`Zq;&-vWk){n;Itx_&7^6@o5-)BKuk3b7GlGNeUirpC#Jl!Pt^a13vrK zE-2Y02%^=;=*pX5;nn0JENSZuPN6mynd(WTv))r!?RAX56Zn>{HKm}^VGyIStf;*E{~ANoHgTTO3jtrnI0waO$cL)tluE*+!f*Z>KnqyI3G#BN>ky0 zxSjg{v%#DPFbOXJCQni(HHLQ%XXA&WDy z@DUABiQ&aowM@$=L9(iK0b3OHRx(t*fv=l(0b|-in98sP!n=#LlFNJbsX2F;@Xs{? z9gTf(-D&}UnwQ|g-V7SHRf_%V_|EP0PZ0hstD#BXEycGU4i@eYEXJ3CO8oxZfn(R# zOvc+r%dv$K=gG`#nxHR!##d#}rKSgS`D3&9(?Z8zRhFC5oyI0Cr?(9}H|I|rFFjbv zC2gXSL{o@jYbt96>!yM15(J6by)ubZb8nHthd9Xr_Jg08>>xR_T8^FPOR-LDgbmYV z$<^3ZVl%Th8+Ly!@9MdZAJc2UM5J>}QoHL5J*i3McZ6$+8e0lbGj<=|xXB5NJU(#S z^Be`O2!XfN^xzHFxe7zV?l|}L4VLJA zZ)clzXGzW+9L?nBUXVQ+v*fb*dvU&ro?zp?45L~;I~gjx zpt?V5nBwtF81t!s`_d8x)2&gw{jp>Gn;u7f{qrX7!vkYs+l&MJ>b#h-%SNP8p_-8p zVknJy7(kO2)sseT0c{Fad5^cTs739})jI01Ljo$J;5=EF?RIDIGHvMO%Ba z@V}kdV&Ntd-CM&xPv6MS-YG(d*97(<@HlxL-6=XG8_ouPH^LR^3#=l|n*Fd+fsfvL zSa_zeHyadKv5hX%_$tRD4=AzLzbfeNe-DQ;C$WMdTBxvBWA6sXVQ8zSs3EAo$l}*n zk;bD7xPR~}yS!adbk53D2S@fYd1GR2tOYOKMNISi4QOc(VzxJ% zpj$A9$rQgp`1F~gThZ=pRZ|sAeRM^9`F7@RaSDTPl(IzcM%bbNC;T{!*#3`21{tvO zVxutaES-B6124 zxavAi*aRW^&_HHd8iC>4-(YggH1@)<7U`3BA;N1k4!4-IX|X)&wn^i~&|-9L8_z^r zOWE&b8?e+U48z6^hmB_!9%bLAETuFQE*&BAc$pxoFZsg?E%vc@7qr>n;*sazkI%@h*q0 zf18RB`xB52Dq>D%U!dRWz+UgMgod*Y8|fp*F7TU?doqFD_&g8}_a~xuyDrie&16C6 z^w`>1U+7LMN7~rltgF2@>os(kNGv~{EsK&CDP0-Bx@Qd(bt=ZPX4h$K#pofd(y)x( zyCq?phpb}tNrh~&O9=CHEy7R#8nm2DWKpFzuq$*F&J=ag)>{QQnNWmYljlRW}@LOC*q#fnofxhE%OW8zVGYcBRKRACRCq;P$l103w4@Ix&Dx11J1R5A(n zXQS~zYXQuQ-?6=-Jkh6&5u)eX>)7ZyG3-v=2+_5?pg;6sj4!KVd7gITFZ}_Vi*Q+Xpj8_5RFSIuiQ6G1!!-ga;c= z<0<(-Ju(Gq(&23TseF9dxerSXB=}o=2Gv;~;S;9M49+XVMG}k+y)NLfK`1WVT*18E zqeaR}X>3iyBvEcvBs1A8C2DQ67Y)le$Nqe3NA}E-%uY33RDAJ1ll94A2W0oK;N#(R z&{>&rC6BRXUlKb~Y>wiVe(V&d!G?IMu$(vZS>78F2G|c`f8^9*INS{>{OleFD+6_% zBqU}`N9Bb$ys)lD{2>{{%ZczeLXH{L-lq70*4TJ&B8t3MVspg|mR_`1^xjKDG_T|r zTfIPu>3BS4g)25P7t=lL&Z$)_&fkPJoeGAOK{@8u_Y&Q|v;`R|2CVPrL9G8tBR0IK z5)af>Sl;)u__NcRbzcf%mX&K!=XDal_npN%Pcvq;eh#kMC!*5S0i{<%k)TuuL|mW)(#XM{B?*;Dv4qv zhnR>87VpBEGo29qRu+W`L)oEiS?t+|{RpTT%({*HF_Rw`;iYpOu5+!Jdg~4r3thJJ z-5m_xwUO}#moR&A7955rB7{8I&#E-UE?b7Uv>dqa^@GKm@3^%^oy8f>LDhrN(3&v? z-`h6ecHeL`bT^Y#@c3$DHUiF7+^SW4Le zW<2@{ONiLbYR27wpF=d8_;?y?T^WS9ssQM}>@S+XVhD@47mf5=2^jKBUo>FIK+&Vw zW!UsqO4M=xrs(ur@|JmqXtRVJyr_eH#lTff z=wEvW1y8TAg%hW;L{D}0Gxf0OL2d%O8`+M`I|W!Y(1h*$mCq9Y{6nJ4FIY+C(XyYb z;T`M6Chm9z1*4TnFx5rZ_P#JED8l^*noP5?m6=G!u^*|!*%h-X*fdE2f^Q;Cdi0up z{W?P(dDEdN^;*>1uz~IQIaws{_Z2n`)7auvd67ox9FeouO%{0TA`AEF%~rSG#M0@i zZ0fob%z3god;d|EDSb3zby0e({P}WrWs8UzBZ95*@nnJT4k9J{B|HsovN1mtaP)Q} zx(6;q%k|foJXVfP>Zxb(el09pvKyZd4aMO#ia4$E88Z%tA$Rv%96ewIN12%@T9@}f z>1!jB=U9hB};r7Fg8|+|-Q1iX~o0KrFaZaVv4zYzWY5j<5=bB!U{nwHxx4V`7&Kw~5 zI_HWcKlG6h#xD~2>YinKx>i`fR9Emjc935bv;*<-$--m5nQV65o~p6kvn9oS4|8q^ z6jWb#@{OiHxK!DZfbmxo-*lrPx|lw()^!!?WGw?*Ump@N%FI?z^a`#zL#YK zX6rGhH?GXUvjPh1WZ1iWBLs$4;N<;5cszS2RhFJWbp9Hw;f+|}$S3r3?_}-(X=C`` zT+Zi&5{_Qaq>2)ro=x+{YHb@t)rHVsxdCv|Y9Ue4QXJiVk!trwvgnp#>f=;Nx7OES zs^<+d>O%;e=ZV{Cw1QmENzdG!kc{vS(lAz?ble8i98!qlf#0C zOcvdnQvwJo%(}6Za^gqL-NeR*9x7F#7War# zRF5Qkwcp(EW)G_P+s)mt`oHhbA@{$d=$pA&Mr;c+Q_PXNj2TS3Dr;>25d^@5> zRY)pFkKt@IN5iPv7-kzT(C<^S7|??QGj8Vz75~&=Zs@u&E400^wyZ7`$eeNsP zwX7cR#vi7!VHJ|8v1M2iaFVwvS;f{ZKgY`s(d3tToZ&fs7|+kq5d2MUIK8rcCjL{{ zDgJYH9q)5195)(u@l`KHocwkto^^iVZ4>@*7aHXF5zP_&8ogp_)iDt=dOzY4e07C+ zeJ*n2OOB55cYIE*Bhomv3vT=lPc2c+gV|2qXHBZB56l;o?v@IxHXo66Ppc6+1y6Rx zG*5Usd7&huR8#16*FdskQjE}de>Ync*bCBqBDlwKUKrHzmA4@G>aFAVQ@rq8_@|N} ztP;M_C97j}@@bdwNT?CG2y>3ph@|!(x@6QcMp;m!bwc9kC+t9X%= zf}JK zLtpN4y1#s+n2+@nx2C#O`D$1=4b&aUk7?52IfdbZ+D9GX-UAn2Rn(W?`|3HL*#5Pu z+d2}dGo<)c!HYS&$H7?H$gxq^4$m@_l-Jp4R>JdaPjxs=+xwVb#gqxe2M-q4haYn*U;px~?)QT6oq zaQ0}LsWAMox$qi4`6*_tqRAipt6wJ>va@z!Y>BT~^^D2OnCzkB7#I1Dd72xs9Jeh( zxWZyd`HY3^qVHo`dt6ua%X$OdjWQC_=l$ZYt<)4knoY&t?$0PX-&5S}63#~+PZp=1 zJzEvf{Es&_c0ot^5KJ8Nnl45Ki>JQy8f$O!uD? zUo(cyskdUgD)dET%r)5J{0Mvt&}B?tN9?EiF? zHfIiA7htB)inkRpI6UMARM&_RwreCt=*%L)eWG@^pe?v$1wxR3mMT8U_$62iy z+?%)*GSY)EYKR`(#>t~n`V`hm1*2h<1uTaIV}2hO40~7tm!(;&9g;EM8X`53DAkUe*IWV>Alut^2CvBIK(9zGue z-z&z<@8JcCEjh{7C1&Hz#f4D&F%>q`q#<2*iJngTOYYWJF+2VN;`+LZW-T1dwl7}9 zs7r;h4}mP*xtt}{LUdw>Pm^Me+e}4GNtf8P z%uts5cNWaUXR!myAE9)rlw#+N5jooRVLvYC!8uz|d1gg7VAv@wDeve7PrcEKROc~4;+&+w@epMLQJ_+3$YEhRH z4~tzt$<`;H&3JhniZ6cBE6u404pI>Pa;{+><0MRNpDvrIGk{$;4iMe+b`weWpTtCE zx}x>F{@{skM9;h(yiJTX;Vq7%{Kru*t^fX!n<)Fz$4~b7wOce3C+c zxG3yZvT^Cy5xJxJR|mcP1~#uHMc#2YhKAZUL5!HVa?wahb}glH2#-D_|Mt> zf=19nQ-PDe?m;afBPeID0x7l};+%$R|6f&t{QpLisb>p?i?N0{&PIrK2mZmgmtWcA z@*XDsAzfXOxTre$o(DTf?WON2i4xqSV%nZoEyPJF{#8&AWBR6oCR8+_7{@pE4X=Qvp(-!Iwmg;I-2 z%D11eSuRKLZ<2v{bqTq$*J6hNe_^9Rj`+M>zGQo=lO$!zO(ttO2Syk4ncONp$zC-- zVc7Nr$@H%?_!$GFBwBNkBk?alUHElM!goX*<2Ka)<+s1qb)Mq7gDIF@!bq=W0_T;(ZvNRRIhbAz zMP)0|d?i`III9`flhcGTHUGpegAMs%qiXm!b?p=|^#rwbgd^mT1^->9AJyv(5)4bH z3v;_=P<;F(#kTLGUaDur3s0y}ue0j}4Ve(`!MAu}@TGWue)J*XM)xM}%Yt*_1LdxK z;!$_Or&clF2~#8ss*+gY zVhxshCRyU0smt;QSV;^Ta*!jpgzmS;Gq!v=rewEPNp%%F^;PfBnf8Ias##pUdT&M1gVrwSogi{H zk;G4H%Hn3qeW11Dt@!)dN5zJXVy-dgJf9j+geF%Bf1~-3(09^lZvO~EX; zzbGr?%BIi4wOJBAkBj+lkNJOaXKVL2Ht*^e_H?8T6Aj8^^W5E-^@#^8DYKdVUaT$( z>*vaL*K{E$YBMXE^Be93Rk(eYvD57{5OmRwz0H$i8s}t~ykR`-xVz|FR*APJvG~5= z40KY@p;Wa3F-c*N+87AlK$%TCn27Anp{UJOL$js?2T#Akx4aW*PdEox{XDD@D>Lo+ zQ!&*~jtw~f6xCZcl6guq`#DWpv?Q<_eSWWI<7N(EMf(0M+VDAyBb7yC)=XmD4msGV z?qJ!;15px8qMM`Z) z_V;2}wq&s9^~o4>Arqk z)skh{XtAF!N3r~&zo3)Q3}2ZPEU(PODQRUk!^eQ#z3jxUJJ&!fUWQq&^R?=)_D8pJsI6aA+5}NfKQ55o1;0PVBAo#fX3RaAbESOMhZ23b?M$ zvIeNKO$vuuFAYUebm2NSEZUaoDy2hQIhk3WSc?3WBUnnMG@HvsvY#{M*xrc;k+N5e z5$9$gKi3{tvm)8Bw^P}&`IGVQ>0XTUzla)oOYVi|pz!<}nyh}&e1-GqSTGG^pXCF) zGa$Dh0!Ml<+;4IhL zvSYDz?32SHYTyb0lFDqV3M~B z2ky2aXz&ZN`xb#kUnWDU+!z-??+k7~l@pk=I~_gZ*JufnO$ zb0lAN6=~Y*Fu8XaIj=CmjHdHA-bXANxurzpUl=S}G$3DOnYo`ypQ{pyR#vmQC+tL@ zI1Mx?6-u zJ`8Dd`?Cz|7zavPpmVG~_%^o^`LzdPw_Tkk&HviQdL|e~d-_<=?VrU!kmTX6;z6Gg?)9Bj`OJh0mVEf{g8f zHJAP;YcT)Wxk_gL)c>FT@O-|7^Aq%X{QbQ~A&WmIxtW4SFkMN~1k*Z7w!eNCO z*LSX&;Ff<%{QrvDo)13kQ12CVYulU zX7bjLMoj5u)5{jKRjF#sUE?5o*YOJP9)~cAwunN9#Ib~#sx;t1D4yJtV~;#6*|@SS zob1=bv2%N~y;*&jcC{zdc9CY$Dq%S9GJ{3fY=gx%e{9`k#w-o`G12qBtat$sV$Rsz zh=TuV)Y`YX{r^O*w;kgn0!?}I^-CbhHsGYa7`?e+%I#V-n#3zZk)rD@^bOABlQINO z&+sj>25DoPR2U7&O{vn$xIx4E=nH#BcvGLKBKmDm&fm1t7YZwa;OJ2!0HyPmkbrWi`a+JL9>9bysNYjrC#;4LLgWcdPh-YO^>Z zEs9kbh5tWm&1bF)y)+w!lRFB@@Rk<*Izq9?bszQH7KxPknYip`v>NbyFADRa?VW?x@LpHMXV)3Tph% zJgQmPYg)}-aYfDiHm+uVx};{atlxiS%6~@E6xZSul-S~Q_j#35phmvacCO0l&PYw} z=P@U)u52QgeKODKm7_d2_^2G$bI6?&%W(=xEl!$=oldiRuC4lCjJ$a`*H82}j4Wl3 z$`&QEM4N2!`OKLQQHY|&mQb`%T1cf5Wy_v@O%b6|S+c%o-uA6sNy}G@s3?`vLh0%E z$9?~CKlgK8&)+lGTxZUlIrDnWbzUc8jMET#kSnM?i!1SO9itl;I9pO4b3zPfaR<{m z+?30z+>6 Q*jx&*zsMgQji<9Gkp3Q(2*b;<)CzBJDJ0!(U&;kgdG*fp&9W+ z`)DyZ-X^F7C~{YlisPn0lrjlN%TmvK_BXB*=h$$5PpaNaS4 z2-rEJS;LiNpN0Z3%4H;5#{#*Xn2)QDDuVk*K4@Si8Y`xjqin}kI(lM~YK{(3*<-6w z`V$@MnUn->+gUPrU5_Z6z};UZLwhOHsFD3gccPMQ*v) zq4N+3zN&M%GkZ+Ht$hsbZ$82J{dh)$rFyAta|!J~=ugcCREXn)@7&I`xrAzWa*pcj zqA#B#$lzBSrf9)G3?S8l6iarI!?{<;k<`2F+O7^HwObT#_Vr;`ZFeSB+V43=4$3S{ zD*`k1rxZk=(m56J+!J;3V6bZeNX4tsPrj$w4^)(Byi7r-)9#?rpoeJxKo++{Jr+)_ zktOSUWEkxVW!RJP1|8K*rDh>=^g--j#?@&qSNg?kNH1+C{8{-N$Fpf9rBMqEzeN!f z2|2QMk0Xw=QAL5jOqrIk4`^wt2k`M(u=-*8uxyZO}=@mv(yf$+;t#Ja$Bo+F_N|^D|{m2>qPmkWe{R(`Fo5**G7H-Z`We7ag zK}h~-W{VLA&B#vTXb7v2-G2q~n#RA>&)XWddum}@qhW54S{d020L%MA!5q?@n!qZLAe*FlZ?Zn*JV3NQFENqpYPfYAOmq^IK*?K)(})h#PPLem-YQE3{A zBsUo2U7g5lM?CRQ7l3=q>PZm^f(O;!;A_#-@bHv7V|@ERPJj7H=F{#n;u4lbf0}u7 z<5v7--pCly6`c`iv5_wMVw%A*?oLJzNpQo(5j%8M_6fS&|%xiOCq79qK>-YYoS+BPtRnC~BXnKRY&76-a>OVzC zlKW70odmk6_KW-Qa1v5G+rYiMD;C;ikJ855MP%vVGGz8-9V9&+;EdDF(C;%Ha?*0x zMG=lroZ?OlZgvsrQUQ`xl|+kLKM)u35!C#445WIw^p@Z;+CSLBHP=5&L+(aXa_2Xi z+d{~T?Mckju5HY$win#2uj1(ZIbb~a48g^6CvlyAh+}YGnARIdA-~C)RD6*DoPT{5 z>{jT(!~Ln89_=X7>nO_{wiF@Xg5%Nm?sKG;&yY6HOhi!+eR+H*6S?h`{M0m6nXS(G z4?S9@#TuRTp|^kWV9Ec@KO-! z>*Zi&iYjSds7otq7NK2Xl1xvyE!02Q!+kwoO=~@6S-mY6IDVojxFqHb3Ocxfd4C8~ zz5o%t_}zQRGMq-G!sLil=`51mtxMvTs=|6grtx+n z2v25_<1hHAxzrGn2vCO!7eA`FCm$UBw-KjTBdE8$mK4UdktTU<@cq7v^tzs;G5WEj zG3pzWTy=vA+)pM~)=n{BQjgR3JKSi}xim)YnIK$!OvqPle(HTllGwO>Km#|+*w(fy z+!AkL=A%_Qbu)|SYKxZ9{ke(s^s1RqIvPRq%MZdrgrz(`J7?S;BQBA?Di9`NXlynXc$Ll3pR2iyJFCl$1|{cKn*Ha zSWb`K^k+^C-y#Oj6VcusBMqiMX2Gu;;rO@%!lN}$*@`Gm+XRF- z@`hM(S0TJv8;SqdY0v_k-E>dj>7#si5Dz3;w5{2Drq2WD_T+;MwU@> z!_gd7eegv_F4L*}#uw-e-3cx4FOdE-PSp90D0P{5PoAn8qwWiNhoEE8qBn+OTv=2Qr@Z1V&QEgwJm840w+$kpP1s=Qfo;$jgxtYAz9z->x zi?QaS-Owf^OwPFq6Gl4%oj76&C-{CqqfaGGS>M9^eY>b({QXwic;X({XV?>cy~j}1 zi@%uul|{%Y-wakVD)gbGEec(gO@h|Bviy4;$Y_27P4Pd%O|un*%c%iq!>6rm`lW{` zv3EY@gloa9#kd%)4LFe6_W z#3jmcysR7wF60NRWR|;vnLW4bAT_K_wf4&{4mGr2T9p9GN%- z9z=??@;99|O1|j{kPQ<&Njx zAv=DYpj)qKF~R|r+~Hy!GUss-Q`y?hY&$NBV(a#ES1}=`%RcPnN?JcbV%l27X?-vE zLk1s>DLqF7zbSIGy2HrrPc6u})ra)lDP|7EY^4XLT}Dx}_hLnf^JE~Bp9+h7eMFISU2 zdt=Dt@70_IDlOdc`o#@D4u9n~UK%37@df1Q?tYHD`X&;6B9(~xRQ~hljK;~d`8a&V zE*xJYz|Qz6#``6*1^EttCd*Inp&G3ks8L9qR$cA{Wj{H%`@$C11+9Yz6XEQyB|Xd- z=L|Y?pqS*u)WYXx%sFZB1aC`BBw<_PIThvC5T|{Jm}*htxIGJIemajFHcn7m@jlv6 z6b@@V&Y=ssRis;@i)(S+otDNt;hw$l2$}2{M%ObW@akJ1VTR`kBKE#ltu z(mosQ;!gzjWe5p9ag>()yGOOnr^zeNDP%7zjQu z;#@u5UV0GBCi{q&sXf0&7!dz`X*516yt44zs{Cn>2f(8S7o)CL5G?)i{R z5lMJHvKP%MvLIV3;+W`BOtzC&;b-W6+g>(mD_>*!9A&huGmRa0dk?>TqF9f&(-~vU>2TtNAkFX8 z#A^@4vnX5y3SM^7X|p70p;i%hYF{sv>Gb0Ydiqf>^AWB{&1^{X5#_4Ou4PT5*TJ#_ zwcHOIXR-w)uZdYuDqP)m7DZ15qgVZF(ca>BT;r}Bc-*&(R=oB^?%Yhe&)on?Jxb?n z49h1MWBQmoW>=YmQ}NijIh8vu_?-D|@Q`k4422=rA1wdM%iNq!Yj6mg2H~#rz+k~v zEXns95+rQ#g%}CA)@ele*T_@<&U-{#za5!AwnZuLY-n4k0tA1yfV`f&C}?UnN9?cw zN*4Ml$=I6S?uP1<{O0XAtM;A!Hd>$EK_5a&P_F zPd?UbP(v#}Cd;~)EWb6YA=$N+{AY0o1@p){h5lxK#~^1*N#Z8HOE zox;(U<(pCIG)XGdJfEz+5KZ5O?!a$@C3tP@k1Xe>5`;1d?Z{v~WcyyO0QHE9z|M=C0a$fsR_#F~4^f6W+&svM*i$=`EYdJ`GEO-`D)GY+w>D z+&#=%zfhr};MzFPE|yhr-oSHH+eU*zR)7^cUe?SZquuaaA*U1%SlIeI+8k17Qxk@jC* z=F6HyI(AF|zNY;|XE7!9UcWe3><)47T(f|nE*WI_b_EE#=8<*$FG;|IFHB0j67{m( z#&o(0p+Pl!Qok++i9Fm-)yZ|5(LbM7N{5m9yPmuW{ZQWXCEw8g$LrYG)I6Z6a*YM? z!tk*D9e#K%nti`?CFPZcv6dm@?7JoF$jy!-JUKA~hJvqyd0{t^ICq;EI$9x7qkEiz zEqM%utz4Hme^9^TBAPZ;jyy(gb0-FF)4-B_WLkL^6_!wiWM(+QtGW*~ET6s58L5?Sf$xL0m<*o^KI@}GOXU^eqtDTT{<2wXL ztMd%rB*T$19lR~M7Ba=E`IIoSBPvo%KhVzuLhlb~+Aqyn*?jIwM$3 z;d74;)-AdZv!odO+MWfv+XSC`W(-w-?BM3+_c;A|7^qZSf=eT&(Ap~xtSCPu-F5`w zV{?Gt;Q?~`x)p!%-wfA&hM)_1L@o|~Nn z-$g56+uj}M<_6a39daamSUxl>zA4l>kSk>A+{H1Z>|k z3W@^(sQ2DIWbuLzD{nP|`h!1^%#v;-GNA@Vl9$njvLLv9uMG2qd|<+&8ExGshTjS( z!Q#!%_{viY(EPdr*Z7sfN9GrJjzyxdUInNVc?PPlogiOo3fg4fwbEdGKEO3A|5G?l;wcO8(e$wa3IP6K+i z7mYTrhG?rwv~uAMRFiiF&K;>h-MP-7a48$?cFBU~A$!;frIKB5~MT$EmT4xb%* zj5Qp_(A4K8aDUJa_zh>ke2WZxChRUCk3f88pC@h`;KS9QXY+I}*P*mS?y%}bByJDY z007rr8k3=Oo~=LZU`6awK}KJewY3jSey3%*5W zqLyTR@bkuS`alON9!Z0HLZ{%B_!|_N%LBHc1zx??f>$ZxFhj-$&DvRk>h{(k$K-jy z`4R)S)_z4hhq9pTPZg@nae>ay!yxIJg3rWd0qVs#>0t;yINcdm2Lyw<(_-9y`~jr= zxd0MJ&O=A97tXkK5S|sP!$H+{kh#ptRQVcwQngtgN#NkU% zBe=c0009=*AXx`Wg0yD{1PsNe1_78{J6JA1QNa; z0Pi0GKqihs^s7-ceY+#RB_9n}dfk9me-myGCBddvN$k2U2$n7>hXnTrV7gcUbhCd# zRaF@ZO*;eGk&hwko;b)V3Bkzk0rb3KF0gem$jf3TxQYBiil--0^=oTL8NH06f4)G2 z)~mrm^bp))NO zPB)E)^r@k~8_8&V3 z@{T1S+T?+iIc4x;!zqaOmB7clp1|?7KHzS%3@peyTzD=VM{YU-xYrTxt&hQK3x0xr z)>oKq(*wIZVxh<^2Sio`!q!hWpfTYN=pDHOf&+1Ah6EoruW|sRfO%NW#}h11oI*Q- zxMcsm_Pl4G!`m?`B)!pa-WS4m-EAMp%GN07Xs6M6hL+Pc6j4v0R0aNAU#n6 zuIkSOO&fo3k<)~rn`e;&9))?WdGJ^{5y{6d!WE}Z0cS89b~!GBua7cdoOy;N*1kr5 zf1I#U)M40~TLgI>Ind~H9{R>l!u{t-U>+_772P%97ke4qD@j1YrW2?+^E--rbqeOV zCxLoC2b%^ghk~*zP*ki1{yB@`S9l8Y{Q3~G9VOuFhrdX+{Sx%`je^JeHaK0h93FK{ zgV=K|C?QN5ro{?D!!sSc`HwaZ6%B$B{UCfm@e};nAOUj@ZUN*o4R-vu6IR4#g5gCI zeCC)Ujt$;%6hi9vw=;XG&yn@A7###5w}ppZI}(JjR!n+ySpg>tSC5 z7aFdGLESVPkQt}2Tw(=m>@$HkKc2v%hAA|C_jQ3SJUajSK45 zgJjHlklCY(H%zX9PDeBNcKQZ*zv)KTNCeOZW6;0y8Law}0HVctPEI%AM%58eewB!} z+};UMeRFVJRXhAT&;^^;72~jg$FMpt8+iKHkxT4$yg1YX+%k{D9Q9^&;aD#&-YbK1 zPRGM~7jYb{`p>qTrD#0G7{8M|3z1?G_}TN#5W4#yJQgs)p;Q;EW~YN+jRYtK8o~Ro z#*mv{1w*$F<5Rl*s;T;~4n$w+p^h_QL%mt02vABg}N)4QEH} zU>dy(sz6)&KWN?+l*Cw#6U~K70+I_6vDL4@sz_C^iX#d zzH;;n;@cze&z}e3@J&T{FslP@-g=7cMQ=k^LO9$w5eX9qe8En*6s@%~hEse~aNIB) zLL|%~;>ZyY?}>oYEzV#cAOJ`6J3**f1`I2v@lse79G0XE!YeiK;bv7lOY{&3w@ct( zcNOsV3UBQHClpE#+`{bdXxRBl75ry?fcW8V2pVn!i?1R$WW5tAjq`wV-v!9HatS<* z>;$!aHvh2C0`Mx90bY^?=uH#BdB@_A4etsp%S(iFwNFuOmM6p?j(`}SJP?|`3f}FP zhZt>FY`M$^_I<8~OV4Xje!eYOWM9Yq4JO(qQ}a2}**YRDKBT$b&DMg(y&wAIe*uN(k&Zo1tdF8P1@#fd4V%|eY)Od&ZbVYfW+_hl#Wjk2tHWQ+BqT$y%0SMSX z6JHqafqV28OpN^k(eip|=^BNN-7Dch;2}^E{|wgKH6U;NA&P%v1JAVO@T2GFAzAZ3 z_*-}u8~c_bw*%p@ZtMzryzK}3q#fP5D^5cUUlACd>@-cEGbE*dFW zNyC!>DNx!Tf~t;pq4T3pP~WD75RkJM4H&P6=PPC49qmOK>z|_4Tlf%PM>$#v`6zA0 z0=N;U1ZusXk<~S65VcW+r;ajkX9a-H`MFrN*q8ULS{^cD3-C&31+TOH!hL2d z1>4+ypu94hSpRM~2Msep*)*n}p><$OTq7v|NPyscsQ22m_;frk> z5;L3x-*PEXt5U+JGgIO9!nH8J`!|{$p8zs0+Aw3qX1Mu#9)1|G1xlu$N2%8R;Bch? z^*piBvpCDh8CEJpf-P4S2gL9(&(xNByhhK}&uCBa8>%fl;6DaQcQJD8z{3mX=zyxkwls7$7HF{um#0l4& zkcZSe)9_LSMZ9-u9voRMf|t8RfS-p64E$P!-zYA{J0GUtCB2!bW;({+dT$`$84HH6 z85W!|h6_dOaM{!mG&G(DjZd6Gc7mW!^B2N|^#Zv0{W1zK;J{z&&3J}TE7D2PhCokA z7~drbR{uE9BIDi2VeeMZXv~D|)_gEmWjdrD7(#y*+QE!{F{s@CGt|zmLuX>{;sp~q zpgPk6j=RMCkHQUiWeH>a?H%IR76HppU7Vr69sBV+fL50PKGW+DZ=>tsh_g4`8F4^; ziVGn5LNllxGlauZFJV}eA2(%Bpf&urP{J-x82@z;VhxXh(0Ks}QURR*G7Uyv`@ybG z4#>GW!XCY77`;3lN3Z&Z#uZM2WYx+iG$5v-$X0iwSmreHARcuj98({q(x}&K7F{qZ!x^Ik%HU1)&huTK+W3(bogZsjPUZ| ztiv%#g{Np`&ladHu0c!GyrCj?5T}^v;`lrmlo>FWDqhG}7KiVbd*Otk9CSD6!+fbc$QKC!`Lkak*VO>q zq`iXl)t_Lan>MZvdBw|ks)^;Fr(uP@J2>pL6K|`25tcg7|L+aX!2ccim;1+T@zy=L zylF$4cx2-gzIag>uV3tkPh7YTS64lNw(sANTY4slytoP0*Y|_bqy^+ST!brg!XP*_ z7esE0;yaZWfb*&!_Vwk$#p^Pl#~Fp5ld-@#;15*i7BY|vg~QUpATBZjemlQ_?BOtM zn`{alGk$>$lZQlIXX5_38?n{gX?R>B1yq`fv54}1DBb6dl_lQwkGov5uYrTHYET{G0()2fgqtt!A|_c5OwW{|s_)lfpm_%FJ8J+ZpRa(! zwyU9asWF!GQh@3!)A24w2pV%X!Y*6}KFPXopb2TFUi>ITPc0)u*KA-Hy<39s&L($zp-6gbs$u| zvtewnEb_6rj3$?;Gg4AJm}k3QHB>~EH58>RLSY>u+*pTC4aa0cIeEsKToJeBbbs9p zV)kV{au9ui(yDHn9$O_&|GzpAr09n6X4?tzHuaC;1M5QYL5qJUEA$mVpS70vG(ZQx z6tc$&XJxRVXBTX^Qh`?LqN z9|!JxX5%2KdtmeFHE8UKgO7n1;fz8u@J)t5)q_>=U|}@)4V;12>VFKmdkJ=qY=hP> zzu@b;d>9Q_1{*K*Kz`X>eBoXfFQcm7OzDj_4iU-39zw;~=hJSSeQqZdd^p7$_29?# z`=|4+SU!Uns{H8ckr}-Axfh{Us2?v*^nka=DA#-MDC7t5a~+<9@JzfGK?XYq)+)P# z(!JgA#h^4g0&kn&Aj$CkFnY)i z^e!a95mT%GKee5U|G9ZTdDAqoIitz-q(+l{uyxa3i<&02{_&l&Iub~rax zN!@Mo3Cn5vt{2->IIhr~Y5lyZbX{DNhyKN;&~2toV!w@>&Oa<|VlzCN0u?qk=})t0 zy3jh(G$onQl;sf8wCrU_)1IVbO|KX2Z~9TaqA7WnUsIph;ifOb+neN>o~D|lz$VFY zkEX2D3rzvX_BRO%i#7qM$A zIlZ2VoS}o29Is=Qoc7I$91p)rjz)DKM`A@Kr#yI=qq!xK6Hwj8(K^+}IreYva%*Ys z*q%y`g?c`x_)s6m_hK8Tb$=x%LL;9OcT$9_xv!7Y6pD6myAaY+a8~=AImAEEBT$l{BS)) zz3C*e2k+BP?Np+7Z9457ECi36I^g(^ALLB$r+Lc%ku75hNbqScU8BW^pQi}X-0&3I zJ!cfk4Q7FBry&otZ?Nq~ZS+M>B%Qo6hi7qpQ6tYH42o_AQH5!)NaKbb%KFFqpNPza zxLw=PA?NqhddmtTc1j%ddVFA0j0Y$hedH8BxJ=|;IZ%II6xTlPH1ar=4Wd~|L~9>I zJ=XE)n+PHL&htO&SfxNqMifvo-i%VR1latp-{^->3TpWsh8VryXiXX+8?Th1gY7NM zyOWw`7kebx-NVN0kiI>eE_)4ach6-vEHXfAhP?63ca!Ml(RFA{wHa>yHD`+~iqIR6 zC8)`?7=4=f#LX(n#*rS6k+oL`Ps=sibV9P1Hg`IaHpd01bY?Rcy>X-|DmQpalV7Ps zoh3OPatXXv_b_EWel$fbnmQO~k_M-BPoyb52Jp!syN@*q0W?=!V5}FY?`LDBr-q z#owt2+fBzm&b(pf*DeG@zu6>>hM@zGWze9x7_QQ>g2g89VYAaIxEpwfcW)#Ddq>By zInMXlq_9b{-XfGOR|z6NvsCdNNo~B!^a`_0vzMqIP~cWA<6}z~sLUi4nZ$ms{pMZ$xq~MbE%HzLb7l)7*@A;6L*~wn2cHi_l11Wcl&g% zS#={-dt5}`N-=a+k_HvI^pWW|+K8TZnd9;$sl41cL87xXkyuU?QK@V0NOQo0^^oAv z!tLv^yV7T@ciVwpx0nyMkWJi<&Ojk^{6G+|#ld0s$u@5@961t0-}}Up>t?H9?iK#<(gzHr6JS#*pAn+NXMqGo?9Ue zr@IBnG@cld9~YoOOe@))@P={r^EL}sZl}!;irHnURjfi%BvIJ;ld8ND$4f5RV1MUM z;JY{%JzFGzdkbu+w!0FQEZs^w7R;fhe&;!Bl4c?1-%CER{537esXzgWCpaVS|54nm zNh>|XvCNS%rsZccnO7lAzvWBQ)BqRKv=^we>_%Mu^*SIiA5-1UyXiuaPvobE9L(Ep z{12m-66>E=Xl!$I!(z@bj|i%=TU4dl9;8U3d%ZC)YGb47DM8rVR|L~HUWL*KEwY}k z8}1qSG5gQ|LF?}JlUEL&?0CpZd@}qg=v+L>><~1j^FCCNI%f}r`)8Ax11yLC_J8D6 z5(m9^Z=uJuXQ5RN|Kez60$J!}L_fsuMGDfTDBH7}85Z3~rZ0NZ;6KlT^*hx^-FjER z77aV3z1$jKimTyiRp_&x3p&}(>0ymZ0s*9DzXNMy-T>o^PGP(4R^W#}()IvROno=7 za(fe?!*ni6YkW@))lJdjZ^~R@x78?8Qx7Ck^yzB1RFrf%ksfNlm3%(h zSx5I3?Nn(OaiCx1NXsJmD>jub@F^ z9a&|Et*qEUE$FVk%%W*|^vuarntk>%yKTi`d`0c?9RqSuJF}ZRQgeYtP(3g zAJ41eZ^iMX(oB?i`J~aF-;@zPJr8#N{D8U+@B{NGg^J8{hPOr1)K^HI?U`9jhSHWG zxqx)$Z*df<8{n&7yrGKgVYreVj@XTM_nJUe!B3{qG=h=5+DXNnW|__R&}wX#@M^T5 zKELtsDFJr2n;tu6twp?3b%+7~FXVM#It;E+#6cGJ>`3cgn)=0x>2<#g%pW_}2MlPv z=uUF*ttIYg-4APdSx{sm13t_kbKZj=;lPVrrzQ5xdJR2#(8UU$f0KlKtUOVQ)+7P7 z33Rkg11an=A$dzHh>VjzlFt<7XdT;ux=Z-cfgkZOmcAK(mGp(Mh%q)wz>s}>ITEzo zG+Ci7CLpVyONaNIh7zI8xXE=nf2WDWe|D z`CxsH%*;N4zRKk>?_L|BO0Pw9q^FrHBfS7M#ExKr+4)Gb{s`@5fO`6u5i#yQR>Vh? zy3GuRqTKn|j_qZFEDd2{as$fru%~x-M$m`Hw1^JB6uVM434hu!#Rz7vMW6O&a6=zY zqpRkRl0An$Ld>$G$WmktT+H!=Q1@yon{EeRI(V$Y-%>Pk<`KG<@_~`#kAaus;i&tC zHLQqLU@T6X6NCF9=zDZEbF_CKmH6<7*EoZp>bu)h_oXFtZ_pTu$p~a+Jy(*5TmI-- zMJy5u%%??HH8&DHZft=sw|UABL}V=S#k6y9kgI@7 zR$PIlkWTcsTGN%sds)T*av9&R)f^#@=}0H_GA~mviSRtk=uGX;WKebjO?Ur~@e=AH z{9U(6yrV7BFpZ%2$#%F=bd$H^cOq4LQN+?X8#Z8-2@T;nu_0}UiSty4(!fB*&(Mi3 z4={lqM;A8zXg)aCtixVGk=%K*ELthK1THM9=0+EMf_g_=D1V$!71o4NftP(qvRW5* zUR}*bJyOBl(*=mjvS{YFUmWxXZKDj{g(DKIrP~VDCvORRO8qa)xGRfx#!A!UD=nzx$+`69(dBSCHwOND z5(e*yD!bgWhJJd{0x|qQ(BGqf8L$7oGLxJF(xsn(@*39Ao7Q#AnXqQEu3GK02I6$ufqbfqr1+NueefgE10CEj*S1u1oYg#idy<^XAVcIz_Clkq<>qD zS#E9ALgG3a z*#7c9w&-alG+PEj?BXC$@$aVDJw{}(nBrNKh22dLD|ZhI$whaD#l+bR=JELx}xWF*7DY7K~2j zv#b3wX_xqRUdwA!)^m{sySqddFG{{mG{!|yQbHK)%8_7k)LZWSc~)di?QwQxQw4RK zp#c?x@zgh|l~mUCpg!%}l&^O=ZM^iGX*@4Z->WwON9Y>tTs(>P{j4BCF>Yl03q)ru z&8BQh5ol)?HFU0ff=YJO(VM~JWUXTqT^SR`O|9Z*Pbkh~Lz55lHtjTlO_S1%LZTNN zw+NoXy>Fg3W?ivo)%RS68TZ3bG4CC^*&>PGIE|9bR~M*tt{ZLe^n#K`LAEj^1deT6 zLwYC95vww9MD}~qaY>e3Zc5?)+`xhOts2B|O(f%Uq#eqZ-9b*1-#O~?*O2&IE!4sP z86CZMm^_p=rj?o;+SnRPY)2rO|ZylMRL4mSTChYHO|ik-CsR$&asC4N$#Qzx9>u5O)agfE5Kp_B6Q8%I?>Pbxa^n?p_xQ$(hn0?eya|3M ztoc2Rs{DC^ciT(B;X_&Mc}q1o(y2`ob420NhDlCSxH$rh6*$oH5ql~Z}ijHZ6$PTEJar_x?DTny7^l9estQ`dd& zyE!2wklRe6_iEG1L|`h7IlQZ%r|6rBP_n;jBh4|B!{*Nu*wMUpYT2*=mtQ=Dt}ZTu z!fkO}p-LZ|KCA;fuEo;M|Ly`;aS2Uro`Pn+PtmOH~;;I`{ZKOeK zjm_b-jswi@v*eYg6tV||QdyhHLe_ie5LhA*Ux)W6c07QfLmrVy{th_5f+Dk|WmsPvBXGE6rEVFv0o9~!NZil> z83?BG`ku?NLjHxUc)AC@)Oi72`Qq8QLClWq(J4fgMZXb$=rW>st{>2&71;is9@*A% zk?K9D21)A}Ze(5wxW2UoyZmyxH`9px8GS+W_%k2|!pNx4E-dM;LT}V^*|m-Rtl;&< zv^@6>k|Uz0tvY^h+3Ec3obTM%}skT+>Ic1vU{mQlQN~_1a(-SgNg8yXnsT( zQT25p{$k%y;axSh&wL}ST&dh}_Cg)Y(OM11ZF;FzksRLhL6TdfJCjMen?c&OrHD=9 z2Acj@mVO+QMeAiv=rPM5%(M4##5326)K=J|P47xLx{FJBC*H=h8x3>VMW(M=Nat(x zITXyw+!e+n6Nk_uB@J{&W&?MJh7%KGX&9zvQyRPZJ~O$w44FNOrk}oM(G7u4sC!Qwv3q2U z4(;n;we1zbv*#1#mkV#$>q1c5x&Y!TTLk8utGpZLj=1^C&c@o_qpbbKPh{KNr>uE? zGP~%E7nq*^)-ZL#2pxRc1MlWOUGI^3b} zo}`mEt9m%526qT4vPF-o>}aL2FnMjDP9>uDk@k((xN$>5AfaGH=gW1#@-xk7mhxWM zmYRk{KV}0jI2o;Vj35uju5v9aR^uQ^1+%MnW9jrCA>j9-kT$rqB2AfE?$EMWFjyc# zh+i?*%J6{kY6Bd=U5+(FpQA%_XR-Z4ZD{G#HPeW1EL{_*4%UM8$cdW3Yx`@&E;p5| z7}7(g=J(K(fpqSYdCsJEiz^(n3*k`Js(egly4(e(;eVmWID~> z$j3P?^N4;Klf*vu-GPZWv0!c-WY_!>F(McF%RzlcTaBar*&5gEDU49Y7`)ta zn-1?j&7N%EPrut=O&gw>j%{jbbkD^_BsV*n2|msP@m6^pDXB|fH_Zmr9IDByeIXc> zDapt<5Q-i9nEokX1@>2>NKQmG`>5`3X0cT(XY`mR&e`uwbj0fE3a%^cgZ(?mk&CNo zo(0hm<@B0;v~e128kz}~$KTSI40$=d7w;1pUy9z`q=e;x)uj7a58Wkkig{Q6m1wNs z<*YRAWP+qMI8wD$WUj(J_T_+vWl5xs?LRY!bw=zwIJWmxneN+4!o8$flQR}!ExxCB zs4S-zWX|He3ODEI>x**sU3Z3fzhdTaq6=}GEFjkBMH!D#e>fP2HQc(LOv2&WobD6l zbYX@MBf)hJYMn<(y+jk|hpH$i>DD2b`~4)H6*>m=e0|vN&CemHTj+P}ON@MBIvqb` z&tzJ4lQ|VFL~m6QovnF+UK^j!bUc&i^j0rrq-U-s`StFMxtBXkFd4wU^ptkhE}_cU zJ`k9i$2?AW#qq5(gzVevId2MbXqmn^P}ASQ6fOEgdYC{I^xp?1y-8>#kizs?%Q4bA zQLu~877vi6jOtZixS1Hjn4Tygaspkzkru~@H_I7OP-g~S3DHu& z0`IGwM;C4|^v-b3uG(`PYi&D5Q|Sie1!mD6%EDyR!yFQ{YLr={5Xlq-J%IS~c7|wr z;l>-ijJoDz?Z)06tX^{+5Q>`FqM9{(krvasUY=9FQdEc1HN4Z7Adm66kqWyb7YGF=wo>|KY4m|G;4 z*=W0*^Xbb%aU6^BHaXkEB$hjhsF-4I6&<(A|Ryv@P?IM3|mp zIX^LCzVoUx>%Gs?N-*iX^5>F+pI3R$)Joyb^ii)M2D ze$of-(f}LYbMDl*5YMv5pjw~I%-J2lIPmE)lS`eMFr~Ak+By$*M<9IfDS-{Wcj>m^ zVJ7;vHD{Lfau`xS1a@T|qwD+c&$>H0uOQ2y?ug~U2MtNqz0I- z*wt;iew!EZ%oKt*ZMu-Grp|23kong=JAbbm4*A@rK6W@#!oQPoAbl|<_+|uxck)ng z1zo8cb|Xw=jp6e|7hF6=V#>EBqFvHH^!pTn9g(S+e`7ke=P(fkDI_^O2JRvb2tK#RTP=f- zV=aYRI_n^1?Ls_^H$Vn<<97)yDrfu)-m)ITz7`2;Xn#1GMEhXM+z9-#<19Q9Uxm)* z)<{X-g0%HlF*`63dn%S=#GD%-XBvQF3FUBMR2jM6pT_5k-gtxD13$5DaDS}~Z~22k z4kIA@*e4XX%mJH|vEUP8kF)xXA>^k84u^G)C=C-R%nsXH0Z@F`H^3N`TaMHY%KPsH3W{#2%?E+(xB#nRC{XwBBdYwSR@ zRZfS?XFtGNhJ_Ac0XUK(gol1H_=~R;Ep0C%|CR=56Ay+sIt+#S`SFFW54OJVg&ytz zG!d(Yy&o&VIHVEkj?SQZS8s+7nKeMgx*+a)h$Ta((d^&^NzKT8Oo|ia7VXClvmfiF=AY@aD_|Xs;3nN-O!% zkE~ z@iq8<$r$M`w?V+w8-@el!8P*`&@R(Mk8yx2CErQ*9}8G-8%Z7TX~aQmVXCb<0iSH+ zrao!&Q;*_!s6MA#cyB=r{<0LqD`%@={_aaiuL4RytPZ&EDx$(nehjSf#n&eBFnN^Q z>~qr?WI0CQ9;^9~_OcYjj8d>IW)S#N@0poCID~qIbKu&jEPhtaB>v0K!p&P}amB*< zaDSa59Oe_j*W#-&CRGfzT6r-eWiu$h%0lJPddhB0jtcP5qP%ZQP&>|L!yf6ws9YsN zofg=RM#lxIv!xdJ+1&!rObHjMOh@Iz3ApZu0+#0RW5zCSOo&N`z@OQe+3Alvxg4m{ zBbL~gkp|zYqtHiI5uLAPfUCLElr3w(h@OK|w_d^OI}5R_Bn*}}T!;SGbKtnbBK&<> z0*$8K!(-oO!{Z2bwC4K?p1-vyf7y7-*T#cdo7;!Hp`zHcxfN%8Q$@}Fk8l7xK*YcI zhv`&n@D+H@TMBi`(kQUo8+BDiK~?81EK>Z5`ALsJ^8R_SnNtiAP50rD;CuLVY6XnW zo(6A}#4+P%EX1j{Kz8=msXR@Dp1kMqcz*^6S1E(#*_&Yg-iW$9YkKeUA`&*HXRD+b~i!7XvloaDC=5N?g#vBYdaO)hPlm zOE1Qt+xyUq_ZYN=u~Arn!Dj8Zuw48r))c5<$|ygMl`q51O384xmInpOyWmL>7Zz&N zV77fYDCziO4z~%$?*9fRN30>f!5p;~)j{d$CQymK1H}dj&`LMLA!9Xo8}tsA+63au z!fzPBD@-?zsny)_^09Xk9$#7YBTU2 z^+YfADAWlnfPL?{!Qw+A3OM^fd5Sf9eXfLCr+HClUMIASCqw-538;*>c82c+Bk) z$a)B4&+k0&Q||;@#UEg87EAUjioly~8{npNE_^<=g!*;uIWCOVpk9{^qJRBCG%mBG z8hdL|@f|mncF_`_MJwXWG;{13{|1|O_JihDU#NW?gKJ+6qhw4w6g&frnmCNd|0v)@ zjR4k&0yOi_1%;t_IFy+Rx?I+{Wak*1f7uG9w=Y7~Z$a4576oUQh~W6eX?Xj>3@qy! zCXp|ykq)Bai1&7=&j^6u*=KN4*nrynO@f*cW=g#|uR?VPWq@5)1x{|NM;9dqJ)ZAH zW9~<2cb^+KHs@lOgcL5CswtO+&GC!1G@c(E09~0RjOX2s#p4QCqiTc~9%^9i`Xfl2 z31B;4JG={w1s7Kr9Ql+1+f)_s;tD=^cHIlek0kI~e+wk%?1TxjfLdG`35BDTAhJpe zny(GQksJ?v_-%oivQ8+4*=?xSG=n6p4J9wq0?LSv*S zrVY!Z3l}%`)*e7UM+tOTFbN_XY>^Swz&ZsMKHVrx6=_(2ocLTkv2YFwYCnMMR{-UP zYQf*(3EX$nz?BV3@ak#`gj_9#$~}W*w!8x#2)F?Lr}=Qg@sOKy7agNV1)D1X9t=L?SCFNJ}WLOxUlC6Q&cGBRRIu|K0M}ur$Bt?Aq zGeRC&1D1HE_%(b>zYcROSs3o00~acFQ6TFi+%y+N{TaVtoyIFzRV0V0fgD0_%7EHn zdH8c+F3O(O!J_4MP%wHLI7@rM{Q)29YZ-u7P%xNKjUZQ-37rRZs0FBsw=6f~nr~}S zf0_|idYV(u-K+2pmkwT-bA+mi*TL_qZ{Zi2Mfr%SVDr8qh+0$t4!if`QKM*d`12Gz zHVLBtD>qyxlMcyiq|vJ|9CE`Pap`3T>@d6wL59ij;rCPA^d}2kFKvbCVE|dIPWY&q zLgdrzF+;qZN?(9*U;iua&$htB=E^XlKMhYc`%oIbo6Ra-qnW?B0mW&F#ZkpdC3MJUZQLgL;z=&09#=4uki?HCO^IbBA2Niu9++JUV6E#VYDVT=F3VslE;ul%$=n5B;5vBXKLePa(z@Zf2@u~Ze zav8v@y&Yrj+QV%V2E!U@cz#L%ow_bk;+s#Sy}JS?|5=6&-8XQ*ksV4OU4-G*1?cRO z3YC`b*j{V``!7Ud4pjttuA`{Nb_DsUTDHw93|JY5VMU!51kXxAc3BwY$zFq(ok8%X z!WZ0D>O)QWN0>Xk4w_Hc0&mrH)P~bIOLQUl>&1ZT$!6g8?f~(bKS`|eF2sbIIM{xA zKXiB?p09n4+gb8(zrqLJ#lFO$(wSJgZV3t(iC}7UAJkM6TtDv;3~B}926u4~*YLvQ zL-rt&6bGND9e_t=39x#o2+E{KVAYwy*L)$y(aG!+Vml8l_QV#vRrlEFt1d4l| z!`XK%Fy^py>&9#h4^USThM!!Yp*~<0&R%^9Hgem8)yIu+@!508U0DLZ1+u|k@hv1%b74v7eGm#? z4C|av!X{Heyz$l;^4{Hp2Zn9XA~y_P0kSYt$Quj~*x}xjTo^$Kg5w@1oRe|_PAi$2 zeF>DIG`E+c+i)lzpTSLiugt)_IXh5s({!`DJ+f#ntBk#(`k1k^8Eqb?<35RZ;GVt_ zpPpSw#ibp4`ZG8cR~I&JE+d?P{2s{1jH%Pt|3Q3H)Z!3LlrJgTqM6QVe%TcQd52u_*1B30ahOJ(nyF81fbTaG`B*s?+Ax)yBA zxC)z-4Di?425{`z4F%hF!Le=^?3>~Z?WWgJ=eauNB={3IWZGl<&M@5A=!CuB!qIkm zGK%dGz<#4=AeqpGA-l5hqLmsxby|immM_6Se?n0*OA)<`=HYd#c)0R84+7UsMb>7sc;1JG?L-;O%E_o=D^2!=i%wNF3v7{0o-OX&`!OCg{mc>c;68(P40o} z%~Du0^A-7iB?I)iMJadWreeGwf>{zD_Qms~tlM0$ z;TuAkOKe;W6+3!-@8A1oZ0kL>PqcxOo#2<=qHQn?d&&Y=M|_iuyDrAOepNeqO} zP(k04yRb?u3`#AxpnshcOm@r#;jTKg@-%=-pPLZ4tQ2@O7vn(OD3r&{g%SEJTzl^Z zF58FEaO+koTnL9To=KLlWfeJy9!+{@1&ICUSq`KR+#s0s#q4iLaCua%&Uz> z-Z$$&cxwV|dys^l3MH`q)uySGsdohT9qCI zN;SjaR(+W9e9;shq@d!YJ_M;V(7iVRcx}VbXJ;6+9`1sbUz5PXFA98YZlGu1dE_nU zr_?7o&@J@@bIUt0ATtZS`eSfgWCl9VaKfV&D9BDuFnNw!53k>ITuzO)I&$BSLod|n_6`&9jD<+ys|F?pNvgM z+gL$rLM$3XzbjB7P213@&=(4mH{qSSR?u;o(xC5H_@#}XI`0?`(l!Nn&a)X-+8hHh z183w+Y02JcVUgM5W1CW;FB8+l)TJBL0xrftN09f zI{6(BQs!t8HHcGyO)0Lo10xa$ZAJd@<&8F8KB0r}^{#-xO+AqK?2SI|Y>3`>2lyYP zkwZ^Zai|mV_qpfz^4tgPT+)dZ?PD;)vZdPWqJg68fQjQXa82(o_z*Y(;Uh^t*34t77Hw3p0Z)ZRAW)~G)VylU`mT)Y>1a^OYlPE6TGXS5z3?f>1YjY4#rXBf2$Y>}MAqGnI98TL*5+no=?n|1@bFw5*ximR!g6r6=0fU& z{d1TsF2Wgt$MD2DA1qmR0p_2g!17Zh@U{wK+-WY<+#-Rx=>_m=`%_pb`4)Em41k26 zQLsg=ZDu^mb)YsAk|fj0_}$2T?42)B)>Hp9h!VFHm(BA;}u|Ux&mraB`G1-57Z6S2UM9!7Zkr(imcLFDsW32a{4uq>&1O2l`Q~$Ltp%~ zRu2WO`jE>f1^SJ0z(hO)SCt+>pM9#Rc=S0OlU{@sVH5D|=w0$e)D2pqc&Oi=cK|Z4 zLYj&<@b^SQJ$o)1nM&ipv`%n1ZULVS4d9K1G&D?(QZ5Tqz70=!^fv%@$Xncx@p(k*R_HL>@HK z--bOAhWKMvHE=iIfc0}rkT=2!O6Pt8>ezQUnW+OdhYdl}=L$6BIpa%BWt4Xm!(M-D z{PZ~%JkMQ##@VAJ;O0?mb*~|>i;Li6WEL2vD1-gVr4ZaLj=OC_!LGF)*Wb6K?lFi} zZ#7XOy%6VHxT9>|IvhASRkymeaCuQ0tUKTf<`uT6bi@LWF>gu5<4t%+AV+7z zjNw0!A6}2%Pp?6K^?ck;S%cB>)fjqo2R!OAhH_D9s$1w3Hm~VNv6w!LU0j7egIBRr zd^=v5!tsDEb&Tlo!`w5;Q#EP?^@NvEzlW##5tj;*?**x&WzpDmNr2KTPC$d^MaY@q zj|bC#6?E3l|$b!C;{* zK5l&j1DUblbtVBWYAm4E3jQYI$`;h1;TbILC_&G*@AzbS0?rzHf&1D|QM(nqQQ_by zWVa?k;bn7(*x!kj_k%Hny8=GH=EE&U`M95Dj%-Z~~b@?Rp?QKKswd^ayblghW? zXBYshzg{F;ru$;7q8IF5rHzdvtMQ|HB?Pos zEdT;u1cK5-0o=4FhjP6%mr|MVMwiE7h$}o%UoZwAS5`x7dmjc#Dq;2%x4&b+kJ>}w z$fNCx-j;bNGfx!YcOO(gGsdhGLG<1<01x+!0!%c+ku%v~Wc3=VByvEpH59sJZ=vWc z2TZHcM59fX31sl&9IZJ^s`j(MU6*yFkk2J^4Ox;fv#D@GGr*0+EgH5GTA!e)`;?bOPx3^lI< zDW{eR$m-lqomcC_Zww#8U=?0I^#D3o&%k`94J|&ZQ&HK|P`&IVIHlWTqrNh7TyEj_ z*_F_weV5udm=DEXJ7B_agg9l6LC(=g5H1+U@TgSy^w|Y*m`2=dl zTw(0cRmf!RhW8(vVP=CLXzmb1ql0fi;&nQ;*)xJtcb1?^8dRye+smmpzm}jxi5uGK zMWepSIh<3XOMO0G0WNB0$d~XIt{e!(q{MMJk+KkrTrKfIhyV)6-2vXUF8FSD3v@k; z#`STL*lBtl1{9*HwQ5{=yJZ&Ke0c>v?23bF+a>Ui`DbX*l|gH%T;RKD0Q3ALF;n^^ zEX`7ctRro(FDMX#)eb{**4}@zEA)TPMEbt*zd5JHr1lkCc&vcv(?axvo%(Fs`TR`R zsRL~HMFTZ{r4-9@B-`}PTWhx3?*}#Vt3zmK@7L_o2~B$D^PTj?w0`#33URt1-GbRr zsm!{RF`JpYc0X%=aXRg>OB*z6_~7QG6|pnD&*CQS?3Fs_$;poX8a_t>;+-}Zq6N>8 z`t)}&D{YUd`-D1~9OZ$%l?jYXS0e3@+RLhTh5vX?OY{;gvZT)(M5L#a@>K&Yk=qV% z=#?#5x|>66kGhbv#eCp^t>ll4E?gc9CQ)*AWQJolQ5_i~@2(`17~K+5DQb)w#$3>> zR0|ojQEt`3h^E`u*UA?U}o z6F$6fgDUMLSYw|E!Ary7MC1i>s7mFZc5j-} z@YGHQoa0xsn0ZI(?QeT(zNqbF`U=$9r^Rlw1{35#(0-J>Xv$()-yNq*yROow7L1S^ z9xvEh_MhmFp#sd#xe4^2?CJCilVsXX=z}R`;t%h|D+v4LZYJo=FkP6~LsSKdXssD3 zOpo+c=0K}7EPWvXjlmWi(F4zzjM86B_cb9-Q|e5P(2}j3vJ5|nd2tDxtGK};Oqglp zcBO}l7qaf|=^*#twVNIuUd-I}EnzjGA9=ZJ6aA5;OV5nLseDRc_hksN7MpT2m7*uu zD=vhBZ0DIQIYzQto)P;!;%MP|8JX2#aOqqlY^i<+=clx;v+Y&9zi$u(4_qhCTqD?| zev=rfx_2`IMm;P+{^cR?4>N$oNdu_HG&qy=jY#ah0pHd| zLvw{WREf$%LJ1#;)n~!OYYQQ8rzoaQ^Mbop8Q>=t4F%Rgu;us;NUM1c+~e6ebU2N) zI7ksKD_`^<5y$aFNgPP(fwG1Ocw9RGcjtIw)`CIc?rb2}O;ceJH9>g#_Cg-#2)vOK zCpig|WaZ3_gmq90#=gEIwrrZDEe?R#`GZ8HnwxaUX~5v*0U}V)0CoWyP+x8cnm00G z?XF;`Iq{2ZSTP&CUakW8QA}P7%>|0Lg(TI#Apaox|H|amJlp>p%*qNiU<~y|7Id-|66E-fv%rh0mIcowroE!X{;2jsIl#?g3yJ$Wp2CT!Oa%9j` z0uQ<9F^Z)*B=Y+6(k?6)7>=FJAT zSD)dYU?;06y@jrq2siz?bd0@cLkh9*y~sA4qYZy9&STU{@BBy1T6Rzo5_io&hv&0;_u6zcMed!SE_#QHE%mUbU3l?~tgZLf#7;z^T zY-ZSk;g7>`Bw+~D*Qr9X?-U9Q+u$t0qhObR?Vn)QUt>vGEL8trc&&H?0Bc_s%Jipm z5<6(xB(@p!;a2UaQzJNAEag`M6~wgXSK!OJeITJ1Nb4AdVQsrBv@M;+bemej zN`5Oba-4w!j^Q5x%dc{mRJD1S@F%|Tv<`!5dtIWy{&7wv5 zd+CKKcWKQ<56PEpn(RT2Bit)^Pp50h!`sLjMzB&I_R}l>_4}N^`>mPdjz`~oC)Hnz zAT8)Ja*VjCxVUjV_FEe)BHu!jNj8%99@zGECWR3Gl86DeUIFkor$PH`nSb)nl)uJut3bQqzwnyV&Es{8Hi*^E zE|6J1`tlGbdN_vp6p+r@cm6u~`k!HZ^d+btrC;H(^{GH-Ad{uPegJm4 z4Kj{bpH#biZG`-z8%@s!Xwn-*Jn6chiEIa!1X-8h#S)5rO*``aU~g#K$JR0Cr{@kf z*F01`|hWO*)lL5kOJRRM_S7EK%HaIVBi`g@e6Ks-!w)Z~R+x7yb zR)pfM`>WBcY67@QCP>|w60l$8K{VeW@p3eV>z~WvSRXGs#w>u7Obx7y+XhmP++f`; z4hVPYfvu@EzI)DvFYGo!u-1Fv3tj`_b1y=C+zd$g`iIyx_571uCE)zdE}Fm**ICjMKV!27?}XgQKr_dV- ze45e>U{M4%)oqNdi3|HQi%rXXkA;>TUrZO*o$}%&tvL4psPuHu>?kL;zUxMMklICb zY8vSvC1d)r>P`-8x*n@`K?%F8`3xwn-vMizS3tgk33yJwPS(A=Lk>)+(<`}85w|SD zbRJ12t^GEzYhF9eoukS$Hqmr=Omy8j>15{e%sCwSE;A4|bz;<{ylNAVxN%k+@Nte# zYoG;>I-z@w2=YoJ$63>WoIKA%Z+I6BZ!XT~D0P@@ zT|<6(E0Ale>lt+5uf4swlNKB*CKc>5W_VK`$fe2v*HaN_OJy-S7Z-5MG>n+rHn;KV zmJxDLCyO>4`$_a|F4E1-V$80@Tw19@fY~DTlf125WE#1~ll~w+56H5Ubgm;0SY0_n zzx?1tT7;X(51opCtvG-Cy=`F()g~83?!Hhlo7fcqJVtwQ?T0%kJFkPdnVrO_M^U7Y zV*(HEtOPC@J6L?EA0p>#qWgL)P?dNHJJX+lpspTn4!cJdzsLa314TsLV;f`#tq1P0 zQC!y+4o?ypxb$*1I5&^MD`j`k;_8Iow-sU4Pgw|cOM^hEs{mbX0KpuXUU?sSg>vDc z@MU=2wiA?Uf~eo`q>0_Y1z0j?9lrSB4jpTHv11?~Is_DO!zUf+>+ykGb7fF2tQ@NL zpM-}U@({RB2R?Rvg+GGA=o}M>DOe(H-c*fOvtS}+wn7hAS+|H-vBlToRws!Xn3bkT|vc|8$rGx=*Z#|6#olLZ*r!B9FTW+g}b zBrp>`XP5)IL8gN85%h0CnqAa*fu%XB%GNo$i9;3LrTc6}$oB;-jyRtu+@XWmm+?9K zn1mKU*=73jJp*DeWwGUXa?Db}jo?)uNxxjy&5Ca5uldH+z$`nj2F^xD{v-CMub+ko z^gprUmILzWHKqngCF1!P034s$*roMw4Ple>pX znCAlLV1?2ZI!MA8=3iO_n!An3h96;5Eic=rLmlQTiZCJHtr@N*>;5A<_C1k<4ed4% zS}KmCi$9SnH$Tv|zJy8lmEpGa9-L*a4)R_5Nm7#_L^?=PU6(weyGEZ_J-bPs^u)ng zi$mbfzX=z-UkZx#o@iFP7AhAHfaKmnNd2G$PTPxtqp}}e9?C(B_dcKpHsZ5|32@Sy z7bgRxut`1|4nEOA>zhq*$gvyv%Q{7-W2Fa0P`kWqJOw`TqStLSS} zpOFQ{Gykpk{#Kq3Ev|5?J1UtXt$Eb?LJsUS3FdTf52_W~D$goT^k=>feWsT<>DFcj zEn`mJX(G-d`*G=w$4v6m1u)q;6Z``OI7VHLoTTkx?5->AEP1iF&=K8Frq$HZ%apYk zQNv;SNmwbVigy9uk6YlSLn?4Pa>!TdnOGH*&WJkCWn0lo(2*g@X*sbGf}*OK#hga6 z#N3Y&c@_zwAsSv>gJA(Yep%7Bx( z_zNp{ewc}T*%*_$bp-YYxiZ1Jj+_IA^H9Kd2BX4prK9Glp^NkYk$icNZl+$-aWj=5 zAViXJ@O?<~jV&4byFT>S!>8znMX~g(qrSAnR(|6Cw1a4=+cIv;<;jOR=1l#sCi1WV7>m%fdw_<7e9Jnduj=C8FxX^z-?6jKz-_5g8;!iT%)91sjFKlp^6xEZrDjxVgB_Fom zDnt6a5$^Vp!d?e2%5VR49NHrW*UPf8L9GDn3Rc0KmgA_tLK!!SyoW79Z=kJb0Ajn| z!j)s9DArU1BYvWI(z^-xM3SL56L8jxVMt7NhN_mwu%dH4#EHK7C(-{`CSP@HyZv8a zmR!-d`ib&9GgsbaD~cBvQnymR*5Ps6C$2nQC?z-)C z+;KyqE_T{pIB+DN;aU}E7GCPXS!$ENe2Mxa9PfK+)_pas_Uw2(T2>1#->m0A32A?= z3-RH@q`ud6X|s-)87^7^k`k-1%+Sg#S5UEzFE|`7T}vl(o^0cc@y;=O6;R4qnkin- zQzc&epD>GytI6ztuhzAC)L-pv4^=admM>I!Qt{vSY%MI1QRAa}D~2)O;~Dh}=7Z3^ z5d7<&ime9z|KF3Hxs!Tco$x>Jx>7yL*3K4CHB zTwM;wtnXl-hK`xfC$GQX^$)zpKQ)L-!e6hrk*Sk+Yp9#ye6ntN;@Tl;rhIIuk z=6IHI9W>)ylZd4svtALW06$vfc_SIi*iJ+(yg2NWf{f9eYC4`Q0WE>m@XR!r5xS|% zd^%lCcX2YAW7WFMx!6Ojo>%RpCtw5V+|IDB&wN8sAJ1_7=FXt}zD08O)<|Lkqg9*H z9?Y3#bRBh9j$WH>hr#PIGhI;7E`^5mq#1MEs)QZP`w`->Z(;m5H#;3XGWx z%uBZ2wPaR*r75vG$D+>*&tWo--K+W8J;8eMiNh{jevN)NxQsE_wU}s?<&*83!`Z=i z#92cc!DL?jDspN?Fj?~;sYd+8QF1!QfRej@m?Kkajy?lX93eR?cych5^KHwf+N^gW zz;}2fV}AY{XCrGi-I!8FTwNDKr~5UUzhD;JYe<3mPa|YNsD<9J;v%C|@|@P&yp56X z)}y67+e{q>RAECR7xSpQj8^xLA^C0tHAx0$w7o{nN1POKE7cfY?#uep?!<(~qxn;o_NyE1FjU>i<5 zHA+OJb2+*bzI5cx%@Azgz#Jye8TpZ$^!qRMtec6&Fvly7?!NMdjJ>}F+*67%BgUHE zoN=7A6wkuO!F%kGj5r!3KC(ADD3Vm<1>?kp%#TSEx-mV0eJWuiYf>bT+-H_CH%p$f z=7e)I%JQC7oYlYR2AKu4#D`!i=TIIqTyX&>>pw9S3u^Gh_2AlW%~f^6`5kyl`Wn4- zuQ$h6}2_pwTvjrmel2O)PW=VG#$qvb79w=pl+?%yf zR?W>3-LsZ_&k3jAUmQ zpkEtnXQB(e{Mz%He?h;$^SXabg>vYaM2Ulw)cw(w)MH0y9Mk#)D$8uB+r<`C%=bRn zzjqbo*sny%s{O{zLyzIv;XT0j%@*WmtH81MZg`+H8zjA!!iAd~aM^NIYW2}FIP6B? zX~83i>wX4x>SAzCXce-O&H_5>LP+mRI6bEa_&SSV@!BGA^=cVJ~JJS%GU(H{uU@Wn7*S1H#d1 z=+t`@_wG}{X2Wrq#%qDeNo^p0WHtKg9i}{PM&L2{1?NjL;n)X%2(K0cu{nW|ayt#< z{54QFf0+C>;)X|>Eil#_2=mI%faraImc7eh!gvrKoO^?l-itwG^EBABcPUaLmf-wU zh%$T~NaY8nAx%Gl+Yw7KPVXK}_jNsB(;lyL)-?N6dcsk>!9h>!P7 zo1$Qi5~d6nVt?Ekm|bs+t4eEN^}s!tb#4~^xwH!|c=$nWvk8vnmw<0tI7pm{0}lly zXpz&0L)!Ha=Mn|y8e751MiRQpli;28Hdu5jmr@UjrF?FOWACX#JfKSW^~>Xv`zcWL&HaABd$ z0b2D-G`;j_dd=2lW2`PiK6>u~0dhRdnU&kwSN)MMjD%-JnB4QQV{ekYT@zQMOf=#e zS&@FCByC3+Yh~9icHaxhns?DDbV-RdOI@FvW-@cw)vI{flACt0WviBv=l7N9`yG9z z1Cna&6J>+!x~Dr#{}ZP>u{f5xy=fLqtoQ?8uD=HB0B?-Shy;aFZrr5i06*14aC`p? zaBmg_S7AjsDciu3lnjOB=?TQU=mRs zaB`7=H+C|l@oO*%>U1DFJsHG(iwSwTPmzTEktfsNcd=B|*aW6ngNbuF@f*5NhPQtu zXQSrA*1>4jKk(XLs>ednv94Ri?cb~{PmL6Q{rU5MuPo=(ona^E*U=ejinOZ71+qL? zg@mZSq1F7-Sa-ionvPdITJ}DAE@#gK!q2ZUtOiX3_DE_k`B=xMJ*FqrbcQ{kk0o17E1$KkSeGoCU%=MM z6#~zaECM0zWE#&Iy5NulEDmKz`=?}b;t6 zJN4MH)3?wyiC=Pi>?V{vHvI)vivK=NOj>6rt-?df1Ty{+pcmt6|9(XEf7f9PoA^{K<~!Q zu)aPCSndHt+}juA>sv|gwQk_*+6lR$Da1VGHL>H>fRrN=aN2Gwn6w6x-9H0Jv%C=K zj`We;%=% z(_yR8wyCTXz@4%KL`I>CR1bLouhDi8vmYl{r_Z8(%xnZl|039xIu0@$Kat04PEac) z*TI3J9PHmP3#l2xaG}x%dOvxSKl*pb51n90EaZj-OXk6ELu&|rmISdqA6VVZ-tar- z56K!wP~kcY4%P=juY4mk*$0!=3*VDnx`$!TRIMrK=Y}6FZs3^j0N!44xNUtLZo5o} zpykyhGR6=#f2#+G|M5>&mjAM~p~>qygE)bIGYivIt_5o^Wc}aK-?%iGqwnv555>OJ z_Li*VLpjmnQV6p%6#Pp@HU+yu(U} z&m=cgI!*OVdPq^lI96P2&MWFiOyn@L6%uDq9d&kI@hX?5h5$?JLYur&IU$l1K zcMfM%NUOHT+N`$3>LhbPAr&H%W)WUTt6J=0aSTNnaLau{i+=Xy)Lvaw`}ouV1PpIs zUCEk7l$?&inFBfGrG6Swk?ms6g{pG?Ohsgo$75#I+zyiSMH!yhuBW$x7UzRc4XGGN zW8RdmGU1MYS;HH0nYbxPLx*-TZTxF3{_*i=R=V-Sk@?;1{}lbU(is?&_k^5Xs!i>; z`V3zhv{3z7I{ts$ym>U1U;92x=CO<+88XX|IdbiDUnnW1S)~-EqLI>|A~J*!nWKy; z356o=eO>oWR8kQ|A*E80XhP$!&u^{o^T+3Xp6~lv&tLC)|G3t@)_w15t$Xit@8dj< zW1Z(Vi#dfuyrb~i^^z^@O7gSwI^ZkwYGzW1ddRKoxtSZ@ev zX)AHzmuj?FClY4p%j593bD;XB9B!I^7tZMyfbi8Ja9Udk^SY10^IQ&?A8^N?lCFd2 ziwbb4c>&GG1@Y4T15i7)8M=Eu!&mLc&|-U=*6nIWua?im6V-*-RoenT-Oa;(5l#?( zDGbvcFF^a?E4tV449*FC0htT$!BSfp+{|;O-IoYs(aYBOo01Sl=k|cJ>K+i<)`uwP z0C<%@2E(dC*eopwOU;;oSCi*~|2kC=`&x%r;WQ9kvI{3|dJGZ)7+U{)|H*JIgfvM( z^QzT2lC1!fKg?#TvDH7#*eher{u>uY_ETnkit%)w!AaUtf}ZB6@XDDRxF>*{+Gy%9 z>10Oz&6`FJV@^u=fo^2I%h=g^g*StgdqXk2yBN*3S5~m(z$DgnY7PYVjBsoVKcm~1 zw&N66KlA`aG4e9U$ieoRaOcb>)Ujod&B@%rn`C&I_y^r$LX%-;v=AIpbBiYRszJVt@Dc{vR&P0*(9hN#ipP3zc=~kvXk&!}8Dn*FE`B zebiz2Sru-5TJe9!C3smt%Q_VYEw^Os#WSO2_|M?2C-U*<`7?3J>3i_5e>Q&chqL#p zl;Jk_DCih02C8Bsd@Sh4Qm)4w+yYO6^kRK{9XH`W^aAGxY2(Kon{Zy09{u=5BFfJW zhO1LD@RYezpmVX^KZ%;)pGmmAi{sr%NatnN#PWX4dj7Y}{nvI?&MshgkFABU$x)z6 z#)*}~C6u8o42P0t!;qCcWjZ|%xmoKW4$la+&L4%e^F!oEUKSeMoD1`V4Ul!!7c?L- ziX?7o!NaXV)TrNKGIEiiuuVqHE^{5GVeLgyjs(a#gwT_%pNU3N453}D$ksGJl0RDo zm6g~ztZ6c5njc7UrQ8>?#$q z>|IEqG?XMDSY4Ho4KskvdPX33^1@fucaQX3bx|Z8a zW(Y21&5u21uHU_n(j7(Et<*CJo^b}c1Xi*07Fa{H${L&$vJ7V1mN2}t2bfCNcf`cO zml-yRWK!h{?W4YFqjmT0F_OPW$;;=1)SmC*i2M?PlhSTXz|#aqS#^+UIUR?DtJ68p zmx{yV;6fyqtqF^(gyF4iH=1%IlVg)oK#pH`WTtJ4g1XB6u()YD63q>D(8#>W8ir5e zYB=(kB{k0}lV#3qdFexNS-lzyQCrE`WJ8csjzDL#50FiR=h5`(#*F!UY4(uVSClpL zI~>_2%nZBJT-9GoILdpBIGZ=RlGD!X$(ApZ$t(A6j*0V0j#73Cyt?8?BCB+$jV=rs zYS>75icKNOU9-r;*m&Y%bek9+3#ArCNuvUp&m7VG-JCaH%m2=R{E*(hXCC6G^;Ir~NB=PS9Q_O{uON(&O=P>y7;SZB|KMU;a zPXeob5_nAyz=6sI?IdXk@GOE*qa3JL{*HXUoP=V00`79J(_vXTxb3Wf!v@g|oHlC= z>}}(4(dYl0M=1Zs=DyaOoU)hDw3pMmPNaVHs2fwEaF$Y0y!wqPohd#Ow|{*PQf*JLSyet( zZk>f6>xJUC38CO{SQkG!u7~ITVJo;*5bGQBjRS%nBDt%l(DsMZP|Tm_e?{XxV9CZn zBT_rbg>yi@HgyZ4F#)530Km-)YiDZk#4AJ0`OjTK`sU%wXZ zSd~Mzy4x{sQZqouZj`j{Izt*11lbwxGm!kLbmCa(Ml@dCszXb}nPeRaN}8B*>fj|3 zJGd}eN*AVlVhfT+t$j#Gm<*_~$3tXzAZ4cBU8-So#n zEir=EZl=c_$SCHn)mLY`f61{kJ+<(aIwy4R-Cb-Ty$Y`iO(JtPXQKwYLI@kR0z(du zS@vYy3EjgJah*{Z-1m zun#-Ak6Y@w#qsIvj~r!|=-%S47HdRnUS#7%7BjidPj=d)V~eoIx~CBKMU--zn8q?W z14uoQ&8uk3MO!SG$e}Hk;msc$-b%2QEWms`r&mGIx|DLeS@8B zN6ZzjvG-5*tZ|oI2IDp%jHe5Ukf32X z_RQoWvgvpia{MaI&XR~=X5^$Y5;xYv+7L1JriB<>sk}|%8&uHl{MjJj(2fq8#FE(U zvW$3XCvn#CMXn#-AkpU!V7G%5IVYn?y!M*>y}I#V`!Y>k6OTGPg*vTb{Pg1>*dP7` z-J|C8A?e?E`hn><^a;j`C-dP~^B%&ofiyh0c{bi8qmB7KHh{Ym52^&df!~2Yr%ZJz z+zS zO?@bQovel>_HKjNqqXqV={)SUd;_~(Sl~OZjHfTr!?}|uL1sV+oi!j#>-+oB_5-OP zYSMy>IWOqU>s@HzbPvv(NX0W2O#t2+gtZ&p;q&|Xcuv(U{C(05$SWBJ-RTwJ8NLkr zq^V$2@iXA@q6qFLxZraOlklj{XOI-P#O~#tz|~v?mF5ee{&EZqY@UP__B=#s>+V2> zP6+Oh3xSh0#yH=(2uvfMg2w9+nEjT*1(K0C_M9y(Y9mQAlMm1*oa*6zzBxU$HG*E@ zF9t6vt?9G{&h*(k%`k8AO4>eIANxLt21SboEV4VxK`i`C6`C)a!Ivay+AonGc!PzobR->IBGmAjOJ%UEZvo)Ad(gdC1#U#;VXp`i zd@ryE^i7_?9M@>5Q^^N={*^G(60IWTzfv>Jug`aJI4L<%$unb=lOy>DwpWId0%EKJD z?A`^tV&WjK&lPql55fnd74SH!5mFu3V~N4FxD|`xyA#QAJ_TT-@JC;A=|j4{zs{5J@s&TeXj31N0U2oj(VT?9X*fU z;SIHJcj)LAaSVya4$I%&b*Rod=RoeoI37-ja`;%v)|bBcgmcCY*T>B7;GUf`$UA>9 z$suw#?KouT#BKmNK*qo6KInLucIP_n5_Y`m6v`Vn*;t>WmEkC0cgFF|jS+`ONB8oK zXSzAcyPo5=TZz%$7tHHFzR;@we^WI7Uw!^T)F%Cshu959zuH`RpZf_^U4cc*>t^VxhP<_uBA3x&CtWxT@l(vEthzq$9JQG%lNi`c>D~ z`K{PsUv*!e`5HH2`;d`i-^f52I+Kx~Zw}dEGQ{~+cN(QE zx=HR9Bk+^iLe}_mC~ajcPSe9Y^i%MQedKRZm?E6dxmrGsA~O`3N7kWa_i)?a9qxav zYr~?gG+%@UtX?lgzgx{<6|1ATcmF|r)?+Vjx!8#VtK{h!KKtl%ejeD(UX;ExqaQka z3$W@YHSF+#3nJ5};X;2^+^BI0$hmVcdxkq=_l3d1%nDdN$p8n%D}&1HaQNm;l!xQkZ{2M5h&444$_K;a>0B(;zf_c$N@KPR!>vn@EV}&)=3k`un zEfKmv^(@v08T^hbh{s-3;1*#Wd_F^+R+#q~F3aqOE7uC)WrYAl4M||9569q6Xe)&E z=c2@C3t@QAW@x=~6O1&ika}<=bca}g;^Z(8cxi%v)Lek3_ApShaf4mvHE`cf6^=3I zAgbvSY|FEP(zZt+>vS6?*4Ke-mlv#RJp&mZoM9+c^q(-uzfQ7uIOo6VZs}SQ#t7+1 z(Vs(xxuKb}famuD^n7~Qb1Ct4yk-XOZ+*h>=PNOncv<+RdlkD$cQJTeyMq_)ZiU<* zCs9>_CYrP1wGq`Y_qvm!VUHZ+BDR6e>8xsdDje4#ZY zCOZx`Rthk03mnL-cXz3Si=_eXVl*fn$2enkQemvm2=w_?g*&M@Z02noYS$0HuZH0Oqc!;Ks->XY zB8E3lR>2AjQ=vHH23&4P2gg=(IG7}a)p$x+$~y(0A1i}5kDEX-#tj6&Sb>C`1>XIz z8N|ZNVS08Ryza<^d^#P3oz?KNKn7giAB8w>1&9j!{L@V1w4~v`iJJX1X|7}HMK*2K zG{{}?in-yF3X=t9vMx7fa=FuwW9za-Xhx|lZTlk`JS4lYpul|i&fcTy%rZeMH528y zX)$HR^3YW&hfefbuo-)ou!=b$!1u14EVe=DhgCW%6O6Y{Jy1%d&)=frGbAA|W(>)F zEa2Q#Nd@2aVjwYe5&b?U%#7#%uAM%s8WjjcBeR}r&a*=q|1oOK@#FAg_Y``uycq6? zTLcN39F#1u3~yT}L?683jIZXHfY6yS*wd5+8!gu3ghi<^p*I6Re=mqT)4JiJKqu0C z0a&T%6C6%EiP!a9#q*SRK-nb~7)meLZBPPkYwsomv2i^V!$hSfPvYxg>9E%XY&M>uSwFo^ z^E`8jm;1Yhw=zA0cgA@mFZA1-zww8^-qg0DDeF_2)Q}ICdi>TH9p2PVl~7#Ddy5LX zS-?{G%p1z%=~n9BMPK}XR`Jm7X%1o6T)4$e1Xr5Y)mzTg!6w`L*j}Z3%wBIbW>3=y ziAYT3_Lzv!%T{N?k3dWEuwW97%h4pp9iCUJbJ|wXYN(s$Mna`0o<6}a;DBPg`Ly>jomF!{{`7RMpDx5%?KQlNf zm1r)PAVXu8?ChRnIO$9d_rU6UY>E8YmwkHl!;S{-lu;>ef6h^=glmtVD3-Gt2a`$A zHi}-CGlSh*?a3^%)_}{VpWucayupk*A&Y* zr2%(POos@)Mpcu$!?PJb;LqVY=)~a4LIv&-K|bzr5kc&6_cxQaubSOty9&*Ca}|gG zb#psg2?M*DfKTf@C3&$EUDsa4S-k%xIrm5pwjZ0sTn$d7nqWD(?|v7JYVScOSH5OG zb&Qa+HKnjMu#A`%s3HH?#~Aa=w~*Vc$@V1Glqr39fa9VbL2XG`#hLT)7bC0pn-l9@ zgccb&(zZy9du4$V{b;^4+wYl;1=M<2jYEFy%U)d~JtF>R4cgeKNi#TKHZH)AHpDYQ z_SXPy@qzPO8pz6aKj!?b<&0ZPH}h>$GSg+$#{|mvk=NPNP^Xy$k>bBc_Nc2c8IBgr zKE*Rs^b=c#I#o#~Wy@g?jxq^a`j(m{NIEU~(CtoEn(IgkrL$k)7sb78=U zUMhK-d%#7B{;n>;&Rf?**{aHLy|*iKvnTbES6PL)?n^IQ)GmY!n`$86UYW@6tzfER zZbN|LN$Q|cCFB+9L3PPj&JmfJ?97~PjHCE(&da&K>?O?peQ8n!D%g&bthC!T<=`DqZx&jP2=!-#? z+^#P!^v;!PT+xgYTxBtb8#Y|P4k|~o@0VmjaTv|@pKFej3{D( zC7TYt`h}G%p1^mWTw>4F>|;}3pMl)g=MXF>$$G05l1;c1NBlNL;kcO7T6!P#3-B@d zze6EIUx$@}3W^q)K{98WkneK~C`V2Uc{KGlXJ<@Y;q*MlakD5a3;&Aaa&M5zxJj_UvYVNmeVA0~ zoFJmr>73nr4LNb|vR~Io9C?S5=LvbQqOU}11U>t7TXS}L( z*x_HXxcBRHY$}nA6od%6-0lRr5-W%ZBTt;1`U&q-IW**Cl64Lhr1|S0%aXJdUxiQEY+~v!Ro*d_N9H;-zss5E${DQUUgRK|o*bfG@zw`yiCtFnT ztmZ*15GIW8OQ*m{iZuS#WI_)cr{J7(V|e+|sqo>s3H^Qi6pqcm0`D?XAjjn@C`fIk z_jF_Wn1(sN{jChfvX$^9TU+{9z+u=yS7X1;Zs4@T5o@3`@MF$CFt-BGmvx2GCD~v# zy%BtGn$b3Q_+fXA6TD+K!8HE4P*K(k>q~#))6umw-}>Ej=Q}yt=}I|{s&vPPpOs^S zsZU|8S}m5{!V0LdkzPR@s+zYyaPufSq>E`LUTGj%an+f2c zloF69MR4DGK8*i*h5XH8VYo*bJhJvd_lc>n0B*sB0}Juoow+a}y$d()KaC~E_@VQ_ zAEu=sjK5@sgHV$e#HdB#8{h9jd?S}WpejN;Jl~IV?@80s1+LPo9ip+R>k&wZ-;WOm zM&Z*(;*hXzD!mD(WBr8!n76ToMDQSr)JZg76W+lN<=`nEt-2nF_9Hh6n97nts^XvqwBEV^JL%+V!~a=R4mH}b(>+}!}1ir{tK zDfpz-cG_c>6CHBq8VFe@;}cOV{^X>J=kfS(Wbi$Bz2PucxjKfTKl|hLs?xYCXgd9w z{Dj*g_h8M;mH79i3>+68fd`E5;jX4hwCm9}i1#mtbN=dh_`*!^yrF>|C0ZdQtRI2( zPJH>$6F5@x3eR)%fZkXU_^?wD7cWu7Qe`$+?EGG+R(}S%1B$pUdkjWb?8W3&B|Z0e zJ06=Rh6CU0;N+!zbYw^co>~+FtAcfCAss!u)XN)Te?6=f9SFV4BydcU7~LY62db_b zcyLn+Y!5sH3fCWDVX3FkZJ`KD^RB?IKg#c=h&Y_z_7t|MCV_Q}419d13g1+p;R6O+ zV7O%k_K|r4?>Z&GBq|ryrV8SoT^Le7wjuvZsn8jsi(B(9VV~v$w33+NyH>GwS`Q$7jzIUc~uamKi0nHH|CDu< z4q1lh>%PXTL?-$7j=)Vrn#0znB%1&7G;y0|7 z7={fudqCqQIiHF&yS z0OI-%7Ob?xDK9D7@Mn`l!E-J?vP}>3X^&$533G=;y#{>0%o58BsbkuG7~0eP@$%?Q z`q-KlEL3WV+w*ELss02Fme-**+y?h96r@i;D4i84PS*+lfWrMF5ODM>tdrA(tE!7& zN1`|MdXGTGC=Y#hYX(X8bC6)W052?%2D6A!WHd_*+=L3C?$aq`9~%k2{KwEctHb|9 zJ^j_;9dRgu$0usdi@dP=Z`${-UmvJCBdPd)6x(0@zuyg@DtLsoR;ne78^g%n4X2@h z%~L8pc^cREWhMKe*9|7ETtTTu^nz068W<{%X4okw5avHk<*vJp)&}Vk{sAdcyR(I} z+>MY~xQtr8&=mO5A(Fl|)?O?#lH>BWg!9!x4#@}J2Jgx`PQBt~`{|2jKv8HeN*0L* z?;AF#{mxE~=wKNqjPsqkqGSTCA~T@rN;G3O^qIrWJj=zcCi{HFQsW zCd=!6#;kBuVc6fU=&Mo>nio6;#w;DdqCB6p2^BLQq6W;pE2q$_mKA8Vx*WppyO8@! z3FdTQ6WJtkfm**^9I2kjrpAds<)OX_sYWU?Gun!%oI-|dYGaX*^8j_(W}FknCkYMj zqp8=l4LX&vkdgm(6&abw-;k_$$&soYq!e}H*!}cka7jJFKI?u1=RWA-HQQIQ>t`yl zL44C`MV^;g+=jVdxQDr2rat`F@e@1ql(PZIvT_*eTJPlZmQbR?v$?*<*cyj0?irZ5|)*soA<{M>Eyw~w$=ZF_- zQCdorW3-U1k0ob8rYIIVmu9 zTMJee1;eu0Nf5Ur6HXaEMe7syqb`FyRQ_8TUIoeGZ+^#7bo?4@*wX{neEaa-7j1~l zQinzS0noTA94nMhhsQmo@AL}myfi*>7XKV$Izf@s%%wx)_?K%v`O2dsGCh+TS8Jd+d8BTATK+Tto zFiuYZr_@&1^Ue`ULrUPV$X!r2-3Zo~$5F1q9thdRfv%xy_%LGwXxABn@XIM+Rn-1Z zDrNujlYE3v{5Q@S$LTO@=g80Q(H&uXT|bekCw%zBGY9s3TLh5A6hK=QA-Ads<_KN4 z7ip0Kk`};Bb)P{53%*f%-`X=vmz+Vf-+K{BPXrsKLr|lX86)MN$xPa&&&)IBa_qk? zW~O9c;Vd6+q2k;ZZF~&+RQ82&YCfIx4fO1+De#96;&Iv78WD@{}O|!xO1O>a@vj1t+ zR+kO_H&Nr?E60`RTgZ@kE9g+!GC1Tkmz6oYgRNO72z$1^g#9u+c0^W<`zxlAjp$ZE z>6H>FUt5z+?kcXE<8=}-x@pXXHZJAaqlC;XsX2)obzsf?7_{FeniO67gf6tbBL|=_+xys=K zHT%$jgAVR!Hikzw-{96oS@;4jXuvNU_!8eyRihzLm2UtRqT69~eghajI0j7{9ngGT z8!))C2)ccAp}lE8S~o2je6kL~t63LNmIy&}W~>AypEC5=Z4qpIy$QlLm!PKZ9seX_ zi~X6#Z=FQmwRsEbPyD#d>+DMAk=|&YKvg)8jxFV-FHYjENXh?ORR4M_d44JNFAjDr$L$_|r}c!hVIUAI zkO|}!C8>u1x$d`1Ed;o6Yq9D$l4SRQ~Q!G%8f3If+&YP zhQ6nZ-v<3AHB*Ce2TEo=S~k(#XUDj^BHn|W;&Yh(W;5!N$RX;3x5!542h?ZT1yoN$ zGDAfwupwq`)Kt?aBw>F)a|kJ-iEm;IpOFWi|1_6KhO5HB>ol@$w2soZxUaLbnzug?!H7zvQXAd!@iSAhS)>meg+EM0mmjo_n zKO$p;5;iVc0a>SQWX^g$WwI+Ai9tyb?wX!R#CP&J+^%RGjTtvW1!dv;oU3T#5MDlW<1m0vd^X{6}B!$3kaC z8GX6W%ngf=@T9+i`7w1LwJ|Uh72cnQ=f2BENy+*!@8}xH_)$-dHD*#u9f6!HDLGVA zqYd2Nv4n^T0TsQjoU>iOi=;;CQB`j;I5T_Xq5aW1l2oWcM%0@a<6F^nk4XTvKW`oq zoYcUvKh{US&r+on7M61aXUeeqisYgA6wO@KdVqY?IZVfXmgF97qfUF5lkz4DCN=u4 zefP!dkbCMO`Z(hL|l*ZgA`?h;?Zhw^|^6}Gf29}4BSL&$QbBy%7xXHP^GlWWD zlc-+<)+E|SgW)YSlgrK&xg90S$VIfE$c~;H4}Sc(VYVf+RxY-o zF6hy6lGON!3S65&%J&>%y;IJDjFt;?yipyL5|1+Ob2=E0AN{2Bob|Vfkq3Wa#K&r%RO};~=I3n(vG$t&$DsklHcWnp;n8SfEa-4je;f z1@9sG;bJ!B$QnqHv!Y_GCL_~eLy})=!SrieW6ro2QD0-v$#HzfblGNsgl7`dyv2>o zSlt0tattxOt%!_+JeYjZQX(xrnVg*uq@y^Fc#PB{R%aha`L_>MQnLUan*cmeEk+GK zC3QoGYwTA}KR|YDvZ^!N(SU3`9&t!uF7nslkmpnS$z)+Ylwav)pHr~_O|^)E30rg4 zcE3>UhVXs#pz;8Cj#07d2#Y;4bPCY$az*3Yg#& zL2Pg&ye@nmVfwc8lDBHQMEhJ3Q)pkwxhV%z`YqtLZtMA3h%dVmoWp`oVrYJ7!<)T*3b(p+a183dOhw8hhq1Is-@@&EZ zitj+eIW<9r=*9v9>ybwnf$;FTnCCG96Vqcj+zwO(3zzw&=gSx2D~)*7`7Fh z@-0C!%n>%HCRd&5sOa4Y~Od8`2|O*t@|asn>+e?(JHOoCjoKBVNo z3@Lw$L{2C6L-n*-lzMUrB-$6E{i1JC)gn1$qE-m|oYdAupu{0FSPe;mk zWR2mYC^Sh?vkFJSm_P80AoZAWckxKCPR|hsdd=A0qBv|rEflj+D z2G(J5a3_5>#Q5l7`#)p6mpS5Hvut6NQ!HHat^_p+c_^K^3KlKfhIzj(qG9!G_<+=L zn6@hgKlChv;&DHG#ybUj@**LqR2ozo74W30e()T11eu~tIBa2vN1fAPbM8YpU%w6Q zJvvTR8U(_Fd@1nzVG2R%zDVk|E7CVS4GR<(21VioiW82Zzr(fkEtfbb5XcI$3Ls?jHVu-c+=q`@wk-OY_10pr_a-Ll}LEOT+H{ z`dGIMp_TsjaL3{Wd^#(Jg2oduL}x;1_bkZst3@l+&!KrEpHTR?Hzfbmz*VLa=w_!K z4683d!(rNxbZI|4ynPG}x=I57tGyt7;5bZa(ZuXy7A9^A!Pgb`xbiI@zH`VIr1|RL z_Pr$76Cnj0u?LuWpAAOJ380<4{hw;JX(4~_L;Xiu?Ql{SoxNC*p>KF#k!ky}Lg8Y1 zy^JmUdASa5@K{S1^kep+f(yo$O^|XT5?{7l2_3~v%<@hP`0Q>@c3JtMYm6z!>xm>d zDXt>N+e%Oaeo5)*i-D-V9SKRwMIn6`s1+g7?31QHNLR=q`Y~Fl^i3A2EQ+YRR@#cSfov^&jCWOO^hc{*OHOAcWtZ zMq{)(`s{Mv8+a32hR` zICAc0#i4@j8z|<15$}9nFM6V_=%A;2ma}|f9M8)uVlOp{v!l6T^v#FeXz}5GJa6I* zz7ceYjMtcfj`Cf`wke7F(U;8x8hNm{HT=Pj_=Akf1%|n+f|_ItndjjL7^TSqWXHWH zOp;m%W%9NJ)y?TbpDi^x5|&!1udo1ZJG2-s&J`scKTpHdWL)vPVk)y(N`pN06i@7J& zcVG_~{S1bgep{GZG7FK3u?~ZbGeB@xC*^tC1&obbh^VbC(s2nVuJb;iuh~At$B4J;ur+nd(x6(co2f1<1i-&pPFHrqxJ!*58gt9-JOUPdw z?-x!&Pd^PH19A$Ye@=nt&-;Pgw1Ybv3s9X_63FQOL@k{jAa48?_Kd&AZlS_3e3OTl z#SLI>uPbzaej~mZ_XFA#7+k7Uh`N@|z#g?5alNHF_BS7byA=wwXuKe%x2R%|J;gYp zaulQmcjLVqwQ=vR*Cme z+m3XioBPzjzFdm_m7IWYe~iUCHN)uFYZh#ibg`+WEVg)*4VG85aP6`ako?LW>^9EB zKX2ZKrqy3i+0*@?-|Pp%HLfs{R0g*msKD`eYM|<9i`T5H#$5rAV3gj5M_Y=ZNQ*^_ zBP7ruJsbD6Z^SQJQbAm_2S$1l@Jvg2fEooXSbPMmXH>zMXEaPLItfi_GqKomeykzw z3xT~tm{ZmYiMc9xm9`;XsnZ9$kIlwcw(f>=@*c2%djwv7EgC)#<%7tHE_jg}1sWmd z__I|Od>+eyCb5!#g2sPkmYdsc8vlzNn%v!4za%ZvQBp>z{?nY(PJ0gs)n{vG*SEXa zIV|q*sh2Aga)(Q<^7hCaj0Eq z!}FbJ;MsPs;0Elwz`KxG$z3k4=WsY+y5rPmx4DT`<@CFmiuEVMePVr+wmSa*xc}oX6)Q1#igzz_CU5nx2fV4q zn|Ny@&3HLZjy$!3ZM-WJh?kh&^l$vmny_^0hg&H%Fu8%+IkSNx!b*t0eiAx=!35Q) zG*J6LcKz>u=eDqD=yUZV?=K$XUO2prt5t5z&V^I#+H_I+M2rnn<)eq3-5c;bp`(o0 z{ckKUl8@PcsD}NPondd(v4N4b^#+=5XGZDyvQjf5v<)Boh0ZmLk=6zVKDVR|YO z(Q~0e@Stun7Pcd3)_gmpJyMQ-ipHRA9$h5uM+q`T){rsshwZ%I2Ft}2nL}a2#QXtf z&(wy(9fzyP@`EaM_4XRZbxZ=Ekxqbiy*!5P_2qp3qR;H}%C=WXkR(pF-S$PyL#Pgl zb2$1`hF#yZo4G{`FmtP=7!A`=_GpI)oA9-mx%T-U3{1`LR?XY{}qn@ zV?Oe*XA<+SeKHh!`B0bD&Y*j}Q;|0R0Ge~dkI_@NhV&yhk#8wIO&N*>rUWis^3WbWeN9G3eXI%($;XFK@g@R`23R?n;GXA&8hsP%=-kolRVBT5t0Qx4vT!Y#xN(oUaUj$Z_$OLQ<-G_&*(_h zHZTi5Ly1cGLF*G=u;Oh3P3Jja-N}cokIrH03@@YCIb7mVX-KBMxsIscyU2x%XlCkE zb>u&q#<hji^?JBH@(<#A5AJ%6j}B!=!CRZl8HXUVuWG zv;Ycm@1yJ`*CW^8XUL@;t##_kS!B_JIOHc7h$_A{(Tkp@5Eu7cCUJc)bH`ntT~(6H ztgKgNUg!d&)_I$Qg6|;rp-!~Ew+>{Dqu5ImqOiw=M}3?&4OTsS4z?+;DZkt`RNA>E zOk?wEM#NhRiG5$l@fVZj&_)+P;PEA-u}m1!)e6bH-wK>`U1gBeypNRM9cHffMKjks zDv6tREHN^4h5SoPDY2vhvg6nc#^cmy`$Mm%(BHlZ;Ui54S&bq|p;of3viz*`>ILll7{oegZX)Yv zSTd>HLC}5eN$%&IA({<8Inx?`aEfiOGTHKeoT?rD#P6Iup1L_19h{uNv@69R>D>?Tqhn(w+CSNbP zW0~{$jG7fg@p;8Do|;dnOy0G>E60D~_9CffFip0je~%^5L)VM&PTc`4bUX<^2sWfE z)Xnh8!fSBHdKgZe48!w^tMU9fv2f^1KOVL`1$UHQfz8BW%*K3%Q$h=|YTJ8Qn-POU zW5VD_atY4(D2FeI$Ag)C8pKM?fz9iju*!mJ2$ipg3+nga!?lgL=-4_iR5rjC7ag!M zNe5|HEBfO*54?QwFa$1q2I2y{fsUxfK^H_F%;hTZhs8HRcJWkPJV}`LtQW$reKfQ> z=fcJAF*I9C3jdnsh8MhV1y65Ry!xav{y*G(c{EjT8@5bk2$?fv$SjeNu%G)mBoqmi zRLT%44N^%;nPtq7QpQ9{RLGF*XP-SF{{3-t1~!Zl%0+$gaD>q&3G0~^wz(Buwe@$o>#^z$(KO$5rs z3~=gpVe7)@t+;IEBi!z#h-K0oaf%wSgw66)jiEHWcHIpw;$6_NQ4L(8vT@=3TOcNX z6PCH$fb8@h7+QQ29%-b*dFjOvII{`)cjUo(nE)`=7s40EzabIxJSdARf%8WdSvBVh zv1#>5)>=VD%yH9T)nAuq)lhtRkLOJ6eCsx(89#)QhPn9ibRme34uAzB2Vr!=4WHu~ z0_&eAafTYfBDpTC-Xq%h>zxhIS+oR{l7irp@;z8@&;tWjj-d3r5US!n!rQA^c)E2V z$cbuVlZ91Ka{nfr5qHBEI(uMSnFGjPRKQOATVN=XAI`ha!A4p|_&$|JK=0AbY)--f>?FH3Ovp_UI3~vi~kB=VH!6#By;^NB3_?_=4h@aO2 zV*y3{{2?#C@ZdZg85sbyi3dB4&&NmBZG!iXidb-+52y$%z!RS`p?3d!_;OkdoD#pn zMb82dyEG0f%qt*oof!ob6S*6AEaQdVLmOdel?M@uUTZ`%-(&{)Eo!pGoWUHVJYxfO2TpMqbenBj#>4&xYKbL=`WhTUHs zhFLjPkf>sczw{^KVIO}i<^2}7Te!pK)k!e!>xUnOW#P?s(s)@Q3s2i@3Jp@fKs5d* zvM-l}=d=U7UA75A19;(Als4!D7yw&A1RO8jMIYu%gWrx$SpTLJ#YI@c@o+J)^VYy? z)g$oL*e=$ipA*>ZyFP2(T^YP~M=8z@dW;uH&tzQ`8-(GDMc9>!gE(6jly>?+p1mi2 zQK5lF4===b`LjUFu>dPvd;Mo@HL+yk9fyMe0&b+n(*TKDj)IdqmuY+uOH)N!JTGo1&#IHX)sgHkmA8u5#60 zuCv3CP1sADrVU|L+*-=aW?bFaW+dF%=Gk6`buu}Km8#WfbIpE>&615=>*PFr&fT73 zYnL-(HbGVUt^YsfYJWL8cdrs?8uf_gIv)4nmamNADyW8YO`aV18xMHh5^2=1poKbg z$rOp;0OW9K4f^Z5|LOrB@H2_M_NRlj`{78|Asa(nsW-s53SVKDh=<`l(V48mFS1O_ z?uU$u)J4lDj;)aWQ;bP#nqcHL%n@h*SN5XlUgo~xZ%Ceb19qHmW5f$uVZ`YznXAwN zv(#$9IXZy(Z6eNg;mU*IU=-7HRF2_GRUq-l+#n}97QFZ6GUf6p^@j5pt&?#XHplNOcn$(G5q*%3(udovC02~EQ6__sfpfTN#LuxAz3qNIZq=4YakS%%DzqBY_g4kM9)AGB=p zQ7V19Jfn5jh_?KFwDIe(Kfp3sz={&}qpUu4ATSXnWOoek> z-=TH0t=OOB%gA8&9JnOgOq=hFV~XF-h1#v{=$OJ5CdjmrsMMV#N%n6V7cH7exOyVc zo}oiq`bg3lX%4jK9v0D}jxd^u2hp+Zv!H5lK8zN3Fd3#2;Kg4|`JKCIm9OXmLLzyM zE*mzoVIFEZ@*XQy?q;7xX6*Tc8q9;9NO;M!h7tXY zINxnuA;hbfw*J|~6l;D*c~z;%Q=%TFAh$-dFI+U;T!C>)xeRI5N0=%Ld$!u}C^{1z zh1TQ-pyQz{Na-R0BCu!<^WvU9V{@hq?NMP0Chc0{zmaB~@DAeHd=c2K3mB!} z>CAS+Rxq0=VQyVt!sOkPWiIwDA@u={%>CR8jKRndd$*1NxP%@CNwGfK(6gExvCk$Z z-|U$^DzQZ9Q4ejM63s;YOryImL^7Mi&NANL=A&KFJ}|KOA({QqpWOR$3dEcfsYk-fcW-TqGJO!$$@%g$CQPD^rnVv+iN;^mTBKoQPi?LQ}_UGCE$pe06dl+8h zEM>j(YzE#jxf91!L_&#mDmGc6g(t*}S^eD_SktNj2XiA}LedM{>MjAjwhmZ((-I;r zEm#?B2RvYy48IR(;Z4e;NZ}a^um6Q$+FPa zQc?v(KaFSk>@>sahuv8+hG97VmI2E^&>k!Jq~K(0NASH{4<{eoL=iKKKzy1ZuCqu1 z1KS9E;nq{is++p$ECmSqDlaqS3N^*sSU^I3w;4oiTA$8~sMb_#Y& zO~L^B0Xx;rad3++zH4;|rZ#Ry^U`m?qb@g?T#y2ubq1gr2@vis0A^9`$l*f|3YIvH z`*cm9G$0Fl+11GR_Di689>cFmW!xp_&f56%I|RI0h?f}6$FXIRSTE@b42p|lTfr&7 zM%nOVVKNYt9}td8k$}*gKRD$i^7B81H)CyxK3$JSb)MldOEx-TD~1IE*TG=zX4ox# z3Oc^|ewGi6!E``B>p`*-+ws~mg;?%{gx8W63KW0hq8!aFXk!>o)}xTEKX z1(qKLW92XSp~fx<K{i3@F28|%HewvYr&ze6o2UShMT=N;6;85{4kM))XWojl3NAQXE%W6Pzm}H{Q%9j zQbsqPxP)^o6DCkL? z3&HK`XdP=4th(2WUd_9RTn$e^ux&FERVYTUIUi9l+5xhu&FFpeKX%1X1ke9PZ`>zn zPWOE1qKbWwQ5OxDBJWNEB6IFmZ%S(tWljFfJ-cl-Rcr3Fru?o1Qj@kpfW{*$T+@1MU>hL>}lKf~X6 zz~9R0M@m%^|J{G-i@F}8!8OCio!(5>vyEuC!Ww2-w>x^GQb65`x`+}k4N$yxA5r~T zOD4Ja9X0L3bSCPuCE~w#(<*K3Carj@6h>DF(eywu+5f(ps(YM;viZB|z-yfruQD9q z+Nnr55G0J)=F_fswu>-5yc?KjJP6rxZP8r$Qfm6NLNxVZ813KukX+|CBA&6&K>gAc z66807yi*i{kn@}A*X~mE+dG|BF~_6H!!{nYGtiDYoaRizR!S2!P5Z{L;tfQ2;*-@j z=W-&m!vrldNJVs=0&@+=uD8@Gr)hN$L)2(gjNA~sf>*O)Z<_*ixNmWbg zUG8jjs6>(pJtB)vZfK!)yyuW?qwVBPT_aKM|Ikodu#C=Dd`W(~eV{)qIzm0#^Av6N zRzm}5DfOTHmuh5!;ozKASSjK?^5jfXZ*MJu@>RQ0X>k^s!*dXaok@q9R16C{-9e!6 zFczzv0c)RGqZ<}Qpq&{3J({i1sIU$eQATLFw>a<|*n^zfb3rQg3bGipgDHbGm``dZ zoFAD$2_?IruEihfR;D8F2tJsv--QC506Rd$zjr~C6&Ktp%i#=9E^O3_#PxU2!j;Pf zNODO&`ZVnZYF~B)ssw0c)-!@0IZH$1{&naFRgd^COoMF`S5W61J@`EI5KZ|=fMZDy zh}z76T8TJB6bnH-!U&SS{h)@9DTD2nB4{{01DY?Hz|#;~7TcIE*w(tjsR8Q~}$b(wvZY278{Xd=kHO|>h zv#A+><38rmn1}AGE%@)VKe2lz*C-^ByZXX*Rtn#4wwFzcwPKYnTWWtSE3}B8HRN1` z*X_E^x)NxEt#r3Bne}t=In7k|gT`7oIGRhI+W9j|4f(M4)jBwuUPsCf+=a$PiA2e? z3LeTi!KF*-$nTOM2^3)8=mG^AjFGWeoxx|Y#BwWESh_8)^eV;|WFKIj z!2sNRHwKb#2w=V6VVFBZ8}k=z$K^w%;B!nJOQ+4oH5TJY-*_0GmbnJ-j29p5DTJ|b z9&EW-6NGC7u=&(AFgj2K;W}#Yk{H3JR4M#gR-E<8uOB&m z!Q%l?E>nub&`P{Ga6T5$dXMu02e9tJTevn@87tH*#YN6@@Hxk`I4~~|9ACaf-TP+3 z_MaHPF?j_BRTuH*9dh{Qj7rSAP6sz?hr&mljX3nncFfuH1#HZw;SF!r!5Upt__TQo zzA#@8D?PpkPqUh!pzSu?d`;o)FMfjHG;{pwvjFz{&5M5tmxF%nJ?Qtg0i~i3|77<6 zwT=nSkosR#XS3zg*$V<+lhay(oKC?L93h{lNQj4vTwT@4kj`C(=dd9w``J9WJVTOt z?MC5>*Rg1`!fxx$fk#Pl!wqJ`nPc=~=Q`@mIvxhCF``e11u!nHEvPed6*IL`9{uRZ zrEctPZk(m*jy4Ul(0)!aWq9oYwJmQi)#1DsdEC5++;_*K!A^d%HBNxyMt`D=*Gwl~ ze|V{a%8!4~ssFlfx%fj^?y)fD-EPjxrte`kZz60eUWKmBk^=7^`4IXjhE+NH1~iWI z!_vM&xV`KnN)Z#X9=N&_7NeWc|DuQsWzI|-oHbQ;N4%HTb1?P!_QLb&nh4BA=J zfzG@+fKt`xppFoA2%cGq%09#*7t3;#6BmTWe{2J<=HJM;G!ji#il8Z@RoAgZ;}@yTtwYTZ%-Pz9-qAeoaxHa74>AT@Q<=c=S+vK?NMfFu4qq*wlLo2` z_3*fplj$PB8}^0d=uq^hMf1sF`xVrqp*W;TDU&nq-BhXGb^6n$7&_#WGBVP6Nu?XG z>9f0MF%#Ec(2mlfIVg!CC6*oSh$Lkd*gpB+DFy%dBgNuZ$0px;v;dyhlC*Nc?iSq}2v#z%@%T@iU9h}M8Q z+POWG{>5_~Df`O8af!z?8g=@2miw>g+Bkdx)d?oy(TsywM6VYXeip<*o!6)zdsM;F z;{rlky^yG+J1i9#167_-xX`Z#LxTyxGCBpBmcppkMH>dgm2u$gLZ~=;7gV1|L6Y8j zD7}yXzoKro3CiF+%y_NUC$az$f`omEKMAE%?kGR2t(YjKG>`N z8!t~1#6Po)VfXO^IDJVJDBZV38<`0t-)@TgHTz+pP8@f>>4fGw4RGSu0{M5-u}P#<>5!qy4e8828*RbEx*6ihmtlP^)S<+fLNFsdz-dk0Z9?WXW+O>wdN)Rp^wH=`17ICeb zinH0dVgmT#0Vmv)9!F%E+i-BqhI!+u#5842A-BYARG?c%JwLp+@qHCd1h7S8UspEC zjF6Xj)M}Q6*a;k}y8#!5yR%O}dkr2@zIdMYOt#17R!Svvkl_xWrdrH&8Ht(U zB;3x6nfF41*}`}-?w>N4p;}`UlbZ);-%DeGPbt(hXKD21fC9a;Ar0MW5P}rm1IX#E zD>{;Ois<|Zv}(B&O&0iRQiY#eiM6OIUp#lk$(@N2UfcqslVu+?R8 zeqkrfDr*79cPaSSM-_bF-Wse@X@Q>vOXADxZvYXTfbL~!z~VfFo-?zs<=1wI`f7_u z^zxyFRf=P(46)1QIyk#Z5QkJ7;{Z7xc=mWRwYEAG#qK?WHdyOIRZKW8(3QY;qx-P= zKrK4;tps?3)_|P+0DAVh0JN|Rv_3CITOvNeaxE3mdNBu%jHIKOJ$B%_Diy6?J|FhW z%Yf<41jy$uh5Mt;;H4J_ERAVcFZu}-WbK0V_QN13yB6@~x!_~LXbHdO0}!l47|SXgW$tX5nJi&azL%sXWqyi^YtA{mHH zdkK-ITd~@cBv46c0mX!S;92wx9{)j2MQOj##g7TlavxwS@;ln~9UHzjM*`bXg{k`xsZbUxrT#d$_u8 z3t6kKRj@t#-_X%ZdC*Z@ZM{BN06W<0G0#@*WZqrd#Ol|`VV-_81v}LZbWhJ+=)uZ( zw7~^#7ZJ-R(~SRPw|iFJF}&y6MO=28i$5^Dcve3jEA7=4G@=~NI{4jzm9oG9mw*CZ zv|}b~{p1JMQNB7Pn52*A%f-XTseRx+sD-0^Z^O)`)rdDL1Ztuev#xEr%(~Z8$TF-B z!={JSSYcaA@cWgq|F;M6_n>X~N<}Qx7ze@UXSZ0J*FOjStW;QOk&mj^H{-b%4OkK4 zb+CHy1PWf<{7>fqTZ6(WA?v9Ozi+5A|m&HvjamSlR6>_;Ux+B(6 ziiOqNKnHX$`iHep4ZY=TzVaa1f_ zYY}@-(KV6`^R$6Jq;7~@{hSzTsEZN~ zen?jBENS!%xJ^bjkcQ^-S*%v|!2%~w}6LueNr_8Bhlyz_h zxLSOqzXoSe+T9dAZ(urcRDVQ&;X0w_o2uw)O zc`%u-OYN~dfmSQ36Zx&LX`^~yszu^4IotFOrOKWni4zOaAs1z8dVwoiuQ7*=Tu`B` zqfJ1yN{lvt;YFUlEk`07N*KS{*2sr*2wmnaBKM>ba_~w(rwg`vF{(ZlZ*)a$3g zaO>A53g5*<>fK7}qUKrpG>B1U2PV+5CnAhR&H?iLv_866vW!`NSP&ak@GyK*EJi5s z1L>Y$1-F${@P$3`XhiH6Rr6?=oSe=^;Xei%5>r z4noGSgKc~Ub5Toyu3Hev?ApLWQyRW#M@2kSuwe_SKUPk7%~m1Hs930cQbteNOCf=f zn<#QgH+`T}2}zvQAl64YbY6uETE6mLi76mqiUZDEizS%tOs0(_m~d3I8jHJ zdU_$nRdNu0CxxUdcT;=iHj$m5G)Ss!E#)CHPOC~}qr~M&B>a6Zx_VO(eP}=TpL-UC zokgx21AupB8nwLt8D(^k4~{;{Ln>dRkXV)#eE&EbA~kQoUC2bYR|^71XCBJA^9nJ8 z;xPO9Hc0EpLJQ=^AxnEJic{)GJ3>>?2i8THJn9T#ORoT5|7UnDZiZ*XsKCb+XOOA) z5?EN;i+XqsVB3@h4h^$~eey9-VR8Z4I9bD^0eKL!>4T`?e28qgh~hr4MnSF7@cv8z zDxDJv$IdNAPFGJ-#(QtWxiK+3qIv=8I_08WNnencm>yhI_CT{t&!CZ<5z5?J2G!UZ z!m5dtsK+W4==BN6%%4W3{5#Nv>*65LO`-WE58(8sJS6Tt7j3)Z4+3s+U^YJvc~46Q zgB%lhvOgO171Y4Ws0Q&L4nn$e?-10V1u19F z+u3++T`h_n`-bwxT49~^pYboVe+i)^^Ehh=Pgl9qj zt0J^8!5zKqxdq)ZEw4!CRr7uzpPpxcjchH}=~>=v;5~B8q{-pZS63#u8XF zxdH;-Q&>aR2O>lI(eNud_&Dkb;uBS%G<*cwGDC1d$acv7`V$bpD-6s&kHn7LhHcxL z(ZQ8F!0wSFoPU2Gq^~+a(SaFw`t)mPwPQWv-!22Jg#^7@vK)E&7(mi^9ITO84d_t} zbSsr1>X94dR(?R54l;1p@v9+w z(FmF{SAeanU@UMz2^Q40NU>Sc?vgcy%j`HK&(a}UMQq}w$4F?a87eZWd zq*o?^a<>68)Wse)&Hy3yWi0kOuE@P>a{sbxSWSIv#|>YVOQ8V0 zoEDDjrDw5aT@;ujUKLF1(R!5Z;DMcE;h4LZ^#-T?JYbf-Sp|#QEwBr(T z&pi`e*JViXXAi1RX+N2)%_Lha7J~hoPLj&;L|eX(6Ak}p+ALO;-0*u%9Tt-#qf;Bv zT>(w{*zY0IY+OnPC%GhJtE%;(qB7{i4lEC+*X-C&LoB!5nd~lw>zs4Ku}JmoJS=Qr z!``kw2bBcX!JH!rs32tlNl*KNJb5C~;;S6Uh|EN&5YzVACu`eDR&d6GuD-Q~&eM=v2>>m@zke-c>5 zGw|AvJw%x2H8Nknm9*^0qrbncC43n|)X@dngi<7Q=m!d!Zp?(3Iu`RzMVy%!^C1bJ zU!&Htc-p5R4Ez*o(9&N&(VOIK60Eri&YUPh+n+_yD0+xW<7hMSorg%YO9i#mKA+yV ze2F2S7O%WVB*BUZAz6KA*DUPd5$JH;4hSiMiPXFgP|M|u}M;cn=l z)z+s+kw!8fES*^gu2tKR=5Y>N#dQICb8-f=pg9`8d@5pgd6zH?PEassnKzn&9y9dE zesW>sUUcVO47~ym)6f-4&7*=rYOx2(7uNyxRa(sHVH%w@tfmjn&ZCbQrJ|~>gzC($ zVht6~M1nIgC+pIFKTYwc%*9x$*MwM3GbE)v zC*bP|Z8Y5t9;-dAey@Pq(cNoa_Q%8f<7*M}I3 z-}xx&hF#;`X^-i1UDp|vaCb&>VHXrbBO!kYGajACq~eE)XGo9nVGX z%uD5QqAqeAwRH@^!&#~9K>tb7-Y7yef9_&`<%U^pe>fK|pA!UxYe?wZL4q6F8@m%! zndhr6qhjG}bRF+eu;#;{XVB58me9bt!aqWtO^#w>va`TFPKQw`oJQ1B7lVb%d}dfo znECze8N*tzo%t4|fuz)hm?KZ6*ta(hkZ%D=R>zLTAPGep9gUR4|4GA9x?LBXsxRT& zl8dpj@d?~-ua0HPqOt6{8vJ~(F8*?*2v3aH!j%CE&>}nh^JyJicol&84c6kP3+CX3 zHrGI7i73XSZn*t{GydH31B%pk!E3=SU|X;gUi)7L3%y?0t1}zA4=2Ip&Hm6?b{_1i zN8!iw1<)3x1vaBcVVP19eAhk#Y>zpxSH|;CAmzcAw|ii?&Xc9HA(Hj<6CbN?t0>ET zpP2RK1Gn(Dk`*lF$}|)oegG@`i{h)NZy~={9X!q(g@u%ILEp|07av%GZKCtBSnho6 zvcM8rsW8m8`~}CP9kI{*?a=z<6rB09x{|w};ahDcOg!EPow=c4%J^cPW5Rfk`chDQ zJOwN6n!~nzSx{ywgFQzCu*ym$ta3XWNUJvP%qsx*7cuZTF9d8O6JWZD0&6^36^D16 z2BqxvkX4Jtq(Div4GOjkfXtXPh`zf3_M#d1b=yMN*Rd6Rssq7! zkeAiKT!U7nr*JuLF+A6cfe_AZ+-H>rIf8XSdJePhu9ack+n2=poW=#$WlAnhh?*r@cl1* z*iXp`W5U*fnUg6J`2=%W&tPS9E6&O0H;43itGnaacBox zxgrNdrB=Z4S2J0=E~mo2z3XvSSUV&=sKvQLm+{TX7oaHq1wQ1A;qslSK&GC6XPX{I z)s--wdk_q||BN-MhuXPuuy@B62-%$uQKJWNlJ`kKY3?v`?Io-!RKeXTs`%<#7ML7T z!Na=hxLRNWWlviO{=%oh@SHEcko^$eC$}N5oLSJ=QVp&fbzmQr4(~1pLgH;fOl)+q z^Mx&V*yl8lqH5vD;l))|O8?F1#Q;?gH3al;CSnAUk7@Il@3u2;RW#UZy z?8r&TdTWKpS_AQfoiBDf(+BCZuj2W(M^I5YEEWA(=;K$!xw-opTY9|^XE^CLgqRDkSFmSsGN+|6 z3Fg{tYi~QW&ZC9O4-*DkKS|IXyFiURc}KlV)U#U4c%f9@)eLjv1liX~8jmdEq60q@ zt!4=3)4M$yDOa<#By;Z}koBx5#hr$5dFfI5tKl-V2Q8!}OS>!|e+Z@dwjH5@mwQqh z12@tADm-X#mN{K6;ZKzu+k>~oc!CQ5Pm;EMs4>mz4Sl-KmpzfOl>NO|iZk2m8_b>^ z!)y@rf#`D)FftIr@O9pxv=*c?ZZ{~>z4#02Ytp6S1bGiPkk4@n`(%>^ zZY(COaTikWIQ1Vp!jJ5aVHK}S#*g2&VN<6+81!U69{xE8?v3Wb!%kgnye$p}A3cI2 zy?anvzX>uNBj`{UMt#lq(L}NXYRkqbq^}Y*n|9)>r+lD=F9RwT&V&^OL)5K@5oo+W z2d#TC4FY#=fqi3Qz^f_*QA&Op||r$}u|18(meLHrxt z(Nf-ge1DA)Na5{Z|1}fPq(4-rrone#E8r8`0V>Z6eaKi>3pywUY9mP{hi%tMz95n4d;^L+)k$Uu^5iQSJBQH z5*!81m!wO^pWQxR9^TH?XD9S)V)P z3U5chlC6okd=zMV>%!}NnygK9rTY5|kVETj^5AU@;!{&XKO|oe<3K^=C2T^*=N=;m znj;z$zNT306LBFWUKEOoSxFvb&9J<=jb@i9q6)=64Fx;@xCFnqr=AktT{|LuB4BOGYccl*BW7wDm0k zvRQvQX$bsD9laLqE4iPyOH9rHz=uy z5AEI@Zk62@PWNn{MHY@r!x^3sdRncY)q5tDUdQK4m=IoS8C#Sw`#iz$Efm4~PwO!O z9!<=ncz&pikH`C3`0@6mr6e@D1O*-V)fkvN4i<~E$$V8BF8Z=)%gZ|KdaX7hF0BBw z*-2#SvQy0R^%JP4fK7G{O0cyKPEwC2uLDO&3nqCeQZXJwi?j!^huW?XB$Uoi z%V|u(_a{gm%9$n$7m#mw)98513>}bAWM&)_WaNhhnJr}!z)JH*u_@n)?$QtvWg)`s z$bF2q?V6;U9FHN}Mpshiv;y{ic!0L=Fh!a<#q>v^(~vK&hB92Xq4EqXK-IRy)~}e_ zdY+#cyvs();j*x>vVuGtKk@JN`hUfZvy(6cA4rGW5nm9i{{n1KE>S&e+-}zbSrmPv-5O)W9g&NT2SRVMU$AVOc1W<120guTd zbRC`pRaFBIes6$OJvk7QzK0}dWPz8#DAeu~0z2j{{Qj;4r;IZpUO*F~E~i89LmkLn zs|-d)?$8ju8mwe%VC?t;?0YF1dK~-kmd}FlO>8OlS=C8t>$TwD^ZH=-Gg}-~66H$C8IAtnCn->iCATY%^i!(FjOeFAHr0_Q2HLgGbiG=#iZp6mBm;?(>A8 z{OD7}teDOkoPG!&S#lJbI0o>uB@O(q>_z^eo2Y9A6s*~|7<5ywAiK{m;M!O2P_xCtmqCT^-Kj!r5+%oc{`wE zfQ<_eJ40e(0tkOshu}>b_%VMq3e}dvDs|cL%}D@n5|zbb-w(kXz7qJT^oK)+T?GyQ zZTOdSFeJ(3L5xZ)+LUPnrTbpM?ZGZ||5stq+mLa`0(g zD&D+R5YG$eLX_Sr1;~zb;X1eZL88jO~43R4y^S*Gim8^%f;pRV7`#DN!_gfI_GcnYbC{P%2~}iiK_FW*cLrR%_?ZZ*xgnz`am0Ac_dn`#!$!wqYzLyjsetIaC__docHze1w z%^cTL$_>k4;pqs{wLTVJO>U!GND`CyRvPZ|T%=&af&n`Y>TE|$R@gK8Ov8KPDz3`* zzvE4Q%2HM{a;lk{>LKDVd52Ot{)4z}lZEq3+DM~=DcKt*hSGB!iGT7+2)J&B($8KY z{l5_kI~GM(MyqjtDRnU>d|XgV6a_QgWTsM|Vy`iM&5))tSYUaYvp!-D5|r>@CP(a; zO2>S3(AAOlOT0m)FCIb0r3%bVkA1`<>k2xy>KARgRt@bRxrJoY>dCY3Uo6BA)HWu~ z5vTZf-KHYHanSn9R|If-jE~N#^x(8VEmo>m^EBWj+=HgyW`Vbwz?ZHTXjXF zRa9^v3B6?JEYyx!1)&oa8sQuSS5br( zy8Hm%3>%=Q54n*2IS2|w@56H;9e6h;jL(!h2U>>E9v!$*;bYCiLC&D;* zxjy9SXQ8;D4e*Eh!%wv%p#QlPTHF-CtWqDkwrv0YzBRc}xUvZV!i%j&fFLSScn*9}7jz!3xhDAY3mL7gWzkbzEg02?OC|> zZV9NxF9(s%R0zrlgYml|uy39LNQp~A*DpoyiDI zS1=aw5mz@#;A(GuT=h7Eo*4lkb59+5q?2&>nvuq^LChST*~R$!lCE7*pshoGZ-V4Xs}j4`UjDB zqu-{Qc<7r9etu4fv1Fx^It7M^iv_cV17s=HLKdmya?q6V4g*<6i3TiMD;g@z3pgvEj4*lc9_5u5+kqsgTD45gW-N{X0GRYf}yMh zdvWyu^QuIQtvf%N{oV5vT=Tz*>h&6#?kl>m;hY`>eAz{WqlAe3vI$hS-icb`dl}Zv zybcE4CXK#EmCT%-!%TzKeCF%WLiUE~dC*xoLL2b-gVHe}=3(O&!uPb2-j+erA4bhM zZP~L>hW;Yv)bB&YM7D}*R$fFqd>3a*A4S936|U&)L=`=^u^L4tOR(oXWys0#9q`mt zp1Bv2LhE$6w4pK0*$ z{R6UM#08yQl*>r{S(Aq@9RrP2duWk5#2FTwV)Vxf*>(G`vukF1v9sQUvqi(l;E`46v0eG&%QIhA?)gcmZF0z-ZslhE3CI;cCGcleW_g zvqjD@>YG|9gVl0a*l!NgCL9HZO%JKBpE;C6+a~g6broW^ePow*9U~9-9iw_uPLPMo zE)o4XC+OGW+T>BB2s5aw&U7r(K^psvs6iD=B!6F*NRG}T&yTJ`8%jLTNTD|3OV33q z0_jw1MgkEqGhwRuoS=n=pFOTvz_jz#BhH;MCPjWZ`?}I5_F;`SV*lO%#SJfIyjJf5 z$vaOdJJA4kv4;#+yB5K)J5~a|r->{4cQ9r9WtjIyFVRPfAoM4XFg0lv)Gfo2#+;v} z%#%@nqFc_3{Ds4yxK4|CeI*F89rbat=OJ|T^><34IG9OOn@;seP^8V~Ym@=@N7*d{MX zKF?DUkSPcAVtT0XR!@)^<{?+!n$cez`bk~>Y1H?%3@NIZF+Mk^&`Pr|dgQ}HIyGb% z&-!J7pM56mV|Sz2)3!fg3%r)*6geCLJ5eEak8&K-?z0nY3^+_ds503r@|N6J2mop> zmrTu7V8W3CI#D z0YyIo6nU5z`i>8wD<;jP%To*SI=i5R&L|R=ZcDi|pC_dw8CG_InCOi>#@bguzZQJ+X`#W4ALrVZJo@(aecXe`ei1dxr6w z30v)FGg*21=;qg0RKa(EDUX>>pAFxSZa5ugrc0VrmA)?sDtk$D6%3GOlOb8QhDB~= zF1Bi(KAXDv%Z3^slqc3#-%#Ux#`7-ZQ#Lyi>P3Wz=%~)OyMVVrM zz?`QQGm(BDT?_ch_Unm5*J7zM3)q%FWrjQN4KI}St7QK_>2M>?k z^cek%WNh)EBr@kxS&4I*4Msbux3zmoymmNgy#AG;{gv?vQw}t~9O3k@_{q-PmChvg zeqbkb$xwGUALRu1rL%26XTct$`)nue8sgF;D5xDy}1_kwrfL^dP5%rU2 zXL|pj4P(n8E{lhKW4#jVF_F0ZjTh6hvD;oBMlEKPx>*fDkr zyO{Rj=?NSt-O!9b4K~17*=&3&_9Hk6?*qU2Rji`-T5vj56fdmi!HImnaDsClsHt_Z z(Ek~BKox-6-=Os8Jit<~h5y6dn@4jM?fv62W*#y`2&D|6BoSx7_n{=IB!vb;8fcEv zEQB&;$e5{!BqSNj=j`Jnp^!P1G^tdYOc_$Y?p^D7erw(JJkPr8{`viDpL5pQXP@_Z z@4erz_qdx6M&sS9J)k{J2S2+Z2uojtfM&le9-S!*s!55EV(bA1)$wSb{|PXgc>(6= z`ax83C=~T)z>$@ac#KzZuDz@O8TX=A3&Sq><>I|}_?;tX+ z6qrCMh&a{_yzbuUXUS@?98!bj#ap0aohCfUUx0TVc!Lz)2Eq9wYWQaDJ$%=2AMV$^ z4j$9FusnGw{-ezc&h}BTF+&~J?brnFMtA5HxoI$?JRW9VKLAXTH(2#0q0Ez-u>Hp^ zxSN}Ybofr7j!QPkU}-eAJuCuGxwCO-u>ls?%@1n|H{(hbLGYW}3+csrxaV#r@>uBy z56go=*{T4RI$wmH-j5O4^cDEH0g#?eLDReoFlO=+)jUvv6Z}T_ZLbKN`QZZBbr2{{ zseqdLRp8e0U>#8geNr%i%lHc99CQNl_LXq%Mj*I($zn4T7yRYGC>Bvvqu<6NFu8af z5Bzz8P5Mi)o0>K5iR=Zzx(aLuW_ZzI2|Tb{5WlUtjaxjr;Z+w8-Z7|#TLcMkJIi6~ zP!o)qUxPQN`LHhE42XPtA0R;$J zU;|?*-7x=36R@uZ=*>Awv_MY|1gETkj`T<@z4kIZR~m!T{d@3Qzjd&Cy(usRm-)sn_%$|cU3+mvw7Ik`+^xyuE_3+KbH|hJ_INbW0z;nA)@5w;{i?c$G_Ty@Sxv)usogsS6^m;Va!p`;_Ct11&842uD4LL;5vZ@LXamW2V8*#)ImZ_$01BaoS;Nk9MY0Q>IP(vN5C zp(|GI1$VxKbmUVBd}k{kwsKd(jb+|gBPJMDS6;@R=1;(pZxCY4#qbMteth)yHJDy) zkI!Z_0_ztKlOndbaCHOJ96tif{+z=b3{HSa*dZuyDTT6)37{+g9j==PKtZ|y&O5D- zYukz;roI-2d>+6u>#HF0bQH3)R>O^3!Em^L1|A>a15Ru$w)ZZ>af*kr%0nwS)E)zq z-knhJ(F*r0Qo;I>XRzgQL45!2avX4HHExQQhNgG^Ku^rT)y2!;+BGdG9lZ|Ax5Q!1 zhg~@3o;(P=z6R>_Q#dB;2L=t<06tD&m~#viSFeUG<`}nWxc< zoO1)qpKbmfZT=tjO{v3U|A{4c;o5T6Wv4rc?b^pioRy>Hv%jHz2P?^;vApWJ z`E6h^osS)}_z5YcyKvCUaArr56yDY^ia)Jbj#BJ40%yA%>v1@d$q2AT-pwjxLPQhu zYDr-2bK6n;!4Be#H-K)Q8`@brRJA6>o0)veF!JL3Xrwrib1I;dFiToEF8hz77FSW? z0UwzvDJ4?%L>9_QFCj&B_y6^j0M!jg@VD~U$V&b?h<&VsSk->y?KBsl_rqCtPqvoAu@kKEwti$_pqmJ5p~ zRsqv5g%^J@hfXq#f^;X)+7M0X$&Lk;viY!4)gHb_+F^lQNx)a6@IV0{ej1kz0!0xp zTPgr7YgWR%?!bS?lKT%2ewc#=SM24B{~@Nr^ePAKFi`)$^EJf^W;1iXJ|!*E_2^Dw z6j2`Qhp~r>?23qdvf+Lt8NBy{k?3B>>UXGOq0uOqdBBOpibmjSr?XJ5_y9_DP04`H zcc!~(JMjA+VpdDnpy#dc%>`1tSvS#l%n!$<=&|-mw*KvBCTAiJd4<_A0lqiE|LkjY zuW~n1d4G+WlwHOd3g%@_E)m7Nw%5^mIRj?rd|T9z;SDa*e2nYnF&xrp$X4(aSKFNt zsP6h2SN*4`ls&lhB&&FK2{up6W83wMfTI!!;U8oPZ=F2eHR%k0=q4rsm4f){olNJi z*F=W<^qy>rF{{4i8{^}zN3_QyQQSibV)-FaGl3D<8@O234_uHXU_qf&y#f8C*c z6TdMg0y7!$ZG9Z0&ofX!%yK9xQDu^B)^d($y8SEW{qO(!O+p_zeDJ_W&n|(HQf>HQ zUxo#h1n^XtBUGQLgOM%jIAUH0$mhFakNqORXZ8#_EV}UFC-Yz>(!?R72AEx;1)mIg zvHGz+fVZ5-PI?h&Ug1~VcIr5Yc0@th<8#>X`(1csvIW|#w!+GGRor)W5T@=YfysI)T}|MjX71HbQp z?12YxvVb3(B#gtQxw^1?@6Uf{_WvU`e$(4puEgRFo4+M9?9dzCwC@R)J`IN#nR9= zcd|;*M-JNk-XP&NC#I==HRqN11*9>UPWBATkuyhcQyHrpI6WV7VauZ%aA^-O`J%1{ zxo;d%=c%K_O4Nh1(&-QL@>B~`o#{-DADaZ8b15j%D2byYM=^T?JIO8u7i9Rt5BNFW zD2ZA`7~Mza5pVt3VksNq)pV5%L-YJs4stBTC5dc#)A*8=I$vo?f4v9WI z6P-D8l%#%$KmtQ`9HZDHl$}N-!%59U&&&(WmRr4{`aC&|!N5`w9uOd-58t8Z$1)hc z-f2ux;&<~Hp4lX5^HSz#t01K-Im(=NjzMWP$0)}$>Zotx2_yFF60;hgM0_n;%uRzd556TKuP0#Az=%;+a)wgh>bvLDjX=_e zhc$Tq$~@Ihgk0FuMBS-e2<2`0=7Y5g$ZgR|6s<**3pyf5N@aSLea?Qy>&7(drbPe> zxMF}5FNUBep)N>cdWQMnzDhFs`UPiSX8>An;S6@J;>>07GI${VnQ|($|5vZ$f6vQ1 ze9NHlxDj6WvmfzRw}Q&Y1UTc(p`4q|Kz5EeOrJwww%$Jam24=4ycdL_F>TZ^C=W{# zijhvka=3U!5H4EsBbCjnVAf@U3g>=DTpx9`YtBZ9&=m!jYwO@;3LpHy2`KNpBq+6Z zpaywQ=y&*z0$&y)9k2Vy&ig6cLRQfG7ZJX|6nx)(SO&};b^TRZ46&RU$0LCYe!}ZrRlzkV)fo&4V!)qM9yqgU&7Z&1eG8Z6GH68Zf z5~bA5UGO)ZXh_Z93-J%bz{GVomfOD?ICuNN()k!Qlq`fJq!f_xXEFE?5(azMq=Lig zvrs%w29I8v176oMp&?2H`h6q75U>w>v1wA_x{|nu2Y=B9ys@!+iB#l(y|5 zYTK{~qMp7(*`L25m2*b$ye1!v1p$=58^E;43s|^%0Dguq01MsCm`)!sFO8fDI#FR@ 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 diff --git a/tests/data/configs/basic_align_config.yaml b/tests/data/configs/basic_align_config.yaml index 13c5b0e2..c4f1c843 100644 --- a/tests/data/configs/basic_align_config.yaml +++ b/tests/data/configs/basic_align_config.yaml @@ -1,6 +1,5 @@ beam: 100 retry_beam: 400 -use_mp: false features: type: "mfcc" diff --git a/tests/data/configs/basic_ipa_config.yaml b/tests/data/configs/basic_ipa_config.yaml index 02401ded..5e74f5c8 100644 --- a/tests/data/configs/basic_ipa_config.yaml +++ b/tests/data/configs/basic_ipa_config.yaml @@ -1,6 +1,5 @@ beam: 10 retry_beam: 40 -use_mp: false features: type: "mfcc" diff --git a/tests/data/configs/basic_segment_config.yaml b/tests/data/configs/basic_segment_config.yaml index 35e51eaf..21ce67da 100644 --- a/tests/data/configs/basic_segment_config.yaml +++ b/tests/data/configs/basic_segment_config.yaml @@ -1,4 +1,3 @@ -use_mp: false energy_threshold: 9 energy_mean_scale: 0.5 diff --git a/tests/data/configs/basic_train_config.yaml b/tests/data/configs/basic_train_config.yaml index 906f11cb..55dde909 100644 --- a/tests/data/configs/basic_train_config.yaml +++ b/tests/data/configs/basic_train_config.yaml @@ -1,6 +1,5 @@ beam: 100 retry_beam: 400 -use_mp: false features: type: "mfcc" diff --git a/tests/data/configs/different_punctuation_config.yaml b/tests/data/configs/different_punctuation_config.yaml index 16528fde..146ee7ae 100644 --- a/tests/data/configs/different_punctuation_config.yaml +++ b/tests/data/configs/different_punctuation_config.yaml @@ -1,6 +1,5 @@ beam: 10 retry_beam: 400 -use_mp: false word_break_markers: .-'][ punctuation: .-'][ diff --git a/tests/data/configs/g2p_config.yaml b/tests/data/configs/g2p_config.yaml index 8956baf6..1a6e4c19 100644 --- a/tests/data/configs/g2p_config.yaml +++ b/tests/data/configs/g2p_config.yaml @@ -2,4 +2,3 @@ punctuation: "、。।,@<>\"(),.:;¿?¡!\\&%#*~【】,…‥「」『』〝 clitic_markers: "'’" compound_markers: "-" num_pronunciations: 1 -use_mp: False diff --git a/tests/data/configs/ivector_train.yaml b/tests/data/configs/ivector_train.yaml index 8880db5d..96713b4e 100644 --- a/tests/data/configs/ivector_train.yaml +++ b/tests/data/configs/ivector_train.yaml @@ -1,4 +1,3 @@ -use_mp: false features: type: "mfcc" diff --git a/tests/data/configs/lda_sat_train.yaml b/tests/data/configs/lda_sat_train.yaml index 9274da09..cf33fbf3 100644 --- a/tests/data/configs/lda_sat_train.yaml +++ b/tests/data/configs/lda_sat_train.yaml @@ -1,6 +1,5 @@ beam: 10 retry_beam: 400 -use_mp: false features: type: "mfcc" diff --git a/tests/data/configs/lda_train.yaml b/tests/data/configs/lda_train.yaml index 39a72aab..2e44e5a6 100644 --- a/tests/data/configs/lda_train.yaml +++ b/tests/data/configs/lda_train.yaml @@ -1,6 +1,5 @@ beam: 10 retry_beam: 400 -use_mp: false features: type: "mfcc" diff --git a/tests/data/configs/mono_align.yaml b/tests/data/configs/mono_align.yaml index f717295a..f7268c51 100644 --- a/tests/data/configs/mono_align.yaml +++ b/tests/data/configs/mono_align.yaml @@ -1,3 +1,2 @@ beam: 100 retry_beam: 400 -use_mp: false diff --git a/tests/data/configs/mono_train.yaml b/tests/data/configs/mono_train.yaml index 63e1b415..db842898 100644 --- a/tests/data/configs/mono_train.yaml +++ b/tests/data/configs/mono_train.yaml @@ -1,6 +1,5 @@ beam: 10 retry_beam: 400 -use_mp: false features: type: "mfcc" diff --git a/tests/data/configs/no_punctuation_config.yaml b/tests/data/configs/no_punctuation_config.yaml index 69eb6d91..ec409a21 100644 --- a/tests/data/configs/no_punctuation_config.yaml +++ b/tests/data/configs/no_punctuation_config.yaml @@ -1,6 +1,5 @@ beam: 10 retry_beam: 400 -use_mp: false punctuation: word_break_markers: compound_markers: diff --git a/tests/data/configs/pitch_tri_train.yaml b/tests/data/configs/pitch_tri_train.yaml index f4ba46ae..e685df84 100644 --- a/tests/data/configs/pitch_tri_train.yaml +++ b/tests/data/configs/pitch_tri_train.yaml @@ -1,12 +1,12 @@ beam: 10 retry_beam: 400 -use_mp: false features: type: "mfcc" use_energy: false frame_shift: 10 use_pitch: true + use_voicing: true training: - monophone: diff --git a/tests/data/configs/pron_train.yaml b/tests/data/configs/pron_train.yaml index d22d1903..17a3333b 100644 --- a/tests/data/configs/pron_train.yaml +++ b/tests/data/configs/pron_train.yaml @@ -1,6 +1,5 @@ beam: 10 retry_beam: 400 -use_mp: false features: type: "mfcc" diff --git a/tests/data/configs/sat_train.yaml b/tests/data/configs/sat_train.yaml index f5d81e80..af699474 100644 --- a/tests/data/configs/sat_train.yaml +++ b/tests/data/configs/sat_train.yaml @@ -1,6 +1,5 @@ beam: 10 -retry_beam: 400 -use_mp: false +retry_beam: 500 features: type: "mfcc" diff --git a/tests/data/configs/test_groups.yaml b/tests/data/configs/test_groups.yaml new file mode 100644 index 00000000..26ee3e30 --- /dev/null +++ b/tests/data/configs/test_groups.yaml @@ -0,0 +1,59 @@ +bilabial_stops: + - p + - b +labiodental_obstruents: + - f + - v +dental_obstruents: + - th + - dh +coronal_stops: + - t + - d +coronal_affricates: + - ch + - jh +coronal_fricatives: + - sh + - zh + - s + - z +rhotics: + - r +nasals: + - m + - n + - ng +laterals: + - l +dorsal_obstruents: + - g + - k +voiceless_glottals: + - hh +central_vowels: + - ah + - er + - uh + - ih +front_diphthongs: + - ay + - oy +back_diphthongs: + - ow + - aw +low_vowels: + - aa + - ao +high_front_vowels: + - iy +front_glides: + - y +mid_front_vowels: + - ae + - eh + - ey +high_back_vowels: + - uw +back_glides: + - W diff --git a/tests/data/configs/test_rules.yaml b/tests/data/configs/test_rules.yaml new file mode 100644 index 00000000..9344fea2 --- /dev/null +++ b/tests/data/configs/test_rules.yaml @@ -0,0 +1,5 @@ +rules: + - following_context: '' + preceding_context: '' + replacement: ih + segment: iy diff --git a/tests/data/configs/train_g2p_acoustic.yaml b/tests/data/configs/train_g2p_acoustic.yaml index 0617720d..b5b87b5d 100644 --- a/tests/data/configs/train_g2p_acoustic.yaml +++ b/tests/data/configs/train_g2p_acoustic.yaml @@ -1,6 +1,5 @@ beam: 100 -retry_beam: 400 -use_mp: false +retry_beam: 800 features: type: "mfcc" diff --git a/tests/data/configs/train_g2p_config.yaml b/tests/data/configs/train_g2p_config.yaml index a5203920..54cd28f5 100644 --- a/tests/data/configs/train_g2p_config.yaml +++ b/tests/data/configs/train_g2p_config.yaml @@ -2,7 +2,6 @@ punctuation: "、。।,@<>\"(),.:;¿?¡!\\&%#*~【】,…‥「」『』〝 clitic_markers: "'’" compound_markers: "-" num_pronunciations: 1 # Used if running in validation mode -use_mp: True order: 7 random_starts: 25 seed: 1917 diff --git a/tests/data/configs/transcribe.yaml b/tests/data/configs/transcribe.yaml index 6925f2df..e69de29b 100644 --- a/tests/data/configs/transcribe.yaml +++ b/tests/data/configs/transcribe.yaml @@ -1 +0,0 @@ -use_mp: true diff --git a/tests/data/configs/tri_train.yaml b/tests/data/configs/tri_train.yaml index b18dc944..3f00ebea 100644 --- a/tests/data/configs/tri_train.yaml +++ b/tests/data/configs/tri_train.yaml @@ -1,6 +1,5 @@ beam: 10 retry_beam: 400 -use_mp: false features: type: "mfcc" diff --git a/tests/data/configs/xsampa_train.yaml b/tests/data/configs/xsampa_train.yaml index c68244d0..eb4329bf 100644 --- a/tests/data/configs/xsampa_train.yaml +++ b/tests/data/configs/xsampa_train.yaml @@ -1,6 +1,5 @@ beam: 10 retry_beam: 400 -use_mp: false ignore_case: false punctuation: .-'][ diff --git a/tests/data/dictionaries/abstract.txt b/tests/data/dictionaries/test_abstract.txt similarity index 100% rename from tests/data/dictionaries/abstract.txt rename to tests/data/dictionaries/test_abstract.txt diff --git a/tests/data/dictionaries/acoustic.txt b/tests/data/dictionaries/test_acoustic.txt old mode 100755 new mode 100644 similarity index 100% rename from tests/data/dictionaries/acoustic.txt rename to tests/data/dictionaries/test_acoustic.txt diff --git a/tests/data/dictionaries/basic.txt b/tests/data/dictionaries/test_basic.txt old mode 100755 new mode 100644 similarity index 100% rename from tests/data/dictionaries/basic.txt rename to tests/data/dictionaries/test_basic.txt diff --git a/tests/data/dictionaries/chinese_dict.txt b/tests/data/dictionaries/test_chinese_dict.txt old mode 100755 new mode 100644 similarity index 100% rename from tests/data/dictionaries/chinese_dict.txt rename to tests/data/dictionaries/test_chinese_dict.txt diff --git a/tests/data/dictionaries/extra_annotations.txt b/tests/data/dictionaries/test_extra_annotations.txt old mode 100755 new mode 100644 similarity index 100% rename from tests/data/dictionaries/extra_annotations.txt rename to tests/data/dictionaries/test_extra_annotations.txt diff --git a/tests/data/dictionaries/frclitics.txt b/tests/data/dictionaries/test_frclitics.txt old mode 100755 new mode 100644 similarity index 100% rename from tests/data/dictionaries/frclitics.txt rename to tests/data/dictionaries/test_frclitics.txt diff --git a/tests/data/dictionaries/test_hindi.txt b/tests/data/dictionaries/test_hindi.txt new file mode 100644 index 00000000..bed1a563 --- /dev/null +++ b/tests/data/dictionaries/test_hindi.txt @@ -0,0 +1,3 @@ +हैं ɦ ɛ̃ː +हूं ɦ ũː +हौंसला ɦ ɔ̃ː s̪ l̪ aː diff --git a/tests/data/dictionaries/test_japanese.txt b/tests/data/dictionaries/test_japanese.txt new file mode 100644 index 00000000..9bbdf7fc --- /dev/null +++ b/tests/data/dictionaries/test_japanese.txt @@ -0,0 +1,5 @@ +はい h a i +はい h aː +何 n a ɴ +何 n a ɲ i +でしょう d e ɕ oː diff --git a/tests/data/dictionaries/tabbed_dictionary.txt b/tests/data/dictionaries/test_mixed_format_dictionary.txt similarity index 89% rename from tests/data/dictionaries/tabbed_dictionary.txt rename to tests/data/dictionaries/test_mixed_format_dictionary.txt index a36d5efb..ac991980 100644 --- a/tests/data/dictionaries/tabbed_dictionary.txt +++ b/tests/data/dictionaries/test_mixed_format_dictionary.txt @@ -1,11 +1,11 @@ -'m m +'m 1.0 m ’m m -i’m ay m ih -this dh ih s -is ih z -the dh ah +i’m 0.01 ay m ih +this 1.0 0.43 1.23 0.85 dh ih s +is 1.0 0.5 1.0 1.0 ih z +the 1.0 0.5 1.0 1.0 dh ah acoustic ah k uw s t ih k -corpus k ao r p us +corpus k ao r p ah s i'm ay m talking t aa k ih ng pretty p r eh t iy diff --git a/tests/data/dictionaries/mixed_format_dictionary.txt b/tests/data/dictionaries/test_tabbed_dictionary.txt similarity index 99% rename from tests/data/dictionaries/mixed_format_dictionary.txt rename to tests/data/dictionaries/test_tabbed_dictionary.txt index f27ab555..0b46a823 100644 --- a/tests/data/dictionaries/mixed_format_dictionary.txt +++ b/tests/data/dictionaries/test_tabbed_dictionary.txt @@ -1,4 +1,4 @@ -'m 1.0 m +'m 1.0 m ’m m i’m 0.01 ay m ih this 1.0 0.43 1.23 0.85 dh ih s diff --git a/tests/data/dictionaries/vietnamese_ipa.txt b/tests/data/dictionaries/test_vietnamese_ipa.txt similarity index 100% rename from tests/data/dictionaries/vietnamese_ipa.txt rename to tests/data/dictionaries/test_vietnamese_ipa.txt diff --git a/tests/data/dictionaries/xsampa.txt b/tests/data/dictionaries/test_xsampa.txt similarity index 100% rename from tests/data/dictionaries/xsampa.txt rename to tests/data/dictionaries/test_xsampa.txt diff --git a/tests/data/lab/common_voice_en_22058266.lab b/tests/data/lab/common_voice_en_22058266.lab index 2924abf2..aea8ceb8 100644 --- a/tests/data/lab/common_voice_en_22058266.lab +++ b/tests/data/lab/common_voice_en_22058266.lab @@ -1 +1 @@ -Firefox +Fire fox diff --git a/tests/data/lab/devanagari.lab b/tests/data/lab/devanagari.lab new file mode 100644 index 00000000..c3d8d557 --- /dev/null +++ b/tests/data/lab/devanagari.lab @@ -0,0 +1 @@ +हैंः हूं हौंसला diff --git a/tests/data/lab/french_clitics.lab b/tests/data/lab/french_clitics.lab new file mode 100644 index 00000000..1fdf31e9 --- /dev/null +++ b/tests/data/lab/french_clitics.lab @@ -0,0 +1 @@ +aujourd aujourd'hui m'appelle purple-people-eater vingt-six m'm'appelle c'est m'c'est m'appele m'ving-sic flying'purple-people-eater diff --git a/tests/data/lab/japanese.lab b/tests/data/lab/japanese.lab new file mode 100644 index 00000000..6b363347 --- /dev/null +++ b/tests/data/lab/japanese.lab @@ -0,0 +1 @@ +「はい」、。! 『何 でしょう』 diff --git a/tests/data/lab/multilingual_ipa.txt b/tests/data/lab/multilingual_ipa.txt index 1b95f2c2..0f1fe9d5 100644 --- a/tests/data/lab/multilingual_ipa.txt +++ b/tests/data/lab/multilingual_ipa.txt @@ -1 +1 @@ -i can't think of an animal that's less chad-like than a sloth +i can't think of an animal that's less chad like than a sloth diff --git a/tests/data/lab/multilingual_ipa_2.txt b/tests/data/lab/multilingual_ipa_2.txt index 84068419..360ada97 100644 --- a/tests/data/lab/multilingual_ipa_2.txt +++ b/tests/data/lab/multilingual_ipa_2.txt @@ -1 +1 @@ -welcome to a series of platchat videos where we're gonna tackle every single team in the overwatch league twenty twenty +welcome to a series of plat chat videos where we're gonna tackle every single team in the overwatch league twenty twenty diff --git a/tests/data/lab/multilingual_ipa_5.txt b/tests/data/lab/multilingual_ipa_5.txt index f4e40df5..d0010a1c 100644 --- a/tests/data/lab/multilingual_ipa_5.txt +++ b/tests/data/lab/multilingual_ipa_5.txt @@ -1 +1 @@ -i'm sideshow joined by custa and reinforce we've got a special edition of platchat +i'm sideshow joined by custer and reinforce we've got a special edition of plat chat diff --git a/tests/data/lab/punctuated.lab b/tests/data/lab/punctuated.lab index 47743e02..715e6327 100644 --- a/tests/data/lab/punctuated.lab +++ b/tests/data/lab/punctuated.lab @@ -1 +1 @@ -oh yes, they - they, you know, they love her' and so' 'i mean... ‘you +oh yes, they - they, you know, they love her' and so' 'something 'i mean... ‘you The village name is Anglo Saxon in origin, and means 'Myrsa's woodland'. diff --git a/tests/data/textgrid/multilingual_ipa_3.TextGrid b/tests/data/textgrid/multilingual_ipa_3.TextGrid index 2662e443..6c21535b 100644 --- a/tests/data/textgrid/multilingual_ipa_3.TextGrid +++ b/tests/data/textgrid/multilingual_ipa_3.TextGrid @@ -6,7 +6,6 @@ Object class = "TextGrid" 1 "IntervalTier" -"IntervalTier" "speaker_one" 0 1.3062999999999994 diff --git a/tests/test_abc.py b/tests/test_abc.py index afa4b760..5052bec8 100644 --- a/tests/test_abc.py +++ b/tests/test_abc.py @@ -7,7 +7,6 @@ def test_typing(basic_corpus_dir, basic_dict_path, temp_dir): am_trainer = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=temp_dir, ) trainer = SatTrainer(identifier="sat", worker=am_trainer) assert type(trainer).__name__ == "SatTrainer" diff --git a/tests/test_acoustic_modeling.py b/tests/test_acoustic_modeling.py index 3424292f..c6b421b0 100644 --- a/tests/test_acoustic_modeling.py +++ b/tests/test_acoustic_modeling.py @@ -1,17 +1,18 @@ -import argparse import os import shutil +import time + +import pytest from montreal_forced_aligner.acoustic_modeling.trainer import TrainableAligner from montreal_forced_aligner.alignment import PretrainedAligner +from montreal_forced_aligner.db import PhonologicalRule -def test_trainer(basic_dict_path, basic_corpus_dir, generated_dir): - data_directory = os.path.join(generated_dir, "temp", "train_test") +def test_trainer(basic_dict_path, temp_dir, basic_corpus_dir): a = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=data_directory, ) assert a.final_identifier == "sat_4" assert a.training_configs[a.final_identifier].subset == 0 @@ -22,61 +23,74 @@ def test_trainer(basic_dict_path, basic_corpus_dir, generated_dir): def test_basic_mono( mixed_dict_path, basic_corpus_dir, - generated_dir, mono_train_config_path, + mono_align_config_path, mono_align_model_path, mono_output_directory, + db_setup, ): - data_directory = os.path.join(generated_dir, "temp", "mono_train_test") - shutil.rmtree(data_directory, ignore_errors=True) - args = argparse.Namespace(use_mp=True, debug=False, verbose=True) a = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=mixed_dict_path, - temporary_directory=data_directory, - **TrainableAligner.parse_parameters(mono_train_config_path, args=args) + **TrainableAligner.parse_parameters(mono_train_config_path) ) a.train() a.export_model(mono_align_model_path) - - data_directory = os.path.join(generated_dir, "temp", "mono_align_test") - shutil.rmtree(data_directory, ignore_errors=True) + assert os.path.exists(mono_align_model_path) + a.clean_working_directory() + a.remove_database() + time.sleep(3) a = PretrainedAligner( corpus_directory=basic_corpus_dir, dictionary_path=mixed_dict_path, acoustic_model_path=mono_align_model_path, - temporary_directory=data_directory, - **PretrainedAligner.parse_parameters(args=args) + **PretrainedAligner.parse_parameters(mono_align_config_path) ) a.align() a.export_files(mono_output_directory) + assert os.path.exists(mono_output_directory) + a.clean_working_directory() + a.remove_database() def test_pronunciation_training( - mixed_dict_path, basic_corpus_dir, generated_dir, pron_train_config_path + mixed_dict_path, + basic_corpus_dir, + generated_dir, + pron_train_config_path, + rules_path, + groups_path, + db_setup, ): - data_directory = os.path.join(generated_dir, "temp", "pron_train_test") export_path = os.path.join(generated_dir, "pron_train_test_export", "model.zip") - shutil.rmtree(data_directory, ignore_errors=True) - args = argparse.Namespace(use_mp=True, debug=False, verbose=True) a = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=mixed_dict_path, - temporary_directory=data_directory, - **TrainableAligner.parse_parameters(pron_train_config_path, args=args) + rules_path=rules_path, + groups_path=groups_path, + **TrainableAligner.parse_parameters(pron_train_config_path) ) a.train() + assert "coronal_fricatives" in a.phone_groups + assert set(a.phone_groups["coronal_fricatives"]) == {"s", "z", "sh"} + with a.session() as session: + assert session.query(PhonologicalRule).count() > 0 + rule_query = session.query(PhonologicalRule).first() + assert rule_query.probability > 0 + assert rule_query.probability < 1 a.cleanup() + a.clean_working_directory() + a.remove_database() assert not os.path.exists(export_path) assert not os.path.exists( os.path.join(generated_dir, "pron_train_test_export", os.path.basename(mixed_dict_path)) ) + a = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=mixed_dict_path, - temporary_directory=data_directory, - **TrainableAligner.parse_parameters(pron_train_config_path, args=args) + **TrainableAligner.parse_parameters(pron_train_config_path) ) a.train() a.export_model(export_path) @@ -88,47 +102,31 @@ def test_pronunciation_training( os.path.basename(mixed_dict_path).replace(".txt", ".dict"), ) ) - - -def test_basic_tri(basic_dict_path, basic_corpus_dir, generated_dir, tri_train_config_path): - data_directory = os.path.join(generated_dir, "temp", "tri_test") - shutil.rmtree(data_directory, ignore_errors=True) - a = TrainableAligner( - corpus_directory=basic_corpus_dir, - dictionary_path=basic_dict_path, - temporary_directory=data_directory, - debug=True, - verbose=True, - **TrainableAligner.parse_parameters(tri_train_config_path) - ) - a.train() + a.clean_working_directory() + a.remove_database() def test_pitch_feature_training( - basic_dict_path, basic_corpus_dir, generated_dir, pitch_train_config_path + basic_dict_path, basic_corpus_dir, pitch_train_config_path, db_setup ): - data_directory = os.path.join(generated_dir, "temp", "tri_pitch_test") - shutil.rmtree(data_directory, ignore_errors=True) a = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=data_directory, debug=True, verbose=True, **TrainableAligner.parse_parameters(pitch_train_config_path) ) assert a.use_pitch a.train() - assert a.get_feat_dim() == 48 + assert a.get_feat_dim() == 45 + a.clean_working_directory() + a.remove_database() -def test_basic_lda(basic_dict_path, basic_corpus_dir, generated_dir, lda_train_config_path): - data_directory = os.path.join(generated_dir, "temp", "lda_test") - shutil.rmtree(data_directory, ignore_errors=True) +def test_basic_lda(basic_dict_path, basic_corpus_dir, lda_train_config_path, db_setup): a = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=data_directory, debug=True, verbose=True, **TrainableAligner.parse_parameters(lda_train_config_path) @@ -136,18 +134,21 @@ def test_basic_lda(basic_dict_path, basic_corpus_dir, generated_dir, lda_train_c a.train() assert len(a.training_configs[a.final_identifier].realignment_iterations) > 0 assert len(a.training_configs[a.final_identifier].mllt_iterations) > 1 + a.clean_working_directory() + a.remove_database() -def test_basic_sat(basic_dict_path, basic_corpus_dir, generated_dir, sat_train_config_path): - data_directory = os.path.join(generated_dir, "temp", "sat_test") +@pytest.mark.skip("Inconsistent failing") +def test_basic_sat( + basic_dict_path, basic_corpus_dir, generated_dir, sat_train_config_path, db_setup +): + data_directory = os.path.join(generated_dir, "sat_test") output_model_path = os.path.join(data_directory, "sat_model.zip") shutil.rmtree(data_directory, ignore_errors=True) - args = argparse.Namespace(use_mp=True, debug=True, verbose=True) a = TrainableAligner( - **TrainableAligner.parse_parameters(sat_train_config_path, args=args), + **TrainableAligner.parse_parameters(sat_train_config_path), corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=data_directory, disable_mp=False ) a.train() @@ -155,6 +156,6 @@ def test_basic_sat(basic_dict_path, basic_corpus_dir, generated_dir, sat_train_c a.export_model(output_model_path) assert os.path.exists(output_model_path) - assert os.path.exists( - os.path.join(data_directory, "basic_train_acoustic_model", "sat", "trans.1.0.ark") - ) + assert os.path.exists(os.path.join(a.output_directory, "sat", "trans.1.1.ark")) + a.clean_working_directory() + a.remove_database() diff --git a/tests/test_alignment_pretrained.py b/tests/test_alignment_pretrained.py index 2453aa5d..42ab4966 100644 --- a/tests/test_alignment_pretrained.py +++ b/tests/test_alignment_pretrained.py @@ -2,20 +2,25 @@ import shutil from montreal_forced_aligner.alignment import PretrainedAligner -from montreal_forced_aligner.db import PhoneInterval, Utterance, WordInterval +from montreal_forced_aligner.db import PhoneInterval, Utterance, WordInterval, WorkflowType def test_align_sick( - english_dictionary, english_acoustic_model, basic_corpus_dir, temp_dir, test_align_config + english_dictionary, + english_acoustic_model, + basic_corpus_dir, + temp_dir, + test_align_config, + db_setup, ): - temp = os.path.join(temp_dir, "align_export_temp") + temp_dir = os.path.join(temp_dir, "align_corpus_cli") + shutil.rmtree(temp_dir, ignore_errors=True) a = PretrainedAligner( corpus_directory=basic_corpus_dir, dictionary_path=english_dictionary, acoustic_model_path=english_acoustic_model, - temporary_directory=temp, - debug=True, - verbose=True, + oov_count_threshold=1, + temporary_directory=temp_dir, **test_align_config ) a.align() @@ -24,10 +29,17 @@ def test_align_sick( assert "AY_S" in a.phone_mapping a.export_files(export_directory) assert os.path.exists(os.path.join(export_directory, "michael", "acoustic_corpus.TextGrid")) + a.clean_working_directory() + a.remove_database() def test_align_one( - english_dictionary, english_acoustic_model, basic_corpus_dir, temp_dir, test_align_config + english_dictionary, + english_acoustic_model, + basic_corpus_dir, + temp_dir, + test_align_config, + db_setup, ): temp = os.path.join(temp_dir, "align_one_temp") a = PretrainedAligner( @@ -40,16 +52,18 @@ def test_align_one( clean=True, **test_align_config ) + a.initialize_database() + a.create_new_current_workflow(WorkflowType.online_alignment) a.setup() with a.session() as session: - utterance = session.query(Utterance).first() + utterance = session.get(Utterance, 3) assert utterance.alignment_log_likelihood is None assert utterance.features is not None assert len(utterance.phone_intervals) == 0 a.align_one_utterance(utterance, session) with a.session() as session: - utterance = session.query(Utterance).first() + utterance = session.get(Utterance, 3) assert utterance.alignment_log_likelihood is not None assert len(utterance.phone_intervals) > 0 @@ -60,14 +74,16 @@ def test_align_one( session.commit() with a.session() as session: - utterance = session.query(Utterance).first() + utterance = session.get(Utterance, 3) assert utterance.alignment_log_likelihood is None assert utterance.features is None assert len(utterance.phone_intervals) == 0 a.align_one_utterance(utterance, session) with a.session() as session: - utterance = session.query(Utterance).first() + utterance = session.get(Utterance, 3) assert utterance.alignment_log_likelihood is not None assert utterance.features is None assert len(utterance.phone_intervals) > 0 + a.clean_working_directory() + a.remove_database() diff --git a/tests/test_commandline_adapt.py b/tests/test_commandline_adapt.py index 00ffbb8d..20eabbc9 100644 --- a/tests/test_commandline_adapt.py +++ b/tests/test_commandline_adapt.py @@ -1,7 +1,8 @@ import os -from montreal_forced_aligner.command_line.adapt import run_adapt_model -from montreal_forced_aligner.command_line.mfa import parser +import click.testing + +from montreal_forced_aligner.command_line.mfa import mfa_cli def test_adapt_basic( @@ -20,18 +21,23 @@ def test_adapt_basic( english_acoustic_model, adapted_model_path, "--beam", - "100", + "15", "-t", - temp_dir, + os.path.join(temp_dir, "adapt_cli"), "--clean", - "--debug", + "--no-debug", ] - args, unknown = parser.parse_known_args(command) - run_adapt_model(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception assert os.path.exists(adapted_model_path) -# @pytest.mark.skip(reason='Optimization') def test_adapt_multilingual( multilingual_ipa_corpus_dir, mfa_speaker_dict_path, @@ -58,6 +64,12 @@ def test_adapt_multilingual( "--clean", "--debug", ] - args, unknown = parser.parse_known_args(command) - run_adapt_model(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception assert os.path.exists(adapted_model_path) diff --git a/tests/test_commandline_align.py b/tests/test_commandline_align.py index 7e444d63..8eda1a3d 100644 --- a/tests/test_commandline_align.py +++ b/tests/test_commandline_align.py @@ -1,10 +1,9 @@ import os +import click.testing from praatio import textgrid as tgio -from montreal_forced_aligner.alignment.pretrained import PretrainedAligner -from montreal_forced_aligner.command_line.align import run_align_corpus -from montreal_forced_aligner.command_line.mfa import parser +from montreal_forced_aligner.command_line.mfa import mfa_cli def assert_export_exist(old_directory, new_directory): @@ -19,35 +18,33 @@ def assert_export_exist(old_directory, new_directory): assert os.path.exists(os.path.join(new_root, new_f)) -def test_align_arguments( +def test_align_no_speaker_adaptation( basic_corpus_dir, generated_dir, english_dictionary, temp_dir, english_acoustic_model, ): - + output_directory = os.path.join(generated_dir, "basic_output") command = [ "align", basic_corpus_dir, english_dictionary, "english_us_arpa", - os.path.join(generated_dir, "basic_output"), + output_directory, "-t", - temp_dir, + os.path.join(temp_dir, "test_align_no_speaker_adaptation"), "-q", "--clean", "--debug", "--uses_speaker_adaptation", "False", ] - args, unknown_args = parser.parse_known_args(command) - params = PretrainedAligner.parse_parameters(args=args, unknown_args=unknown_args) - assert not params["uses_speaker_adaptation"] + click.testing.CliRunner().invoke(mfa_cli, command, catch_exceptions=False) + assert os.path.exists(output_directory) -# @pytest.mark.skip(reason='Optimization') -def test_align_basic( +def test_align_single_speaker( basic_corpus_dir, generated_dir, english_dictionary, @@ -63,15 +60,23 @@ def test_align_basic( english_acoustic_model, output_directory, "-t", - temp_dir, + os.path.join(temp_dir, "test_align_single_speaker"), "--config_path", basic_align_config_path, "-q", "--clean", "--debug", + "--single_speaker", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(output_directory) @@ -104,15 +109,22 @@ def test_align_duplicated( english_acoustic_model, output_directory, "-t", - temp_dir, + os.path.join(temp_dir, "test_align_duplicated"), "--config_path", basic_align_config_path, "-q", "--clean", "--debug", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(output_directory) @@ -142,7 +154,7 @@ def test_align_multilingual( english_mfa_acoustic_model, os.path.join(generated_dir, "multilingual"), "-t", - temp_dir, + os.path.join(temp_dir, "test_align_multilingual"), "--config_path", basic_align_config_path, "-q", @@ -151,8 +163,15 @@ def test_align_multilingual( "--output_format", "short_textgrid", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_align_multilingual_speaker_dict( @@ -171,7 +190,7 @@ def test_align_multilingual_speaker_dict( english_mfa_acoustic_model, os.path.join(generated_dir, "multilingual_speaker_dict"), "-t", - temp_dir, + os.path.join(temp_dir, "test_align_multilingual_speaker_dict"), "--config_path", basic_align_config_path, "-q", @@ -180,8 +199,15 @@ def test_align_multilingual_speaker_dict( "--output_format", "json", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_align_multilingual_tg_speaker_dict( @@ -200,7 +226,7 @@ def test_align_multilingual_tg_speaker_dict( english_mfa_acoustic_model, os.path.join(generated_dir, "multilingual_speaker_dict_tg"), "-t", - temp_dir, + os.path.join(temp_dir, "test_align_multilingual_tg_speaker_dict"), "--config_path", basic_align_config_path, "-q", @@ -208,8 +234,15 @@ def test_align_multilingual_tg_speaker_dict( "--debug", "--include_original_text", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_align_evaluation( @@ -230,19 +263,29 @@ def test_align_evaluation( english_mfa_acoustic_model, os.path.join(generated_dir, "align_eval_output"), "-t", - temp_dir, + os.path.join(temp_dir, "test_align_evaluation"), "--config_path", basic_align_config_path, "-q", "--clean", "--debug", + "--no_use_mp", + "--fine_tune", + "--phone_confidence", "--reference_directory", basic_reference_dir, "--custom_mapping_path", eval_mapping_path, ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_align_split( @@ -262,17 +305,26 @@ def test_align_split( english_mfa_acoustic_model, os.path.join(generated_dir, "multilingual"), "-t", - temp_dir, + os.path.join(temp_dir, "test_align_split"), "--config_path", basic_align_config_path, "-q", "--clean", "--debug", + "--output_format", + "json", "--audio_directory", audio_dir, ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_align_stereo( @@ -291,15 +343,22 @@ def test_align_stereo( english_acoustic_model, output_dir, "-t", - temp_dir, + os.path.join(temp_dir, "test_align_stereo"), "--config_path", basic_align_config_path, "-q", "--clean", "--debug", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value tg = tgio.openTextgrid( os.path.join(output_dir, "michaelandsickmichael.TextGrid"), includeEmptyIntervals=False @@ -323,15 +382,22 @@ def test_align_mp3s( english_acoustic_model, output_dir, "-t", - temp_dir, + os.path.join(temp_dir, "test_align_mp3s"), "--config_path", basic_align_config_path, "-q", "--clean", "--debug", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value tg = tgio.openTextgrid( os.path.join(output_dir, "common_voice_en_22058267.TextGrid"), includeEmptyIntervals=False @@ -355,15 +421,22 @@ def test_align_opus( english_acoustic_model, output_dir, "-t", - temp_dir, + os.path.join(temp_dir, "test_align_opus"), "--config_path", basic_align_config_path, "-q", "--clean", "--debug", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value tg = tgio.openTextgrid( os.path.join(output_dir, "13697_11991_000000.TextGrid"), includeEmptyIntervals=False @@ -387,15 +460,22 @@ def test_swedish_cv( swedish_cv_acoustic_model, output_dir, "-t", - temp_dir, + os.path.join(temp_dir, "test_swedish_cv"), "--config_path", basic_align_config_path, "-q", "--clean", "--debug", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value output_speaker_dir = os.path.join(output_dir, "se10x016") assert os.path.exists(output_speaker_dir) @@ -427,15 +507,22 @@ def test_swedish_mfa( swedish_cv_acoustic_model, output_dir, "-t", - temp_dir, + os.path.join(temp_dir, "test_swedish_mfa"), "--config_path", basic_align_config_path, "-q", "--clean", "--debug", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value output_speaker_dir = os.path.join(output_dir, "se10x016") assert os.path.exists(output_speaker_dir) @@ -469,14 +556,21 @@ def test_acoustic_g2p_model( model_path, output_directory, "-t", - temp_dir, + os.path.join(temp_dir, "test_acoustic_g2p_model"), "--config_path", basic_align_config_path, "--clean", "--debug", ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(output_directory) diff --git a/tests/test_commandline_classify_speakers.py b/tests/test_commandline_classify_speakers.py deleted file mode 100644 index dee13305..00000000 --- a/tests/test_commandline_classify_speakers.py +++ /dev/null @@ -1,35 +0,0 @@ -import os - -import pytest - -from montreal_forced_aligner.command_line.classify_speakers import run_classify_speakers -from montreal_forced_aligner.command_line.mfa import parser - - -@pytest.mark.skip("Speaker diarization functionality disabled.") -def test_cluster( - basic_corpus_dir, - english_ivector_model, - generated_dir, - transcription_acoustic_model, - transcription_language_model, - temp_dir, -): - output_path = os.path.join(generated_dir, "cluster_test") - command = [ - "classify_speakers", - basic_corpus_dir, - "english_ivector", - output_path, - "-t", - temp_dir, - "-q", - "--clean", - "--debug", - "--cluster", - "-s", - "2", - "--disable_mp", - ] - args, unknown = parser.parse_known_args(command) - run_classify_speakers(args) diff --git a/tests/test_commandline_configure.py b/tests/test_commandline_configure.py index 33dad8d1..fc5a8c75 100644 --- a/tests/test_commandline_configure.py +++ b/tests/test_commandline_configure.py @@ -1,14 +1,13 @@ import os -from montreal_forced_aligner.command_line.mfa import create_parser -from montreal_forced_aligner.config import ( - generate_config_path, - get_temporary_directory, - load_global_config, - update_global_config, -) +import click.testing +import pytest +from montreal_forced_aligner.command_line.mfa import mfa_cli +from montreal_forced_aligner.config import generate_config_path + +@pytest.mark.skip() def test_configure( temp_dir, basic_corpus_dir, @@ -16,27 +15,11 @@ def test_configure( english_dictionary, basic_align_config_path, english_acoustic_model, + global_config, ): path = generate_config_path() if os.path.exists(path): os.remove(path) - GLOBAL_CONFIG = load_global_config() - assert GLOBAL_CONFIG == { - "clean": False, - "verbose": False, - "quiet": False, - "debug": False, - "overwrite": False, - "terminal_colors": True, - "terminal_width": 120, - "cleanup_textgrids": True, - "detect_phone_set": False, - "num_jobs": 3, - "blas_num_threads": 1, - "use_mp": True, - "temporary_directory": get_temporary_directory(), - } - parser = create_parser() command = [ "configure", "--always_clean", @@ -47,68 +30,29 @@ def test_configure( "--disable_mp", "--always_verbose", ] - args, unknown = parser.parse_known_args(command) - print(GLOBAL_CONFIG) - print(args) - update_global_config(args) + click.testing.CliRunner().invoke(mfa_cli, command, catch_exceptions=False) assert os.path.exists(path) - GLOBAL_CONFIG = load_global_config() - assert GLOBAL_CONFIG == { - "clean": True, - "verbose": True, - "quiet": False, - "debug": False, - "overwrite": False, - "terminal_colors": True, - "terminal_width": 120, - "cleanup_textgrids": True, - "detect_phone_set": False, - "num_jobs": 10, - "blas_num_threads": 1, - "use_mp": False, - "temporary_directory": temp_dir, - } + global_config.load() + + assert global_config.current_profile_name == "test" + assert global_config.current_profile.num_jobs == 10 + assert not global_config.current_profile.use_mp + assert global_config.current_profile.verbose + assert global_config.current_profile.clean + command = ["configure", "--never_clean", "--enable_mp", "--never_verbose"] - parser = create_parser() - args, unknown = parser.parse_known_args(command) - update_global_config(args) + click.testing.CliRunner().invoke(mfa_cli, command, catch_exceptions=False) + assert os.path.exists(path) - GLOBAL_CONFIG = load_global_config() - assert GLOBAL_CONFIG == { - "clean": False, - "verbose": False, - "quiet": False, - "debug": False, - "overwrite": False, - "terminal_colors": True, - "terminal_width": 120, - "cleanup_textgrids": True, - "detect_phone_set": False, - "num_jobs": 10, - "blas_num_threads": 1, - "use_mp": True, - "temporary_directory": temp_dir, - } - parser = create_parser() + global_config.load() + assert global_config.current_profile_name == "test" + assert global_config.current_profile.use_mp + assert not global_config.current_profile.verbose + assert not global_config.current_profile.clean - command = [ - "align", - basic_corpus_dir, - english_dictionary, - english_acoustic_model, - os.path.join(generated_dir, "basic_output"), - "-t", - get_temporary_directory(), - "--config_path", - basic_align_config_path, - "-q", - "--clean", - "-d", - ] - args, unknown = parser.parse_known_args(command) - assert args.num_jobs == 10 - assert args.temporary_directory == get_temporary_directory() - assert args.clean - assert not args.disable_mp - if os.path.exists(path): - os.remove(path) + global_config.clean = True + global_config.debug = True + global_config.verbose = True + global_config.use_mp = False + global_config.temporary_directory = temp_dir + global_config.save() diff --git a/tests/test_commandline_create_segments.py b/tests/test_commandline_create_segments.py index fcc43688..3909382f 100644 --- a/tests/test_commandline_create_segments.py +++ b/tests/test_commandline_create_segments.py @@ -1,7 +1,11 @@ import os +import shutil -from montreal_forced_aligner.command_line.create_segments import run_create_segments -from montreal_forced_aligner.command_line.mfa import parser +import click.testing +import pytest + +from montreal_forced_aligner.command_line.mfa import mfa_cli +from montreal_forced_aligner.diarization.speaker_diarizer import FOUND_SPEECHBRAIN def test_create_segments( @@ -11,19 +15,62 @@ def test_create_segments( basic_segment_config_path, ): output_path = os.path.join(generated_dir, "segment_output") + shutil.rmtree(output_path, ignore_errors=True) + command = [ + "segment", + basic_corpus_dir, + output_path, + "-t", + os.path.join(temp_dir, "sad_cli"), + "-q", + "--clean", + "--debug", + "-v", + "--config_path", + basic_segment_config_path, + ] + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(os.path.join(output_path, "michael", "acoustic_corpus.TextGrid")) + + +def test_create_segments_speechbrain( + basic_corpus_dir, + generated_dir, + temp_dir, + basic_segment_config_path, +): + if not FOUND_SPEECHBRAIN: + pytest.skip("SpeechBrain not installed") + output_path = os.path.join(generated_dir, "segment_output") command = [ - "create_segments", + "segment", basic_corpus_dir, output_path, "-t", - temp_dir, + os.path.join(temp_dir, "sad_cli_speechbrain"), "-q", "--clean", "--debug", "-v", + "--speechbrain", "--config_path", basic_segment_config_path, ] - args, unknown = parser.parse_known_args(command) - run_create_segments(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(os.path.join(output_path, "michael", "acoustic_corpus.TextGrid")) diff --git a/tests/test_commandline_diarize_speakers.py b/tests/test_commandline_diarize_speakers.py new file mode 100644 index 00000000..dfd6fddf --- /dev/null +++ b/tests/test_commandline_diarize_speakers.py @@ -0,0 +1,146 @@ +import os + +import click.testing +import pytest + +from montreal_forced_aligner.command_line.mfa import mfa_cli +from montreal_forced_aligner.diarization.speaker_diarizer import FOUND_SPEECHBRAIN + + +def test_cluster_mfa( + combined_corpus_dir, + multilingual_ivector_model, + generated_dir, + transcription_acoustic_model, + transcription_language_model, + temp_dir, +): + output_path = os.path.join(generated_dir, "cluster_test_mfa") + command = [ + "diarize", + combined_corpus_dir, + multilingual_ivector_model, + output_path, + "-t", + os.path.join(temp_dir, "diarize_cli"), + "--cluster", + "--cluster_type", + "kmeans", + "--expected_num_speakers", + "3", + "--clean", + "--evaluate", + ] + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(output_path) + + +def test_classify_mfa( + combined_corpus_dir, + multilingual_ivector_model, + generated_dir, + transcription_acoustic_model, + transcription_language_model, + temp_dir, +): + output_path = os.path.join(generated_dir, "classify_test_mfa") + command = [ + "diarize", + combined_corpus_dir, + multilingual_ivector_model, + output_path, + "-t", + os.path.join(temp_dir, "diarize_cli"), + "--classify", + "--clean", + "--evaluate", + ] + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(output_path) + + +def test_cluster_speechbrain( + combined_corpus_dir, + generated_dir, + transcription_acoustic_model, + transcription_language_model, + temp_dir, +): + if not FOUND_SPEECHBRAIN: + pytest.skip("SpeechBrain not installed") + output_path = os.path.join(generated_dir, "cluster_test_sb") + command = [ + "diarize", + combined_corpus_dir, + "speechbrain", + output_path, + "-t", + os.path.join(temp_dir, "diarize_cli"), + "--cluster", + "--cluster_type", + "kmeans", + "--expected_num_speakers", + "3", + "--clean", + "--no_use_pca", + "--evaluate", + ] + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(output_path) + + +def test_classify_speechbrain( + combined_corpus_dir, + generated_dir, + transcription_acoustic_model, + transcription_language_model, + temp_dir, +): + if not FOUND_SPEECHBRAIN: + pytest.skip("SpeechBrain not installed") + output_path = os.path.join(generated_dir, "classify_test_sb") + command = [ + "diarize", + combined_corpus_dir, + "speechbrain", + output_path, + "-t", + os.path.join(temp_dir, "diarize_cli"), + "--classify", + "--clean", + "--evaluate", + ] + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(output_path) diff --git a/tests/test_commandline_g2p.py b/tests/test_commandline_g2p.py index 442e5bad..9b864454 100644 --- a/tests/test_commandline_g2p.py +++ b/tests/test_commandline_g2p.py @@ -1,20 +1,23 @@ import os -from montreal_forced_aligner.command_line.g2p import run_g2p -from montreal_forced_aligner.command_line.mfa import parser -from montreal_forced_aligner.command_line.train_g2p import run_train_g2p +import click.testing + +from montreal_forced_aligner.command_line.mfa import mfa_cli +from montreal_forced_aligner.command_line.utils import check_databases from montreal_forced_aligner.dictionary import MultispeakerDictionary -def test_generate_pretrained(english_g2p_model, basic_corpus_dir, temp_dir, generated_dir): +def test_generate_pretrained( + english_g2p_model, basic_corpus_dir, temp_dir, generated_dir, db_setup +): output_path = os.path.join(generated_dir, "g2p_out.txt") command = [ "g2p", - english_g2p_model, basic_corpus_dir, + english_g2p_model, output_path, "-t", - temp_dir, + os.path.join(temp_dir, "g2p_cli"), "-q", "--clean", "--num_pronunciations", @@ -22,41 +25,62 @@ def test_generate_pretrained(english_g2p_model, basic_corpus_dir, temp_dir, gene "--use_mp", "False", ] - args, unknown = parser.parse_known_args(command) - run_g2p(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(output_path) - d = MultispeakerDictionary(output_path, temporary_directory=temp_dir) + check_databases() + d = MultispeakerDictionary(output_path) d.dictionary_setup() assert len(d.word_mapping(1)) > 0 def test_generate_pretrained_threshold( - english_g2p_model, basic_corpus_dir, temp_dir, generated_dir + english_g2p_model, basic_corpus_dir, temp_dir, generated_dir, db_setup ): output_path = os.path.join(generated_dir, "g2p_out.txt") command = [ "g2p", - english_g2p_model, basic_corpus_dir, + english_g2p_model, output_path, "-t", - temp_dir, + os.path.join(temp_dir, "g2p_cli"), "-q", "--clean", "--g2p_threshold", "0.95", ] - args, unknown = parser.parse_known_args(command) - run_g2p(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(output_path) - d = MultispeakerDictionary(output_path, temporary_directory=temp_dir) + check_databases() + d = MultispeakerDictionary(output_path) d.dictionary_setup() assert len(d.word_mapping(1)) > 0 -def test_train_g2p(basic_dict_path, basic_g2p_model_path, temp_dir, train_g2p_config_path): +def test_train_g2p( + basic_dict_path, + basic_g2p_model_path, + temp_dir, + train_g2p_config_path, +): command = [ "train_g2p", basic_dict_path, @@ -70,13 +94,23 @@ def test_train_g2p(basic_dict_path, basic_g2p_model_path, temp_dir, train_g2p_co "--config_path", train_g2p_config_path, ] - args, unknown = parser.parse_known_args(command) - run_train_g2p(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(basic_g2p_model_path) def test_train_g2p_phonetisaurus( - basic_dict_path, basic_phonetisaurus_g2p_model_path, temp_dir, train_g2p_config_path + basic_dict_path, + basic_phonetisaurus_g2p_model_path, + temp_dir, + train_g2p_config_path, ): command = [ "train_g2p", @@ -91,8 +125,15 @@ def test_train_g2p_phonetisaurus( "--phonetisaurus" "--config_path", train_g2p_config_path, ] - args, unknown = parser.parse_known_args(command) - run_train_g2p(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(basic_phonetisaurus_g2p_model_path) @@ -102,24 +143,33 @@ def test_generate_dict( g2p_basic_output, temp_dir, g2p_config_path, + db_setup, ): command = [ "g2p", - basic_g2p_model_path, basic_corpus_dir, + basic_g2p_model_path, g2p_basic_output, "-t", - temp_dir, + os.path.join(temp_dir, "g2p_cli"), "-q", "--clean", "--debug", "--config_path", g2p_config_path, ] - args, unknown = parser.parse_known_args(command) - run_g2p(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(g2p_basic_output) - d = MultispeakerDictionary(dictionary_path=g2p_basic_output, temporary_directory=temp_dir) + check_databases() + d = MultispeakerDictionary(dictionary_path=g2p_basic_output) d.dictionary_setup() assert len(d.word_mapping()) > 0 @@ -130,26 +180,33 @@ def test_generate_dict_phonetisaurus( g2p_basic_phonetisaurus_output, temp_dir, g2p_config_path, + db_setup, ): command = [ "g2p", - basic_phonetisaurus_g2p_model_path, basic_corpus_dir, + basic_phonetisaurus_g2p_model_path, g2p_basic_phonetisaurus_output, "-t", - temp_dir, + os.path.join(temp_dir, "g2p_cli"), "-q", "--clean", "--debug", "--config_path", g2p_config_path, ] - args, unknown = parser.parse_known_args(command) - run_g2p(args, unknown) - assert os.path.exists(g2p_basic_phonetisaurus_output) - d = MultispeakerDictionary( - dictionary_path=g2p_basic_phonetisaurus_output, temporary_directory=temp_dir + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(g2p_basic_phonetisaurus_output) + check_databases() + d = MultispeakerDictionary(dictionary_path=g2p_basic_phonetisaurus_output) d.dictionary_setup() assert len(d.word_mapping()) > 0 @@ -160,25 +217,34 @@ def test_generate_dict_text_only( g2p_basic_output, temp_dir, g2p_config_path, + db_setup, ): text_dir = basic_split_dir[1] command = [ "g2p", - basic_g2p_model_path, text_dir, + basic_g2p_model_path, g2p_basic_output, "-t", - temp_dir, + os.path.join(temp_dir, "g2p_cli"), "-q", "--clean", "--debug", "--config_path", g2p_config_path, ] - args, unknown = parser.parse_known_args(command) - run_g2p(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(g2p_basic_output) - d = MultispeakerDictionary(dictionary_path=g2p_basic_output, temporary_directory=temp_dir) + check_databases() + d = MultispeakerDictionary(dictionary_path=g2p_basic_output) d.dictionary_setup() assert len(d.word_mapping()) > 0 @@ -189,45 +255,33 @@ def test_generate_dict_textgrid( generated_dir, temp_dir, g2p_config_path, + db_setup, ): output_file = os.path.join(generated_dir, "tg_g2pped.dict") command = [ "g2p", - english_g2p_model, multilingual_ipa_tg_corpus_dir, + english_g2p_model, output_file, "-t", - temp_dir, + os.path.join(temp_dir, "g2p_cli"), "-q", "--clean", "--debug", "--config_path", g2p_config_path, ] - args, unknown = parser.parse_known_args(command) - run_g2p(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(output_file) - d = MultispeakerDictionary(dictionary_path=output_file, temporary_directory=temp_dir) - d.dictionary_setup() - assert len(d.word_mapping()) > 0 - - -def test_generate_orthography_dict(basic_corpus_dir, orth_basic_output, temp_dir): - command = [ - "g2p", - basic_corpus_dir, - orth_basic_output, - "-t", - temp_dir, - "-q", - "--clean", - "--debug", - "--use_mp", - "False", - ] - args, unknown = parser.parse_known_args(command) - run_g2p(args, unknown) - assert os.path.exists(orth_basic_output) - d = MultispeakerDictionary(dictionary_path=orth_basic_output, temporary_directory=temp_dir) + check_databases() + d = MultispeakerDictionary(dictionary_path=output_file) d.dictionary_setup() assert len(d.word_mapping()) > 0 diff --git a/tests/test_commandline_history.py b/tests/test_commandline_history.py index 77abb40e..77f04229 100644 --- a/tests/test_commandline_history.py +++ b/tests/test_commandline_history.py @@ -1,23 +1,53 @@ -from montreal_forced_aligner.command_line.mfa import parser, print_history +import click.testing + +from montreal_forced_aligner.command_line.mfa import mfa_cli def test_mfa_history(): command = ["history", "--depth", "60"] - args, unknown = parser.parse_known_args(command) - print_history(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value command = ["history"] - args, unknown = parser.parse_known_args(command) - print_history(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_mfa_history_verbose(): command = ["history", "-v", "--depth", "60"] - args, unknown = parser.parse_known_args(command) - print_history(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value command = ["history", "-v"] - args, unknown = parser.parse_known_args(command) - print_history(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value diff --git a/tests/test_commandline_lm.py b/tests/test_commandline_lm.py index 36f29e48..5db78590 100644 --- a/tests/test_commandline_lm.py +++ b/tests/test_commandline_lm.py @@ -1,57 +1,81 @@ import os -from montreal_forced_aligner.command_line.mfa import parser -from montreal_forced_aligner.command_line.train_lm import run_train_lm +import click.testing +from montreal_forced_aligner.command_line.mfa import mfa_cli -def test_train_lm(basic_corpus_dir, temp_dir, generated_dir, basic_train_lm_config_path): + +def test_train_lm( + basic_corpus_dir, + temp_dir, + generated_dir, + basic_train_lm_config_path, +): temp_dir = os.path.join(temp_dir, "train_lm") + output_model_path = os.path.join(generated_dir, "test_basic_lm.zip") command = [ "train_lm", basic_corpus_dir, - os.path.join(generated_dir, "test_basic_lm.zip"), + output_model_path, "-t", - temp_dir, + os.path.join(temp_dir, "train_lm_cli"), "--config_path", basic_train_lm_config_path, "-q", "--clean", ] - args, unknown = parser.parse_known_args(command) - run_train_lm(args) - assert os.path.exists(args.output_model_path) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(output_model_path) -def test_train_lm_text(basic_split_dir, temp_dir, generated_dir, basic_train_lm_config_path): +def test_train_lm_text( + basic_split_dir, + temp_dir, + generated_dir, + basic_train_lm_config_path, +): temp_dir = os.path.join(temp_dir, "train_lm_text") text_dir = basic_split_dir[1] + output_model_path = os.path.join(generated_dir, "test_basic_lm_split.zip") command = [ "train_lm", text_dir, - os.path.join(generated_dir, "test_basic_lm_split.zip"), + output_model_path, "-t", - temp_dir, + os.path.join(temp_dir, "train_lm_cli"), "--config_path", basic_train_lm_config_path, "-q", "--clean", ] - args, unknown = parser.parse_known_args(command) - run_train_lm(args) - assert os.path.exists(args.output_model_path) + click.testing.CliRunner().invoke(mfa_cli, command, catch_exceptions=False) + assert os.path.exists(output_model_path) def test_train_lm_dictionary( - basic_split_dir, basic_dict_path, temp_dir, generated_dir, basic_train_lm_config_path + basic_split_dir, + basic_dict_path, + temp_dir, + generated_dir, + basic_train_lm_config_path, ): temp_dir = os.path.join(temp_dir, "train_lm_dictionary") text_dir = basic_split_dir[1] + output_model_path = os.path.join(generated_dir, "test_basic_lm_split.zip") command = [ "train_lm", text_dir, - os.path.join(generated_dir, "test_basic_lm_split.zip"), + output_model_path, "-t", - temp_dir, + os.path.join(temp_dir, "train_lm_cli"), "--dictionary_path", basic_dict_path, "--config_path", @@ -59,39 +83,47 @@ def test_train_lm_dictionary( "-q", "--clean", ] - args, unknown = parser.parse_known_args(command) - run_train_lm(args) - assert os.path.exists(args.output_model_path) + click.testing.CliRunner().invoke(mfa_cli, command, catch_exceptions=False) + assert os.path.exists(output_model_path) def test_train_lm_arpa( - transcription_language_model_arpa, temp_dir, generated_dir, basic_train_lm_config_path + transcription_language_model_arpa, + temp_dir, + generated_dir, + basic_train_lm_config_path, ): temp_dir = os.path.join(temp_dir, "train_lm_arpa") + output_model_path = os.path.join(generated_dir, "test_basic_lm_split.zip") command = [ "train_lm", transcription_language_model_arpa, - os.path.join(generated_dir, "test_basic_lm_split.zip"), + output_model_path, "-t", - temp_dir, + os.path.join(temp_dir, "train_lm_cli"), "--config_path", basic_train_lm_config_path, "-q", "--clean", ] - args, unknown = parser.parse_known_args(command) - run_train_lm(args) - assert os.path.exists(args.output_model_path) + click.testing.CliRunner().invoke(mfa_cli, command, catch_exceptions=False) + assert os.path.exists(output_model_path) -def test_train_lm_text_no_mp(basic_split_dir, temp_dir, generated_dir, basic_train_lm_config_path): +def test_train_lm_text_no_mp( + basic_split_dir, + temp_dir, + generated_dir, + basic_train_lm_config_path, +): text_dir = basic_split_dir[1] + output_model_path = os.path.join(generated_dir, "test_basic_lm_split.zip") command = [ "train_lm", text_dir, - os.path.join(generated_dir, "test_basic_lm_split.zip"), + output_model_path, "-t", - temp_dir, + os.path.join(temp_dir, "train_lm_cli"), "--config_path", basic_train_lm_config_path, "-q", @@ -99,6 +131,5 @@ def test_train_lm_text_no_mp(basic_split_dir, temp_dir, generated_dir, basic_tra "-j", "1", ] - args, unknown = parser.parse_known_args(command) - run_train_lm(args) - assert os.path.exists(args.output_model_path) + click.testing.CliRunner().invoke(mfa_cli, command, catch_exceptions=False) + assert os.path.exists(output_model_path) diff --git a/tests/test_commandline_model.py b/tests/test_commandline_model.py index 3beaa3a0..1e8f9f4e 100644 --- a/tests/test_commandline_model.py +++ b/tests/test_commandline_model.py @@ -1,31 +1,18 @@ import os -from argparse import Namespace +import click.testing import pytest -from montreal_forced_aligner.command_line.model import ( - ModelTypeNotSupportedError, - RemoteModelNotFoundError, - run_model, -) +from montreal_forced_aligner.command_line.mfa import mfa_cli +from montreal_forced_aligner.exceptions import RemoteModelNotFoundError from montreal_forced_aligner.models import AcousticModel, DictionaryModel, G2PModel, ModelManager -class DummyArgs(Namespace): - def __init__(self): - self.action = "" - self.model_type = "" - self.name = "" - self.github_token = "" - self.ignore_cache = False - - def test_get_available_languages(): manager = ModelManager() manager.refresh_remote() model_type = "acoustic" acoustic_models = manager.remote_models[model_type] - print(manager.remote_models) assert "archive" not in acoustic_models assert "english_us_arpa" in acoustic_models @@ -41,96 +28,178 @@ def test_get_available_languages(): def test_download(): - args = DummyArgs() - args.action = "download" - args.name = "sdsdsadad" - args.model_type = "acoustic" + command = [ + "model", + "download", + "acoustic", + "sdsdsadad", + ] with pytest.raises(RemoteModelNotFoundError): - run_model(args) - - args = DummyArgs() - args.action = "download" - args.name = "english_us_arpa" - args.model_type = "acoustic" - - run_model(args) - - assert os.path.exists(AcousticModel.get_pretrained_path(args.name)) - - args = DummyArgs() - args.action = "download" - args.name = "english_us_arpa" - args.model_type = "g2p" - - run_model(args) - - assert os.path.exists(G2PModel.get_pretrained_path(args.name)) - - args = DummyArgs() - args.action = "download" - args.name = "english_us_arpa" - args.model_type = "dictionary" - - run_model(args) - - assert os.path.exists(DictionaryModel.get_pretrained_path(args.name)) - - args = DummyArgs() - args.action = "download" - args.name = "" - args.ignore_cache = True - args.model_type = "dictionary" - - run_model(args) - - args = DummyArgs() - args.action = "download" - args.name = "" - args.model_type = "dictionary" - - run_model(args) + click.testing.CliRunner().invoke(mfa_cli, command, catch_exceptions=False) + + command = [ + "model", + "download", + "acoustic", + "english_us_arpa", + ] + + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + + assert os.path.exists(AcousticModel.get_pretrained_path("english_us_arpa")) + + command = [ + "model", + "download", + "g2p", + "english_us_arpa", + ] + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + + assert os.path.exists(G2PModel.get_pretrained_path("english_us_arpa")) + + command = [ + "model", + "download", + "dictionary", + "english_us_arpa", + ] + + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + + assert os.path.exists(DictionaryModel.get_pretrained_path("english_us_arpa")) + + command = ["model", "download", "acoustic", "--ignore_cache"] + + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + + command = [ + "model", + "download", + "dictionary", + ] + + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_inspect_model(): - args = DummyArgs() - args.action = "inspect" - args.name = "english_us_arpa" - args.model_type = "acoustic" - run_model(args) + command = [ + "model", + "inspect", + "acoustic", + "english_us_arpa", + ] + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_list_model(): - args = DummyArgs() - args.action = "list" - args.model_type = "acoustic" - run_model(args) + command = [ + "model", + "list", + "acoustic", + ] + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_save_model(transcription_acoustic_model): - args = DummyArgs() - args.action = "save" - args.model_type = "acoustic" - args.path = transcription_acoustic_model - run_model(args) - - args = DummyArgs() - args.action = "inspect" - args.name = "mono_model" - args.model_type = "acoustic" - run_model(args) + command = [ + "model", + "save", + "acoustic", + transcription_acoustic_model, + "--name", + "test_acoustic", + "--overwrite", + ] + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(AcousticModel.get_pretrained_path("test_acoustic")) + + command = ["model", "inspect", "acoustic", "test_acoustic"] + + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_expected_errors(): - args = DummyArgs() - args.action = "download" - args.name = "bulgarian" - args.model_type = "not_acoustic" - with pytest.raises(ModelTypeNotSupportedError): - run_model(args) - - args = DummyArgs() - args.action = "download" - args.name = "not_bulgarian" - args.model_type = "acoustic" + command = ["model", "download", "not_acoustic", "bulgarian"] + + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + assert isinstance(result.exception, SystemExit) + + command = ["model", "download", "acoustic", "not_bulgarian"] + with pytest.raises(RemoteModelNotFoundError): - run_model(args) + click.testing.CliRunner().invoke(mfa_cli, command, catch_exceptions=False) diff --git a/tests/test_commandline_train.py b/tests/test_commandline_train.py index dc254569..6c09bab7 100644 --- a/tests/test_commandline_train.py +++ b/tests/test_commandline_train.py @@ -1,11 +1,12 @@ import os -from montreal_forced_aligner.command_line.mfa import parser -from montreal_forced_aligner.command_line.train_acoustic_model import run_train_acoustic_model +import click.testing + +from montreal_forced_aligner.command_line.mfa import mfa_cli def test_train_acoustic_with_g2p( - basic_corpus_dir, + combined_corpus_dir, english_us_mfa_dictionary, generated_dir, temp_dir, @@ -14,25 +15,34 @@ def test_train_acoustic_with_g2p( ): if os.path.exists(acoustic_g2p_model_path): os.remove(acoustic_g2p_model_path) + output_directory = os.path.join(generated_dir, "train_g2p_textgrids") command = [ "train", - basic_corpus_dir, + combined_corpus_dir, english_us_mfa_dictionary, - os.path.join(generated_dir, "basic_output"), + acoustic_g2p_model_path, + "--output_directory", + output_directory, "-t", - temp_dir, + os.path.join(temp_dir, "train_cli"), "-q", "--clean", "--quiet", "--debug", "--config_path", train_g2p_acoustic_config_path, - "-o", - acoustic_g2p_model_path, ] - args, unknown = parser.parse_known_args(command) - run_train_acoustic_model(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(acoustic_g2p_model_path) + assert os.path.exists(output_directory) def test_train_and_align_basic_speaker_dict( @@ -45,11 +55,12 @@ def test_train_and_align_basic_speaker_dict( ): if os.path.exists(textgrid_output_model_path): os.remove(textgrid_output_model_path) + output_directory = os.path.join(generated_dir, "ipa speaker output") command = [ "train", multilingual_ipa_tg_corpus_dir, mfa_speaker_dict_path, - os.path.join(generated_dir, "ipa speaker output"), + textgrid_output_model_path, "-t", os.path.join(temp_dir, "temp dir with spaces"), "--config_path", @@ -57,9 +68,18 @@ def test_train_and_align_basic_speaker_dict( "-q", "--clean", "--debug", - "-o", - textgrid_output_model_path, + "--output_directory", + output_directory, + "--single_speaker", ] - args, unknown = parser.parse_known_args(command) - run_train_acoustic_model(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(textgrid_output_model_path) + assert os.path.exists(output_directory) diff --git a/tests/test_commandline_train_dict.py b/tests/test_commandline_train_dict.py index c16416bc..d2eb42f4 100644 --- a/tests/test_commandline_train_dict.py +++ b/tests/test_commandline_train_dict.py @@ -1,8 +1,8 @@ import os -from montreal_forced_aligner.command_line.align import run_align_corpus -from montreal_forced_aligner.command_line.mfa import parser -from montreal_forced_aligner.command_line.train_dictionary import run_train_dictionary +import click.testing + +from montreal_forced_aligner.command_line.mfa import mfa_cli def test_train_dict( @@ -21,7 +21,7 @@ def test_train_dict( english_acoustic_model, output_path, "-t", - temp_dir, + os.path.join(temp_dir, "train_dictionary_cli"), "-q", "--clean", "--debug", @@ -30,8 +30,15 @@ def test_train_dict( basic_align_config_path, "--use_mp", ] - args, unknown = parser.parse_known_args(command) - run_train_dictionary(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value dict_path = os.path.join(output_path, "english_us_arpa.dict") assert os.path.exists(output_path) @@ -43,12 +50,20 @@ def test_train_dict( english_acoustic_model, textgrid_output, "-t", - temp_dir, + os.path.join(temp_dir, "train_dictionary_cli"), "-q", "--clean", "--debug", "--config_path", basic_align_config_path, ] - args, unknown = parser.parse_known_args(command) - run_align_corpus(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(textgrid_output) diff --git a/tests/test_commandline_train_ivector.py b/tests/test_commandline_train_ivector.py index 1c92bc0b..966f52d6 100644 --- a/tests/test_commandline_train_ivector.py +++ b/tests/test_commandline_train_ivector.py @@ -1,9 +1,8 @@ import os -from montreal_forced_aligner.command_line.mfa import parser -from montreal_forced_aligner.command_line.train_ivector_extractor import ( - run_train_ivector_extractor, -) +import click.testing + +from montreal_forced_aligner.command_line.mfa import mfa_cli def test_basic_ivector( @@ -18,13 +17,20 @@ def test_basic_ivector( basic_corpus_dir, ivector_output_model_path, "-t", - temp_dir, + os.path.join(temp_dir, "train_ivector_cli"), "--config_path", train_ivector_config_path, "-q", "--clean", "--debug", ] - args, unknown = parser.parse_known_args(command) - run_train_ivector_extractor(args) - assert os.path.exists(args.output_model_path) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + assert os.path.exists(ivector_output_model_path) diff --git a/tests/test_commandline_transcribe.py b/tests/test_commandline_transcribe.py index baed3475..15dfcb4d 100644 --- a/tests/test_commandline_transcribe.py +++ b/tests/test_commandline_transcribe.py @@ -1,7 +1,8 @@ import os -from montreal_forced_aligner.command_line.mfa import parser -from montreal_forced_aligner.command_line.transcribe import run_transcribe_corpus +import click.testing + +from montreal_forced_aligner.command_line.mfa import mfa_cli def test_transcribe( @@ -23,7 +24,7 @@ def test_transcribe( transcription_language_model, output_path, "-t", - temp_dir, + os.path.join(temp_dir, "transcribe_cli"), "-q", "--clean", "--debug", @@ -31,8 +32,15 @@ def test_transcribe( "--config_path", transcribe_config_path, ] - args, unknown = parser.parse_known_args(command) - run_transcribe_corpus(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(os.path.join(output_path, "michael", "acoustic_corpus.lab")) @@ -56,7 +64,7 @@ def test_transcribe_arpa( transcription_language_model_arpa, output_path, "-t", - temp_dir, + os.path.join(temp_dir, "transcribe_cli"), "-q", "--clean", "--debug", @@ -66,8 +74,15 @@ def test_transcribe_arpa( "--config_path", transcribe_config_path, ] - args, unknown = parser.parse_known_args(command) - run_transcribe_corpus(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value assert os.path.exists(os.path.join(output_path, "michael", "acoustic_corpus.lab")) @@ -89,15 +104,24 @@ def test_transcribe_speaker_dictionaries( transcription_language_model, output_path, "-t", - temp_dir, + os.path.join(temp_dir, "transcribe_cli"), "-q", "--clean", "--debug", "--config_path", transcribe_config_path, ] - args, unknown = parser.parse_known_args(command) - run_transcribe_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + + assert os.path.exists(output_path) def test_transcribe_speaker_dictionaries_evaluate( @@ -118,10 +142,11 @@ def test_transcribe_speaker_dictionaries_evaluate( transcription_language_model, output_path, "-t", - temp_dir, + os.path.join(temp_dir, "transcribe_cli"), "-q", "--clean", "--debug", + "--no_use_mp", "--language_model_weight", "16", "--word_insertion_penalty", @@ -130,5 +155,14 @@ def test_transcribe_speaker_dictionaries_evaluate( transcribe_config_path, "--evaluate", ] - args, unknown = parser.parse_known_args(command) - run_transcribe_corpus(args, unknown) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value + + assert os.path.exists(output_path) diff --git a/tests/test_commandline_validate.py b/tests/test_commandline_validate.py index 8c13a57a..374de087 100644 --- a/tests/test_commandline_validate.py +++ b/tests/test_commandline_validate.py @@ -1,29 +1,41 @@ import os -from montreal_forced_aligner.command_line.mfa import parser -from montreal_forced_aligner.command_line.validate import ( - run_validate_corpus, - run_validate_dictionary, -) +import click.testing + +from montreal_forced_aligner.command_line.mfa import mfa_cli def test_validate_corpus( - multilingual_ipa_tg_corpus_dir, english_mfa_acoustic_model, english_us_mfa_dictionary, temp_dir + multilingual_ipa_tg_corpus_dir, + english_mfa_acoustic_model, + english_us_mfa_dictionary, + temp_dir, ): command = [ "validate", multilingual_ipa_tg_corpus_dir, english_us_mfa_dictionary, + "--acoustic_model_path", english_mfa_acoustic_model, "-t", - temp_dir, + os.path.join(temp_dir, "validate_cli"), "-q", + "--oov_count_threshold", + "0", "--clean", - "--disable_mp", + "--no_use_mp", "--test_transcriptions", + "--phone_confidence", ] - args, unknown = parser.parse_known_args(command) - run_validate_corpus(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_validate_training_corpus( @@ -43,9 +55,18 @@ def test_validate_training_corpus( "--clean", "--config_path", mono_train_config_path, + "--test_transcriptions", + "--phone_confidence", ] - args, unknown = parser.parse_known_args(command) - run_validate_corpus(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_validate_xsampa( @@ -67,8 +88,15 @@ def test_validate_xsampa( "--config_path", xsampa_train_config_path, ] - args, unknown = parser.parse_known_args(command) - run_validate_corpus(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_validate_dictionary( @@ -87,8 +115,15 @@ def test_validate_dictionary( "-j", "1", ] - args, unknown = parser.parse_known_args(command) - run_validate_dictionary(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value def test_validate_dictionary_train( @@ -100,7 +135,14 @@ def test_validate_dictionary_train( "validate_dictionary", basic_dict_path, "-t", - os.path.join(temp_dir, "dictionary_validation"), + os.path.join(temp_dir, "dictionary_validation_train"), ] - args, unknown = parser.parse_known_args(command) - run_validate_dictionary(args) + result = click.testing.CliRunner(mix_stderr=False, echo_stdin=True).invoke( + mfa_cli, command, catch_exceptions=True + ) + print(result.stdout) + print(result.stderr) + if result.exception: + print(result.exc_info) + raise result.exception + assert not result.return_value diff --git a/tests/test_config.py b/tests/test_config.py index 7ca3d445..6ccfef04 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -18,7 +18,6 @@ def test_monophone_config(basic_corpus_dir, basic_dict_path, temp_dir): am_trainer = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=temp_dir, ) config = MonophoneTrainer(identifier="mono", worker=am_trainer) config.compute_calculated_properties() @@ -53,7 +52,6 @@ def test_triphone_config(basic_corpus_dir, basic_dict_path, temp_dir): am_trainer = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=temp_dir, ) config = TriphoneTrainer(identifier="tri", worker=am_trainer) config.compute_calculated_properties() @@ -65,7 +63,6 @@ def test_lda_mllt_config(basic_corpus_dir, basic_dict_path, temp_dir): am_trainer = TrainableAligner( corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=temp_dir, ) assert am_trainer.beam == 10 @@ -92,7 +89,6 @@ def test_load_align( acoustic_model_path=english_acoustic_model, corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=temp_dir, **params ) @@ -111,7 +107,6 @@ def test_load_align( acoustic_model_path=english_acoustic_model, corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, - temporary_directory=temp_dir, **params ) assert aligner.beam == 10 @@ -122,10 +117,7 @@ def test_load_align( def test_load_basic_train(basic_corpus_dir, basic_dict_path, temp_dir, basic_train_config_path): params = TrainableAligner.parse_parameters(basic_train_config_path) am_trainer = TrainableAligner( - corpus_directory=basic_corpus_dir, - dictionary_path=basic_dict_path, - temporary_directory=temp_dir, - **params + corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, **params ) assert am_trainer.beam == 100 @@ -144,29 +136,20 @@ def test_load_basic_train(basic_corpus_dir, basic_dict_path, temp_dir, basic_tra def test_load_mono_train(basic_corpus_dir, basic_dict_path, temp_dir, mono_train_config_path): params = TrainableAligner.parse_parameters(mono_train_config_path) am_trainer = TrainableAligner( - corpus_directory=basic_corpus_dir, - dictionary_path=basic_dict_path, - temporary_directory=temp_dir, - **params + corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, **params ) for t in am_trainer.training_configs.values(): - assert not t.use_mp assert t.use_energy - assert not am_trainer.use_mp assert am_trainer.use_energy am_trainer.cleanup() def test_load_ivector_train(basic_corpus_dir, temp_dir, train_ivector_config_path): params = TrainableIvectorExtractor.parse_parameters(train_ivector_config_path) - trainer = TrainableIvectorExtractor( - corpus_directory=basic_corpus_dir, temporary_directory=temp_dir, **params - ) + trainer = TrainableIvectorExtractor(corpus_directory=basic_corpus_dir, **params) for t in trainer.training_configs.values(): - assert not t.use_mp assert t.use_energy - assert not trainer.use_mp trainer.cleanup() @@ -174,10 +157,7 @@ def test_load(basic_corpus_dir, basic_dict_path, temp_dir, config_directory): path = os.path.join(config_directory, "basic_train_config.yaml") params = TrainableAligner.parse_parameters(path) am_trainer = TrainableAligner( - corpus_directory=basic_corpus_dir, - dictionary_path=basic_dict_path, - temporary_directory=temp_dir, - **params + corpus_directory=basic_corpus_dir, dictionary_path=basic_dict_path, **params ) assert len(am_trainer.training_configs) == 4 assert isinstance(am_trainer.training_configs["monophone"], MonophoneTrainer) diff --git a/tests/test_corpus.py b/tests/test_corpus.py index c211bd81..36a0c664 100644 --- a/tests/test_corpus.py +++ b/tests/test_corpus.py @@ -1,15 +1,17 @@ import os import shutil +import pytest + from montreal_forced_aligner.corpus.acoustic_corpus import ( AcousticCorpus, AcousticCorpusWithPronunciations, ) from montreal_forced_aligner.corpus.classes import FileData, UtteranceData from montreal_forced_aligner.corpus.helper import get_wav_info -from montreal_forced_aligner.corpus.text_corpus import TextCorpus -from montreal_forced_aligner.data import TextFileType -from montreal_forced_aligner.db import OovWord, Word +from montreal_forced_aligner.corpus.text_corpus import DictionaryTextCorpus, TextCorpus +from montreal_forced_aligner.data import TextFileType, WordType +from montreal_forced_aligner.db import Word def test_mp3(mp3_test_path): @@ -24,32 +26,17 @@ def test_opus(opus_test_path): assert info.duration > 0 -def test_speaker_word_set( - multilingual_ipa_tg_corpus_dir, multispeaker_dictionary_config_path, temp_dir -): - corpus = AcousticCorpusWithPronunciations( - corpus_directory=multilingual_ipa_tg_corpus_dir, - dictionary_path=multispeaker_dictionary_config_path, - temporary_directory=temp_dir, - ) - corpus.load_corpus() - sanitize = corpus.sanitize_function - split, san = sanitize.get_functions_for_speaker("speaker_one") - sp = san.split_clitics("chad-like") - assert len(sp) > 1 - assert san.oov_word not in sp - - -def test_add(basic_corpus_dir, generated_dir): +def test_add(basic_corpus_dir, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "corpus_tests") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.temporary_directory = output_directory corpus = AcousticCorpus( corpus_directory=basic_corpus_dir, - use_mp=True, - temporary_directory=output_directory, ) - corpus._load_corpus() + corpus.clean_working_directory() + corpus.remove_database() + corpus.load_corpus() with corpus.session() as session: new_speaker = "new_speaker" corpus.add_speaker(new_speaker, session) @@ -71,254 +58,300 @@ def test_add(basic_corpus_dir, generated_dir): utts = corpus.get_utterances(file=new_file_name, speaker=new_speaker) assert len(utts) == 1 assert utts[0].text == "blah blah" - print(utts[0].id) corpus.delete_utterance(utts[0].id) assert len(corpus.get_utterances(file=new_file_name, speaker=new_speaker)) == 0 + corpus.remove_database() -def test_basic_txt(basic_corpus_txt_dir, basic_dict_path, generated_dir): +def test_basic_txt(basic_corpus_txt_dir, basic_dict_path, generated_dir, db_setup): output_directory = os.path.join(generated_dir, "corpus_tests") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) corpus = AcousticCorpus( - corpus_directory=basic_corpus_txt_dir, - use_mp=False, - temporary_directory=output_directory, - use_pitch=True, + corpus_directory=basic_corpus_txt_dir, use_pitch=True, use_voicing=True ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() - print(corpus.no_transcription_files) assert len(corpus.no_transcription_files) == 0 - assert corpus.get_feat_dim() == 48 + assert corpus.get_feat_dim() == 45 + corpus.remove_database() -def test_acoustic_from_temp(basic_corpus_txt_dir, basic_dict_path, generated_dir): +@pytest.mark.xfail +def test_acoustic_from_temp( + basic_corpus_txt_dir, basic_dict_path, generated_dir, global_config, db_setup +): output_directory = os.path.join(generated_dir, "corpus_tests") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.temporary_directory = output_directory + global_config.clean = False corpus = AcousticCorpus( corpus_directory=basic_corpus_txt_dir, - use_mp=False, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 0 assert corpus.get_feat_dim() == 39 + corpus.remove_database() new_corpus = AcousticCorpus( corpus_directory=basic_corpus_txt_dir, - use_mp=False, - temporary_directory=output_directory, ) + new_corpus.clean_working_directory() + new_corpus.remove_database() new_corpus.load_corpus() assert len(new_corpus.no_transcription_files) == 0 assert new_corpus.get_feat_dim() == 39 + global_config.clean = True + corpus.remove_database() -def test_text_corpus_from_temp(basic_corpus_txt_dir, basic_dict_path, generated_dir): +def test_text_corpus_from_temp( + basic_corpus_txt_dir, basic_dict_path, generated_dir, global_config, db_setup +): output_directory = os.path.join(generated_dir, "corpus_tests") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.temporary_directory = output_directory + global_config.clean = False corpus = TextCorpus( corpus_directory=basic_corpus_txt_dir, - use_mp=False, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert corpus.num_utterances > 0 + corpus.remove_database() + new_corpus = TextCorpus( + corpus_directory=basic_corpus_txt_dir, + ) + new_corpus.clean_working_directory() + new_corpus.remove_database() + new_corpus.load_corpus() + assert new_corpus.num_utterances > 0 + global_config.clean = True + new_corpus.remove_database() -def test_extra(basic_dict_path, extra_corpus_dir, generated_dir): - output_directory = os.path.join(generated_dir, "corpus_tests") + +def test_extra(basic_dict_path, extra_corpus_dir, generated_dir, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "extra") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.temporary_directory = output_directory corpus = AcousticCorpusWithPronunciations( corpus_directory=extra_corpus_dir, dictionary_path=basic_dict_path, - use_mp=False, - num_jobs=2, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 0 assert corpus.get_feat_dim() == 39 + corpus.remove_database() -def test_stereo(basic_dict_path, stereo_corpus_dir, generated_dir): +def test_stereo(basic_dict_path, stereo_corpus_dir, generated_dir, global_config, db_setup): - output_directory = os.path.join(generated_dir, "corpus_tests") + output_directory = os.path.join(generated_dir, "corpus_tests", "stereo") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.temporary_directory = output_directory corpus = AcousticCorpus( corpus_directory=stereo_corpus_dir, - use_mp=False, - num_jobs=1, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 0 assert corpus.get_feat_dim() == 39 assert corpus.get_file(name="michaelandsickmichael").num_channels == 2 + corpus.remove_database() -def test_stereo_short_tg(basic_dict_path, stereo_corpus_short_tg_dir, generated_dir): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_stereo_short_tg( + basic_dict_path, stereo_corpus_short_tg_dir, generated_dir, global_config, db_setup +): + output_directory = os.path.join(generated_dir, "corpus_tests", "stereo_short") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.temporary_directory = output_directory corpus = AcousticCorpus( corpus_directory=stereo_corpus_short_tg_dir, - use_mp=False, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 0 assert corpus.get_feat_dim() == 39 assert corpus.get_file(name="michaelandsickmichael").num_channels == 2 + corpus.remove_database() -def test_flac(basic_dict_path, flac_corpus_dir, generated_dir): - output_directory = os.path.join(generated_dir, "corpus_tests") - if os.path.exists(output_directory): - shutil.rmtree(output_directory, ignore_errors=True) - - corpus = AcousticCorpus( - corpus_directory=flac_corpus_dir, - use_mp=False, - temporary_directory=output_directory, - ) - corpus.load_corpus() - assert len(corpus.no_transcription_files) == 0 - assert corpus.get_feat_dim() == 39 - - -def test_audio_directory(basic_dict_path, basic_split_dir, generated_dir): +def test_audio_directory(basic_dict_path, basic_split_dir, generated_dir, global_config, db_setup): audio_dir, text_dir = basic_split_dir - output_directory = os.path.join(generated_dir, "corpus_tests") + output_directory = os.path.join(generated_dir, "corpus_tests", "audio_dir") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.temporary_directory = output_directory corpus = AcousticCorpus( corpus_directory=text_dir, - use_mp=False, audio_directory=audio_dir, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 0 assert corpus.get_feat_dim() == 39 assert corpus.num_files > 0 + corpus.clean_working_directory() + corpus.remove_database() + + +def test_flac(basic_dict_path, flac_corpus_dir, generated_dir, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "flac") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.temporary_directory = output_directory + global_config.use_mp = False + corpus = AcousticCorpus( - corpus_directory=text_dir, - use_mp=True, - audio_directory=audio_dir, - temporary_directory=output_directory, + corpus_directory=flac_corpus_dir, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 0 assert corpus.get_feat_dim() == 39 - assert corpus.num_files > 0 + corpus.clean_working_directory() + corpus.remove_database() -def test_flac_mp(basic_dict_path, flac_corpus_dir, generated_dir): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_flac_mp(basic_dict_path, flac_corpus_dir, generated_dir, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "flac_mp") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.use_mp = True + global_config.temporary_directory = output_directory corpus = AcousticCorpus( corpus_directory=flac_corpus_dir, - use_mp=True, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 0 assert corpus.get_feat_dim() == 39 assert corpus.num_files > 0 + corpus.clean_working_directory() + corpus.remove_database() -def test_flac_tg(basic_dict_path, flac_tg_corpus_dir, generated_dir): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_flac_tg(basic_dict_path, flac_tg_corpus_dir, generated_dir, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "flac_no_mp") if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) + global_config.temporary_directory = output_directory + global_config.use_mp = False corpus = AcousticCorpus( corpus_directory=flac_tg_corpus_dir, - use_mp=False, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 0 assert corpus.get_feat_dim() == 39 assert corpus.num_files > 0 + corpus.clean_working_directory() + corpus.remove_database() -def test_flac_tg_mp(basic_dict_path, flac_tg_corpus_dir, generated_dir): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_flac_tg_mp(basic_dict_path, flac_tg_corpus_dir, generated_dir, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "flac_tg_mp") + global_config.temporary_directory = output_directory + global_config.use_mp = True if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) corpus = AcousticCorpus( corpus_directory=flac_tg_corpus_dir, - use_mp=True, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 0 assert corpus.get_feat_dim() == 39 assert corpus.num_files > 0 + corpus.clean_working_directory() + corpus.remove_database() -def test_24bit_wav(transcribe_corpus_24bit_dir, basic_dict_path, generated_dir): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_24bit_wav( + transcribe_corpus_24bit_dir, basic_dict_path, generated_dir, global_config, db_setup +): + output_directory = os.path.join(generated_dir, "corpus_tests", "24bit") + global_config.temporary_directory = output_directory if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) corpus = AcousticCorpus( corpus_directory=transcribe_corpus_24bit_dir, - use_mp=False, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert len(corpus.no_transcription_files) == 2 assert corpus.get_feat_dim() == 39 assert corpus.num_files > 0 + corpus.clean_working_directory() + corpus.remove_database() -def test_short_segments(shortsegments_corpus_dir, generated_dir): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_short_segments(shortsegments_corpus_dir, generated_dir, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "short_segments") + global_config.temporary_directory = output_directory if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) corpus = AcousticCorpus( corpus_directory=shortsegments_corpus_dir, - use_mp=False, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() assert corpus.num_utterances == 3 assert len([x for x in corpus.utterances() if not x.ignored]) == 2 assert len([x for x in corpus.utterances() if x.features is not None]) == 2 assert len([x for x in corpus.utterances() if x.ignored]) == 1 assert len([x for x in corpus.utterances() if x.features is None]) == 1 + corpus.clean_working_directory() + corpus.remove_database() -def test_speaker_groupings(multilingual_ipa_corpus_dir, generated_dir, english_us_mfa_dictionary): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_speaker_groupings( + multilingual_ipa_corpus_dir, generated_dir, english_us_mfa_dictionary, global_config, db_setup +): + output_directory = os.path.join(generated_dir, "corpus_tests", "speaker_groupings") + global_config.temporary_directory = output_directory if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) corpus = AcousticCorpusWithPronunciations( corpus_directory=multilingual_ipa_corpus_dir, dictionary_path=english_us_mfa_dictionary, - use_mp=True, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() with corpus.session() as session: files = corpus.files(session) @@ -332,14 +365,12 @@ def test_speaker_groupings(multilingual_ipa_corpus_dir, generated_dir, english_u break else: raise Exception(f"File {name} not loaded") + corpus.clean_working_directory() + corpus.remove_database() del corpus - shutil.rmtree(output_directory) new_corpus = AcousticCorpusWithPronunciations( corpus_directory=multilingual_ipa_corpus_dir, dictionary_path=english_us_mfa_dictionary, - num_jobs=1, - use_mp=True, - temporary_directory=output_directory, ) new_corpus.load_corpus() files = new_corpus.files() @@ -353,37 +384,43 @@ def test_speaker_groupings(multilingual_ipa_corpus_dir, generated_dir, english_u break else: raise Exception(f"File {name} not loaded") + new_corpus.clean_working_directory() + new_corpus.remove_database() -def test_subset(multilingual_ipa_corpus_dir, generated_dir): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_subset(multilingual_ipa_corpus_dir, generated_dir, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "subset") + global_config.temporary_directory = output_directory if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) corpus = AcousticCorpus( corpus_directory=multilingual_ipa_corpus_dir, - use_mp=True, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() sd = corpus.split_directory s = corpus.subset_directory(5) assert os.path.exists(sd) assert os.path.exists(s) + corpus.clean_working_directory() + corpus.remove_database() -def test_weird_words(weird_words_dir, generated_dir, basic_dict_path): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_weird_words(weird_words_dir, generated_dir, basic_dict_path, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "weird_words") + global_config.temporary_directory = output_directory if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) corpus = AcousticCorpusWithPronunciations( corpus_directory=weird_words_dir, dictionary_path=basic_dict_path, - use_mp=True, - temporary_directory=output_directory, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() with corpus.session() as session: w = ( @@ -423,8 +460,8 @@ def test_weird_words(weird_words_dir, generated_dir, basic_dict_path): print(weird_words.oovs) oovs = [ x[0] - for x in session.query(OovWord.word).filter( - OovWord.dictionary_id == corpus._default_dictionary_id + for x in session.query(Word.word).filter( + Word.word_type == WordType.oov, Word.dictionary_id == corpus._default_dictionary_id ) ] print(oovs) @@ -437,7 +474,7 @@ def test_weird_words(weird_words_dir, generated_dir, basic_dict_path): "[me_really]", "[me____really]", "[me_really]", - "", + "", "<_s>", } ) @@ -445,95 +482,115 @@ def test_weird_words(weird_words_dir, generated_dir, basic_dict_path): weird_words.text == "i’m talking-ajfish me-really [me-really] [me'really] [me_??_really] asds-asda sdasd-me " ) + print(weird_words.normalized_text.split()) assert weird_words.normalized_text.split() == [ "i'm", "talking", "ajfish", "me", "really", - "[me_really]", - "[me_really]", - "[me____really]", + "[bracketed]", + "[bracketed]", + "[bracketed]", "asds-asda", "sdasd", "me", - "", - "<_s>", + "", + "[bracketed]", ] - assert weird_words.normalized_text_int.split()[-1] == str( - corpus.word_mapping(corpus._default_dictionary_id)[corpus.bracketed_word] - ) print(oovs) assert "'m" not in oovs + corpus.clean_working_directory() + corpus.remove_database() -def test_punctuated(punctuated_dir, generated_dir, basic_dict_path): - output_directory = os.path.join(generated_dir, "corpus_tests") +def test_punctuated( + punctuated_dir, generated_dir, english_us_mfa_dictionary, global_config, db_setup +): + output_directory = os.path.join(generated_dir, "corpus_tests", "punctuated") + global_config.temporary_directory = output_directory if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) corpus = AcousticCorpusWithPronunciations( corpus_directory=punctuated_dir, - dictionary_path=basic_dict_path, - use_mp=True, - temporary_directory=output_directory, + dictionary_path=english_us_mfa_dictionary, ) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() print(corpus.files()) print(corpus.utterances()) punctuated = corpus.get_utterances(file="punctuated")[0] assert ( - punctuated.text == "oh yes, they - they, you know, they love her' and so' 'i mean... ‘you" + punctuated.text + == "oh yes, they - they, you know, they love her' and so' 'something 'i mean... ‘you The village name is Anglo Saxon in origin, and means 'Myrsa's woodland'." ) assert ( - punctuated.normalized_text == "oh yes they they you know they love her' and so i mean 'you" + punctuated.normalized_text + == "oh yes they they you know they love her and so something i mean you the village name is anglo saxon in origin and means myrsa 's woodland" ) + corpus.clean_working_directory() + corpus.remove_database() def test_alternate_punctuation( - punctuated_dir, generated_dir, basic_dict_path, different_punctuation_config_path + punctuated_dir, + generated_dir, + basic_dict_path, + different_punctuation_config_path, + global_config, + db_setup, ): from montreal_forced_aligner.acoustic_modeling.trainer import TrainableAligner output_directory = os.path.join(generated_dir, "corpus_tests", "alternate") + global_config.temporary_directory = output_directory if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) params, skipped = AcousticCorpusWithPronunciations.extract_relevant_parameters( TrainableAligner.parse_parameters(different_punctuation_config_path) ) - params["use_mp"] = True corpus = AcousticCorpusWithPronunciations( corpus_directory=punctuated_dir, dictionary_path=basic_dict_path, - temporary_directory=output_directory, **params, ) corpus.load_corpus() punctuated = corpus.get_utterances(file="punctuated")[0] assert ( - punctuated.text == "oh yes, they - they, you know, they love her' and so' 'i mean... ‘you" + punctuated.text + == "oh yes, they - they, you know, they love her' and so' 'something 'i mean... ‘you The village name is Anglo Saxon in origin, and means 'Myrsa's woodland'." ) + corpus.clean_working_directory() + corpus.remove_database() def test_no_punctuation( - punctuated_dir, generated_dir, basic_dict_path, no_punctuation_config_path + punctuated_dir, + generated_dir, + basic_dict_path, + no_punctuation_config_path, + global_config, + db_setup, ): from montreal_forced_aligner.acoustic_modeling.trainer import TrainableAligner output_directory = os.path.join(generated_dir, "corpus_tests", "no_punctuation") + global_config.temporary_directory = output_directory if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) params, skipped = AcousticCorpusWithPronunciations.extract_relevant_parameters( TrainableAligner.parse_parameters(no_punctuation_config_path) ) - params["use_mp"] = False corpus = AcousticCorpusWithPronunciations( corpus_directory=punctuated_dir, dictionary_path=basic_dict_path, - temporary_directory=output_directory, **params, ) + corpus.clean_working_directory() + corpus.remove_database() assert not corpus.punctuation assert not corpus.compound_markers assert not corpus.clitic_markers @@ -542,66 +599,134 @@ def test_no_punctuation( print(corpus.punctuation) print(corpus.word_break_markers) assert ( - punctuated.text == "oh yes, they - they, you know, they love her' and so' 'i mean... ‘you" - ) - assert punctuated.normalized_text.split() == [ - "oh", - "yes,", - "they", - "-", - "they,", - "you", - "know,", - "they", - "love", - "her'", - "and", - "so'", - "'i", - "mean...", - "‘you", - ] + punctuated.text + == "oh yes, they - they, you know, they love her' and so' 'something 'i mean... ‘you The village name is Anglo Saxon in origin, and means 'Myrsa's woodland'." + ) + assert ( + punctuated.normalized_text + == "oh yes, they - they, you know, they love her' and so' 'something 'i mean... ‘you the village name is anglo saxon in origin, and means 'myrsa's woodland'." + ) weird_words = corpus.get_utterances(file="weird_words")[0] assert ( weird_words.text == "i’m talking-ajfish me-really [me-really] [me'really] [me_??_really] asds-asda sdasd-me " ) + print(weird_words.normalized_text) assert weird_words.normalized_text.split() == [ "i’m", "talking-ajfish", "me-really", - "[me-really]", - "[me'really]", - "[me_??_really]", + "[bracketed]", + "[bracketed]", + "[bracketed]", "asds-asda", "sdasd-me", - "", - "", + "", + "", ] + corpus.clean_working_directory() + corpus.remove_database() def test_xsampa_corpus( - xsampa_corpus_dir, xsampa_dict_path, generated_dir, different_punctuation_config_path + xsampa_corpus_dir, + xsampa_dict_path, + generated_dir, + different_punctuation_config_path, + global_config, + db_setup, ): from montreal_forced_aligner.acoustic_modeling.trainer import TrainableAligner output_directory = os.path.join(generated_dir, "corpus_tests", "xsampa") + global_config.temporary_directory = output_directory if os.path.exists(output_directory): shutil.rmtree(output_directory, ignore_errors=True) params, skipped = AcousticCorpusWithPronunciations.extract_relevant_parameters( TrainableAligner.parse_parameters(different_punctuation_config_path) ) - params["use_mp"] = True corpus = AcousticCorpusWithPronunciations( corpus_directory=xsampa_corpus_dir, dictionary_path=xsampa_dict_path, - temporary_directory=output_directory, **params, ) - print(corpus.quote_markers) + corpus.clean_working_directory() + corpus.remove_database() corpus.load_corpus() xsampa = corpus.get_utterances(file="xsampa")[0] assert ( xsampa.text == r"@bUr\tOU {bstr\{kt {bSaIr\ Abr\utseIzi {br\@geItIN @bor\n {b3kr\Ambi {bI5s@`n Ar\g thr\Ip@5eI Ar\dvAr\k" ) + corpus.clean_working_directory() + corpus.remove_database() + + +def test_japanese(japanese_dir, japanese_dict_path, generated_dir, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "japanese") + global_config.temporary_directory = output_directory + if os.path.exists(output_directory): + shutil.rmtree(output_directory, ignore_errors=True) + + corpus = DictionaryTextCorpus( + corpus_directory=japanese_dir, dictionary_path=japanese_dict_path + ) + corpus.clean_working_directory() + corpus.remove_database() + corpus.load_corpus() + print(corpus.files()) + print(corpus.utterances()) + + punctuated = corpus.get_utterances(file="japanese")[0] + assert punctuated.text == "「はい」、。! 『何 でしょう』" + assert punctuated.normalized_text == "はい 何 でしょう" + corpus.clean_working_directory() + corpus.remove_database() + + +def test_devanagari(devanagari_dir, hindi_dict_path, generated_dir, global_config, db_setup): + output_directory = os.path.join(generated_dir, "corpus_tests", "devanagari") + global_config.temporary_directory = output_directory + if os.path.exists(output_directory): + shutil.rmtree(output_directory, ignore_errors=True) + + corpus = DictionaryTextCorpus(corpus_directory=devanagari_dir, dictionary_path=hindi_dict_path) + corpus.clean_working_directory() + corpus.remove_database() + corpus.load_corpus() + print(corpus.files()) + print(corpus.utterances()) + + punctuated = corpus.get_utterances(file="devanagari")[0] + assert punctuated.text == "हैंः हूं हौंसला" + assert punctuated.normalized_text == "हैंः हूं हौंसला" + corpus.clean_working_directory() + corpus.remove_database() + + +def test_french_clitics( + french_clitics_dir, frclitics_dict_path, generated_dir, global_config, db_setup +): + output_directory = os.path.join(generated_dir, "corpus_tests", "french_clitics") + global_config.temporary_directory = output_directory + if os.path.exists(output_directory): + shutil.rmtree(output_directory, ignore_errors=True) + + corpus = DictionaryTextCorpus( + corpus_directory=french_clitics_dir, dictionary_path=frclitics_dict_path + ) + corpus.clean_working_directory() + corpus.remove_database() + corpus.load_corpus() + + punctuated = corpus.get_utterances(file="french_clitics")[0] + assert ( + punctuated.text + == "aujourd aujourd'hui m'appelle purple-people-eater vingt-six m'm'appelle c'est m'c'est m'appele m'ving-sic flying'purple-people-eater" + ) + assert ( + punctuated.normalized_text + == "aujourd aujourd'hui m' appelle purple-people-eater vingt six m' m' appelle c'est m' c'est m' appele m' ving sic flying'purple-people-eater" + ) + corpus.clean_working_directory() + corpus.remove_database() diff --git a/tests/test_dict.py b/tests/test_dict.py index 7f7bd388..7720d4e1 100644 --- a/tests/test_dict.py +++ b/tests/test_dict.py @@ -1,22 +1,21 @@ import os import shutil -import pytest - -from montreal_forced_aligner.alignment.pretrained import PretrainedAligner from montreal_forced_aligner.db import Pronunciation from montreal_forced_aligner.dictionary.multispeaker import MultispeakerDictionary -def test_abstract(abstract_dict_path, generated_dir): +def test_abstract(abstract_dict_path, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "dictionary_tests", "abstract") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) dictionary = MultispeakerDictionary( - dictionary_path=abstract_dict_path, temporary_directory=output_directory + dictionary_path=abstract_dict_path, position_dependent_phones=True ) + dictionary.clean_working_directory() + dictionary.remove_database() dictionary.dictionary_setup() - assert dictionary assert set(dictionary.phones) == {"sil", "spn", "phonea", "phoneb", "phonec"} assert set(dictionary.kaldi_non_silence_phones) == { "phonea_B", @@ -32,42 +31,35 @@ def test_abstract(abstract_dict_path, generated_dir): "phonec_E", "phonec_S", } + dictionary.remove_database() -def test_tabbed(tabbed_dict_path, basic_dict_path, generated_dir): +def test_tabbed(tabbed_dict_path, basic_dict_path, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "dictionary_tests", "tabbed") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) - tabbed_dictionary = MultispeakerDictionary( - dictionary_path=tabbed_dict_path, temporary_directory=output_directory - ) + tabbed_dictionary = MultispeakerDictionary(dictionary_path=tabbed_dict_path) + tabbed_dictionary.clean_working_directory() + tabbed_dictionary.remove_database() tabbed_dictionary.dictionary_setup() - basic_dictionary = MultispeakerDictionary( - dictionary_path=basic_dict_path, temporary_directory=output_directory - ) + basic_dictionary = MultispeakerDictionary(dictionary_path=basic_dict_path) + basic_dictionary.clean_working_directory() + basic_dictionary.remove_database() basic_dictionary.dictionary_setup() assert tabbed_dictionary.word_mapping(1) == basic_dictionary.word_mapping(1) + tabbed_dictionary.clean_working_directory() + basic_dictionary.clean_working_directory() + basic_dictionary.remove_database() + tabbed_dictionary.remove_database() -@pytest.mark.skip("Outdated models") -def test_missing_phones( - basic_corpus_dir, generated_dir, german_prosodylab_acoustic_model, german_prosodylab_dictionary -): - output_directory = os.path.join(generated_dir, "dictionary_tests") - aligner = PretrainedAligner( - acoustic_model_path=german_prosodylab_acoustic_model, - corpus_directory=basic_corpus_dir, - dictionary_path=german_prosodylab_dictionary, - temporary_directory=output_directory, - ) - aligner.setup() - - -def test_extra_annotations(extra_annotations_path, generated_dir): +def test_extra_annotations(extra_annotations_path, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "dictionary_tests", "extras") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) - dictionary = MultispeakerDictionary( - dictionary_path=extra_annotations_path, temporary_directory=output_directory - ) + dictionary = MultispeakerDictionary(dictionary_path=extra_annotations_path) + dictionary.clean_working_directory() + dictionary.remove_database() dictionary.dictionary_setup() dictionary.write_lexicon_information() from montreal_forced_aligner.db import Grapheme @@ -75,65 +67,43 @@ def test_extra_annotations(extra_annotations_path, generated_dir): with dictionary.session() as session: g = session.query(Grapheme).filter_by(grapheme="{").first() assert g is not None + dictionary.clean_working_directory() + dictionary.remove_database() -def test_abstract_noposition(abstract_dict_path, generated_dir): +def test_abstract_noposition(abstract_dict_path, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "dictionary_tests", "abstract_no_position") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) dictionary = MultispeakerDictionary( dictionary_path=abstract_dict_path, position_dependent_phones=False, - temporary_directory=output_directory, ) + dictionary.clean_working_directory() + dictionary.remove_database() dictionary.dictionary_setup() dictionary.write_lexicon_information() assert set(dictionary.phones) == {"sil", "spn", "phonea", "phoneb", "phonec"} + dictionary.clean_working_directory() + dictionary.remove_database() -def test_frclitics(frclitics_dict_path, generated_dir): - output_directory = os.path.join(generated_dir, "dictionary_tests", "fr_clitics") - shutil.rmtree(output_directory, ignore_errors=True) - dictionary = MultispeakerDictionary( - dictionary_path=frclitics_dict_path, - position_dependent_phones=False, - temporary_directory=output_directory, - ) - dictionary.dictionary_setup() - dictionary.write_lexicon_information() - s, spl = dictionary.sanitize_function.get_functions_for_speaker("default") - assert spl.to_int("aujourd") == spl.word_mapping[spl.oov_word] - assert spl.to_int("aujourd'hui") != spl.word_mapping[spl.oov_word] - assert spl.to_int("m'appelle") == spl.word_mapping[spl.oov_word] - assert spl.to_int("purple-people-eater") == spl.word_mapping[spl.oov_word] - assert spl("aujourd") == ["aujourd"] - assert spl("aujourd'hui") == ["aujourd'hui"] - assert spl("vingt-six") == ["vingt", "six"] - assert spl("m'appelle") == ["m'", "appelle"] - assert spl("m'm'appelle") == ["m'", "m'", "appelle"] - assert spl("c'est") == ["c'est"] - assert spl("m'c'est") == ["m'", "c'", "est"] - assert spl("purple-people-eater") == ["purple-people-eater"] - assert spl("m'appele") == ["m'", "appele"] - assert spl("m'ving-sic") == ["m'", "ving", "sic"] - assert spl("flying'purple-people-eater") == ["flying'purple-people-eater"] - - -def test_english_clitics(english_dictionary, generated_dir): +def test_english_clitics(english_dictionary, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "dictionary_tests", "english_clitics") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) dictionary = MultispeakerDictionary( dictionary_path=english_dictionary, position_dependent_phones=False, - temporary_directory=output_directory, phone_set_type="AUTO", ) + dictionary.clean_working_directory() + dictionary.remove_database() dictionary.dictionary_setup() dictionary.write_lexicon_information() assert dictionary.phone_set_type.name == "ARPA" assert dictionary.extra_questions_mapping - for k, v in dictionary.extra_questions_mapping.items(): - print(k) - print(v) + for v in dictionary.extra_questions_mapping.values(): assert len(v) == len(set(v)) assert all(x.endswith("0") for x in dictionary.extra_questions_mapping["stress_0"]) assert all(x.endswith("1") for x in dictionary.extra_questions_mapping["stress_1"]) @@ -149,22 +119,21 @@ def test_english_clitics(english_dictionary, generated_dir): assert all(x in dictionary.extra_questions_mapping["fricatives"] for x in voiceless_fricatives) assert set(dictionary.extra_questions_mapping["close"]) == {"IH", "UH", "IY", "UW"} assert set(dictionary.extra_questions_mapping["close_mid"]) == {"EY", "OW", "AH"} - - s, spl = dictionary.sanitize_function.get_functions_for_speaker("default") - assert spl.split_clitics("l'orme's") == ["l'", "orme", "'s"] - - assert list(s("Hello 'smart guy'.")) == ["hello", "smart", "guy"] + dictionary.clean_working_directory() + dictionary.remove_database() -def test_english_mfa(english_us_mfa_dictionary, generated_dir): +def test_english_mfa(english_us_mfa_dictionary, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "dictionary_tests", "english_mfa") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) dictionary = MultispeakerDictionary( dictionary_path=english_us_mfa_dictionary, position_dependent_phones=False, - temporary_directory=output_directory, phone_set_type="AUTO", ) + dictionary.clean_working_directory() + dictionary.remove_database() dictionary.dictionary_setup() dictionary.write_lexicon_information() assert dictionary.phone_set_type.name == "IPA" @@ -176,18 +145,21 @@ def test_english_mfa(english_us_mfa_dictionary, generated_dir): assert "dental" in dictionary.extra_questions_mapping dental = {"f", "v", "θ", "ð"} assert all(x in dictionary.extra_questions_mapping["dental"] for x in dental) + dictionary.clean_working_directory() + dictionary.remove_database() -@pytest.mark.skip("No support for mixed formats") -def test_mandarin_pinyin(pinyin_dictionary, generated_dir): +def test_mandarin_pinyin(pinyin_dictionary, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "dictionary_tests", "pinyin") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) dictionary = MultispeakerDictionary( dictionary_path=pinyin_dictionary, position_dependent_phones=False, - temporary_directory=output_directory, phone_set_type="AUTO", ) + dictionary.clean_working_directory() + dictionary.remove_database() dictionary.dictionary_setup() dictionary.write_lexicon_information() assert dictionary.phone_set_type.name == "PINYIN" @@ -213,75 +185,40 @@ def test_mandarin_pinyin(pinyin_dictionary, generated_dir): } assert set(dictionary.extra_questions_mapping["dorsal_variation"]) == {"h", "k", "g"} assert "uai1" in dictionary.extra_questions_mapping["tone_1"] + dictionary.clean_working_directory() + dictionary.remove_database() -def test_devanagari(english_dictionary, generated_dir): - output_directory = os.path.join(generated_dir, "dictionary_tests", "devanagari") - shutil.rmtree(output_directory, ignore_errors=True) - d = MultispeakerDictionary( - dictionary_path=english_dictionary, - position_dependent_phones=False, - temporary_directory=output_directory, - ) - test_cases = ["हैं", "हूं", "हौं"] - for tc in test_cases: - assert [tc] == list(d.sanitize(tc)) - - -def test_japanese(english_dictionary, generated_dir): - output_directory = os.path.join(generated_dir, "dictionary_tests", "japanese") - shutil.rmtree(output_directory, ignore_errors=True) - d = MultispeakerDictionary( - dictionary_path=english_dictionary, - position_dependent_phones=False, - temporary_directory=output_directory, - ) - assert ["かぎ括弧"] == list(d.sanitize("「かぎ括弧」")) - assert ["二重かぎ括弧"] == list(d.sanitize("『二重かぎ括弧』")) - - -def test_xsampa_dir(xsampa_dict_path, generated_dir): - output_directory = os.path.join(generated_dir, "dictionary_tests", "xsampa") - shutil.rmtree(output_directory, ignore_errors=True) - - dictionary = MultispeakerDictionary( - dictionary_path=xsampa_dict_path, - position_dependent_phones=False, - punctuation=list(".-']["), - temporary_directory=output_directory, - ) - - dictionary.dictionary_setup() - dictionary.write_lexicon_information() - s, spl = dictionary.sanitize_function.get_functions_for_speaker("default") - assert spl.split_clitics(r"r\{und") == [r"r\{und"] - assert spl.split_clitics("{bI5s@`n") == ["{bI5s@`n"] - assert dictionary.word_mapping(1)[r"r\{und"] - - -def test_multispeaker_config(multispeaker_dictionary_config_path, generated_dir): +def test_multispeaker_config( + multispeaker_dictionary_config_path, generated_dir, global_config, db_setup +): output_directory = os.path.join(generated_dir, "dictionary_tests", "multispeaker") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) dictionary = MultispeakerDictionary( dictionary_path=multispeaker_dictionary_config_path, position_dependent_phones=False, punctuation=list(".-']["), - temporary_directory=output_directory, ) + dictionary.clean_working_directory() + dictionary.remove_database() dictionary.dictionary_setup() dictionary.write_lexicon_information() + dictionary.clean_working_directory() + dictionary.remove_database() -@pytest.mark.skip("No support for mixed formats") -def test_mixed_dictionary(mixed_dict_path, generated_dir): +def test_mixed_dictionary(mixed_dict_path, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "dictionary_tests", "mixed") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) dictionary = MultispeakerDictionary( dictionary_path=mixed_dict_path, position_dependent_phones=False, - temporary_directory=output_directory, ) + dictionary.clean_working_directory() + dictionary.remove_database() dictionary.dictionary_setup() dictionary.write_lexicon_information() with dictionary.session() as session: @@ -306,38 +243,28 @@ def test_mixed_dictionary(mixed_dict_path, generated_dir): pron = session.query(Pronunciation).filter(Pronunciation.pronunciation == "dh ah").first() assert pron is not None assert pron.probability == 1 - assert pron.silence_after_probability is None - assert pron.silence_before_correction is None - assert pron.non_silence_before_correction is None + assert pron.silence_after_probability == 0.5 + assert pron.silence_before_correction == 1.0 + assert pron.non_silence_before_correction == 1.0 + dictionary.clean_working_directory() + dictionary.remove_database() -def test_vietnamese_tones(vietnamese_dict_path, generated_dir): +def test_vietnamese_tones(vietnamese_dict_path, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "dictionary_tests", "vietnamese") + global_config.temporary_directory = output_directory shutil.rmtree(output_directory, ignore_errors=True) - d = MultispeakerDictionary( - dictionary_path=vietnamese_dict_path, - position_dependent_phones=False, - temporary_directory=output_directory, - phone_set_type="IPA", - ) - d.dictionary_setup() - assert d.get_base_phone("o˨˩ˀ") == "o" - assert "o" in d.kaldi_grouped_phones - assert "o˨˩ˀ" in d.kaldi_grouped_phones["o"] - assert "o˦˩" in d.kaldi_grouped_phones["o"] - d.db_engine.dispose() - - output_directory = os.path.join(generated_dir, "dictionary_tests", "vietnamese_keep_tone") - d = MultispeakerDictionary( + dictionary = MultispeakerDictionary( dictionary_path=vietnamese_dict_path, position_dependent_phones=False, - temporary_directory=output_directory, - preserve_suprasegmentals=True, phone_set_type="IPA", ) - d.dictionary_setup() - - assert d.get_base_phone("o˨˩ˀ") == "o˨˩ˀ" - assert "o" not in d.kaldi_grouped_phones - assert "o˨˩ˀ" in d.kaldi_grouped_phones - assert "o˦˩" in d.kaldi_grouped_phones + dictionary.clean_working_directory() + dictionary.remove_database() + dictionary.dictionary_setup() + assert dictionary.get_base_phone("o˨˩ˀ") == "o" + assert "o" in dictionary.kaldi_grouped_phones + assert "o˨˩ˀ" in dictionary.kaldi_grouped_phones["o"] + assert "o˦˩" in dictionary.kaldi_grouped_phones["o"] + dictionary.clean_working_directory() + dictionary.remove_database() diff --git a/tests/test_g2p.py b/tests/test_g2p.py index 41a89402..ffe20e97 100644 --- a/tests/test_g2p.py +++ b/tests/test_g2p.py @@ -1,4 +1,5 @@ import os +import shutil from montreal_forced_aligner.dictionary import MultispeakerDictionary from montreal_forced_aligner.g2p.generator import ( @@ -19,7 +20,7 @@ def test_clean_up_word(): assert m == {"+"} -def test_check_bracketed(basic_dict_path): +def test_check_bracketed(basic_dict_path, db_setup): """Checks if the brackets are removed correctly and handling an empty string works""" word_set = ["uh", "(the)", "sick", "", "[a]", "{cold}", ""] expected_result = ["uh", "sick", ""] @@ -27,14 +28,17 @@ def test_check_bracketed(basic_dict_path): assert [x for x in word_set if not dictionary_config.check_bracketed(x)] == expected_result -def test_training(basic_dict_path, basic_g2p_model_path, temp_dir): +def test_training(basic_dict_path, basic_g2p_model_path, temp_dir, global_config, db_setup): + output_directory = os.path.join(temp_dir, "g2p_tests", "train") + global_config.temporary_directory = output_directory trainer = PyniniTrainer( dictionary_path=basic_dict_path, - temporary_directory=temp_dir, random_starts=1, num_iterations=5, evaluate=True, ) + trainer.clean_working_directory() + trainer.remove_database() trainer.setup() trainer.train() @@ -45,28 +49,41 @@ def test_training(basic_dict_path, basic_g2p_model_path, temp_dir): assert model.meta["phones"] == trainer.non_silence_phones assert model.meta["graphemes"] == trainer.g2p_training_graphemes trainer.cleanup() + trainer.clean_working_directory() + trainer.remove_database() -def test_generator(basic_g2p_model_path, basic_corpus_dir, g2p_basic_output, temp_dir): - output_directory = os.path.join(temp_dir, "g2p_tests") +def test_generator( + basic_g2p_model_path, basic_corpus_dir, g2p_basic_output, temp_dir, global_config, db_setup +): + output_directory = os.path.join(temp_dir, "g2p_tests", "gen") + global_config.temporary_directory = output_directory + if os.path.exists(output_directory): + shutil.rmtree(output_directory, ignore_errors=True) + global_config.clean = True gen = PyniniCorpusGenerator( g2p_model_path=basic_g2p_model_path, corpus_directory=basic_corpus_dir, - temporary_directory=output_directory, ) + gen.clean_working_directory() + gen.remove_database() gen.setup() + print(gen.corpus_word_set) assert not gen.g2p_model.validate(gen.corpus_word_set) assert gen.g2p_model.validate([x for x in gen.corpus_word_set if not gen.check_bracketed(x)]) gen.export_pronunciations(g2p_basic_output) assert os.path.exists(g2p_basic_output) gen.cleanup() + gen.clean_working_directory() + gen.remove_database() -def test_generator_pretrained(english_g2p_model, temp_dir): +def test_generator_pretrained(english_g2p_model, temp_dir, global_config, db_setup): words = ["petted", "petted-patted", "pedal"] output_directory = os.path.join(temp_dir, "g2p_tests") + global_config.temporary_directory = output_directory word_list_path = os.path.join(output_directory, "word_list.txt") os.makedirs(output_directory, exist_ok=True) with mfa_open(word_list_path, "w") as f: @@ -75,8 +92,11 @@ def test_generator_pretrained(english_g2p_model, temp_dir): gen = PyniniWordListGenerator( g2p_model_path=english_g2p_model, word_list_path=word_list_path, num_pronunciations=3 ) + gen.clean_working_directory() + gen.remove_database() gen.setup() results = gen.generate_pronunciations() - print(results) assert len(results["petted"]) == 3 gen.cleanup() + gen.clean_working_directory() + gen.remove_database() diff --git a/tests/test_gui.py b/tests/test_gui.py index f71134f7..38a82a89 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -3,29 +3,26 @@ from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpus -def test_save_text_lab( - basic_corpus_dir, - generated_dir, -): +def test_save_text_lab(basic_corpus_dir, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "gui_tests") + global_config.temporary_directory = output_directory corpus = AcousticCorpus( corpus_directory=basic_corpus_dir, - use_mp=True, - temporary_directory=output_directory, ) corpus._load_corpus() - corpus.get_file(name="acoustic_corpus").save() + corpus.get_file(name="acoustic_corpus").save(corpus.corpus_directory) def test_file_properties( stereo_corpus_dir, generated_dir, + global_config, + db_setup, ): output_directory = os.path.join(generated_dir, "gui_tests") + global_config.temporary_directory = output_directory corpus = AcousticCorpus( corpus_directory=stereo_corpus_dir, - use_mp=True, - temporary_directory=output_directory, ) corpus._load_corpus() file = corpus.get_file(name="michaelandsickmichael") @@ -36,12 +33,11 @@ def test_file_properties( assert y.shape[0] == 2 -def test_flac_tg(flac_tg_corpus_dir, generated_dir): +def test_flac_tg(flac_tg_corpus_dir, generated_dir, global_config, db_setup): output_directory = os.path.join(generated_dir, "gui_tests") + global_config.temporary_directory = output_directory corpus = AcousticCorpus( corpus_directory=flac_tg_corpus_dir, - use_mp=True, - temporary_directory=output_directory, ) corpus._load_corpus() - corpus.get_file(name="61-70968-0000").save() + corpus.get_file(name="61-70968-0000").save(corpus.corpus_directory) diff --git a/tests/test_helper.py b/tests/test_helper.py new file mode 100644 index 00000000..1077bd09 --- /dev/null +++ b/tests/test_helper.py @@ -0,0 +1,101 @@ +import yaml + +from montreal_forced_aligner.data import CtmInterval +from montreal_forced_aligner.helper import align_phones, mfa_open + + +def test_align_phones(basic_corpus_dir, basic_dict_path, temp_dir, eval_mapping_path): + with mfa_open(eval_mapping_path) as f: + mapping = yaml.safe_load(f) + reference_phoneset = set() + for v in mapping.values(): + if isinstance(v, str): + reference_phoneset.add(v) + else: + reference_phoneset.update(v) + + reference_sequence = [ + "HH", + "IY0", + "HH", + "AE1", + "D", + "Y", + "ER0", + "G", + "R", + "IY1", + "S", + "IY0", + "S", + "UW1", + "T", + "IH0", + "N", + "D", + "ER1", + "T", + "IY0", + "W", + "AA1", + "SH", + "W", + "AO1", + "T", + "ER0", + "AO1", + "L", + "sil", + "Y", + "IH1", + "R", + ] + reference_sequence = [CtmInterval(i, i + 1, x) for i, x in enumerate(reference_sequence)] + comparison_sequence = [ + "ç", + "i", + "h", + "æ", + "d", + "j", + "ɚ", + "ɟ", + "ɹ", + "iː", + "s", + "i", + "s", + "ʉː", + "t", + "sil", + "ɪ", + "n", + "d", + "ɝ", + "ɾ", + "i", + "w", + "ɑː", + "ʃ", + "w", + "ɑː", + "ɾ", + "ɚ", + "ɑː", + "ɫ", + "sil", + "j", + "ɪ", + "ɹ", + ] + comparison_sequence = [CtmInterval(i, i + 1, x) for i, x in enumerate(comparison_sequence)] + score, phone_errors = align_phones( + reference_sequence, + comparison_sequence, + silence_phone="sil", + custom_mapping=mapping, + debug=True, + ) + + assert score < 1 + assert phone_errors < 1 diff --git a/tests/test_validate.py b/tests/test_validate.py index 416b55e4..335f0936 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -4,21 +4,24 @@ from montreal_forced_aligner.validation.corpus_validator import TrainingValidator -def test_training_validator_arpa(multilingual_ipa_tg_corpus_dir, english_dictionary, temp_dir): - temp_dir = os.path.join(temp_dir, "training_validator") +def test_training_validator_arpa( + multilingual_ipa_tg_corpus_dir, english_dictionary, temp_dir, global_config, db_setup +): + output_directory = os.path.join(temp_dir, "training_validator") + global_config.temporary_directory = output_directory validator = TrainingValidator( corpus_directory=multilingual_ipa_tg_corpus_dir, dictionary_path=english_dictionary, - temporary_directory=temp_dir, phone_set_type="ARPA", + position_dependent_phones=True, ) + validator.clean_working_directory() + validator.remove_database() validator.setup() assert validator.phone_set_type.name == "ARPA" assert validator.extra_questions_mapping assert validator.phone_set_type.name == "ARPA" - for k, v in validator.extra_questions_mapping.items(): - print(k) - print(v) + for v in validator.extra_questions_mapping.values(): assert len(v) == len(set(v)) assert all("0" in x for x in validator.extra_questions_mapping["stress_0"]) assert all("1" in x for x in validator.extra_questions_mapping["stress_1"]) @@ -52,25 +55,28 @@ def test_training_validator_arpa(multilingual_ipa_tg_corpus_dir, english_diction validator.positions, ) } + validator.clean_working_directory() + validator.remove_database() def test_training_validator_ipa( - multilingual_ipa_tg_corpus_dir, english_us_mfa_dictionary, temp_dir + multilingual_ipa_tg_corpus_dir, english_us_mfa_dictionary, temp_dir, global_config, db_setup ): - temp_dir = os.path.join(temp_dir, "training_validator_ipa") + output_directory = os.path.join(temp_dir, "training_validator_ipa") + global_config.temporary_directory = output_directory validator = TrainingValidator( corpus_directory=multilingual_ipa_tg_corpus_dir, dictionary_path=english_us_mfa_dictionary, - temporary_directory=temp_dir, phone_set_type="IPA", + position_dependent_phones=True, ) + validator.clean_working_directory() + validator.remove_database() validator.setup() assert validator.phone_set_type.name == "IPA" assert validator.extra_questions_mapping assert validator.phone_set_type.name == "IPA" - for k, v in validator.extra_questions_mapping.items(): - print(k) - print(v) + for v in validator.extra_questions_mapping.values(): assert len(v) == len(set(v)) assert "dental" in validator.extra_questions_mapping dental = { @@ -81,3 +87,5 @@ def test_training_validator_ipa( ) } assert all(x in validator.extra_questions_mapping["dental"] for x in dental) + validator.clean_working_directory() + validator.remove_database() diff --git a/tox.ini b/tox.ini index cbff4ac1..f16fd92b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38-{win,unix},coverage,lint,check-formatting,manifest +envlist = py38,coverage,lint,check-formatting,manifest minversion = 3.18.0 requires = tox-conda isolated_build = true @@ -8,15 +8,9 @@ skip_missing_interpreters = true [testenv] description = run test suite under {basepython} -platform = unix: linux - win: win32 conda_env = environment.yml conda_channels = conda-forge conda_deps = tox[toml] - pytest[toml] - coverage -conda_install_args= - --override-channels commands = conda list coverage run -m pytest -x @@ -32,13 +26,13 @@ commands = coverage xml coverage html depends = - py38-{win,unix} + py38 ; This env just runs `black` and fails tox if it's not formatted correctly. ; If this env fails on CI, run `tox -e format` locally in order to apply changes. [testenv:check-formatting] basepython = python3.8 -deps = black==21.8b0 +deps = black==22.10.0 skip_install = true commands = black montreal_forced_aligner tests --diff --check --config {toxinidir}/pyproject.toml @@ -62,7 +56,6 @@ ignore = E203 [testenv:docs] basepython = python3.9 -skip_install=true conda_env = rtd_environment.yml commands = interrogate -v --config {toxinidir}/pyproject.toml @@ -76,14 +69,14 @@ commands = check-manifest [testenv:format] basepython = python3.8 -deps = black==21.8b0 +deps = black==22.10.0 skip_install = true commands = black montreal_forced_aligner tests --config {toxinidir}/pyproject.toml [gh-actions] python = - 3.8: py38-unix,coverage + 3.8: py38,coverage [testenv:dev] description = dev environment with all deps at {envdir}

qc15&9UigUoPFnz`r;Q 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 z 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%0DBT>B-!O>+|Tw@N;B`zA$%0N{P<=`k0tS%5ltD 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