import argparse
import hashlib
import os
from pathlib import Path
import numpy as np
import soundfile as sf
import soxr
from piper import PiperVoice, SynthesisConfig
from piper.download_voices import download_voice
HERE = os.path.dirname(os.path.abspath(__file__))
SAMPLE_RATE = 16_000
PAD = SAMPLE_RATE // 10 PEAK = 10 ** (-3 / 20) TRIM_THRESHOLD = 0.01
CLIPS = {
"lights_en": ("en_US-kristin-medium", "turn off the kitchen lights", 2.0),
"lights_de": ("de_DE-thorsten_emotional-medium", "mach das Licht in der Küche aus", 1.9),
"hall_on_en": ("en_US-joe-medium", "turn the hall light on", 2.1),
"names_en": ("en_US-norman-medium", "please call Siobhan and Krzysztof", 2.0),
}
def synthesise(voices, voice_id, text, length_scale):
model = voices / f"{voice_id}.onnx"
if not model.exists():
download_voice(voice_id, voices)
voice = PiperVoice.load(str(model))
config = SynthesisConfig(
noise_scale=0.0,
noise_w_scale=0.0,
length_scale=length_scale,
normalize_audio=False,
)
chunks = [chunk.audio_float_array for chunk in voice.synthesize(text, config)]
return np.concatenate(chunks).astype(np.float32), voice.config.sample_rate
def process(samples, rate):
if rate != SAMPLE_RATE:
samples = soxr.resample(samples, rate, SAMPLE_RATE, quality="HQ")
loud = np.nonzero(np.abs(samples) >= TRIM_THRESHOLD * np.abs(samples).max())[0]
samples = samples[loud[0] : loud[-1] + 1]
pad = np.zeros(PAD, np.float32)
samples = np.concatenate([pad, samples, pad])
samples = samples / np.abs(samples).max() * PEAK
return samples.astype(np.float32)
def write(path, samples):
sf.write(path, samples, SAMPLE_RATE, subtype="PCM_16")
with open(path, "rb") as file:
digest = hashlib.sha256(file.read()).hexdigest()
print(f"{os.path.basename(path)} {len(samples) / SAMPLE_RATE:.2f} s {digest}")
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument(
"--voices",
default=os.path.join(os.path.expanduser("~"), ".cache", "piper-voices"),
help="where Piper voices are kept (downloaded on first use)",
)
parser.add_argument("--out", default=HERE, help="where the clips are written")
parser.add_argument("--only", nargs="*", help="clip names to regenerate, e.g. names_en")
args = parser.parse_args()
voices = Path(args.voices)
voices.mkdir(parents=True, exist_ok=True)
os.makedirs(args.out, exist_ok=True)
for name, (voice_id, text, length_scale) in CLIPS.items():
if args.only and name not in args.only:
continue
samples, rate = synthesise(voices, voice_id, text, length_scale)
write(os.path.join(args.out, f"{name}.wav"), process(samples, rate))
if not args.only or "silence" in args.only:
write(os.path.join(args.out, "silence.wav"), np.zeros(SAMPLE_RATE, np.float32))
if __name__ == "__main__":
main()