montreal_forced_aligner/corpus/classes.py
class Speaker(MfaCorpusClass):
    """
    Class representing information about a speaker

    Parameters
    ----------
    name: str
        Identifier of the speaker

    Attributes
    ----------
    utterances: :class:`~montreal_forced_aligner.corpus.classes.UtteranceCollection`
        Utterances that the speaker is associated with
    cmvn: str, optional
        String pointing to any CMVN that has been calculated for this speaker
    dictionary: :class:`~montreal_forced_aligner.dictionary.PronunciationDictionary`, optional
        Pronunciation dictionary that the speaker is associated with
    """

    def __init__(self, name):
        self._name = name
        self.utterances = UtteranceCollection()
        self.cmvn = None
        self.dictionary: Optional[PronunciationDictionaryMixin] = None
        self.dictionary_name: Optional[str] = None
        self.word_counts = Counter()

    @property
    def name(self) -> str:
        """Speaker name"""
        return self._name

    def __str__(self) -> str:
        """Return Speaker's name"""
        return self.name

    def __eq__(self, other: Union[Speaker, str]) -> bool:
        """Check if a Speaker is equal to another Speaker"""
        if isinstance(other, Speaker):
            return other.name == self.name
        if isinstance(other, str):
            return self.name == other
        raise TypeError("Speakers can only be compared to other speakers and strings.")

    def __lt__(self, other: Union[Speaker, str]) -> bool:
        """Check if a Speaker is less than another Speaker"""
        if isinstance(other, Speaker):
            return other.name < self.name
        if isinstance(other, str):
            return self.name < other
        raise TypeError("Speakers can only be compared to other speakers and strings.")

    def __gt__(self, other: Union[Speaker, str]) -> bool:
        """Check if a Speaker is greater than another Speaker"""
        if isinstance(other, Speaker):
            return other.name > self.name
        if isinstance(other, str):
            return self.name > other
        raise TypeError("Speakers can only be compared to other speakers and strings.")

    def __hash__(self) -> hash:
        """Get the hash of the speaker"""
        return hash(self.name)

    @property
    def num_utterances(self) -> int:
        """Get the number of utterances for the speaker"""
        return len(self.utterances)

    def add_utterance(self, utterance: Utterance) -> None:
        """
        Associate an utterance with a speaker

        Parameters
        ----------
        utterance: :class:`~montreal_forced_aligner.corpus.classes.Utterance`
            Utterance to be added
        """
        self.utterances.add_utterance(utterance)

    def delete_utterance(self, utterance: Utterance) -> None:
        """
        Delete an utterance associated with a speaker

        Parameters
        ----------
        utterance: :class:`~montreal_forced_aligner.corpus.classes.Utterance`
            Utterance to be deleted
        """
        identifier = utterance.name
        del self.utterances[identifier]

    def set_dictionary(self, dictionary: PronunciationDictionaryMixin) -> None:
        """
        Set the dictionary for the speaker

        Parameters
        ----------
        dictionary: :class:`~montreal_forced_aligner.dictionary.PronunciationDictionary`
            Pronunciation dictionary to associate with the speaker
        """
        self.dictionary = dictionary
        self.dictionary_name = dictionary.name

    @property
    def meta(self) -> Dict[str, str]:
        """Metadata for the speaker"""
        data = {
            "name": self.name,
            "cmvn": self.cmvn,
        }
        if self.dictionary is not None:
            data["dictionary"] = self.dictionary.name
        return data
montreal_forced_aligner/corpus/acoustic_corpus.py
class AcousticCorpusMixin(CorpusMixin, FeatureConfigMixin, metaclass=ABCMeta):
    """
    Mixin class for acoustic corpora

    Parameters
    ----------
    audio_directory: str
        Extra directory to look for audio files

    See Also
    --------
    :class:`~montreal_forced_aligner.corpus.base.CorpusMixin`
        For corpus parsing parameters
    :class:`~montreal_forced_aligner.corpus.features.FeatureConfigMixin`
        For feature generation parameters

    Attributes
    ----------
    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
    """

    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 _initialize_from_json(self, data):
        self.features_generated = data.get("features_generated", False)
        self.alignment_done = data.get("alignment_done", False)
        self.transcription_done = data.get("transcription_done", False)
        self.has_reference_alignments = data.get("has_reference_alignments", False)
        self.alignment_evaluation_done = data.get("alignment_evaluation_done", False)

    @property
    def corpus_meta(self):
        return {
            "features_generated": self.features_generated,
            "alignment_done": self.alignment_done,
            "transcription_done": self.transcription_done,
            "has_reference_alignments": self.has_reference_alignments,
            "alignment_evaluation_done": self.alignment_evaluation_done,
        }

    def load_reference_alignments(self, reference_directory: str):
        self.log_info("Loading reference files...")
        indices = []
        jobs = []
        with tqdm.tqdm(total=len(self.files)) as pbar:
            for root, _, files in os.walk(reference_directory, followlinks=True):
                root_speaker = os.path.basename(root)
                for f in files:
                    if f.endswith(".TextGrid"):
                        file_name = f.replace(".TextGrid", "")
                        if file_name not in self.files:
                            continue
                        if self.use_mp:
                            indices.append(file_name)
                            jobs.append((os.path.join(root, f), root_speaker))
                        else:
                            file = self.files[file_name]
                            intervals = parse_aligned_textgrid(os.path.join(root, f), root_speaker)
                            for u in file.utterances:
                                if file.text_type == TextFileType.LAB:
                                    for v in intervals.values():
                                        self.utterances[u.name].reference_phone_labels = v
                                else:
                                    if u.speaker_name not in intervals:
                                        continue
                                    for interval in intervals[u.speaker_name]:
                                        if interval.begin >= u.begin and interval.end <= u.end:
                                            self.utterances[u.name].reference_phone_labels.append(
                                                interval
                                            )
                            pbar.update(1)
            if self.use_mp:
                with mp.Pool(self.num_jobs) as pool:
                    gen = pool.starmap(parse_aligned_textgrid, jobs)
                    for i, intervals in enumerate(gen):
                        pbar.update(1)
                        file_name = indices[i]
                        file = self.files[file_name]
                        for u in file.utterances:
                            if file.text_type == TextFileType.LAB:
                                for v in intervals.values():
                                    self.utterances[u.name].reference_phone_labels = v
                            else:
                                if u.speaker_name not in intervals:
                                    continue
                                for interval in intervals[u.speaker_name]:
                                    if interval.begin >= u.begin and interval.end <= u.end:
                                        self.utterances[u.name].reference_phone_labels.append(
                                            interval
                                        )
        self.has_reference_alignments = True

    def load_corpus(self) -> None:
        """
        Load the corpus
        """
        self._load_corpus()

        self.initialize_jobs()
        self.write_corpus_information()
        self.create_corpus_split()
        self.generate_features()

    def generate_features(self, overwrite: bool = False, compute_cmvn: bool = True) -> None:
        """
        Generate features for the corpus

        Parameters
        ----------
        overwrite: bool
            Flag for whether to ignore existing files, defaults to False
        compute_cmvn: bool
            Flag for whether to compute CMVN, defaults to True
        """
        if not overwrite and os.path.exists(
            os.path.join(self.corpus_output_directory, "feats.scp")
        ):
            self.features_generated = True
            return
        self.log_info(f"Generating base features ({self.feature_type})...")
        if self.feature_type == "mfcc":
            self.mfcc()
        self.combine_feats()
        if compute_cmvn:
            self.log_info("Calculating CMVN...")
            self.calc_cmvn()
        self.write_corpus_information()
        self.features_generated = True
        self.create_corpus_split()

    def create_corpus_split(self) -> None:
        """Create the split directory for the corpus"""
        if self.features_generated:
            super().create_corpus_split()
        else:
            split_dir = self.split_directory
            os.makedirs(os.path.join(split_dir, "log"), exist_ok=True)
            for job in self.jobs:
                job.output_for_features(split_dir)

    def write_corpus_information(self) -> None:
        """
        Output information to the temporary directory for later loading
        """
        super().write_corpus_information()
        self._write_feats()

    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_name in j.current_dictionary_names:
            feat_path = features[dict_name]
            cmvn_path = cmvns[dict_name]
            utt2spk_path = utt2spks[dict_name]
            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,
    ) -> List[Dict[str, 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 = {}
            for dict_name in j.current_dictionary_names:
                feat_path = features[dict_name]
                cmvn_path = cmvns[dict_name]
                utt2spk_path = utt2spks[dict_name]
                fmllr_trans_path = None
                try:
                    fmllr_trans_path = fmllrs[dict_name]
                    if not os.path.exists(fmllr_trans_path):
                        fmllr_trans_path = None
                except KeyError:
                    pass
                vad_path = vads[dict_name]
                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:
                        if not os.path.exists(lda_mat_path):
                            raise Exception(f"Could not find {lda_mat_path}")
                        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_name] = feats
            strings.append(feat_strings)
        return strings

    def compute_vad_arguments(self) -> List[VadArguments]:
        """
        Generate Job arguments for :class:`~montreal_forced_aligner.corpus.features.ComputeVadFunction`

        Returns
        -------
        list[:class:`~montreal_forced_aligner.corpus.features.VadArguments`]
            Arguments for processing
        """
        return [
            VadArguments(
                os.path.join(self.split_directory, "log", f"compute_vad.{j.name}.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
        ]

    def calc_fmllr_arguments(self) -> List[CalcFmllrArguments]:
        """
        Generate Job arguments for :class:`~montreal_forced_aligner.corpus.features.CalcFmllrFunction`

        Returns
        -------
        list[:class:`~montreal_forced_aligner.corpus.features.CalcFmllrArguments`]
            Arguments for processing
        """
        feature_strings = self.construct_feature_proc_strings()
        return [
            CalcFmllrArguments(
                os.path.join(self.working_log_directory, f"calc_fmllr.{j.name}.log"),
                j.current_dictionary_names,
                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,
            )
            for j in self.jobs
        ]

    def mfcc_arguments(self) -> List[MfccArguments]:
        """
        Generate Job arguments for :class:`~montreal_forced_aligner.corpus.features.MfccFunction`

        Returns
        -------
        list[:class:`~montreal_forced_aligner.corpus.features.MfccArguments`]
            Arguments for processing
        """
        return [
            MfccArguments(
                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"),
                self.mfcc_options,
            )
            for j in self.jobs
        ]

    def mfcc(self) -> None:
        """
        Multiprocessing function that converts sound files into MFCCs.

        See :kaldi_docs:`feat` for an overview on feature generation in Kaldi.

        See Also
        --------
        :class:`~montreal_forced_aligner.corpus.features.MfccFunction`
            Multiprocessing helper function for each job
        :meth:`.AcousticCorpusMixin.mfcc_arguments`
            Job method for generating arguments for helper function
        :kaldi_steps:`make_mfcc`
            Reference Kaldi script
        """
        self.log_info("Generating MFCCs...")
        log_directory = os.path.join(self.split_directory, "log")
        os.makedirs(log_directory, exist_ok=True)
        with tqdm.tqdm(total=self.num_utterances) as pbar:
            if self.use_mp:
                manager = mp.Manager()
                error_dict = manager.dict()
                return_queue = manager.Queue()
                stopped = Stopped()
                procs = []
                for i, args in enumerate(self.mfcc_arguments()):
                    function = MfccFunction(args)
                    p = KaldiProcessWorker(i, return_queue, function, error_dict, stopped)
                    procs.append(p)
                    p.start()
                while True:
                    try:
                        num_utterances = return_queue.get(timeout=1)
                        # print(utterance)
                        if stopped.stop_check():
                            continue
                    except Empty:
                        for proc in procs:
                            if not proc.finished.stop_check():
                                break
                        else:
                            break
                        continue
                    pbar.update(num_utterances)
                for p in procs:
                    p.join()
                if error_dict:
                    for v in error_dict.values():
                        raise v
            else:
                for args in self.mfcc_arguments():
                    function = MfccFunction(args)
                    for num_utterances in function.run():
                        pbar.update(num_utterances)

    def calc_cmvn(self) -> None:
        """
        Calculate CMVN statistics for speakers

        See Also
        --------
        :kaldi_src:`compute-cmvn-stats`
            Relevant Kaldi binary
        """
        spk2utt = os.path.join(self.corpus_output_directory, "spk2utt.scp")
        feats = os.path.join(self.corpus_output_directory, "feats.scp")
        cmvn_directory = os.path.join(self.features_directory, "cmvn")
        os.makedirs(cmvn_directory, exist_ok=True)
        cmvn_ark = os.path.join(cmvn_directory, "cmvn.ark")
        cmvn_scp = os.path.join(cmvn_directory, "cmvn.scp")
        log_path = os.path.join(cmvn_directory, "cmvn.log")
        with open(log_path, "w") as logf:
            subprocess.call(
                [
                    thirdparty_binary("compute-cmvn-stats"),
                    f"--spk2utt=ark:{spk2utt}",
                    f"scp:{feats}",
                    f"ark,scp:{cmvn_ark},{cmvn_scp}",
                ],
                stderr=logf,
                env=os.environ,
            )
        shutil.copy(cmvn_scp, os.path.join(self.corpus_output_directory, "cmvn.scp"))
        for s, cmvn in load_scp(cmvn_scp).items():
            self.speakers[s].cmvn = cmvn
        self.create_corpus_split()

    def calc_fmllr(self) -> None:
        """
        Multiprocessing function that computes speaker adaptation transforms via
        Feature space Maximum Likelihood Linear Regression (fMLLR).

        See Also
        --------
        :class:`~montreal_forced_aligner.corpus.features.CalcFmllrFunction`
            Multiprocessing helper function for each job
        :meth:`.AcousticCorpusMixin.calc_fmllr_arguments`
            Job method for generating arguments for the helper function
        :kaldi_steps:`align_fmllr`
            Reference Kaldi script
        :kaldi_steps:`train_sat`
            Reference Kaldi script
        """
        begin = time.time()
        self.log_info("Calculating fMLLR for speaker adaptation...")

        arguments = self.calc_fmllr_arguments()
        with tqdm.tqdm(total=self.num_speakers) as pbar:
            if self.use_mp:
                manager = mp.Manager()
                error_dict = manager.dict()
                return_queue = manager.Queue()
                stopped = Stopped()
                procs = []
                for i, args in enumerate(arguments):
                    function = CalcFmllrFunction(args)
                    p = KaldiProcessWorker(i, return_queue, function, error_dict, stopped)
                    procs.append(p)
                    p.start()
                while True:
                    try:
                        _ = 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
                    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 = CalcFmllrFunction(args)
                    for _ in function.run():
                        pbar.update(1)

        self.speaker_independent = False
        self.log_debug(f"Fmllr calculation took {time.time() - begin}")

    def compute_vad(self) -> None:
        """
        Compute Voice Activity Detection features over the corpus

        See Also
        --------
        :class:`~montreal_forced_aligner.corpus.features.ComputeVadFunction`
            Multiprocessing helper function for each job
        :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
        begin = time.time()
        self.log_info("Computing VAD...")

        arguments = self.compute_vad_arguments()
        with tqdm.tqdm(total=self.num_speakers) as pbar:
            if self.use_mp:
                manager = mp.Manager()
                error_dict = manager.dict()
                return_queue = manager.Queue()
                stopped = Stopped()
                procs = []
                for i, args in enumerate(arguments):
                    function = ComputeVadFunction(args)
                    p = KaldiProcessWorker(i, return_queue, function, error_dict, stopped)
                    procs.append(p)
                    p.start()
                while True:
                    try:
                        done, no_feats, unvoiced = 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
                    pbar.update(done + no_feats + unvoiced)
                for p in procs:
                    p.join()
                if error_dict:
                    for v in error_dict.values():
                        raise v
            else:
                for args in arguments:
                    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}")

    def combine_feats(self) -> None:
        """
        Combine feature generation results and store relevant information
        """
        split_directory = self.split_directory
        ignore_check = []
        for job in self.jobs:
            feats_path = job.construct_path(split_directory, "feats", "scp")

            with open(feats_path, "r") as inf:
                for line in inf:
                    line = line.strip()
                    if line == "":
                        continue
                    f = line.split(maxsplit=1)
                    if self.utterances[f[0]].ignored:
                        continue
                    self.utterances[f[0]].features = f[1]
        for utterance in self.utterances:
            if utterance.features is None or utterance.duration < 0.13:
                utterance.ignored = True
                ignore_check.append(utterance.name)
        if ignore_check:
            self.log_warning(
                "There were some utterances ignored due to short duration, see the log file for full "
                "details or run `mfa validate` on the corpus."
            )
            self.log_debug(
                f"The following utterances were too short to run alignment: "
                f"{' ,'.join(ignore_check)}"
            )
        self.write_corpus_information()

    def _write_feats(self):
        """Write feats scp file for Kaldi"""
        if any(x.features is not None for x in self.utterances):
            with open(
                os.path.join(self.corpus_output_directory, "feats.scp"), "w", encoding="utf8"
            ) as f:
                for utterance in self.utterances:
                    if not utterance.features:
                        continue
                    f.write(f"{utterance.name} {utterance.features}\n")

    def get_feat_dim(self) -> int:
        """
        Calculate the feature dimension for the corpus

        Returns
        -------
        int
            Dimension of feature vectors
        """
        feature_string = self.construct_base_feature_string()
        with open(os.path.join(self.features_log_directory, "feat-to-dim.log"), "w") as log_file:
            subset_proc = subprocess.Popen(
                [
                    thirdparty_binary("subset-feats"),
                    "--n=1",
                    feature_string,
                    "ark:-",
                ],
                stderr=log_file,
                stdout=subprocess.PIPE,
            )
            dim_proc = subprocess.Popen(
                [thirdparty_binary("feat-to-dim"), "ark:-", "-"],
                stdin=subset_proc.stdout,
                stdout=subprocess.PIPE,
                stderr=log_file,
            )
            stdout, stderr = dim_proc.communicate()
            feats = stdout.decode("utf8").strip()
        return int(feats)

    def _load_corpus_from_source_mp(self) -> None:
        """
        Load a corpus using multiprocessing
        """
        begin_time = time.process_time()
        manager = mp.Manager()
        job_queue = manager.Queue()
        return_queue = manager.Queue()
        return_dict = manager.dict()
        return_dict["sound_file_errors"] = manager.list()
        return_dict["decode_error_files"] = manager.list()
        return_dict["textgrid_read_errors"] = manager.dict()
        finished_adding = Stopped()
        stopped = Stopped()
        try:
            sanitize_function = self.sanitize_function
        except AttributeError:
            sanitize_function = None
        procs = []
        for i in range(self.num_jobs):
            p = CorpusProcessWorker(
                i,
                job_queue,
                return_dict,
                return_queue,
                stopped,
                finished_adding,
                self.speaker_characters,
                sanitize_function,
            )
            procs.append(p)
            p.start()
        try:

            use_audio_directory = False
            all_sound_files = {}
            if self.audio_directory and os.path.exists(self.audio_directory):
                use_audio_directory = True
                for root, _, files in os.walk(self.audio_directory, followlinks=True):
                    exts = find_exts(files)
                    wav_files = {k: os.path.join(root, v) for k, v in exts.wav_files.items()}
                    other_audio_files = {
                        k: os.path.join(root, v) for k, v in exts.other_audio_files.items()
                    }
                    all_sound_files.update(other_audio_files)
                    all_sound_files.update(wav_files)
            file_counts = 0
            begin = time.process_time()
            self.log_debug(f"Walking through {self.corpus_directory}...")
            for root, _, files in os.walk(self.corpus_directory, followlinks=True):
                exts = find_exts(files)
                relative_path = root.replace(self.corpus_directory, "").lstrip("/").lstrip("\\")

                if self.stopped.stop_check():
                    break
                self.log_debug(f"Inside relative root {relative_path}:")
                self.log_debug(f"    Found {len(exts.identifiers)} identifiers")
                self.log_debug(f"    Found {len(exts.wav_files)} .wav files")
                self.log_debug(f"    Found {len(exts.other_audio_files)} other audio files")
                self.log_debug(f"    Found {len(exts.lab_files)} .lab files")
                self.log_debug(f"    Found {len(exts.textgrid_files)} .TextGrid files")
                if not use_audio_directory:
                    all_sound_files = {}
                    exts.wav_files = {k: os.path.join(root, v) for k, v in exts.wav_files.items()}
                    exts.other_audio_files = {
                        k: os.path.join(root, v) for k, v in exts.other_audio_files.items()
                    }
                    all_sound_files.update(exts.other_audio_files)
                    all_sound_files.update(exts.wav_files)
                for file_name in exts.identifiers:
                    if self.stopped.stop_check():
                        break
                    wav_path = None
                    transcription_path = None
                    if file_name in all_sound_files:
                        wav_path = all_sound_files[file_name]
                    if file_name in exts.lab_files:
                        lab_name = exts.lab_files[file_name]
                        transcription_path = os.path.join(root, lab_name)

                    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:
                        self.transcriptions_without_wavs.append(transcription_path)
                        continue
                    if transcription_path is None:
                        self.no_transcription_files.append(wav_path)
                    job_queue.put((file_name, wav_path, transcription_path, relative_path))
                    file_counts += 1
            self.log_debug(f"Time to walk directory: {time.process_time() - begin}")

            finished_adding.stop()
            self.log_debug("Finished adding jobs!")

            with tqdm.tqdm(total=file_counts) as pbar:
                while True:
                    try:
                        file = return_queue.get(timeout=1)
                        if self.stopped.stop_check():
                            continue
                    except Empty:
                        for proc in procs:
                            if not proc.finished_processing.stop_check():
                                break
                        else:
                            break
                        continue
                    self.add_file(File.load_from_mp_data(file))
                    pbar.update(1)
            self.log_debug(f"Processing queue: {time.process_time() - begin_time}")

            if "error" in return_dict:
                raise return_dict["error"][1]

            for k in ["sound_file_errors", "decode_error_files", "textgrid_read_errors"]:
                if hasattr(self, k):
                    if return_dict[k]:
                        self.log_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(return_dict[k])} errors:")
                        if k == "textgrid_read_errors":
                            getattr(self, k).update(return_dict[k])
                            for f, e in return_dict[k].items():
                                self.log_debug(f"{f}: {e.error}")
                        else:
                            self.log_debug(", ".join(return_dict[k]))
                            setattr(self, k, return_dict[k])

        except KeyboardInterrupt:
            self.log_info("Detected ctrl-c, please wait a moment while we clean everything up...")
            self.stopped.stop()
            finished_adding.stop()
            job_queue.join()
            self.stopped.set_sigint_source()
            while True:
                try:
                    _ = return_queue.get(timeout=1)
                    if self.stopped.stop_check():
                        continue
                except Empty:
                    for proc in procs:
                        if not proc.finished_processing.stop_check():
                            break
                    else:
                        break
        finally:

            finished_adding.stop()
            job_queue.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)"
                )
                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"
                )

    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
        if self.audio_directory and os.path.exists(self.audio_directory):
            use_audio_directory = True
            for root, _, files in os.walk(self.audio_directory, followlinks=True):
                if self.stopped.stop_check():
                    return
                exts = find_exts(files)
                exts.wav_files = {k: os.path.join(root, v) for k, v in exts.wav_files.items()}
                exts.other_audio_files = {
                    k: os.path.join(root, v) for k, v in exts.other_audio_files.items()
                }
                all_sound_files.update(exts.other_audio_files)
                all_sound_files.update(exts.wav_files)
        self.log_debug(f"Walking through {self.corpus_directory}...")
        for root, _, files in os.walk(self.corpus_directory, followlinks=True):
            exts = find_exts(files)
            relative_path = root.replace(self.corpus_directory, "").lstrip("/").lstrip("\\")
            if self.stopped.stop_check():
                return
            self.log_debug(f"Inside relative root {relative_path}:")
            self.log_debug(f"    Found {len(exts.identifiers)} identifiers")
            self.log_debug(f"    Found {len(exts.wav_files)} .wav files")
            self.log_debug(f"    Found {len(exts.other_audio_files)} other audio files")
            self.log_debug(f"    Found {len(exts.lab_files)} .lab files")
            self.log_debug(f"    Found {len(exts.textgrid_files)} .TextGrid files")
            if not use_audio_directory:
                all_sound_files = {}
                wav_files = {k: os.path.join(root, v) for k, v in exts.wav_files.items()}
                other_audio_files = {
                    k: os.path.join(root, v) for k, v in exts.other_audio_files.items()
                }
                all_sound_files.update(other_audio_files)
                all_sound_files.update(wav_files)
            for file_name in exts.identifiers:

                wav_path = None
                transcription_path = None
                if file_name in all_sound_files:
                    wav_path = all_sound_files[file_name]
                if file_name in exts.lab_files:
                    lab_name = exts.lab_files[file_name]
                    transcription_path = os.path.join(root, lab_name)
                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:
                    self.transcriptions_without_wavs.append(transcription_path)
                    continue
                if transcription_path is None:
                    self.no_transcription_files.append(wav_path)
                try:
                    file = File.parse_file(
                        file_name,
                        wav_path,
                        transcription_path,
                        relative_path,
                        self.speaker_characters,
                        sanitize_function,
                    )
                    self.add_file(file)
                except TextParseError as e:
                    self.decode_error_files.append(e)
                except TextGridParseError as e:
                    self.textgrid_read_errors[e.file_name] = e
        if self.decode_error_files or self.textgrid_read_errors:
            self.log_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(
                    f"There were {len(self.decode_error_files)} errors decoding text files:"
                )
                self.log_debug(", ".join(self.decode_error_files))
            if self.textgrid_read_errors:
                self.log_debug(
                    f"There were {len(self.textgrid_read_errors)} errors decoding reading TextGrid files:"
                )
                for f, e in self.textgrid_read_errors.items():
                    self.log_debug(f"{f}: {e.error}")

        self.log_debug(f"Parsed corpus directory in {time.time() - begin_time} seconds")
montreal_forced_aligner/data.py
class TextFileType(enum.Enum):
    """Enum for types of text files"""

    NONE = 0
    TEXTGRID = 1
    LAB = 2
montreal_forced_aligner/corpus/classes.py
class File(MfaCorpusClass):
    """
    File class for representing metadata and associations of Files

    Parameters
    ----------
    wav_path: str, optional
        Sound file path
    text_path: str, optional
        Transcription file path
    relative_path: str, optional
        Relative path to the corpus root

    Attributes
    ----------
    utterances: :class:`~montreal_forced_aligner.corpus.classes.UtteranceCollection`
        Utterances in the file
    speaker_ordering: list[Speaker]
        Ordering of speakers in the transcription file
    wav_info: :class:`~montreal_forced_aligner.data.SoundFileInformation`
        Information about sound file
    waveform: numpy.array
        Audio samples
    aligned: bool
        Flag for whether a file has alignments

    Raises
    ------
    :class:`~montreal_forced_aligner.exceptions.CorpusError`
        If both wav_path and text_path are None
    """

    textgrid_regex = re.compile(r"\.textgrid$", flags=re.IGNORECASE)
    wav_regex = re.compile(r"\.wav$", flags=re.IGNORECASE)

    def __init__(
        self,
        wav_path: Optional[str] = None,
        text_path: Optional[str] = None,
        relative_path: Optional[str] = None,
        name: Optional[str] = None,
    ):
        self.wav_path = wav_path
        self.text_path = text_path
        wav_check = self.wav_path is not None
        text_check = self.text_path is not None
        self._name = name
        if not self._name:
            if wav_check:
                self._name = os.path.splitext(os.path.basename(self.wav_path))[0]
            elif text_check:
                self._name = os.path.splitext(os.path.basename(self.text_path))[0]
            else:
                raise CorpusError("File objects must have either a wav_path or text_path")
        self.relative_path = relative_path
        self.wav_info: Optional[SoundFileInformation] = None
        self.waveform = None
        self.speaker_ordering: List[Speaker] = []
        self.utterances = UtteranceCollection()
        self.aligned = False
        if text_check:
            if self.text_path.lower().endswith(".textgrid"):
                self.text_type = TextFileType.TEXTGRID
            else:
                self.text_type = TextFileType.LAB
        else:
            self.text_type = TextFileType.NONE
        if wav_check:

            if self.wav_path.lower().endswith(".wav"):
                self.sound_type = SoundFileType.WAV
            else:
                self.sound_type = SoundFileType.SOX
        else:
            self.sound_type = SoundFileType.NONE

    @property
    def multiprocessing_data(self) -> FileData:
        """
        Data object for the file
        """
        return FileData(
            self._name,
            self.wav_path,
            self.text_path,
            self.relative_path,
            self.wav_info,
            [s.name for s in self.speaker_ordering],
            [u.multiprocessing_data for u in self.utterances],
        )

    @classmethod
    def load_from_mp_data(cls, file_data: FileData) -> File:
        """
        Construct a File from a multiprocessing file data class

        Parameters
        ----------
        file_data: :class:`~montreal_forced_aligner.data.FileData`
            Data for the loaded file

        Returns
        -------
        :class:`~montreal_forced_aligner.corpus.classes.File`
            Loaded file
        """
        file = File(
            file_data.wav_path,
            file_data.text_path,
            relative_path=file_data.relative_path,
            name=file_data.name,
        )
        file.wav_info = file_data.wav_info
        for s in file_data.speaker_ordering:
            file.add_speaker(Speaker(s))
        for u in file_data.utterances:
            u = Utterance.load_from_mp_data(u, file)
            file.utterances.add_utterance(u)
        return file

    @classmethod
    def parse_file(
        cls,
        file_name: str,
        wav_path: Optional[str],
        text_path: Optional[str],
        relative_path: str,
        speaker_characters: Union[int, str],
        sanitize_function: Optional[MultispeakerSanitizationFunction] = None,
    ):
        """
        Parse a collection of sound file and transcription file into a File

        Parameters
        ----------
        file_name: str
            File identifier
        wav_path: str
            Full sound file path
        text_path: str
            Full transcription path
        relative_path: str
            Relative path from the corpus directory root
        speaker_characters: int, optional
            Number of characters in the file name to specify the speaker
        sanitize_function: Callable, optional
            Function to sanitize words and strip punctuation

        Returns
        -------
        :class:`~montreal_forced_aligner.corpus.classes.File`
            Parsed file
        """
        file = File(wav_path, text_path, relative_path=relative_path, name=file_name)
        if file.has_sound_file:
            root = os.path.dirname(wav_path)
            file.wav_info = get_wav_info(wav_path)
        else:
            root = os.path.dirname(text_path)
        if not speaker_characters:
            speaker_name = os.path.basename(root)
        elif isinstance(speaker_characters, int):
            speaker_name = file_name[:speaker_characters]
        elif speaker_characters == "prosodylab":
            speaker_name = file_name.split("_")[1]
        else:
            speaker_name = file_name
        root_speaker = None
        if speaker_characters or file.text_type != TextFileType.TEXTGRID:
            root_speaker = Speaker(speaker_name)
        file.load_text(
            root_speaker=root_speaker,
            sanitize_function=sanitize_function,
        )
        return file

    def __eq__(self, other: Union[File, str]) -> bool:
        """Check if a File is equal to another File"""
        if isinstance(other, File):
            return other.name == self.name
        if isinstance(other, str):
            return self.name == other
        raise TypeError("Files can only be compared to other files and strings.")

    def __lt__(self, other: Union[File, str]) -> bool:
        """Check if a File is less than another File"""
        if isinstance(other, File):
            return other.name < self.name
        if isinstance(other, str):
            return self.name < other
        raise TypeError("Files can only be compared to other files and strings.")

    def __gt__(self, other: Union[File, str]) -> bool:
        """Check if a File is greater than another File"""
        if isinstance(other, File):
            return other.name > self.name
        if isinstance(other, str):
            return self.name > other
        raise TypeError("Files can only be compared to other files and strings.")

    def __hash__(self) -> hash:
        """Get the hash of the file"""
        return hash(self.name)

    @property
    def name(self) -> str:
        """Name of the file"""
        name = self._name
        if self.relative_path:
            prefix = self.relative_path.replace("/", "").replace("\\", "")
            if not name.startswith(prefix):
                name = f"{prefix}_{name}"
        return name

    @property
    def is_fully_aligned(self) -> bool:
        """
        Check if all utterances have been aligned
        """
        for u in self.utterances:
            if u.ignored:
                continue
            if u.word_labels is None:
                return False
            if u.phone_labels is None:
                return False
        return True

    def __repr__(self) -> str:
        """Representation of File objects"""
        return f'<File {self.name} Sound path="{self.wav_path}" Text path="{self.text_path}">'

    def save(
        self,
        output_directory: Optional[str] = None,
        text_type: Optional[TextFileType] = None,
        save_transcription: bool = False,
    ) -> None:
        """
        Output File to TextGrid or lab.  If ``text_type`` is not specified, the original file type will be used,
        but if there was no text file for the file, it will guess lab format if there is only one utterance, otherwise
        it will output a TextGrid file.

        Parameters
        ----------
        output_directory: str, optional
            Directory to output file, if None, then it will overwrite the original file
        backup_output_directory: str, optional
            If specified, then it will check whether it would overwrite an existing file and
            instead use this directory
        text_type: TextFileType, 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
        """
        utterance_count = len(self.utterances)
        if text_type is None:
            text_type = self.text_type
            if text_type == TextFileType.NONE:
                if utterance_count == 1:
                    text_type = TextFileType.LAB
                else:
                    text_type = TextFileType.TEXTGRID
        if text_type == TextFileType.LAB:
            if utterance_count == 0 and os.path.exists(self.text_path) and not save_transcription:
                os.remove(self.text_path)
                return
            elif utterance_count == 0:
                return
            output_path = self.construct_output_path(output_directory, enforce_lab=True)
            with open(output_path, "w", encoding="utf8") as f:
                for u in self.utterances:
                    if save_transcription:
                        f.write(u.transcription_text if u.transcription_text else "")
                    else:
                        f.write(u.text)
            return
        elif text_type == TextFileType.TEXTGRID:
            output_path = self.construct_output_path(output_directory)
            max_time = self.duration
            tiers = {}
            for speaker in self.speaker_ordering:
                if speaker is None:
                    tiers["speech"] = textgrid.IntervalTier("speech", [], minT=0, maxT=max_time)
                else:
                    tiers[speaker] = textgrid.IntervalTier(speaker.name, [], minT=0, maxT=max_time)

            tg = textgrid.Textgrid()
            tg.maxTimestamp = max_time
            for utterance in self.utterances:

                if utterance.speaker is None:
                    speaker = "speech"
                else:
                    speaker = utterance.speaker
                if not self.aligned:

                    if save_transcription:
                        tiers[speaker].entryList.append(
                            Interval(
                                start=utterance.begin,
                                end=utterance.end,
                                label=utterance.transcription_text
                                if utterance.transcription_text
                                else "",
                            )
                        )
                    else:
                        tiers[speaker].entryList.append(
                            Interval(
                                start=utterance.begin, end=utterance.end, label=utterance.text
                            )
                        )
            for t in tiers.values():
                tg.addTier(t)
            tg.save(output_path, includeBlankSpaces=True, format="long_textgrid")

    @property
    def meta(self) -> Dict[str, Any]:
        """Metadata for the File"""
        return {
            "wav_path": self.wav_path,
            "text_path": self.text_path,
            "name": self._name,
            "relative_path": self.relative_path,
            "wav_info": self.wav_info.meta,
            "speaker_ordering": [x.name for x in self.speaker_ordering],
        }

    @property
    def has_sound_file(self) -> bool:
        """Flag for whether the File has a sound file"""
        return self.sound_type != SoundFileType.NONE

    @property
    def has_text_file(self) -> bool:
        """Flag for whether the File has a text file"""
        return self.text_type != TextFileType.NONE

    @property
    def aligned_data(self) -> Dict[str, Dict[str, List[CtmInterval]]]:
        """
        Word and phone alignments for the file

        Returns
        -------
        dict[str, dict[str, list[CtmInterval]]]
            Dictionary of word and phone intervals for each speaker in the file
        """
        data = {}
        for s in self.speaker_ordering:
            if s.name not in data:
                data[s.name] = {"words": [], "phones": []}
        for u in self.utterances:
            if u.word_labels is None:
                continue
            data[u.speaker_name]["words"].extend(u.word_labels)
            data[u.speaker_name]["phones"].extend(u.phone_labels)
        return data

    def clean_up(self) -> None:
        """
        Recombine words that were split up as part of initial text processing
        """
        for u in self.utterances:
            if not u.word_labels:
                continue
            cur_ind = 0
            actual_labels = []
            dictionary = u.speaker.dictionary
            for word in u.text.split():
                splits = dictionary.lookup(word)
                b = 1000000
                e = -1
                for w in splits:
                    cur = u.word_labels[cur_ind]
                    if w == cur.label or cur.label == dictionary.oov_word:
                        if cur.begin < b:
                            b = cur.begin
                        if cur.end > e:
                            e = cur.end
                    cur_ind += 1
                lab = CtmInterval(b, e, word, u.name)
                actual_labels.append(lab)
            u.word_labels = actual_labels
            u.phone_labels = [
                x for x in u.phone_labels if x.label != dictionary.optional_silence_phone
            ]

    def construct_output_path(
        self,
        output_directory: Optional[str] = None,
        enforce_lab: bool = False,
    ) -> 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
        enforce_lab: bool
            Flag for whether to enforce generating a lab file over a TextGrid

        Returns
        -------
        str
            Output path
        """
        if enforce_lab:
            extension = ".lab"
        else:
            extension = ".TextGrid"
        if output_directory is None:
            if self.text_path is None:
                return os.path.splitext(self.wav_path)[0] + extension
            return self.text_path
        if self.relative_path:
            relative = os.path.join(output_directory, self.relative_path)
        else:
            relative = output_directory
        tg_path = os.path.join(relative, self._name + extension)
        os.makedirs(os.path.dirname(tg_path), exist_ok=True)
        return tg_path

    def load_text(
        self,
        root_speaker: Optional[Speaker] = None,
        sanitize_function: Optional[MultispeakerSanitizationFunction] = None,
    ) -> None:
        """
        Load the transcription text from the text_file of the object

        Parameters
        ----------
        root_speaker: :class:`~montreal_forced_aligner.corpus.classes.Speaker`, 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:
                text = load_text(self.text_path)
            except UnicodeDecodeError:
                raise TextParseError(self.text_path)
            utterance = Utterance(speaker=root_speaker, file=self, text=text)
            utterance.parse_transcription(sanitize_function)
            self.add_utterance(utterance)
        elif self.text_type == TextFileType.TEXTGRID:
            try:
                tg = textgrid.openTextgrid(self.text_path, includeEmptyIntervals=False)
            except Exception:
                exc_type, exc_value, exc_traceback = sys.exc_info()
                raise TextGridParseError(
                    self.text_path,
                    "\n".join(traceback.format_exception(exc_type, exc_value, exc_traceback)),
                )

            num_tiers = len(tg.tierNameList)
            if num_tiers == 0:
                raise TextGridParseError(self.text_path, "Number of tiers parsed was zero")
            if self.num_channels > 2:
                raise (Exception("More than two channels"))
            for tier_name in tg.tierNameList:
                ti = tg.tierDict[tier_name]
                if tier_name.lower() == "notes":
                    continue
                if not isinstance(ti, textgrid.IntervalTier):
                    continue
                if not root_speaker:
                    speaker_name = tier_name.strip()
                    speaker = Speaker(speaker_name)
                    self.add_speaker(speaker)
                else:
                    speaker = root_speaker
                for begin, end, text in ti.entryList:
                    text = text.lower().strip()
                    if not text:
                        continue
                    begin, end = round(begin, 4), round(end, 4)
                    end = min(end, self.duration)
                    utt = Utterance(speaker=speaker, file=self, begin=begin, end=end, text=text)
                    utt.parse_transcription(sanitize_function)
                    if not utt.text:
                        continue
                    self.add_utterance(utt)
        else:
            utterance = Utterance(speaker=root_speaker, file=self)
            self.add_utterance(utterance)

    def add_speaker(self, speaker: Speaker) -> None:
        """
        Add a speaker to a file

        Parameters
        ----------
        speaker: :class:`~montreal_forced_aligner.corpus.classes.Speaker`
            Speaker to add
        """
        if speaker not in self.speaker_ordering:
            self.speaker_ordering.append(speaker)

    def add_utterance(self, utterance: Utterance) -> None:
        """
        Add an utterance to a file

        Parameters
        ----------
        utterance: :class:`~montreal_forced_aligner.corpus.classes.Utterance`
            Utterance to add
        """
        self.utterances.add_utterance(utterance)
        self.add_speaker(utterance.speaker)

    def delete_utterance(self, utterance: Utterance) -> None:
        """
        Delete an utterance from the file

        Parameters
        ----------
        utterance: :class:`~montreal_forced_aligner.corpus.classes.Utterance`
            Utterance to remove
        """
        identifier = utterance.name
        del self.utterances[identifier]

    def load_info(self) -> None:
        """
        Load sound file info if it hasn't been already
        """
        if self.wav_path is not None:
            self.wav_info = get_wav_info(self.wav_path)

    @property
    def duration(self) -> float:
        """Get the duration of the sound file"""
        if self.wav_path is None:
            return 0
        if not self.wav_info:
            self.load_info()
        return self.wav_info.duration

    @property
    def num_channels(self) -> int:
        """Get the number of channels of the sound file"""
        if self.wav_path is None:
            return 0
        if not self.wav_info:
            self.load_info()
        return self.wav_info.num_channels

    @property
    def num_utterances(self) -> int:
        """Get the number of utterances for the sound file"""
        return len(self.utterances)

    @property
    def num_speakers(self) -> int:
        """Get the number of speakers in the sound file"""
        return len(self.speaker_ordering)

    @property
    def sample_rate(self) -> int:
        """Get the sample rate of the sound file"""
        if self.wav_path is None:
            return 0
        if not self.wav_info:
            self.load_info()
        return self.wav_info.sample_rate

    @property
    def format(self) -> str:
        """Get the sound file format"""
        if not self.wav_info:
            self.load_info()
        return self.wav_info.format

    @property
    def sox_string(self) -> str:
        """String used for converting sound file via SoX within Kaldi"""
        if not self.wav_info:
            self.load_info()
        return self.wav_info.sox_string

    def load_wav_data(self) -> None:
        """
        Load the sound file into memory as a numpy array
        """
        self.waveform, _ = librosa.load(self.wav_path, sr=None, mono=False)

    def normalized_waveform(
        self, begin: float = 0, end: Optional[float] = None
    ) -> Tuple[np.array, np.array]:
        if self.waveform is None:
            self.load_wav_data()
        if end is None or end > self.duration:
            end = self.duration

        begin_sample = int(begin * self.sample_rate)
        end_sample = int(end * self.sample_rate)
        if len(self.waveform.shape) > 1 and self.waveform.shape[0] == 2:
            y = self.waveform[:, begin_sample:end_sample] / np.max(
                np.abs(self.waveform[:, begin_sample:end_sample]), axis=0
            )
            y[np.isnan(y)] = 0
        else:
            y = self.waveform[begin_sample:end_sample] / np.max(
                np.abs(self.waveform[begin_sample:end_sample]), axis=0
            )
        x = np.arange(start=begin_sample, stop=end_sample) / self.sample_rate
        return x, y

    def for_wav_scp(self) -> str:
        """
        Generate the string to use in feature generation

        Returns
        -------
        str
            SoX string if necessary, the sound file path otherwise
        """
        if self.sox_string:
            return self.sox_string
        return self.wav_path
montreal_forced_aligner/exceptions.py
class KaldiProcessingError(MFAError):
    """
    Exception class for when a Kaldi binary has an exception

    Parameters
    ----------
    error_logs: list[str]
        List of Kaldi logs that had errors
    log_file: str, optional
        Overall log file to find more information
    """

    def __init__(self, error_logs: List[str], log_file: Optional[str] = None):
        super().__init__(
            f"There were {len(error_logs)} job(s) with errors when running Kaldi binaries."
        )

        if log_file is not None:
            self.message_lines.append(
                f" For more details, please check {self.printer.error_text(log_file)}"
            )
        self.error_logs = error_logs
        self.log_file = log_file

    def update_log_file(self, logger: logging.Logger) -> None:
        """
        Update the log file output

        Parameters
        ----------
        logger: logging.Logger
            Logger
        """
        if logger.handlers:
            self.log_file = logger.handlers[0].baseFilename
        self.message_lines = [
            f"There were {len(self.error_logs)} job(s) with errors when running Kaldi binaries."
        ]
        if self.log_file is not None:
            self.message_lines.append(
                f" For more details, please check {self.printer.error_text(self.log_file)}"
            )
montreal_forced_aligner/abc.py
class KaldiFunction(metaclass=abc.ABCMeta):
class MfaCorpusClass(metaclass=abc.ABCMeta):
class TemporaryDirectoryMixin(metaclass=abc.ABCMeta):
class MfaWorker(metaclass=abc.ABCMeta):
class TopLevelMfaWorker(MfaWorker, TemporaryDirectoryMixin, metaclass=abc.ABCMeta):
class ExporterMixin(metaclass=abc.ABCMeta):
class ModelExporterMixin(ExporterMixin, metaclass=abc.ABCMeta):
class FileExporterMixin(ExporterMixin, metaclass=abc.ABCMeta):
class TrainerMixin(ModelExporterMixin):
class AdapterMixin(ModelExporterMixin):
class MfaModel(abc.ABC):
    def __init__(self, args: typing.NamedTuple):
    def run(self):
    def name(self) -> str:
    def __init__(
        self,
        temporary_directory: str = None,
        **kwargs,
    ):
    def identifier(self) -> str:
    def data_source_identifier(self) -> str:
    def output_directory(self) -> str:
    def corpus_output_directory(self) -> str:
    def corpus_output_directory(self, directory: str) -> None:
    def dictionary_output_directory(self) -> str:
    def dictionary_output_directory(self, directory: str) -> None:
    def language_model_output_directory(self) -> str:
    def language_model_output_directory(self, directory: str) -> None:
    def __init__(
        self,
        use_mp: bool = True,
        debug: bool = False,
        verbose: bool = False,
        **kwargs,
    ):
    def log_debug(self, message: str) -> None:
    def log_error(self, message: str) -> None:
    def log_info(self, message: str) -> None:
    def log_warning(self, message: str) -> None:
    def extract_relevant_parameters(cls, config: MetaDict) -> Tuple[MetaDict, List[str]]:
    def get_configuration_parameters(cls) -> Dict[str, Type]:
    def configuration(self) -> MetaDict:
    def working_directory(self) -> str:
    def working_log_directory(self) -> str:
    def data_directory(self) -> str:
    def __init__(
        self,
        num_jobs: int = 3,
        clean: bool = False,
        **kwargs,
    ):
    def __del__(self):
    def setup(self) -> None:
    def working_directory(self) -> str:
    def parse_args(cls, args: Optional[Namespace], unknown_args: Optional[List[str]]) -> MetaDict:
    def parse_parameters(
        cls,
        config_path: Optional[str] = None,
        args: Optional[Namespace] = None,
        unknown_args: Optional[List[str]] = None,
    ) -> MetaDict:
    def workflow_identifier(self) -> str:
    def worker_config_path(self):
    def cleanup(self) -> None:
    def save_worker_config(self):
    def _validate_previous_configuration(self, conf: MetaDict) -> bool:
    def check_previous_run(self) -> bool:
    def identifier(self) -> str:
    def output_directory(self) -> str:
    def workflow_directory(self) -> str:
    def log_file(self):
    def setup_logger(self):
    def log_debug(self, message: str) -> None:
    def log_info(self, message: str) -> None:
    def log_warning(self, message: str) -> None:
    def log_error(self, message: str) -> None:
    def __init__(self, overwrite: bool = False, **kwargs):
    def meta(self) -> MetaDict:
    def export_model(self, output_model_path: str) -> None:
    def __init__(self, cleanup_textgrids: bool = True, **kwargs):
    def backup_output_directory(self) -> Optional[str]:
    def export_files(self, output_directory: str) -> None:
    def __init__(self, num_iterations: int = 40, **kwargs):
    def initialize_training(self) -> None:
    def train(self) -> None:
    def train_iteration(self) -> None:
    def finalize_training(self) -> None:
    def adapt(self) -> None:
    def pretrained_directory(cls) -> str:
    def get_available_models(cls) -> List[str]:
    def get_pretrained_path(cls, name: str, enforce_existence: bool = True) -> str:
    def valid_extension(cls, filename: str) -> bool:
    def generate_path(cls, root: str, name: str, enforce_existence: bool = True) -> Optional[str]:
    def pretty_print(self) -> None:
    def meta(self) -> MetaDict:
    def add_meta_file(self, trainer: TrainerMixin) -> None:
montreal_forced_aligner/corpus/classes.py
class Utterance(MfaCorpusClass):
    """
    Class for information about specific utterances

    Parameters
    ----------
    speaker: :class:`~montreal_forced_aligner.corpus.classes.Speaker`
        Speaker of the utterance
    file: :class:`~montreal_forced_aligner.corpus.classes.File`
        File that the utterance belongs to
    begin: float, optional
        Start time of the utterance,
        if None, then the utterance is assumed to start at 0
    end: float, optional
        End time of the utterance,
        if None, then the utterance is assumed to end at the end of the File
    channel: int, optional
        Channel in the file, if None, then assumed to be the first/only channel
    text: str, optional
        Text transcription of the utterance

    Attributes
    ----------
    file_name: str
        Saved File.name property for reconstructing objects following serialization
    speaker_name: str
        Saved Speaker.name property for reconstructing objects following serialization
    transcription_text: str, optional
        Output of transcription is saved here
    ignored: bool
        The ignored flag is set if feature generation does not work for this utterance, or it is too short to
        be processed by Kaldi
    features: str, optional
        Feature string reference to the computed features archive
    phone_labels: list[:class:`~montreal_forced_aligner.data.CtmInterval`], optional
        Saved aligned phone labels
    word_labels: list[:class:`~montreal_forced_aligner.data.CtmInterval`], optional
        Saved aligned word labels
    oovs: list[str]
        Words not found in the dictionary for this utterance
    """

    def __init__(
        self,
        speaker: Speaker,
        file: File,
        begin: Optional[float] = None,
        end: Optional[float] = None,
        channel: Optional[int] = 0,
        text: Optional[str] = None,
    ):
        self.speaker = speaker
        self.file = file
        self.file_name = file.name
        self.speaker_name: str = speaker.name
        if begin is None:
            begin = 0
        if end is None:
            end = self.file.duration
        self.begin = begin
        self.end = end
        self.channel = channel
        self.text = text
        self.transcription_text = None
        self.ignored = False
        self.features = None
        self.phone_labels: Optional[List[CtmInterval]] = None
        self.word_labels: Optional[List[CtmInterval]] = None
        self.reference_phone_labels: Optional[List[CtmInterval]] = []
        self.oovs = set()
        self.normalized_text = []
        self.text_int = []
        self.alignment_log_likelihood = None
        self.word_error_rate = None
        self.character_error_rate = None
        self.phone_error_rate = None
        self.alignment_score = None

    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

        """
        self.normalized_text = []
        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(w)
                for w in self.text.split()
                if w and w not in sanitize.clitic_markers + sanitize.compound_markers
            ]
            self.text = " ".join(words)
            if split is not None:
                for w in words:
                    for new_w in split(w):
                        if not new_w:
                            continue
                        if split.word_set is not None and new_w not in split.word_set:
                            self.oovs.add(new_w)
                        self.normalized_text.append(new_w)

    @property
    def multiprocessing_data(self):
        return UtteranceData(
            self.speaker_name,
            self.file_name,
            self.begin,
            self.end,
            self.channel,
            self.text,
            self.normalized_text,
            self.oovs,
        )

    @classmethod
    def load_from_mp_data(cls, data: UtteranceData, file: File) -> Utterance:
        utterance = Utterance(
            speaker=Speaker(data.speaker_name),
            file=file,
            begin=data.begin,
            end=data.end,
            channel=data.channel,
            text=data.text,
        )
        if data.normalized_text:
            utterance.normalized_text = data.normalized_text
            utterance.oovs = data.oovs
        return utterance

    def __str__(self) -> str:
        """String representation"""
        return self.name

    def __repr__(self) -> str:
        """Object representation"""
        return f'<Utterance "{self.name}">'

    def __eq__(self, other: Union[Utterance, str]) -> bool:
        """Check if an Utterance is equal to another Utterance"""
        if isinstance(other, Utterance):
            return other.name == self.name
        if isinstance(other, str):
            return self.name == other
        raise TypeError("Utterances can only be compared to other utterances and strings.")

    def __lt__(self, other: Union[Utterance, str]) -> bool:
        """Check if an Utterance is less than another Utterance"""
        if isinstance(other, Utterance):
            return other.name < self.name
        if isinstance(other, str):
            return self.name < other
        raise TypeError("Utterances can only be compared to other utterances and strings.")

    def __gt__(self, other: Union[Utterance, str]) -> bool:
        """Check if an Utterance is greater than another Utterance"""
        if isinstance(other, Utterance):
            return other.name > self.name
        if isinstance(other, str):
            return self.name > other
        raise TypeError("Utterances can only be compared to other utterances and strings.")

    def __hash__(self) -> hash:
        """Compute the hash of this function"""
        return hash(self.name)

    @property
    def duration(self) -> float:
        """Duration of the utterance"""
        if self.begin is not None and self.end is not None:
            return self.end - self.begin
        return self.file.duration

    @property
    def meta(self) -> Dict[str, Any]:
        """Metadata dictionary for the utterance"""
        return {
            "speaker": self.speaker.name,
            "file": self.file.name,
            "begin": self.begin,
            "end": self.end,
            "channel": self.channel,
            "text": self.text,
            "ignored": self.ignored,
            "features": self.features,
            "normalized_text": self.normalized_text,
            "oovs": self.oovs,
            "transcription_text": self.transcription_text,
            "reference_phone_labels": self.reference_phone_labels,
            "phone_labels": self.phone_labels,
            "word_labels": self.word_labels,
            "word_error_rate": self.word_error_rate,
            "character_error_rate": self.character_error_rate,
            "phone_error_rate": self.phone_error_rate,
            "alignment_score": self.alignment_score,
            "alignment_log_likelihood": self.alignment_log_likelihood,
        }

    def set_speaker(self, speaker: Speaker) -> None:
        """
        Set the speaker of the utterance and updates other objects

        Parameters
        ----------
        speaker: :class:`~montreal_forced_aligner.corpus.classes.Speaker`
            New speaker
        """
        self.speaker = speaker
        self.speaker.add_utterance(self)
        self.file.add_utterance(self)

    @property
    def is_segment(self) -> bool:
        """Check if this utterance is a segment of a longer file"""
        return (
            self.begin is not None
            and self.end is not None
            and self.begin != 0
            and self.end != self.file.duration
        )

    def add_word_intervals(self, intervals: Union[CtmInterval, List[CtmInterval]]) -> None:
        """
        Add aligned word intervals for the utterance

        Parameters
        ----------
        intervals: Union[CtmInterval, list[CtmInterval]]
            Intervals to add
        """
        if not isinstance(intervals, list):
            intervals = [intervals]
        if self.word_labels is None:
            self.word_labels = []
        if self.is_segment:
            for interval in intervals:
                interval.shift_times(self.begin)
        self.word_labels = intervals

    def add_phone_intervals(self, intervals: Union[CtmInterval, List[CtmInterval]]) -> None:
        """
        Add aligned phone intervals for the utterance

        Parameters
        ----------
        intervals: Union[CtmInterval, list[CtmInterval]]
            Intervals to add
        """
        if not isinstance(intervals, list):
            intervals = [intervals]
        if self.phone_labels is None:
            self.phone_labels = []
        if self.is_segment:
            for interval in intervals:
                interval.shift_times(self.begin)
        self.phone_labels = intervals

    def text_for_scp(self) -> List[str]:
        """
        Generate the text for exporting to Kaldi's text scp

        Returns
        -------
        list[str]
            List of words
        """
        return self.text.split()

    def text_int_for_scp(self) -> Optional[List[int]]:
        """
        Generate the text for exporting to Kaldi's text int scp

        Returns
        -------
        list[int]
            List of word IDs, or None if the utterance's speaker doesn't have an associated dictionary
        """
        if self.speaker.dictionary is None:
            return
        if self.text_int:
            return self.text_int
        if self.normalized_text:
            normalized = True
            text = self.normalized_text
        else:
            normalized = False
            text = self.text_for_scp()
        self.text_int = []
        for i, t in enumerate(text):
            lookup = self.speaker.dictionary.to_int(t, normalized)
            if self.speaker.dictionary.oov_int in lookup:
                self.oovs.add(text[i])
            self.text_int.extend(lookup)
        return self.text_int

    def segment_for_scp(self) -> List[Any]:
        """
        Generate data for Kaldi's segments scp file

        Returns
        -------
        list[Any]
            Segment data
        """
        return [self.file_name.replace(" ", "_MFASPACE_"), self.begin, self.end, self.channel]

    @property
    def name(self) -> str:
        """The name of the utterance"""
        base = f"{self.file_name}"
        base = base.replace(" ", "-space-").replace(".", "-").replace("_", "-")
        if not base.startswith(f"{self.speaker_name}-"):
            base = f"{self.speaker_name}-" + base
        if self.is_segment:
            base = f"{base}-{self.begin}-{self.end}"
        return base.replace(" ", "-space-").replace(".", "-").replace("_", "-")
montreal_forced_aligner/helper.py
def load_scp(path: str, data_type: Optional[Type] = str) -> CorpusMappingType:
    """
    Load a Kaldi script file (.scp)

    Scp files in Kaldi can either be one-to-one or one-to-many, with the first element separated by
    whitespace as the key and the remaining whitespace-delimited elements the values.

    Returns a dictionary of key to value for
    one-to-one mapping case and a dictionary of key to list of values for one-to-many case.

    See Also
    --------
    :kaldi_docs:`io#io_sec_scp_details`
        For more information on the SCP format

    Parameters
    ----------
    path : str
        Path to Kaldi script file
    data_type : type
        Type to coerce the data to

    Returns
    -------
    CorpusMappingType
        Dictionary where the keys are the first column and the values are all
        other columns in the scp file

    """
    scp = {}
    with open(path, "r", encoding="utf8") as f:
        for line in f:
            line = line.strip()
            if line == "":
                continue
            line_list = line.split()
            key = load_scp_safe(line_list.pop(0))
            if len(line_list) == 1:
                value = data_type(line_list[0])
                if isinstance(value, str):
                    value = load_scp_safe(value)
            else:
                value = [data_type(x) for x in line_list if x not in ["[", "]"]]
            scp[key] = value
    return scp
montreal_forced_aligner/utils.py
def get_mfa_version() -> str:
def check_third_party():
def thirdparty_binary(binary_name: str) -> str:
def log_kaldi_errors(error_logs: List[str], logger: logging.Logger) -> None:
def guess_model_type(path: str) -> List[str]:
    def __init__(self, *args, **kwargs):
    def format(self, record: logging.LogRecord):
def parse_logs(log_directory: str) -> None:
    def __init__(self, init_val: int = 0):
    def increment(self) -> None:
    def value(self) -> int:
    def __init__(self, initval: Union[bool, int] = False):
    def stop(self) -> None:
    def stop_check(self) -> int:
    def set_sigint_source(self) -> None:
    def source(self) -> int:
    def __init__(
        self,
        job_name: int,
        job_q: mp.Queue,
        function: Callable,
        return_dict: dict,
        stopped: Stopped,
        return_info: Optional[Dict[int, Any]] = None,
    ):
    def run(self) -> None:
    def __init__(
        self,
        job_name: int,
        return_q: mp.Queue,
        function: KaldiFunction,
        error_dict: dict,
        stopped: Stopped,
    ):
    def run(self) -> None:
def run_non_mp(
    function: Callable,
    argument_list: List[Tuple[Any, ...]],
    log_directory: str,
    return_info: bool = False,
) -> Optional[Dict[Any, Any]]:
def run_mp(
    function: Callable,
    argument_list: List[Tuple[Any, ...]],
    log_directory: str,
    return_info: bool = False,
) -> Optional[Dict[int, Any]]:
class CustomFormatter(logging.Formatter):
class Counter(object):
class Stopped(object):
class ProcessWorker(mp.Process):
class KaldiProcessWorker(mp.Process):
montreal_forced_aligner/corpus/features.py
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,
        }


Question: import multiprocessing as mp
import os
import re
import tqdm
import yaml
from queue import Empty
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Union
from montreal_forced_aligner.abc import FileExporterMixin, MetaDict, TopLevelMfaWorker
from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpusMixin
from montreal_forced_aligner.corpus.classes import File, Speaker, Utterance
from montreal_forced_aligner.corpus.features import VadConfigMixin
from montreal_forced_aligner.data import TextFileType
from montreal_forced_aligner.exceptions import KaldiProcessingError
from montreal_forced_aligner.helper import load_scp
from montreal_forced_aligner.utils import (
    KaldiFunction,
    KaldiProcessWorker,
    Stopped,
    log_kaldi_errors,
    parse_logs,
)
    from argparse import Namespace
    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<done>\d+) utterances.*(?P<no_feats>\d+) had.*(?P<unvoiced>\d+) were.*"
    )

    def __init__(self, args: SegmentVadArguments):
        self.vad_path = args.vad_path
        self.segmentation_options = args.segmentation_options

    def run(self):
        """Run the function"""

        speaker = Speaker("speech")

        vad = load_scp(self.vad_path, data_type=int)
        for recording, frames in vad.items():
            file = File(recording)
            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 recording, Utterance(
                    speaker, file, begin=seg["begin"], end=seg["end"], text="speech"
                )



Answer: class Segmenter(VadConfigMixin, AcousticCorpusMixin, FileExporterMixin, TopLevelMfaWorker):