diff --git a/.vscode/settings.json b/.vscode/settings.json index c108b75..89a552b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,17 @@ { "cSpell.words": [ + "cublas", "CUDA", + "cudnn", + "cufft", + "curand", "manylinux", + "nvrtc", "onnxruntime", "phonemize", "setuptools", + "slackr", + "venv", "zxvf" ] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ea74106..81ccbe1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,59 +1,71 @@ -FROM nvidia/cuda:11.7.1-base-ubuntu22.04 +FROM nvidia/cuda:12.2.2-base-ubuntu22.04 -# Install Piper WORKDIR /usr/src ARG TARGETARCH=amd64 ARG TARGETVARIANT= -ARG PIPER_LIB_VERSION=1.4.0 -ARG WYOMING_PIPER_VERSION='1.5.0' +ARG WYOMING_PIPER_VERSION='1.4.0' ARG PIPER_RELEASE='1.2.0' RUN \ - apt-get update \ - && apt-get install -y --no-install-recommends \ + apt-get update &&\ + apt-get install -y --no-install-recommends \ wget \ curl \ + patch \ python3 \ + python3-dev \ + python3-venv \ python3-pip RUN \ - pip3 install --no-cache-dir -U \ - setuptools \ - wheel \ - \ - && pip3 install --no-cache-dir torch \ - \ - && wget https://github.com/rhasspy/piper-phonemize/releases/download/v1.1.0/piper_phonemize-1.1.0-cp310-cp310-manylinux_2_28_x86_64.whl \ - \ - && 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 \ + mkdir -p /app /data &&\ + python3 -m venv /app &&\ + . /app/bin/activate &&\ + pip3 install --no-cache-dir --upgrade pip setuptools wheel &&\ + pip3 install --no-cache-dir \ + torch + +RUN \ + . /app/bin/activate && \ + pip3 install --no-cache-dir --force-reinstall --no-deps\ "piper-tts==${PIPER_RELEASE}" \ + piper_phonemize==1.1.0 \ + &&\ \ - && pip3 install --no-cache-dir --force-reinstall --no-deps \ - piper_phonemize-1.1.0-py3-none-any.whl \ - \ - && pip3 install --no-cache-dir \ + 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 + pip3 install --no-cache-dir\ + "wyoming-piper==${WYOMING_PIPER_VERSION}"\ + &&\ + \ + wget \ + "https://github.com/rhasspy/piper/releases/download/v${PIPER_RELEASE}/piper_${TARGETARCH}${TARGETVARIANT}.tar.gz" -O -|tar -zxvf - -C /usr/share + + # pip3 install --no-cache-dir --force-reinstall --no-deps\ + # piper_phonemize-1.1.0-py3-none-any.whl &&\ + # \ + # "wyoming-piper @ https://github.com/rhasspy/wyoming-piper/archive/refs/tags/v${WYOMING_PIPER_VERSION}.tar.gz" + # wget https://github.com/rhasspy/piper-phonemize/releases/download/v1.1.0/piper_phonemize-1.1.0-cp310-cp310-manylinux_2_28_x86_64.whl &&\ + # mv piper_phonemize-1.1.0-cp310-cp310-manylinux_2_28_x86_64.whl piper_phonemize-1.1.0-py3-none-any.whl &&\ + # rm -r piper_phonemize-1.1.0-py3-none-any.whl &&\ + +# Patch to enable CUDA arguments for piper +COPY patch/wyoming-piper_cuda.patch /tmp/ +RUN \ + cd /app/lib/python3.10/site-packages/wyoming_piper/ &&\ + patch -p0 < /tmp/wyoming-piper_cuda.patch # Clean up RUN \ - rm -rf /var/lib/apt/lists/* \ - && rm /*.deb \ - && mkdir -p /share/piper + rm -rf /var/lib/apt/lists/* /*.deb /tmp/* -# Patch to enable CUDA arguments for 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 ./ -RUN chmod +x /run.sh +WORKDIR /app +COPY run.sh /app/ +RUN chmod +x /app/run.sh EXPOSE 10200 -ENTRYPOINT ["bash", "/run.sh \"${@}\""] \ No newline at end of file +ENTRYPOINT ["bash", "/app/run.sh"] \ No newline at end of file diff --git a/patch/__main__.py b/patch/__main__.py deleted file mode 100644 index dcfad76..0000000 --- a/patch/__main__.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/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 deleted file mode 100644 index 8c136be..0000000 --- a/patch/process.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import asyncio -import json -import logging -import tempfile -import time -from dataclasses import dataclass -from typing import Any, Dict, Optional - -from .download import ensure_voice_exists, find_voice - -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class PiperProcess: - """Info for a running Piper process (one voice).""" - - name: str - proc: "asyncio.subprocess.Process" - config: Dict[str, Any] - wav_dir: tempfile.TemporaryDirectory - last_used: int = 0 - - def get_speaker_id(self, speaker: str) -> Optional[int]: - """Get speaker by name or id.""" - return _get_speaker_id(self.config, speaker) - - @property - def is_multispeaker(self) -> bool: - """True if model has more than one speaker.""" - return _is_multispeaker(self.config) - - -def _get_speaker_id(config: Dict[str, Any], speaker: str) -> Optional[int]: - """Get speaker by name or id.""" - speaker_id_map = config.get("speaker_id_map", {}) - speaker_id = speaker_id_map.get(speaker) - if speaker_id is None: - try: - # Try to interpret as an id - speaker_id = int(speaker) - except ValueError: - pass - - return speaker_id - - -def _is_multispeaker(config: Dict[str, Any]) -> bool: - """True if model has more than one speaker.""" - return config.get("num_speakers", 1) > 1 - - -# ----------------------------------------------------------------------------- - - -class PiperProcessManager: - """Manager of running Piper processes.""" - - def __init__(self, args: argparse.Namespace, voices_info: Dict[str, Any]): - self.voices_info = voices_info - self.args = args - self.processes: Dict[str, PiperProcess] = {} - self.processes_lock = asyncio.Lock() - - async def get_process(self, voice_name: Optional[str] = None) -> PiperProcess: - """Get a running Piper process or start a new one if necessary.""" - voice_speaker: Optional[str] = None - if voice_name is None: - # Default voice - voice_name = self.args.voice - - if voice_name == self.args.voice: - # Default speaker - voice_speaker = self.args.speaker - - assert voice_name is not None - - # Resolve alias - voice_info = self.voices_info.get(voice_name, {}) - voice_name = voice_info.get("key", voice_name) - assert voice_name is not None - - piper_proc = self.processes.get(voice_name) - if (piper_proc is None) or (piper_proc.proc.returncode is not None): - # Remove if stopped - self.processes.pop(voice_name, None) - - # Start new Piper process - if self.args.max_piper_procs > 0: - # Restrict number of running processes - while len(self.processes) >= self.args.max_piper_procs: - # Stop least recently used process - lru_proc_name, lru_proc = sorted( - self.processes.items(), key=lambda kv: kv[1].last_used - )[0] - _LOGGER.debug("Stopping process for: %s", lru_proc_name) - self.processes.pop(lru_proc_name, None) - if lru_proc.proc.returncode is None: - try: - lru_proc.proc.terminate() - await lru_proc.proc.wait() - except Exception: - _LOGGER.exception("Unexpected error stopping piper process") - - _LOGGER.debug( - "Starting process for: %s (%s/%s)", - voice_name, - len(self.processes) + 1, - self.args.max_piper_procs, - ) - - ensure_voice_exists( - voice_name, - self.args.data_dir, - self.args.download_dir, - self.voices_info, - ) - - onnx_path, config_path = find_voice(voice_name, self.args.data_dir) - with open(config_path, "r", encoding="utf-8") as config_file: - config = json.load(config_file) - - wav_dir = tempfile.TemporaryDirectory() - piper_args = [ - "--model", - str(onnx_path), - "--config", - str(config_path), - "--output_dir", - str(wav_dir.name), - "--json-input", # piper 1.1+ - ] - - if voice_speaker is not None: - if _is_multispeaker(config): - speaker_id = _get_speaker_id(config, voice_speaker) - if speaker_id is not None: - piper_args.extend(["--speaker", str(speaker_id)]) - - if self.args.noise_scale: - piper_args.extend(["--noise-scale", str(self.args.noise_scale)]) - - if self.args.length_scale: - piper_args.extend(["--length-scale", str(self.args.length_scale)]) - - 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 - ) - piper_proc = PiperProcess( - name=voice_name, - proc=await asyncio.create_subprocess_exec( - self.args.piper, - *piper_args, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ), - config=config, - wav_dir=wav_dir, - ) - self.processes[voice_name] = piper_proc - - # Update used - piper_proc.last_used = time.monotonic_ns() - - return piper_proc diff --git a/patch/wyoming-piper_cuda_path.diff b/patch/wyoming-piper_cuda.patch similarity index 60% rename from patch/wyoming-piper_cuda_path.diff rename to patch/wyoming-piper_cuda.patch index d11b30c..36e58fd 100644 --- a/patch/wyoming-piper_cuda_path.diff +++ b/patch/wyoming-piper_cuda.patch @@ -1,11 +1,9 @@ -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", +--- __main__.py 2025-01-04 17:48:27.645862671 +0000 ++++ __main__.py.patched 2025-01-04 17:51:50.193357609 +0000 +@@ -66,6 +66,15 @@ + help="Download latest voices.json during startup", ) + # + parser.add_argument( + "--cuda", + action="store_true", @@ -15,14 +13,13 @@ index 08b20f1..f88312b 100755 + "--espeak-data-dir", + help="Path to espeak-ng data directory" + ) + parser.add_argument("--debug", action="store_true", help="Log DEBUG messages") 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: + +--- process.py 2025-01-04 17:48:27.653862810 +0000 ++++ process.py.patched 2025-01-04 17:52:48.494362993 +0000 +@@ -148,6 +148,14 @@ if self.args.noise_w: piper_args.extend(["--noise-w", str(self.args.noise_w)]) @@ -36,4 +33,4 @@ index a7e90a7..98b0712 100644 + _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 e9592fc..9d80414 100644 --- a/run.sh +++ b/run.sh @@ -1,10 +1,14 @@ #!/usr/bin/env bash export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib/python3.10/dist-packages/nvidia/cudnn/lib/:/usr/local/lib/python3.10/dist-packages/nvidia/cublas/lib:/usr/local/lib/python3.10/dist-packages/nvidia/curand/lib/:/usr/local/lib/python3.10/dist-packages/nvidia/cufft/lib/:/usr/local/lib/python3.10/dist-packages/nvidia/cuda_runtime/lib/:/usr/local/lib/python3.10/dist-packages/nvidia/cuda_nvrtc/lib/ -python3 -m wyoming_piper \ + +PIPER_CPU="/usr/share/piper/piper" +PIPER_GPU="/usr/local/bin/piper" + +source /app/bin/activate +/app/bin/python3 -m wyoming_piper \ --piper '/usr/local/bin/piper' \ --cuda \ --uri 'tcp://0.0.0.0:10200' \ --data-dir /data \ - --data-dir /share/piper \ - --download-dir /data "$@" + --download-dir /data --debug "$@"