Fixes for Docker container (#11)

* Update Dockerfile

* Update run.sh

* Update Dockerfile

* Update __main__.py

* Update process.py

* Update Dockerfile

* Update Dockerfile

* Update settings.json

* Delete wyoming-piper_cuda_path.diff

* Create wyoming-piper_cuda.patch

* Delete __main__.py

* Delete process.py

* Update wyoming-piper_cuda.patch

* Update run.sh

* Update run.sh

* Update Dockerfile

* Update settings.json

* Update run.sh

* Update Dockerfile

* Update Dockerfile

* Update Dockerfile

* Update wyoming-piper_cuda.patch

* Update Dockerfile

---------

Co-authored-by: Robert Dunmire III <rdunmire@i-evolve.com>
This commit is contained in:
Robert Dunmire III
2025-01-04 13:37:56 -05:00
committed by GitHub
co-authored by Robert Dunmire III
parent de7e69b7e2
commit 4a422e7369
6 changed files with 71 additions and 455 deletions
+7
View File
@@ -1,10 +1,17 @@
{
"cSpell.words": [
"cublas",
"CUDA",
"cudnn",
"cufft",
"curand",
"manylinux",
"nvrtc",
"onnxruntime",
"phonemize",
"setuptools",
"slackr",
"venv",
"zxvf"
]
}
+46 -34
View File
@@ -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 \"${@}\""]
ENTRYPOINT ["bash", "/app/run.sh"]
-230
View File
@@ -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
-174
View File
@@ -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
@@ -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
)
)
+7 -3
View File
@@ -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 "$@"