From b6f73bebfa965879e10d5748673d537097c5d6e7 Mon Sep 17 00:00:00 2001 From: Robert Dunmire III Date: Sun, 24 Nov 2024 20:44:28 -0500 Subject: [PATCH] Updates to enable CUDA in piper --- .vscode/settings.json | 8 + Dockerfile | 30 +++- README.md | 2 +- patch/__main__.py | 230 +++++++++++++++++++++++++++++ patch/process.py | 5 +- patch/wyoming-piper_cuda_path.diff | 39 +++++ run.sh | 1 + 7 files changed, 304 insertions(+), 11 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 patch/__main__.py create mode 100644 patch/wyoming-piper_cuda_path.diff diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..46ce693 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "cSpell.words": [ + "CUDA", + "onnxruntime", + "setuptools", + "zxvf" + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a68033b..f1e57f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,41 +6,55 @@ WORKDIR /usr/src ARG TARGETARCH=amd64 ARG TARGETVARIANT= ARG PIPER_LIB_VERSION=1.4.0 -ARG PIPER_RELEASE=v1.2.0 +ARG WYOMING_PIPER_VERSION='1.5.0' +ARG PIPER_RELEASE='1.2.0' RUN \ apt-get update \ && apt-get install -y --no-install-recommends \ + wget \ curl \ python3 \ python3-pip \ \ - && pip3 install --no-cache-dir -U \ + && rm -rf /var/lib/apt/lists/* + +RUN \ + pip3 install --no-cache-dir -U \ setuptools \ wheel \ \ && pip3 install --no-cache-dir torch \ \ - && pip3 install --no-cache-dir \ - "wyoming-piper==${PIPER_LIB_VERSION}" \ + && wget https://github.com/rhasspy/piper-phonemize/releases/download/v1.1.0/piper_phonemize-1.1.0-cp39-cp39-manylinux_2_28_x86_64.whl \ \ - && curl -L -s \ - "https://github.com/rhasspy/piper/releases/download/${PIPER_RELEASE}/piper_${TARGETARCH}${TARGETVARIANT}.tar.gz"|tar -zxvf - -C /usr/share \ + && mv piper_phonemize-1.1.0-cp310-cp310-manylinux_2_28_x86_64.whl piper_phonemize-1.1.0-py3-none-any.whl \ + \ + && pip3 install --no-cache-dir --force-reinstall --no-deps \ + "piper-tts==${PIPER_RELEASE}" \ + \ + && pip3 install --no-cache-dir --force-reinstall --no-deps \ + piper_phonemize-1.1.0-py3-none-any.whl \ \ && pip3 install --no-cache-dir \ onnxruntime-gpu \ + "wyoming-piper @ https://github.com/rhasspy/wyoming-piper/archive/refs/tags/v${WYOMING_PIPER_VERSION}.tar.gz" \ + \ + && rm -r piper_phonemize-1.1.0-py3-none-any.whl \ \ && apt-get purge -y --auto-remove \ build-essential \ python3-dev \ + \ && rm -rf /var/lib/apt/lists/* -# Patch process.py to pass --cuda argument to piper +# Patch to enable CUDA in piper COPY patch/process.py /usr/local/lib/python3.10/dist-packages/wyoming_piper/ +COPY patch/__main__.py /usr/local/lib/python3.10/dist-packages/wyoming_piper/ WORKDIR / COPY run.sh ./ EXPOSE 10200 -ENTRYPOINT ["bash", "/run.sh"] +ENTRYPOINT ["bash", "-c", "exec run.sh \"${@}\"", "--"] \ No newline at end of file diff --git a/README.md b/README.md index c1a7cda..8bfaaad 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # wyoming-piper-gpu -Wyoming Piper docker container with GPU support for Home-Assistant +Wyoming Piper docker container with Nvidia GPU support for Home-Assistant https://github.com/rhasspy/wyoming-piper diff --git a/patch/__main__.py b/patch/__main__.py new file mode 100644 index 0000000..dcfad76 --- /dev/null +++ b/patch/__main__.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import json +import logging +from functools import partial +from pathlib import Path +from typing import Any, Dict, Set + +from wyoming.info import Attribution, Info, TtsProgram, TtsVoice, TtsVoiceSpeaker +from wyoming.server import AsyncServer + +from . import __version__ +from .download import find_voice, get_voices +from .handler import PiperEventHandler +from .process import PiperProcessManager + +_LOGGER = logging.getLogger(__name__) + + +async def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "--piper", + required=True, + help="Path to piper executable", + ) + parser.add_argument( + "--voice", + required=True, + help="Default Piper voice to use (e.g., en_US-lessac-medium)", + ) + parser.add_argument("--uri", default="stdio://", help="unix:// or tcp://") + parser.add_argument( + "--data-dir", + required=True, + action="append", + help="Data directory to check for downloaded models", + ) + parser.add_argument( + "--download-dir", + help="Directory to download voices into (default: first data dir)", + ) + # + parser.add_argument( + "--speaker", type=str, help="Name or id of speaker for default voice" + ) + parser.add_argument("--noise-scale", type=float, help="Generator noise") + parser.add_argument("--length-scale", type=float, help="Phoneme length") + parser.add_argument("--noise-w", type=float, help="Phoneme width noise") + # + parser.add_argument( + "--auto-punctuation", default=".?!", help="Automatically add punctuation" + ) + parser.add_argument("--samples-per-chunk", type=int, default=1024) + parser.add_argument( + "--max-piper-procs", + type=int, + default=1, + help="Maximum number of piper process to run simultaneously (default: 1)", + ) + # + parser.add_argument( + "--update-voices", + action="store_true", + help="Download latest voices.json during startup", + ) + parser.add_argument( + "--cuda", + action="store_true", + help="Use GPU" + ) + # + parser.add_argument("--debug", action="store_true", help="Log DEBUG messages") + parser.add_argument( + "--version", + action="version", + version=__version__, + help="Print version and exit", + ) + args = parser.parse_args() + + if not args.download_dir: + # Default to first data directory + args.download_dir = args.data_dir[0] + + logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) + _LOGGER.debug(args) + + # Load voice info + voices_info = get_voices(args.download_dir, update_voices=args.update_voices) + + # Resolve aliases for backwards compatibility with old voice names + aliases_info: Dict[str, Any] = {} + for voice_info in voices_info.values(): + for voice_alias in voice_info.get("aliases", []): + aliases_info[voice_alias] = {"_is_alias": True, **voice_info} + + voices_info.update(aliases_info) + voices = [ + TtsVoice( + name=voice_name, + description=get_description(voice_info), + attribution=Attribution( + name="rhasspy", url="https://github.com/rhasspy/piper" + ), + installed=True, + version=None, + languages=[ + voice_info.get("language", {}).get( + "code", + voice_info.get("espeak", {}).get("voice", voice_name.split("_")[0]), + ) + ], + speakers=[ + TtsVoiceSpeaker(name=speaker_name) + for speaker_name in voice_info["speaker_id_map"] + ] + if voice_info.get("speaker_id_map") + else None, + ) + for voice_name, voice_info in voices_info.items() + if not voice_info.get("_is_alias", False) + ] + + custom_voice_names: Set[str] = set() + if args.voice not in voices_info: + custom_voice_names.add(args.voice) + + for data_dir in args.data_dir: + data_dir = Path(data_dir) + if not data_dir.is_dir(): + continue + + for onnx_path in data_dir.glob("*.onnx"): + custom_voice_name = onnx_path.stem + if custom_voice_name not in voices_info: + custom_voice_names.add(custom_voice_name) + + for custom_voice_name in custom_voice_names: + # Add custom voice info + custom_voice_path, custom_config_path = find_voice( + custom_voice_name, args.data_dir + ) + with open(custom_config_path, "r", encoding="utf-8") as custom_config_file: + custom_config = json.load(custom_config_file) + custom_name = custom_config.get("dataset", custom_voice_path.stem) + custom_quality = custom_config.get("audio", {}).get("quality") + if custom_quality: + description = f"{custom_name} ({custom_quality})" + else: + description = custom_name + + lang_code = custom_config.get("language", {}).get("code") + if not lang_code: + lang_code = custom_config.get("espeak", {}).get("voice") + if not lang_code: + lang_code = custom_voice_path.stem.split("_")[0] + + voices.append( + TtsVoice( + name=custom_name, + description=description, + version=None, + attribution=Attribution(name="", url=""), + installed=True, + languages=[lang_code], + ) + ) + + wyoming_info = Info( + tts=[ + TtsProgram( + name="piper", + description="A fast, local, neural text to speech engine", + attribution=Attribution( + name="rhasspy", url="https://github.com/rhasspy/piper" + ), + installed=True, + voices=sorted(voices, key=lambda v: v.name), + version=__version__, + ) + ], + ) + + process_manager = PiperProcessManager(args, voices_info) + + # Make sure default voice is loaded. + # Other voices will be loaded on-demand. + await process_manager.get_process() + + # Start server + server = AsyncServer.from_uri(args.uri) + + _LOGGER.info("Ready") + await server.run( + partial( + PiperEventHandler, + wyoming_info, + args, + process_manager, + ) + ) + + +# ----------------------------------------------------------------------------- + + +def get_description(voice_info: Dict[str, Any]): + """Get a human readable description for a voice.""" + name = voice_info["name"] + name = " ".join(name.split("_")) + quality = voice_info["quality"] + + return f"{name} ({quality})" + + +# ----------------------------------------------------------------------------- + + +def run(): + asyncio.run(main()) + + +if __name__ == "__main__": + try: + run() + except KeyboardInterrupt: + pass diff --git a/patch/process.py b/patch/process.py index a73f284..8c136be 100644 --- a/patch/process.py +++ b/patch/process.py @@ -131,7 +131,6 @@ class PiperProcessManager: "--output_dir", str(wav_dir.name), "--json-input", # piper 1.1+ - "--cuda", ] if voice_speaker is not None: @@ -149,6 +148,8 @@ class PiperProcessManager: if self.args.noise_w: piper_args.extend(["--noise-w", str(self.args.noise_w)]) + if self.args.cuda: + piper_args.extend(["--cuda"]) _LOGGER.debug( "Starting piper process: %s args=%s", self.args.piper, piper_args @@ -170,4 +171,4 @@ class PiperProcessManager: # Update used piper_proc.last_used = time.monotonic_ns() - return piper_proc \ No newline at end of file + return piper_proc diff --git a/patch/wyoming-piper_cuda_path.diff b/patch/wyoming-piper_cuda_path.diff new file mode 100644 index 0000000..d11b30c --- /dev/null +++ b/patch/wyoming-piper_cuda_path.diff @@ -0,0 +1,39 @@ +diff --git a/wyoming_piper/__main__.py b/wyoming_piper/__main__.py +index 08b20f1..f88312b 100755 +--- a/wyoming_piper/__main__.py ++++ b/wyoming_piper/__main__.py +@@ -77,6 +77,15 @@ async def main() -> None: + version=__version__, + help="Print version and exit", + ) ++ parser.add_argument( ++ "--cuda", ++ action="store_true", ++ help="Use GPU" ++ ) ++ parser.add_argument( ++ "--espeak-data-dir", ++ help="Path to espeak-ng data directory" ++ ) + args = parser.parse_args() + + if not args.download_dir: +diff --git a/wyoming_piper/process.py b/wyoming_piper/process.py +index a7e90a7..98b0712 100644 +--- a/wyoming_piper/process.py ++++ b/wyoming_piper/process.py +@@ -148,6 +148,14 @@ class PiperProcessManager: + if self.args.noise_w: + piper_args.extend(["--noise-w", str(self.args.noise_w)]) + ++ if self.args.espeak_data_dir: ++ piper_args.extend(["--espeak_data", str(self.args.espeak_data_dir)]) ++ ++ if self.args.cuda: ++ piper_args.extend(["--debug"]) ++ piper_args.extend(["--cuda"]) ++ piper_args.extend(["--use-cuda"]) ++ + _LOGGER.debug( + "Starting piper process: %s args=%s", self.args.piper, piper_args + ) \ No newline at end of file diff --git a/run.sh b/run.sh index 4fc07ea..b8ceec6 100644 --- a/run.sh +++ b/run.sh @@ -3,6 +3,7 @@ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib/python3.10/dist-packages/nvidia/cudnn/lib/ python3 -m wyoming_piper \ --piper '/usr/share/piper/piper' \ + --cuda \ --uri 'tcp://0.0.0.0:10200' \ --data-dir /data \ --data-dir /share/piper \