diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..916341d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 3.2.0 + +- Refactor as `piper_sample_generator` package + +## 3.1.0 + +- Support MPS acceleration on Apple Silicon +- Add `--phoneme-input` flag + +## 3.0.0 + +- Move phonemization to piper 1.3.0 (piper-phonemize is deprecated) +- Move to PyTorch 2 +- Add support for using Piper voices (`.onnx`) directly +- Allow multiple `--model` for Piper voices (`.onnx`) +- Remove silence trimming +- Remove `min-phoneme-count` diff --git a/README.md b/README.md index 3558195..944d0cf 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,51 @@ # Piper Sample Generator -Generates samples using [Piper](https://github.com/rhasspy/piper/) for training a wake word system like [openWakeWord](https://github.com/dscripka/openWakeWord). +Generate spoken audio samples using [Piper][piper] for training a wake word system like [openWakeWord][] or [microWakeWord][]. +Supports normal [Piper voices][piper voices] or a special [generator][] that can mix speaker embeddings (English only). ## Install -Create a virtual environment and install the requirements: - ``` sh -git clone https://github.com/rhasspy/piper-sample-generator.git -cd piper-sample-generator/ - -python3 -m venv .venv -source .venv/bin/activate -python3 -m pip install --upgrade pip -python3 -m pip install -r requirements.txt +pip install piper-sample-generator ``` -Download the LibriTTS-R generator (exported from [checkpoint](https://huggingface.co/datasets/rhasspy/piper-checkpoints/tree/main/en/en_US/libritts_r/medium)): +## Piper Voices + +Download one or more [Piper voices][piper voices] (both the `.onnx` and `.onnx.json` files for each voice). [Audio samples][piper samples] are available. + +As an example, we'll download the U.S. English "lessac" voice in medium quality: + +``` sh +mkdir -p voices +wget -O voices/en_US-lessac-medium.onnx 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx?download=true' +wget -O voices/en_US-lessac-medium.onnx.json 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json?download=true' +``` + +Generate a small set of samples with the CLI: + +``` sh +python3 -m piper_sample_generator 'okay piper.' --model voices/en_US-lessac-medium.onnx --max-samples 10 --output-dir okay_piper/ +``` + +Check the `okay_piper/` directory for 10 WAV files (named `0.wav` to `9.wav`). + +You can add multiple `--model ` arguments to cycle between different voices when generating samples. + +See `--help` for more options, including `--length-scales` (speaking speeds). + +## Generator + +Download the LibriTTS-R generator (exported from [checkpoint][]): ``` sh wget -O models/en-us-libritts-high.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt' ``` - -## Run - Generate a small set of samples with the CLI: ``` sh -python3 generate_samples.py 'okay, piper.' --max-samples 10 --output-dir okay_piper/ +python3 -m piper_sample_generator 'okay piper.' --model models/en-us-libritts-high.pt --max-samples 10 --output-dir okay_piper/ ``` Check the `okay_piper/` directory for 10 WAV files (named `0.wav` to `9.wav`). @@ -37,38 +53,38 @@ Check the `okay_piper/` directory for 10 WAV files (named `0.wav` to `9.wav`). Generation can be much faster and more efficient if you have a GPU available and PyTorch is configured to use it. In this case, increase the batch size: ``` sh -python3 generate_samples.py 'okay, piper.' --max-samples 100 --batch-size 10 --output-dir okay_piper/ +python3 -m piper_sample_generator 'okay piper.' --model models/en-us-libritts-high.pt --max-samples 100 --batch-size 10 --output-dir okay_piper/ ``` On an NVidia 2080 Ti with 11GB, a batch size of 100 was possible (generating approximately 100 samples per second). Setting `--max-speakers` to a value less than 904 (the number of speakers LibriTTS) is recommended. Because very few samples of later speakers were in the original dataset, using them can cause audio artifacts. -See `--help` for more options, including adjust the `--length-scales` (speaking speeds) and `--slerp-weights` (speaker blending) which are cycled per batch. - -Alternatively, you can import the generate function into another Python script: - -```python -from generate_samples import generate_samples # make sure to add this to your Python path as needed - -generate_samples(text = ["okay, piper"], max_samples = 100, output_dir = output_dir, batch_size=10) -``` - -There are some additional arguments available when importing the function directly, see the docstring of `generate_sample` for more information. +See `--help` for more options, including the `--length-scales` (speaking speeds) and `--slerp-weights` (speaker blending) which are cycled per batch. ### Augmentation -Once you have samples generating, you can augment them using [audiomentation](https://iver56.github.io/audiomentations/): +Once you have samples generated, you can augment them using [audiomentation](https://iver56.github.io/audiomentations/): ``` sh -python3 augment.py --sample-rate 16000 okay_piper/ okay_piper_augmented/ +python3 -m piper_sample_generator.augment --sample-rate 22050 okay_piper/ okay_piper_augmented/ ``` This will do several things to each sample: 1. Randomly decrease the volume * The original samples are normalized, so different volume levels are needed -2. Randomly [apply an impulse response](https://iver56.github.io/audiomentations/waveform_transforms/apply_impulse_response/) using the files in `impulses/` +2. Randomly apply an [impulse response][] using the files in `piper_sample_generator/impulses/` * Change the acoustics of the sample to sound like the speaker was in a room with echo or using a poor quality microphone -3. Resample to 16Khz for training (e.g., [openWakeWord](https://github.com/dscripka/openWakeWord)) +3. Resample to 16Khz for training (e.g., [openWakeWord][]) + + +[piper]: https://github.com/OHF-Voice/piper1-gpl/ +[openWakeWord]: https://github.com/dscripka/openWakeWord +[microWakeWord]: https://github.com/kahrendt/microWakeWord/ +[piper voices]: https://huggingface.co/rhasspy/piper-voices +[generator]: https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt +[piper samples]: https://rhasspy.github.io/piper-samples/ +[checkpoint]: https://huggingface.co/datasets/rhasspy/piper-checkpoints/tree/main/en/en_US/libritts_r/medium +[impulse response]: https://iver56.github.io/audiomentations/waveform_transforms/apply_impulse_response/ diff --git a/generate_samples.py b/generate_samples.py deleted file mode 100755 index 349ebde..0000000 --- a/generate_samples.py +++ /dev/null @@ -1,407 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import gc -import itertools as it -import json -import logging -import os -import wave -from pathlib import Path -from typing import List, Union - -import numpy as np -import torch -import torchaudio -import webrtcvad -from piper_phonemize import phonemize_espeak - -from piper_train.vits import commons - -_DIR = Path(__file__).parent -_LOGGER = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) - - -# Main generation function -def generate_samples( - text: Union[List, str], - output_dir: str, - max_samples: int = None, - file_names: List[str] = [], - model: str = os.path.join( - Path(__file__).parent, "models", "en_US-libritts_r-medium.pt" - ), - batch_size: int = 1, - slerp_weights: List[float] = [0.5], - length_scales: List[float] = [0.75, 1, 1.25], - noise_scales: List[float] = [0.667], - noise_scale_ws: List[float] = [0.8], - max_speakers: float = None, - verbose: bool = False, - auto_reduce_batch_size: bool = False, - **kwargs, -) -> None: - """ - Generate synthetic speech clips, saving the clips to the specified output directory. - - Args: - text (List[str]): The text to convert into speech. Can be either a - a list of strings, or a path to a file with text on each line. - output_dir (str): The location to save the generated clips. - max_samples (int): The maximum number of samples to generate. - file_names (List[str]): The names to use when saving the files. Must be the same length - as the `text` argument, if a list. - model (str): The path to the STT model to use for generation. - batch_size (int): The batch size to use when generated the clips - slerp_weights (List[float]): The weights to use when mixing speakers via SLERP. - length_scales (List[float]): Controls the average duration/speed of the generated speech. - noise_scales (List[float]): A parameter for overall variability of the generated speech. - noise_scale_ws (List[float]): A parameter for the stochastic duration of words/phonemes. - max_speakers (int): The maximum speaker number to use, if the model is multi-speaker. - verbose (bool): Enable or disable more detailed logging messages (default: False). - auto_reduce_batch_size (bool): Automatically and temporarily reduce the batch size - if CUDA OOM errors are detected, and try to resume generation. - - Returns: - None - """ - - if max_samples is None: - max_samples = len(text) - - _LOGGER.debug("Loading %s", model) - model_path = Path(model) - model = torch.load(model_path) - model.eval() - _LOGGER.info("Successfully loaded the model") - - if torch.cuda.is_available(): - model.cuda() - _LOGGER.debug("CUDA available, using GPU") - - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - config_path = f"{model_path}.json" - with open(config_path, "r", encoding="utf-8") as config_file: - config = json.load(config_file) - - voice = config["espeak"]["voice"] - sample_rate = config["audio"]["sample_rate"] - num_speakers = config["num_speakers"] - if max_speakers is not None: - num_speakers = min(num_speakers, max_speakers) - - max_len = None - - sample_idx = 0 - is_done = False - settings_iter = it.cycle( - it.product( - slerp_weights, - length_scales, - noise_scales, - noise_scale_ws, - ) - ) - - # Define resampler to get to 16khz (https://pytorch.org/audio/stable/tutorials/audio_resampling_tutorial.html#kaiser-best) - sample_rate = 22050 - resample_rate = 16000 - resampler = torchaudio.transforms.Resample( - sample_rate, - resample_rate, - lowpass_filter_width=64, - rolloff=0.9475937167399596, - resampling_method="kaiser_window", - beta=14.769656459379492, - ) - - speakers_iter = it.cycle(it.product(range(num_speakers), range(num_speakers))) - speakers_batch = list(it.islice(speakers_iter, 0, batch_size)) - if isinstance(text, str) and os.path.exists(text): - texts = it.cycle( - [ - i.strip() - for i in open(text, "r", encoding="utf-8").readlines() - if len(i.strip()) > 0 - ] - ) - elif isinstance(text, list): - texts = it.cycle(text) - else: - texts = it.cycle([text]) - - if file_names: - file_names = it.cycle(file_names) - - batch_idx = 0 - while speakers_batch: - if is_done: - break - - batch_size = len(speakers_batch) - slerp_weight, length_scale, noise_scale, noise_scale_w = next(settings_iter) - - with torch.no_grad(): - speaker_1 = torch.LongTensor([s[0] for s in speakers_batch]) - speaker_2 = torch.LongTensor([s[1] for s in speakers_batch]) - - phoneme_ids = [ - get_phonemes(voice, config, next(texts), verbose) - for i in range(batch_size) - ] - - def right_pad_lists(lists): - max_length = max(len(lst) for lst in lists) - padded_lists = [] - for lst in lists: - padded_l = lst + [1] * ( - max_length - len(lst) - ) # phoneme 1 (corresponding to '^' character seems to work best) - padded_lists.append(padded_l) - return padded_lists - - phoneme_ids = right_pad_lists(phoneme_ids) - - if auto_reduce_batch_size: - oom_error = True - counter = 1 - while oom_error is True: - try: - audio = generate_audio( - model, - speaker_1[0 : batch_size // counter], - speaker_2[0 : batch_size // counter], - phoneme_ids[0 : batch_size // counter], - slerp_weight, - noise_scale, - noise_scale_w, - length_scale, - max_len, - ) - oom_error = False - except torch.cuda.OutOfMemoryError: - torch.cuda.empty_cache() - gc.collect() - counter += 1 # reduce batch size to avoid OOM errors - else: - audio = generate_audio( - model, - speaker_1, - speaker_2, - phoneme_ids, - slerp_weight, - noise_scale, - noise_scale_w, - length_scale, - max_len, - ) - - # Resample audio - audio = resampler(audio.cpu()).numpy() - - audio_int16 = audio_float_to_int16(audio) - for audio_idx in range(audio_int16.shape[0]): - # Use webrtcvad to trip silence from the clips - audio_data = remove_silence(audio_int16[audio_idx].flatten())[None,] - - if isinstance(file_names, it.cycle): - wav_path = output_dir / next(file_names) - else: - wav_path = output_dir / f"{sample_idx}.wav" - with wave.open(str(wav_path), "wb") as wav_file: - wav_file.setframerate(resample_rate) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - wav_file.writeframes(audio_data) - - sample_idx += 1 - if sample_idx >= max_samples: - is_done = True - break - - # print(f"Batch {batch_idx +1}/{max_samples//batch_size} complete", " "*200, end='\r') - - # Next batch - _LOGGER.debug(f"Batch {batch_idx +1}/{max_samples//batch_size} complete") - speakers_batch = list(it.islice(speakers_iter, 0, batch_size)) - batch_idx += 1 - - _LOGGER.info("Done") - - -def remove_silence(x, frame_duration=0.030, sample_rate=16000, min_start=2000): - """Uses webrtc voice activity detection to remove silence from the clips""" - vad = webrtcvad.Vad(0) - if x.dtype == np.float32 or x.dtype == np.float64: - x = (x * 32767).astype(np.int16) - x_new = x[0:min_start].tolist() - step_size = int(sample_rate * frame_duration) - for i in range(min_start, x.shape[0] - step_size, step_size): - vad_res = vad.is_speech(x[i : i + step_size].tobytes(), sample_rate) - if vad_res: - x_new.extend(x[i : i + step_size].tolist()) - return np.array(x_new).astype(np.int16) - - -def generate_audio( - model, - speaker_1, - speaker_2, - phoneme_ids, - slerp_weight, - noise_scale, - noise_scale_w, - length_scale, - max_len, -): - x = torch.LongTensor(phoneme_ids) - x_lengths = torch.LongTensor([len(i) for i in phoneme_ids]) - - if torch.cuda.is_available(): - speaker_1 = speaker_1.cuda() - speaker_2 = speaker_2.cuda() - x = x.cuda() - x_lengths = x_lengths.cuda() - - x, m_p_orig, logs_p_orig, x_mask = model.enc_p(x, x_lengths) - emb0 = model.emb_g(speaker_1) - emb1 = model.emb_g(speaker_2) - g = slerp(emb0, emb1, slerp_weight).unsqueeze(-1) # [b, h, 1] - - if model.use_sdp: - logw = model.dp(x, x_mask, g=g, reverse=True, noise_scale=noise_scale_w) - else: - logw = model.dp(x, x_mask, g=g) - w = torch.exp(logw) * x_mask * length_scale - w_ceil = torch.ceil(w) - y_lengths = torch.clamp_min(torch.sum(w_ceil, [1, 2]), 1).long() - y_mask = torch.unsqueeze( - commons.sequence_mask(y_lengths, y_lengths.max()), 1 - ).type_as(x_mask) - attn_mask = torch.unsqueeze(x_mask, 2) * torch.unsqueeze(y_mask, -1) - attn = commons.generate_path(w_ceil, attn_mask) - - m_p = torch.matmul(attn.squeeze(1), m_p_orig.transpose(1, 2)).transpose( - 1, 2 - ) # [b, t', t], [b, t, d] -> [b, d, t'] - logs_p = torch.matmul(attn.squeeze(1), logs_p_orig.transpose(1, 2)).transpose( - 1, 2 - ) # [b, t', t], [b, t, d] -> [b, d, t'] - - z_p = m_p + torch.randn_like(m_p) * torch.exp(logs_p) * noise_scale - z = model.flow(z_p, y_mask, g=g, reverse=True) - o = model.dec((z * y_mask)[:, :, :max_len], g=g) - - audio = o - return audio - - -def get_phonemes(voice, config, text, verbose): - # Combine all sentences - phonemes = [ - p - for sentence_phonemes in phonemize_espeak(text, voice) - for p in sentence_phonemes - ] - if verbose is True: - _LOGGER.debug("Phonemes: %s", phonemes) - - id_map = config["phoneme_id_map"] - phoneme_ids = list(id_map["^"]) - for phoneme in phonemes: - p_ids = id_map.get(phoneme) - if p_ids is not None: - phoneme_ids.extend(p_ids) - phoneme_ids.extend(id_map["_"]) - - phoneme_ids.extend(id_map["$"]) - return phoneme_ids - - -def slerp(v1, v2, t, DOT_THR=0.9995, zdim=-1): - """SLERP for pytorch tensors interpolating `v1` to `v2` with scale of `t`. - - `DOT_THR` determines when the vectors are too close to parallel. - If they are too close, then a regular linear interpolation is used. - - `zdim` is the feature dimension over which to compute norms and find angles. - For example: if a sequence of 5 vectors is input with shape [5, 768] - Then `zdim = 1` or `zdim = -1` computes SLERP along the feature dim of 768. - - Theory Reference: - https://splines.readthedocs.io/en/latest/rotation/slerp.html - PyTorch reference: - https://discuss.pytorch.org/t/help-regarding-slerp-function-for-generative-model-sampling/32475/3 - Numpy reference: - https://gist.github.com/dvschultz/3af50c40df002da3b751efab1daddf2c - """ - - # take the dot product between normalized vectors - v1_norm = v1 / torch.norm(v1, dim=zdim, keepdim=True) - v2_norm = v2 / torch.norm(v2, dim=zdim, keepdim=True) - dot = (v1_norm * v2_norm).sum(zdim) - - # if the vectors are too close, return a simple linear interpolation - if (torch.abs(dot) > DOT_THR).any(): - res = (1 - t) * v1 + t * v2 - - # else apply SLERP - else: - # compute the angle terms we need - theta = torch.acos(dot) - theta_t = theta * t - sin_theta = torch.sin(theta) - sin_theta_t = torch.sin(theta_t) - - # compute the sine scaling terms for the vectors - s1 = torch.sin(theta - theta_t) / sin_theta - s2 = sin_theta_t / sin_theta - - # interpolate the vectors - res = (s1.unsqueeze(zdim) * v1) + (s2.unsqueeze(zdim) * v2) - - return res - - -def audio_float_to_int16( - audio: np.ndarray, max_wav_value: float = 32767.0 -) -> np.ndarray: - """Normalize audio and convert to int16 range""" - audio_norm = audio * (max_wav_value / max(0.01, np.max(np.abs(audio)))) - audio_norm = np.clip(audio_norm, -max_wav_value, max_wav_value) - audio_norm = audio_norm.astype("int16") - return audio_norm - - -if __name__ == "__main__": - # Get command line arguments - parser = argparse.ArgumentParser() - parser.add_argument("text") - parser.add_argument("--max-samples", required=True, type=int) - parser.add_argument( - "--model", default=_DIR / "models" / "en_US-libritts_r-medium.pt" - ) - parser.add_argument("--batch-size", type=int, default=1) - parser.add_argument("--slerp-weights", nargs="+", type=float, default=[0.5]) - parser.add_argument( - "--length-scales", nargs="+", type=float, default=[1.0, 0.75, 1.25, 1.4] - ) - parser.add_argument( - "--noise-scales", - nargs="+", - type=float, - default=[0.667, 0.75, 0.85, 0.9, 1.0, 1.4], - ) - parser.add_argument("--noise-scale-ws", nargs="+", type=float, default=[0.8]) - parser.add_argument("--output-dir", default="output") - parser.add_argument( - "--max-speakers", - type=int, - help="Maximum number of speakers to use (default: all)", - ) - args = parser.parse_args().__dict__ - - # Generate speech - generate_samples(**args) diff --git a/models/de_DE-mls-medium.pt.json b/models/de_DE-mls-medium.pt.json new file mode 100755 index 0000000..0f50a27 --- /dev/null +++ b/models/de_DE-mls-medium.pt.json @@ -0,0 +1,740 @@ +{ + "dataset": "mls", + "audio": { + "sample_rate": 22050, + "quality": "medium" + }, + "espeak": { + "voice": "de" + }, + "language": { + "code": "de_DE" + }, + "inference": { + "noise_scale": 0.333, + "length_scale": 1, + "noise_w": 0.333 + }, + "phoneme_type": "espeak", + "phoneme_map": {}, + "phoneme_id_map": { + " ": [ + 3 + ], + "!": [ + 4 + ], + "\"": [ + 150 + ], + "#": [ + 149 + ], + "$": [ + 2 + ], + "'": [ + 5 + ], + "(": [ + 6 + ], + ")": [ + 7 + ], + ",": [ + 8 + ], + "-": [ + 9 + ], + ".": [ + 10 + ], + "0": [ + 130 + ], + "1": [ + 131 + ], + "2": [ + 132 + ], + "3": [ + 133 + ], + "4": [ + 134 + ], + "5": [ + 135 + ], + "6": [ + 136 + ], + "7": [ + 137 + ], + "8": [ + 138 + ], + "9": [ + 139 + ], + ":": [ + 11 + ], + ";": [ + 12 + ], + "?": [ + 13 + ], + "X": [ + 156 + ], + "^": [ + 1 + ], + "_": [ + 0 + ], + "a": [ + 14 + ], + "b": [ + 15 + ], + "c": [ + 16 + ], + "d": [ + 17 + ], + "e": [ + 18 + ], + "f": [ + 19 + ], + "g": [ + 154 + ], + "h": [ + 20 + ], + "i": [ + 21 + ], + "j": [ + 22 + ], + "k": [ + 23 + ], + "l": [ + 24 + ], + "m": [ + 25 + ], + "n": [ + 26 + ], + "o": [ + 27 + ], + "p": [ + 28 + ], + "q": [ + 29 + ], + "r": [ + 30 + ], + "s": [ + 31 + ], + "t": [ + 32 + ], + "u": [ + 33 + ], + "v": [ + 34 + ], + "w": [ + 35 + ], + "x": [ + 36 + ], + "y": [ + 37 + ], + "z": [ + 38 + ], + "æ": [ + 39 + ], + "ç": [ + 40 + ], + "ð": [ + 41 + ], + "ø": [ + 42 + ], + "ħ": [ + 43 + ], + "ŋ": [ + 44 + ], + "œ": [ + 45 + ], + "ǀ": [ + 46 + ], + "ǁ": [ + 47 + ], + "ǂ": [ + 48 + ], + "ǃ": [ + 49 + ], + "ɐ": [ + 50 + ], + "ɑ": [ + 51 + ], + "ɒ": [ + 52 + ], + "ɓ": [ + 53 + ], + "ɔ": [ + 54 + ], + "ɕ": [ + 55 + ], + "ɖ": [ + 56 + ], + "ɗ": [ + 57 + ], + "ɘ": [ + 58 + ], + "ə": [ + 59 + ], + "ɚ": [ + 60 + ], + "ɛ": [ + 61 + ], + "ɜ": [ + 62 + ], + "ɞ": [ + 63 + ], + "ɟ": [ + 64 + ], + "ɠ": [ + 65 + ], + "ɡ": [ + 66 + ], + "ɢ": [ + 67 + ], + "ɣ": [ + 68 + ], + "ɤ": [ + 69 + ], + "ɥ": [ + 70 + ], + "ɦ": [ + 71 + ], + "ɧ": [ + 72 + ], + "ɨ": [ + 73 + ], + "ɪ": [ + 74 + ], + "ɫ": [ + 75 + ], + "ɬ": [ + 76 + ], + "ɭ": [ + 77 + ], + "ɮ": [ + 78 + ], + "ɯ": [ + 79 + ], + "ɰ": [ + 80 + ], + "ɱ": [ + 81 + ], + "ɲ": [ + 82 + ], + "ɳ": [ + 83 + ], + "ɴ": [ + 84 + ], + "ɵ": [ + 85 + ], + "ɶ": [ + 86 + ], + "ɸ": [ + 87 + ], + "ɹ": [ + 88 + ], + "ɺ": [ + 89 + ], + "ɻ": [ + 90 + ], + "ɽ": [ + 91 + ], + "ɾ": [ + 92 + ], + "ʀ": [ + 93 + ], + "ʁ": [ + 94 + ], + "ʂ": [ + 95 + ], + "ʃ": [ + 96 + ], + "ʄ": [ + 97 + ], + "ʈ": [ + 98 + ], + "ʉ": [ + 99 + ], + "ʊ": [ + 100 + ], + "ʋ": [ + 101 + ], + "ʌ": [ + 102 + ], + "ʍ": [ + 103 + ], + "ʎ": [ + 104 + ], + "ʏ": [ + 105 + ], + "ʐ": [ + 106 + ], + "ʑ": [ + 107 + ], + "ʒ": [ + 108 + ], + "ʔ": [ + 109 + ], + "ʕ": [ + 110 + ], + "ʘ": [ + 111 + ], + "ʙ": [ + 112 + ], + "ʛ": [ + 113 + ], + "ʜ": [ + 114 + ], + "ʝ": [ + 115 + ], + "ʟ": [ + 116 + ], + "ʡ": [ + 117 + ], + "ʢ": [ + 118 + ], + "ʦ": [ + 155 + ], + "ʰ": [ + 145 + ], + "ʲ": [ + 119 + ], + "ˈ": [ + 120 + ], + "ˌ": [ + 121 + ], + "ː": [ + 122 + ], + "ˑ": [ + 123 + ], + "˞": [ + 124 + ], + "ˤ": [ + 146 + ], + "̃": [ + 141 + ], + "̊": [ + 158 + ], + "̝": [ + 157 + ], + "̧": [ + 140 + ], + "̩": [ + 144 + ], + "̪": [ + 142 + ], + "̯": [ + 143 + ], + "̺": [ + 152 + ], + "̻": [ + 153 + ], + "β": [ + 125 + ], + "ε": [ + 147 + ], + "θ": [ + 126 + ], + "χ": [ + 127 + ], + "ᵻ": [ + 128 + ], + "↑": [ + 151 + ], + "↓": [ + 148 + ], + "ⱱ": [ + 129 + ] + }, + "num_symbols": 256, + "num_speakers": 236, + "speaker_id_map": { + "2422": 0, + "4536": 1, + "2037": 2, + "9565": 3, + "10148": 4, + "6507": 5, + "5055": 6, + "3503": 7, + "252": 8, + "9132": 9, + "3990": 10, + "5753": 11, + "5424": 12, + "2602": 13, + "4174": 14, + "3885": 15, + "12415": 16, + "8470": 17, + "11927": 18, + "9639": 19, + "3494": 20, + "2946": 21, + "5283": 22, + "4533": 23, + "2497": 24, + "12275": 25, + "1649": 26, + "146": 27, + "8337": 28, + "4542": 29, + "589": 30, + "1998": 31, + "3797": 32, + "5244": 33, + "7328": 34, + "7998": 35, + "10179": 36, + "9610": 37, + "20": 38, + "253": 39, + "12899": 40, + "7194": 41, + "3759": 42, + "2677": 43, + "6719": 44, + "1897": 45, + "11990": 46, + "6880": 47, + "19": 48, + "9515": 49, + "327": 50, + "3244": 51, + "5324": 52, + "2234": 53, + "3124": 54, + "2043": 55, + "143": 56, + "8139": 57, + "9646": 58, + "8659": 59, + "9538": 60, + "989": 61, + "5405": 62, + "10087": 63, + "8294": 64, + "4396": 65, + "1474": 66, + "139": 67, + "136": 68, + "10791": 69, + "7242": 70, + "3631": 71, + "9908": 72, + "7906": 73, + "1171": 74, + "7479": 75, + "5632": 76, + "3731": 77, + "4650": 78, + "135": 79, + "145": 80, + "137": 81, + "1757": 82, + "91": 83, + "9514": 84, + "13494": 85, + "1946": 86, + "3277": 87, + "5595": 88, + "278": 89, + "7120": 90, + "7406": 91, + "11695": 92, + "1593": 93, + "3862": 94, + "138": 95, + "141": 96, + "9948": 97, + "1163": 98, + "1054": 99, + "1844": 100, + "4911": 101, + "7261": 102, + "8223": 103, + "7624": 104, + "144": 105, + "13871": 106, + "2974": 107, + "5934": 108, + "7002": 109, + "8769": 110, + "3363": 111, + "3040": 112, + "6067": 113, + "9494": 114, + "8743": 115, + "13255": 116, + "1660": 117, + "3588": 118, + "4748": 119, + "8450": 120, + "5295": 121, + "4705": 122, + "8125": 123, + "7272": 124, + "7320": 125, + "1874": 126, + "1262": 127, + "10870": 128, + "12379": 129, + "4463": 130, + "10349": 131, + "2252": 132, + "8325": 133, + "3052": 134, + "4001": 135, + "7456": 136, + "140": 137, + "4739": 138, + "11299": 139, + "4730": 140, + "6659": 141, + "2034": 142, + "2732": 143, + "2158": 144, + "3698": 145, + "5675": 146, + "6315": 147, + "10904": 148, + "7202": 149, + "11480": 150, + "10625": 151, + "11546": 152, + "4576": 153, + "4512": 154, + "8634": 155, + "13626": 156, + "1613": 157, + "287": 158, + "9207": 159, + "6982": 160, + "1724": 161, + "10191": 162, + "1091": 163, + "2909": 164, + "1965": 165, + "2506": 166, + "4414": 167, + "5764": 168, + "12776": 169, + "1033": 170, + "13726": 171, + "2314": 172, + "6826": 173, + "9706": 174, + "8427": 175, + "9168": 176, + "9287": 177, + "6905": 178, + "4153": 179, + "3330": 180, + "2859": 181, + "5406": 182, + "2840": 183, + "1920": 184, + "9241": 185, + "10163": 186, + "8305": 187, + "12461": 188, + "3276": 189, + "11413": 190, + "10536": 191, + "10614": 192, + "7579": 193, + "8675": 194, + "7483": 195, + "7270": 196, + "8704": 197, + "4468": 198, + "6611": 199, + "11497": 200, + "11772": 201, + "2792": 202, + "11481": 203, + "10162": 204, + "10819": 205, + "8732": 206, + "11328": 207, + "11920": 208, + "6646": 209, + "7486": 210, + "11870": 211, + "12417": 212, + "10364": 213, + "6117": 214, + "6448": 215, + "10433": 216, + "7515": 217, + "5823": 218, + "8567": 219, + "10947": 220, + "11869": 221, + "12335": 222, + "12500": 223, + "13755": 224, + "7006": 225, + "3685": 226, + "5819": 227, + "9353": 228, + "11355": 229, + "12174": 230, + "7150": 231, + "6952": 232, + "11987": 233, + "3995": 234, + "7449": 235 + }, + "piper_version": "1.0.0" +} diff --git a/models/fr_FR-mls-medium.pt.json b/models/fr_FR-mls-medium.pt.json new file mode 100755 index 0000000..ab1bb41 --- /dev/null +++ b/models/fr_FR-mls-medium.pt.json @@ -0,0 +1,629 @@ +{ + "dataset": "mls", + "audio": { + "sample_rate": 22050, + "quality": "medium" + }, + "espeak": { + "voice": "fr" + }, + "language": { + "code": "fr_FR" + }, + "inference": { + "noise_scale": 0.333, + "length_scale": 1, + "noise_w": 0.333 + }, + "phoneme_type": "espeak", + "phoneme_map": {}, + "phoneme_id_map": { + " ": [ + 3 + ], + "!": [ + 4 + ], + "\"": [ + 150 + ], + "#": [ + 149 + ], + "$": [ + 2 + ], + "'": [ + 5 + ], + "(": [ + 6 + ], + ")": [ + 7 + ], + ",": [ + 8 + ], + "-": [ + 9 + ], + ".": [ + 10 + ], + "0": [ + 130 + ], + "1": [ + 131 + ], + "2": [ + 132 + ], + "3": [ + 133 + ], + "4": [ + 134 + ], + "5": [ + 135 + ], + "6": [ + 136 + ], + "7": [ + 137 + ], + "8": [ + 138 + ], + "9": [ + 139 + ], + ":": [ + 11 + ], + ";": [ + 12 + ], + "?": [ + 13 + ], + "X": [ + 156 + ], + "^": [ + 1 + ], + "_": [ + 0 + ], + "a": [ + 14 + ], + "b": [ + 15 + ], + "c": [ + 16 + ], + "d": [ + 17 + ], + "e": [ + 18 + ], + "f": [ + 19 + ], + "g": [ + 154 + ], + "h": [ + 20 + ], + "i": [ + 21 + ], + "j": [ + 22 + ], + "k": [ + 23 + ], + "l": [ + 24 + ], + "m": [ + 25 + ], + "n": [ + 26 + ], + "o": [ + 27 + ], + "p": [ + 28 + ], + "q": [ + 29 + ], + "r": [ + 30 + ], + "s": [ + 31 + ], + "t": [ + 32 + ], + "u": [ + 33 + ], + "v": [ + 34 + ], + "w": [ + 35 + ], + "x": [ + 36 + ], + "y": [ + 37 + ], + "z": [ + 38 + ], + "æ": [ + 39 + ], + "ç": [ + 40 + ], + "ð": [ + 41 + ], + "ø": [ + 42 + ], + "ħ": [ + 43 + ], + "ŋ": [ + 44 + ], + "œ": [ + 45 + ], + "ǀ": [ + 46 + ], + "ǁ": [ + 47 + ], + "ǂ": [ + 48 + ], + "ǃ": [ + 49 + ], + "ɐ": [ + 50 + ], + "ɑ": [ + 51 + ], + "ɒ": [ + 52 + ], + "ɓ": [ + 53 + ], + "ɔ": [ + 54 + ], + "ɕ": [ + 55 + ], + "ɖ": [ + 56 + ], + "ɗ": [ + 57 + ], + "ɘ": [ + 58 + ], + "ə": [ + 59 + ], + "ɚ": [ + 60 + ], + "ɛ": [ + 61 + ], + "ɜ": [ + 62 + ], + "ɞ": [ + 63 + ], + "ɟ": [ + 64 + ], + "ɠ": [ + 65 + ], + "ɡ": [ + 66 + ], + "ɢ": [ + 67 + ], + "ɣ": [ + 68 + ], + "ɤ": [ + 69 + ], + "ɥ": [ + 70 + ], + "ɦ": [ + 71 + ], + "ɧ": [ + 72 + ], + "ɨ": [ + 73 + ], + "ɪ": [ + 74 + ], + "ɫ": [ + 75 + ], + "ɬ": [ + 76 + ], + "ɭ": [ + 77 + ], + "ɮ": [ + 78 + ], + "ɯ": [ + 79 + ], + "ɰ": [ + 80 + ], + "ɱ": [ + 81 + ], + "ɲ": [ + 82 + ], + "ɳ": [ + 83 + ], + "ɴ": [ + 84 + ], + "ɵ": [ + 85 + ], + "ɶ": [ + 86 + ], + "ɸ": [ + 87 + ], + "ɹ": [ + 88 + ], + "ɺ": [ + 89 + ], + "ɻ": [ + 90 + ], + "ɽ": [ + 91 + ], + "ɾ": [ + 92 + ], + "ʀ": [ + 93 + ], + "ʁ": [ + 94 + ], + "ʂ": [ + 95 + ], + "ʃ": [ + 96 + ], + "ʄ": [ + 97 + ], + "ʈ": [ + 98 + ], + "ʉ": [ + 99 + ], + "ʊ": [ + 100 + ], + "ʋ": [ + 101 + ], + "ʌ": [ + 102 + ], + "ʍ": [ + 103 + ], + "ʎ": [ + 104 + ], + "ʏ": [ + 105 + ], + "ʐ": [ + 106 + ], + "ʑ": [ + 107 + ], + "ʒ": [ + 108 + ], + "ʔ": [ + 109 + ], + "ʕ": [ + 110 + ], + "ʘ": [ + 111 + ], + "ʙ": [ + 112 + ], + "ʛ": [ + 113 + ], + "ʜ": [ + 114 + ], + "ʝ": [ + 115 + ], + "ʟ": [ + 116 + ], + "ʡ": [ + 117 + ], + "ʢ": [ + 118 + ], + "ʦ": [ + 155 + ], + "ʰ": [ + 145 + ], + "ʲ": [ + 119 + ], + "ˈ": [ + 120 + ], + "ˌ": [ + 121 + ], + "ː": [ + 122 + ], + "ˑ": [ + 123 + ], + "˞": [ + 124 + ], + "ˤ": [ + 146 + ], + "̃": [ + 141 + ], + "̊": [ + 158 + ], + "̝": [ + 157 + ], + "̧": [ + 140 + ], + "̩": [ + 144 + ], + "̪": [ + 142 + ], + "̯": [ + 143 + ], + "̺": [ + 152 + ], + "̻": [ + 153 + ], + "β": [ + 125 + ], + "ε": [ + 147 + ], + "θ": [ + 126 + ], + "χ": [ + 127 + ], + "ᵻ": [ + 128 + ], + "↑": [ + 151 + ], + "↓": [ + 148 + ], + "ⱱ": [ + 129 + ] + }, + "num_symbols": 256, + "num_speakers": 125, + "speaker_id_map": { + "1840": 0, + "3698": 1, + "123": 2, + "1474": 3, + "12709": 4, + "7423": 5, + "9242": 6, + "8778": 7, + "3060": 8, + "4512": 9, + "6249": 10, + "12541": 11, + "13634": 12, + "10065": 13, + "6128": 14, + "5232": 15, + "5764": 16, + "12713": 17, + "12823": 18, + "6070": 19, + "12501": 20, + "9121": 21, + "1649": 22, + "2776": 23, + "11772": 24, + "5612": 25, + "11822": 26, + "1590": 27, + "5525": 28, + "10827": 29, + "1243": 30, + "13142": 31, + "62": 32, + "13177": 33, + "10620": 34, + "8102": 35, + "8582": 36, + "11875": 37, + "7239": 38, + "9854": 39, + "7377": 40, + "10082": 41, + "12512": 42, + "1329": 43, + "2506": 44, + "6856": 45, + "10058": 46, + "103": 47, + "14": 48, + "6381": 49, + "1664": 50, + "11954": 51, + "66": 52, + "1127": 53, + "3270": 54, + "13611": 55, + "13658": 56, + "12968": 57, + "1989": 58, + "12981": 59, + "7193": 60, + "6348": 61, + "7679": 62, + "2284": 63, + "3182": 64, + "3503": 65, + "2033": 66, + "2771": 67, + "7614": 68, + "125": 69, + "3204": 70, + "5595": 71, + "5553": 72, + "694": 73, + "1624": 74, + "1887": 75, + "2926": 76, + "7150": 77, + "3190": 78, + "3344": 79, + "4699": 80, + "1798": 81, + "1745": 82, + "5077": 83, + "753": 84, + "52": 85, + "4174": 86, + "4018": 87, + "12899": 88, + "1844": 89, + "4396": 90, + "1817": 91, + "2155": 92, + "2946": 93, + "4336": 94, + "4609": 95, + "1977": 96, + "10957": 97, + "204": 98, + "4650": 99, + "5295": 100, + "5968": 101, + "4744": 102, + "2825": 103, + "9804": 104, + "707": 105, + "30": 106, + "115": 107, + "5840": 108, + "2587": 109, + "2607": 110, + "2544": 111, + "28": 112, + "27": 113, + "177": 114, + "112": 115, + "94": 116, + "2596": 117, + "3595": 118, + "7032": 119, + "7848": 120, + "11247": 121, + "7439": 122, + "2904": 123, + "6362": 124 + }, + "piper_version": "1.0.0" +} diff --git a/models/nl_NL-mls-medium.pt.json b/models/nl_NL-mls-medium.pt.json new file mode 100755 index 0000000..5673d00 --- /dev/null +++ b/models/nl_NL-mls-medium.pt.json @@ -0,0 +1,554 @@ +{ + "audio": { + "sample_rate": 22050 + }, + "espeak": { + "voice": "nl" + }, + "language": { + "code": "nl_NL" + }, + "inference": { + "noise_scale": 0.333, + "length_scale": 1, + "noise_w": 0.333 + }, + "phoneme_type": "espeak", + "phoneme_map": {}, + "phoneme_id_map": { + " ": [ + 3 + ], + "!": [ + 4 + ], + "\"": [ + 150 + ], + "#": [ + 149 + ], + "$": [ + 2 + ], + "'": [ + 5 + ], + "(": [ + 6 + ], + ")": [ + 7 + ], + ",": [ + 8 + ], + "-": [ + 9 + ], + ".": [ + 10 + ], + "0": [ + 130 + ], + "1": [ + 131 + ], + "2": [ + 132 + ], + "3": [ + 133 + ], + "4": [ + 134 + ], + "5": [ + 135 + ], + "6": [ + 136 + ], + "7": [ + 137 + ], + "8": [ + 138 + ], + "9": [ + 139 + ], + ":": [ + 11 + ], + ";": [ + 12 + ], + "?": [ + 13 + ], + "X": [ + 156 + ], + "^": [ + 1 + ], + "_": [ + 0 + ], + "a": [ + 14 + ], + "b": [ + 15 + ], + "c": [ + 16 + ], + "d": [ + 17 + ], + "e": [ + 18 + ], + "f": [ + 19 + ], + "g": [ + 154 + ], + "h": [ + 20 + ], + "i": [ + 21 + ], + "j": [ + 22 + ], + "k": [ + 23 + ], + "l": [ + 24 + ], + "m": [ + 25 + ], + "n": [ + 26 + ], + "o": [ + 27 + ], + "p": [ + 28 + ], + "q": [ + 29 + ], + "r": [ + 30 + ], + "s": [ + 31 + ], + "t": [ + 32 + ], + "u": [ + 33 + ], + "v": [ + 34 + ], + "w": [ + 35 + ], + "x": [ + 36 + ], + "y": [ + 37 + ], + "z": [ + 38 + ], + "æ": [ + 39 + ], + "ç": [ + 40 + ], + "ð": [ + 41 + ], + "ø": [ + 42 + ], + "ħ": [ + 43 + ], + "ŋ": [ + 44 + ], + "œ": [ + 45 + ], + "ǀ": [ + 46 + ], + "ǁ": [ + 47 + ], + "ǂ": [ + 48 + ], + "ǃ": [ + 49 + ], + "ɐ": [ + 50 + ], + "ɑ": [ + 51 + ], + "ɒ": [ + 52 + ], + "ɓ": [ + 53 + ], + "ɔ": [ + 54 + ], + "ɕ": [ + 55 + ], + "ɖ": [ + 56 + ], + "ɗ": [ + 57 + ], + "ɘ": [ + 58 + ], + "ə": [ + 59 + ], + "ɚ": [ + 60 + ], + "ɛ": [ + 61 + ], + "ɜ": [ + 62 + ], + "ɞ": [ + 63 + ], + "ɟ": [ + 64 + ], + "ɠ": [ + 65 + ], + "ɡ": [ + 66 + ], + "ɢ": [ + 67 + ], + "ɣ": [ + 68 + ], + "ɤ": [ + 69 + ], + "ɥ": [ + 70 + ], + "ɦ": [ + 71 + ], + "ɧ": [ + 72 + ], + "ɨ": [ + 73 + ], + "ɪ": [ + 74 + ], + "ɫ": [ + 75 + ], + "ɬ": [ + 76 + ], + "ɭ": [ + 77 + ], + "ɮ": [ + 78 + ], + "ɯ": [ + 79 + ], + "ɰ": [ + 80 + ], + "ɱ": [ + 81 + ], + "ɲ": [ + 82 + ], + "ɳ": [ + 83 + ], + "ɴ": [ + 84 + ], + "ɵ": [ + 85 + ], + "ɶ": [ + 86 + ], + "ɸ": [ + 87 + ], + "ɹ": [ + 88 + ], + "ɺ": [ + 89 + ], + "ɻ": [ + 90 + ], + "ɽ": [ + 91 + ], + "ɾ": [ + 92 + ], + "ʀ": [ + 93 + ], + "ʁ": [ + 94 + ], + "ʂ": [ + 95 + ], + "ʃ": [ + 96 + ], + "ʄ": [ + 97 + ], + "ʈ": [ + 98 + ], + "ʉ": [ + 99 + ], + "ʊ": [ + 100 + ], + "ʋ": [ + 101 + ], + "ʌ": [ + 102 + ], + "ʍ": [ + 103 + ], + "ʎ": [ + 104 + ], + "ʏ": [ + 105 + ], + "ʐ": [ + 106 + ], + "ʑ": [ + 107 + ], + "ʒ": [ + 108 + ], + "ʔ": [ + 109 + ], + "ʕ": [ + 110 + ], + "ʘ": [ + 111 + ], + "ʙ": [ + 112 + ], + "ʛ": [ + 113 + ], + "ʜ": [ + 114 + ], + "ʝ": [ + 115 + ], + "ʟ": [ + 116 + ], + "ʡ": [ + 117 + ], + "ʢ": [ + 118 + ], + "ʦ": [ + 155 + ], + "ʰ": [ + 145 + ], + "ʲ": [ + 119 + ], + "ˈ": [ + 120 + ], + "ˌ": [ + 121 + ], + "ː": [ + 122 + ], + "ˑ": [ + 123 + ], + "˞": [ + 124 + ], + "ˤ": [ + 146 + ], + "̃": [ + 141 + ], + "̊": [ + 158 + ], + "̝": [ + 157 + ], + "̧": [ + 140 + ], + "̩": [ + 144 + ], + "̪": [ + 142 + ], + "̯": [ + 143 + ], + "̺": [ + 152 + ], + "̻": [ + 153 + ], + "β": [ + 125 + ], + "ε": [ + 147 + ], + "θ": [ + 126 + ], + "χ": [ + 127 + ], + "ᵻ": [ + 128 + ], + "↑": [ + 151 + ], + "↓": [ + 148 + ], + "ⱱ": [ + 129 + ] + }, + "num_symbols": 256, + "num_speakers": 52, + "speaker_id_map": { + "2450": 0, + "1724": 1, + "1666": 2, + "5809": 3, + "496": 4, + "2506": 5, + "7432": 6, + "3619": 7, + "4429": 8, + "3798": 9, + "12500": 10, + "10587": 11, + "2951": 12, + "1775": 13, + "9861": 14, + "880": 15, + "3034": 16, + "2825": 17, + "5438": 18, + "3245": 19, + "4396": 20, + "11290": 21, + "11936": 22, + "6916": 23, + "10294": 24, + "10079": 25, + "7588": 26, + "7579": 27, + "123": 28, + "3024": 29, + "960": 30, + "10984": 31, + "2792": 32, + "7723": 33, + "4174": 34, + "2981": 35, + "5764": 36, + "6513": 37, + "7884": 38, + "6697": 39, + "12749": 40, + "11157": 41, + "2239": 42, + "10879": 43, + "1085": 44, + "8480": 45, + "8331": 46, + "6282": 47, + "10632": 48, + "2602": 49, + "5367": 50, + "11472": 51 + }, + "piper_version": "1.0.0" +} diff --git a/piper_sample_generator/__init__.py b/piper_sample_generator/__init__.py new file mode 100644 index 0000000..0142775 --- /dev/null +++ b/piper_sample_generator/__init__.py @@ -0,0 +1 @@ +"""Piper sample generator.""" diff --git a/piper_sample_generator/__main__.py b/piper_sample_generator/__main__.py new file mode 100755 index 0000000..3074fb5 --- /dev/null +++ b/piper_sample_generator/__main__.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 +import argparse +import gc +import itertools as it +import json +import logging +import os +import unicodedata +import wave +from collections.abc import Iterable +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Union, cast + +import numpy as np +import torch +from piper import PiperVoice, SynthesisConfig +from piper.phonemize_espeak import EspeakPhonemizer + +try: + from piper_train.vits import commons +except ImportError: + from piper_train.vits import commons + +_LOGGER = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + + +# Main generation function +def generate_samples( + text: Union[List[str], str], + output_dir: Union[str, Path], + model: Union[str, Path], + max_samples: Optional[int] = None, + file_names: Optional[Iterable[str]] = None, + batch_size: int = 1, + slerp_weights: Tuple[float, ...] = (0.5,), + length_scales: Tuple[float, ...] = (0.75, 1, 1.25), + noise_scales: Tuple[float, ...] = (0.667,), + noise_scale_ws: Tuple[float, ...] = (0.8,), + max_speakers: Optional[int] = None, + verbose: bool = False, + phoneme_input: bool = False, + **kwargs, +) -> None: + """ + Generate synthetic speech clips, saving the clips to the specified output directory. + + Args: + text (List[str]): The text to convert into speech. Can be either a + a list of strings, or a path to a file with text on each line. + output_dir (str): The location to save the generated clips. + model (str): The path to the TTS generator model (.pt). + max_samples (int): The maximum number of samples to generate. + file_names (List[str]): The names to use when saving the files. Must be the same length + as the `text` argument, if a list. + batch_size (int): The batch size to use when generated the clips + slerp_weights (List[float]): The weights to use when mixing speakers via SLERP. + length_scales (List[float]): Controls the average duration/speed of the generated speech. + noise_scales (List[float]): A parameter for overall variability of the generated speech. + noise_scale_ws (List[float]): A parameter for the stochastic duration of words/phonemes. + max_speakers (int): The maximum speaker number to use, if the model is multi-speaker. + verbose (bool): Enable or disable more detailed logging messages (default: False). + phoneme_input (bool): Set to indicate given input text is phoneme input. + Returns: + None + """ + + if max_samples is None: + max_samples = len(text) + + _LOGGER.debug("Loading %s", model) + model_path = Path(model) + + torch_model = torch.load(model_path, weights_only=False) + torch_model.eval() + _LOGGER.info("Successfully loaded the model") + + if torch.cuda.is_available(): + torch_model.cuda() + _LOGGER.debug("CUDA available, using GPU") + elif torch.backends.mps.is_available(): + mps_device = torch.device("mps") + torch_model.to(mps_device) + _LOGGER.debug("MPS available, using GPU") + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + config_path = f"{model_path}.json" + with open(config_path, "r", encoding="utf-8") as config_file: + config = json.load(config_file) + + voice = config["espeak"]["voice"] + sample_rate = config["audio"]["sample_rate"] + num_speakers = config["num_speakers"] + if max_speakers is not None: + num_speakers = min(num_speakers, max_speakers) + + max_len = None + + sample_idx = 0 + is_done = False + settings_iter = it.cycle( + it.product( + slerp_weights, + length_scales, + noise_scales, + noise_scale_ws, + ) + ) + + speakers_iter = it.cycle(it.product(range(num_speakers), range(num_speakers))) + speakers_batch = list(it.islice(speakers_iter, 0, batch_size)) + if isinstance(text, str) and os.path.isfile(text): + texts = it.cycle( + [ + i.strip() + for i in open(text, "r", encoding="utf-8").readlines() + if len(i.strip()) > 0 + ] + ) + elif isinstance(text, list): + texts = it.cycle(text) + else: + texts = it.cycle([text]) + + if file_names: + file_names = it.cycle(file_names) + + batch_idx = 0 + while speakers_batch: + if is_done: + break + + batch_size = len(speakers_batch) + slerp_weight, length_scale, noise_scale, noise_scale_w = next(settings_iter) + + with torch.no_grad(): + speaker_1 = torch.LongTensor([s[0] for s in speakers_batch]) + speaker_2 = torch.LongTensor([s[1] for s in speakers_batch]) + + phoneme_ids_by_batch = [] + for i in range(batch_size): + phoneme_ids = get_phonemes( + voice, config, next(texts), verbose, phoneme_input + ) + phoneme_ids_by_batch.append(phoneme_ids) + + def right_pad_lists(lists): + max_length = max(len(lst) for lst in lists) + padded_lists = [] + for lst in lists: + padded_l = lst + [1] * ( + max_length - len(lst) + ) # phoneme 1 (corresponding to '^' character seems to work best) + padded_lists.append(padded_l) + return padded_lists + + phoneme_ids_by_batch = right_pad_lists(phoneme_ids_by_batch) + audio, phoneme_samples = generate_audio( + torch_model, + speaker_1, + speaker_2, + phoneme_ids_by_batch, + slerp_weight, + noise_scale, + noise_scale_w, + length_scale, + max_len, + ) + + # Trim audio to actual length based on phoneme samples + for i in range(audio.shape[0]): + # Fill time after last speech with silence (zeros) + # It will be removed in the next stage with np.trim_zeros + last_sample_idx = int(phoneme_samples[i].flatten().sum().item()) + audio[i, 0, last_sample_idx + 1 :] = 0 + + audio_numpy = audio.cpu().numpy() + + if torch.backends.mps.is_available(): + # There seems to be a memory leak if we don't empty the cache + # after each batch with mps + torch.mps.empty_cache() + gc.collect() + + audio_int16 = audio_float_to_int16(audio_numpy) + for audio_idx in range(audio_int16.shape[0]): + audio_data = np.trim_zeros(audio_int16[audio_idx].flatten()) + + if isinstance(file_names, it.cycle): + wav_path = output_dir / next(file_names) + else: + wav_path = output_dir / f"{sample_idx}.wav" + + wav_file: wave.Wave_write = wave.open(str(wav_path), "wb") + with wav_file: + wav_file.setframerate(sample_rate) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(audio_data) + + sample_idx += 1 + if sample_idx >= max_samples: + is_done = True + break + + # print(f"Batch {batch_idx +1}/{max_samples//batch_size} complete", " "*200, end='\r') + + # Next batch + _LOGGER.debug("Batch %s/%s complete", batch_idx + 1, max_samples // batch_size) + speakers_batch = list(it.islice(speakers_iter, 0, batch_size)) + batch_idx += 1 + + _LOGGER.info("Done") + + +# ----------------------------------------------------------------------------- + + +def generate_samples_onnx( + text: Union[List[str], str], + output_dir: Union[str, Path], + model: Union[str, Path, List[Union[str, Path]]], + max_samples: Optional[int] = None, + file_names: Optional[Iterable[str]] = None, + length_scales: Tuple[float, ...] = (0.75, 1, 1.25), + noise_scales: Tuple[float, ...] = (0.667,), + noise_scale_ws: Tuple[float, ...] = (0.8,), + max_speakers: Optional[int] = None, + phoneme_input: bool = False, + **kwargs, +) -> None: + """ + Generate synthetic speech clips, saving the clips to the specified output directory. + + Args: + text (List[str]): The text to convert into speech. Can be either a + a list of strings, or a path to a file with text on each line. + output_dir (str): The location to save the generated clips. + model (str): The path to the Piper TTS model (.onnx). + max_samples (int): The maximum number of samples to generate. + file_names (List[str]): The names to use when saving the files. Must be the same length + as the `text` argument, if a list. + length_scales (List[float]): Controls the average duration/speed of the generated speech. + noise_scales (List[float]): A parameter for overall variability of the generated speech. + noise_scale_ws (List[float]): A parameter for the stochastic duration of words/phonemes. + max_speakers (int): The maximum speaker number to use, if the model is multi-speaker. + phoneme_input (bool): Set to indicate given input text is phoneme input. + + Returns: + None + """ + + if max_samples is None: + max_samples = len(text) + + if not isinstance(model, list): + model = [model] + + _LOGGER.debug("Loading %s", model) + voices = [PiperVoice.load(m, use_cuda=torch.cuda.is_available()) for m in model] + _LOGGER.info("Successfully loaded model(s)") + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + sample_idx = 0 + settings_iter = it.cycle( + it.product( + voices, + length_scales, + noise_scales, + noise_scale_ws, + ) + ) + + if isinstance(text, str) and os.path.exists(text): + texts = it.cycle( + [ + i.strip() + for i in open(text, "r", encoding="utf-8").readlines() + if len(i.strip()) > 0 + ] + ) + elif isinstance(text, list): + texts = it.cycle(text) + else: + texts = it.cycle([text]) + + if file_names: + file_names = it.cycle(file_names) + + for voice, length_scale, noise_scale, noise_w_scale in settings_iter: + num_speakers = voice.config.num_speakers + if max_speakers is not None: + num_speakers = min(num_speakers, max_speakers) + + for speaker_id in range(num_speakers): + if isinstance(file_names, it.cycle): + wav_path = output_dir / next(file_names) + else: + wav_path = output_dir / f"{sample_idx}.wav" + + text_input = next(texts) + + if phoneme_input: + # For ONNX models with phoneme input, build phoneme IDs manually + phonemes = list(unicodedata.normalize("NFD", text_input)) + + # Build phoneme IDs similar to get_phonemes function + id_map = voice.config.phoneme_id_map + + # Beginning of utterance + phoneme_ids = list(id_map.get("^", [1])) # Default to [1] if not found + phoneme_ids.extend(id_map.get("_", [0])) # Default to [0] if not found + + # Add phonemes + for phoneme in phonemes: + p_ids = id_map.get(phoneme) + if p_ids is not None: + phoneme_ids.extend(p_ids) + phoneme_ids.extend(id_map.get("_", [0])) + else: + _LOGGER.warning( + "Phoneme '%s' not found in model's phoneme map", phoneme + ) + + # End of utterance + phoneme_ids.extend(id_map.get("$", [2])) # Default to [2] if not found + + # Generate audio from phoneme IDs + syn_config = SynthesisConfig( + speaker_id=speaker_id, + length_scale=length_scale, + noise_scale=noise_scale, + noise_w_scale=noise_w_scale, + ) + audio = voice.phoneme_ids_to_audio(phoneme_ids, syn_config) + + # Convert to int16 and write to WAV + audio_int16 = audio_float_to_int16(audio[np.newaxis, :]) + wav_file: wave.Wave_write = wave.open(str(wav_path), "wb") + with wav_file: + wav_file.setframerate(voice.config.sample_rate) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(audio_int16.flatten()) + else: + with wave.open(str(wav_path), "wb") as wav_file: + voice.synthesize_wav( + text_input, + wav_file=wav_file, + syn_config=SynthesisConfig( + speaker_id=speaker_id, + length_scale=length_scale, + noise_scale=noise_scale, + noise_w_scale=noise_w_scale, + ), + ) + + sample_idx += 1 + if sample_idx >= max_samples: + return + + _LOGGER.info("Done") + + +# ----------------------------------------------------------------------------- + + +def generate_audio( + model, + speaker_1, + speaker_2, + phoneme_ids, + slerp_weight, + noise_scale, + noise_scale_w, + length_scale, + max_len, +) -> Tuple[torch.FloatTensor, torch.FloatTensor]: + x = torch.LongTensor(phoneme_ids) + x_lengths = torch.LongTensor([len(i) for i in phoneme_ids]) + + if torch.cuda.is_available(): + speaker_1 = speaker_1.cuda() + speaker_2 = speaker_2.cuda() + x = cast(torch.LongTensor, x.cuda()) + x_lengths = cast(torch.LongTensor, x_lengths.cuda()) + elif torch.backends.mps.is_available(): + mps_device = torch.device("mps") + speaker_1 = speaker_1.to(mps_device) + speaker_2 = speaker_2.to(mps_device) + x = cast(torch.LongTensor, x.to(mps_device)) + x_lengths = cast(torch.LongTensor, x_lengths.to(mps_device)) + + x, m_p_orig, logs_p_orig, x_mask = model.enc_p(x, x_lengths) + emb0 = model.emb_g(speaker_1) + emb1 = model.emb_g(speaker_2) + g = slerp(emb0, emb1, slerp_weight).unsqueeze(-1) # [b, h, 1] + + if model.use_sdp: + logw = model.dp(x, x_mask, g=g, reverse=True, noise_scale=noise_scale_w) + else: + logw = model.dp(x, x_mask, g=g) + w = torch.exp(logw) * x_mask * length_scale + w_ceil = torch.ceil(w) + y_lengths = torch.clamp_min(torch.sum(w_ceil, [1, 2]), 1).long() + y_mask = torch.unsqueeze( + commons.sequence_mask(y_lengths, int(y_lengths.max().item())), 1 + ).type_as(x_mask) + attn_mask = torch.unsqueeze(x_mask, 2) * torch.unsqueeze(y_mask, -1) + attn = commons.generate_path(w_ceil, attn_mask) + + m_p = torch.matmul(attn.squeeze(1), m_p_orig.transpose(1, 2)).transpose( + 1, 2 + ) # [b, t', t], [b, t, d] -> [b, d, t'] + logs_p = torch.matmul(attn.squeeze(1), logs_p_orig.transpose(1, 2)).transpose( + 1, 2 + ) # [b, t', t], [b, t, d] -> [b, d, t'] + + z_p = m_p + torch.randn_like(m_p) * torch.exp(logs_p) * noise_scale + z = model.flow(z_p, y_mask, g=g, reverse=True) + o = model.dec((z * y_mask)[:, :, :max_len], g=g) + + audio = cast(torch.FloatTensor, o) + phoneme_samples = cast(torch.FloatTensor, w_ceil * 256) # hop length + + return audio, phoneme_samples + + +_PHONEMIZER = EspeakPhonemizer() + + +def get_phonemes( + voice: str, + config: Dict[str, Any], + text: str, + verbose: bool = False, + phoneme_input: bool = False, +) -> List[int]: + # Combine all sentences + if phoneme_input: + phonemes = list(unicodedata.normalize("NFD", text)) + else: + phonemes = [ + p + for sentence_phonemes in _PHONEMIZER.phonemize(voice, text) + for p in sentence_phonemes + ] + if verbose is True: + _LOGGER.debug("Phonemes: %s", phonemes) + + id_map = config["phoneme_id_map"] + + # Beginning of utterance + phoneme_ids = list(id_map["^"]) + phoneme_ids.extend(id_map["_"]) + + # Phoneme ids for just the text + text_phoneme_ids = [] + + for phoneme in phonemes: + p_ids = id_map.get(phoneme) + if p_ids is not None: + phoneme_ids.extend(p_ids) + text_phoneme_ids.extend(p_ids) + phoneme_ids.extend(id_map["_"]) + text_phoneme_ids.extend(id_map["_"]) + + # End of utterance + phoneme_ids.extend(id_map["$"]) + + return phoneme_ids + + +def slerp(v1, v2, t: float, DOT_THR: float = 0.9995, zdim: int = -1): + """SLERP for pytorch tensors interpolating `v1` to `v2` with scale of `t`. + + `DOT_THR` determines when the vectors are too close to parallel. + If they are too close, then a regular linear interpolation is used. + + `zdim` is the feature dimension over which to compute norms and find angles. + For example: if a sequence of 5 vectors is input with shape [5, 768] + Then `zdim = 1` or `zdim = -1` computes SLERP along the feature dim of 768. + + Theory Reference: + https://splines.readthedocs.io/en/latest/rotation/slerp.html + PyTorch reference: + https://discuss.pytorch.org/t/help-regarding-slerp-function-for-generative-model-sampling/32475/3 + Numpy reference: + https://gist.github.com/dvschultz/3af50c40df002da3b751efab1daddf2c + """ + + # take the dot product between normalized vectors + v1_norm = v1 / torch.norm(v1, dim=zdim, keepdim=True) + v2_norm = v2 / torch.norm(v2, dim=zdim, keepdim=True) + dot = (v1_norm * v2_norm).sum(zdim) + + # if the vectors are too close, return a simple linear interpolation + if (torch.abs(dot) > DOT_THR).any(): + res = (1 - t) * v1 + t * v2 + + # else apply SLERP + else: + # compute the angle terms we need + theta = torch.acos(dot) + theta_t = theta * t + sin_theta = torch.sin(theta) + sin_theta_t = torch.sin(theta_t) + + # compute the sine scaling terms for the vectors + s1 = torch.sin(theta - theta_t) / sin_theta + s2 = sin_theta_t / sin_theta + + # interpolate the vectors + res = (s1.unsqueeze(zdim) * v1) + (s2.unsqueeze(zdim) * v2) + + return res + + +def audio_float_to_int16( + audio: np.ndarray, max_wav_value: float = 32767.0 +) -> np.ndarray: + """Normalize audio and convert to int16 range""" + audio_norm = audio * (max_wav_value / max(0.01, np.max(np.abs(audio)))) + audio_norm = np.clip(audio_norm, -max_wav_value, max_wav_value) + audio_norm = audio_norm.astype("int16") + return audio_norm + + +# ----------------------------------------------------------------------------- + + +def main() -> int: + """Main entry point.""" + + # Get command line arguments + parser = argparse.ArgumentParser() + parser.add_argument("text") + parser.add_argument( + "--max-samples", + required=True, + type=int, + help="Maximum number of samples to generate", + ) + parser.add_argument( + "--model", + required=True, + action="append", + help="Path to PyTorch generator (.pt) or Piper voice model (.onnx)", + ) + parser.add_argument( + "--batch-size", type=int, default=1, help="CUDA batch size (generator only)" + ) + parser.add_argument( + "--slerp-weights", + nargs="+", + type=float, + default=[0.5], + help="Speaker blending weights (generator only)", + ) + parser.add_argument( + "--length-scales", + nargs="+", + type=float, + default=[1.0, 0.75, 1.25, 1.4], + help="Audio length scales (< 1 is faster, > 1 is slower)", + ) + parser.add_argument( + "--noise-scales", + nargs="+", + type=float, + default=[0.667, 0.75, 0.85, 0.9, 1.0, 1.4], + help="Noise amounts added to audio (most voices use 0.667)", + ) + parser.add_argument( + "--noise-scale-ws", + nargs="+", + type=float, + default=[0.8], + help="Phoneme width variation (most voices use 0.8)", + ) + parser.add_argument( + "--output-dir", + default="output", + help="Directory to output WAV files (default: ./output)", + ) + parser.add_argument( + "--max-speakers", + type=int, + help="Maximum number of speakers to use (default: no limit)", + ) + parser.add_argument( + "--phoneme-input", action="store_true", help="Treat input text as phoneme input" + ) + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args().__dict__ + + # Generate speech + model_paths = [Path(m) for m in args["model"]] + assert model_paths + + if any(mp for mp in model_paths[1:] if mp.suffix != model_paths[0].suffix): + _LOGGER.error("All models must have the same suffix (.pt or .onnx)") + return 1 + + if model_paths[0].suffix == ".onnx": + # Use Piper voice (.onnx) + generate_samples_onnx(**args) + elif model_paths[0].suffix == ".pt": + # Use PyTorch generator (.pt) + if len(model_paths) > 1: + _LOGGER.error("Only one generator (.pt) is supported") + return 1 + + args["model"] = args["model"][0] + generate_samples(**args) + else: + _LOGGER.error("Models must have .pt or .onnx suffix") + return 1 + + return 0 + + +if __name__ == "__main__": + main() diff --git a/augment.py b/piper_sample_generator/augment.py similarity index 87% rename from augment.py rename to piper_sample_generator/augment.py index 834d223..47a226d 100644 --- a/augment.py +++ b/piper_sample_generator/augment.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 import argparse import audioop -import sys import wave from pathlib import Path import numpy as np -from audiomentations import Compose, ApplyImpulseResponse, Gain +from audiomentations import ApplyImpulseResponse, Compose, Gain _DIR = Path(__file__).parent @@ -15,14 +14,14 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("input_dir") parser.add_argument("output_dir") - parser.add_argument("--sample-rate", type=int) + parser.add_argument("--sample-rate", type=int, required=True) args = parser.parse_args() impulses = list((_DIR / "impulses").glob("*.wav")) augment = Compose( transforms=[ - Gain(min_gain_in_db=-12, max_gain_in_db=0), + Gain(min_gain_db=-12, max_gain_db=0), ApplyImpulseResponse(impulses), ] ) @@ -35,9 +34,10 @@ def main() -> None: output_wav = output_dir / (input_wav.relative_to(input_dir)) output_wav.parent.mkdir(parents=True, exist_ok=True) - with wave.open(str(input_wav), "rb") as input_wav_file, wave.open( - str(output_wav), "wb" - ) as output_wav_file: + with ( + wave.open(str(input_wav), "rb") as input_wav_file, + wave.open(str(output_wav), "wb") as output_wav_file, + ): assert input_wav_file.getsampwidth() == 2 assert input_wav_file.getnchannels() == 1 diff --git a/impulses/Accoustic2_Impulse.wav b/piper_sample_generator/impulses/Accoustic2_Impulse.wav similarity index 100% rename from impulses/Accoustic2_Impulse.wav rename to piper_sample_generator/impulses/Accoustic2_Impulse.wav diff --git a/impulses/Blatty Plate.wav b/piper_sample_generator/impulses/Blatty Plate.wav similarity index 100% rename from impulses/Blatty Plate.wav rename to piper_sample_generator/impulses/Blatty Plate.wav diff --git a/impulses/Concrete Room.wav b/piper_sample_generator/impulses/Concrete Room.wav similarity index 100% rename from impulses/Concrete Room.wav rename to piper_sample_generator/impulses/Concrete Room.wav diff --git a/impulses/Derlon Sanctuary.wav b/piper_sample_generator/impulses/Derlon Sanctuary.wav similarity index 100% rename from impulses/Derlon Sanctuary.wav rename to piper_sample_generator/impulses/Derlon Sanctuary.wav diff --git a/impulses/Fat Bass.wav b/piper_sample_generator/impulses/Fat Bass.wav similarity index 100% rename from impulses/Fat Bass.wav rename to piper_sample_generator/impulses/Fat Bass.wav diff --git a/impulses/Reverse Gate.wav b/piper_sample_generator/impulses/Reverse Gate.wav similarity index 100% rename from impulses/Reverse Gate.wav rename to piper_sample_generator/impulses/Reverse Gate.wav diff --git a/impulses/Symphonic.wav b/piper_sample_generator/impulses/Symphonic.wav similarity index 100% rename from impulses/Symphonic.wav rename to piper_sample_generator/impulses/Symphonic.wav diff --git a/impulses/ir_bathroom1.wav b/piper_sample_generator/impulses/ir_bathroom1.wav similarity index 100% rename from impulses/ir_bathroom1.wav rename to piper_sample_generator/impulses/ir_bathroom1.wav diff --git a/pylintrc b/pylintrc index 22a70d0..60fdb1d 100644 --- a/pylintrc +++ b/pylintrc @@ -1,3 +1,6 @@ +[MASTER] +ignored-modules=torch + [MESSAGES CONTROL] disable= format, @@ -31,7 +34,7 @@ disable= missing-class-docstring, missing-function-docstring, import-error, - consider-using-with + relative-beyond-top-level [FORMAT] expected-line-ending-format=LF diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..76cf7d2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=62.3"] +build-backend = "setuptools.build_meta" + +[project] +name = "piper-sample-generator" +version = "3.2.0" +license = {text = "MIT"} +description = "Generate TTS audio samples for training wake word systems" +readme = "README.md" +authors = [ + {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} +] +keywords = ["piper", "sample", "tts", "wakeword"] +requires-python = ">=3.9.0" +dependencies = [ + "audiomentations==0.33.0", + "piper-tts==1.3.0", + "numpy>=2,<3", + "torch>=2,<3", + "torchaudio", + "webrtcvad", +] + +[project.optional-dependencies] +dev = [ + "black==22.12.0", + "flake8==6.0.0", + "isort==5.11.3", + "mypy==0.991", + "pylint==2.15.9", +] + +[project.urls] +"Source Code" = "http://github.com/rhasspy/piper-sample-generator" + +[tool.setuptools] +platforms = ["any"] +zip-safe = true + +[tool.setuptools.packages.find] +include = ["piper_sample_generator*"] + +[tool.setuptools.package-data] +piper_sample_generator = ["impulses/*.wav"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c6ac75a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -audiomentations==0.33.0 -piper-phonemize==1.1.0 -numpy<2 -torch -torchaudio -webrtcvad diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 77190e6..0000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,5 +0,0 @@ -black==22.12.0 -flake8==6.0.0 -isort==5.11.3 -mypy==0.991 -pylint==2.15.9 diff --git a/script/format b/script/format index 19fa1f7..b8b283b 100755 --- a/script/format +++ b/script/format @@ -6,8 +6,13 @@ from pathlib import Path _DIR = Path(__file__).parent _PROGRAM_DIR = _DIR.parent _VENV_DIR = _PROGRAM_DIR / ".venv" -_SCRIPT = _PROGRAM_DIR / "generate_samples.py" +_MODULE_DIR = _PROGRAM_DIR / "piper_sample_generator" -context = venv.EnvBuilder().ensure_directories(_VENV_DIR) -subprocess.check_call([context.env_exe, "-m", "black", str(_SCRIPT)]) -subprocess.check_call([context.env_exe, "-m", "isort", str(_SCRIPT)]) +if _VENV_DIR.exists(): + context = venv.EnvBuilder().ensure_directories(_VENV_DIR) + python_exe = context.env_exe +else: + python_exe = "python3" + +subprocess.check_call([python_exe, "-m", "black", str(_MODULE_DIR)]) +subprocess.check_call([python_exe, "-m", "isort", str(_MODULE_DIR)]) diff --git a/script/lint b/script/lint index d56932d..34222f0 100755 --- a/script/lint +++ b/script/lint @@ -6,11 +6,16 @@ from pathlib import Path _DIR = Path(__file__).parent _PROGRAM_DIR = _DIR.parent _VENV_DIR = _PROGRAM_DIR / ".venv" -_SCRIPT = _PROGRAM_DIR / "generate_samples.py" +_MODULE_DIR = _PROGRAM_DIR / "piper_sample_generator" -context = venv.EnvBuilder().ensure_directories(_VENV_DIR) -subprocess.check_call([context.env_exe, "-m", "black", str(_SCRIPT), "--check"]) -subprocess.check_call([context.env_exe, "-m", "isort", str(_SCRIPT), "--check"]) -subprocess.check_call([context.env_exe, "-m", "flake8", str(_SCRIPT)]) -subprocess.check_call([context.env_exe, "-m", "pylint", str(_SCRIPT)]) -subprocess.check_call([context.env_exe, "-m", "mypy", str(_SCRIPT)]) +if _VENV_DIR.exists(): + context = venv.EnvBuilder().ensure_directories(_VENV_DIR) + python_exe = context.env_exe +else: + python_exe = "python3" + +subprocess.check_call([python_exe, "-m", "black", str(_MODULE_DIR), "--check"]) +subprocess.check_call([python_exe, "-m", "isort", str(_MODULE_DIR), "--check"]) +subprocess.check_call([python_exe, "-m", "flake8", str(_MODULE_DIR)]) +subprocess.check_call([python_exe, "-m", "pylint", str(_MODULE_DIR)]) +subprocess.check_call([python_exe, "-m", "mypy", str(_MODULE_DIR)]) diff --git a/script/run b/script/run index 5fa23ff..ae921c9 100755 --- a/script/run +++ b/script/run @@ -8,5 +8,10 @@ _DIR = Path(__file__).parent _PROGRAM_DIR = _DIR.parent _VENV_DIR = _PROGRAM_DIR / ".venv" -context = venv.EnvBuilder().ensure_directories(_VENV_DIR) -subprocess.check_call([context.env_exe, "generate_samples.py"] + sys.argv[1:]) +if _VENV_DIR.exists(): + context = venv.EnvBuilder().ensure_directories(_VENV_DIR) + python_exe = context.env_exe +else: + python_exe = "python3" + +subprocess.check_call([python_exe, "-m", "piper_sample_generator"] + sys.argv[1:]) diff --git a/script/setup b/script/setup index 92ec185..0ff2e95 100755 --- a/script/setup +++ b/script/setup @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import argparse import subprocess import venv from pathlib import Path @@ -7,6 +8,9 @@ _DIR = Path(__file__).parent _PROGRAM_DIR = _DIR.parent _VENV_DIR = _PROGRAM_DIR / ".venv" +parser = argparse.ArgumentParser() +parser.add_argument("--dev", action="store_true", help="Install dev requirements") +args = parser.parse_args() # Create virtual environment builder = venv.EnvBuilder(with_pip=True) @@ -19,4 +23,10 @@ subprocess.check_call(pip + ["install", "--upgrade", "pip"]) subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"]) # Install requirements -subprocess.check_call(pip + ["install", "-r", str(_PROGRAM_DIR / "requirements.txt")]) +subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)]) + +if args.dev: + # Install dev requirements + subprocess.check_call( + pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"] + )