Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable GPU support #5

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
name: test

# yamllint disable-line rule:truthy
on:
push:
branches: [master]

pull_request:

permissions:
contents: read

jobs:
test_linux:
name: "test on linux"
runs-on: ubuntu-latest
strategy:
matrix:
python_version: ["3.8", "3.9", "3.10", "3.11"]
steps:
- uses: actions/[email protected]
- uses: actions/setup-python@v5
with:
python-version: "${{ matrix.python_version }}"
cache: "pip"
cache-dependency-path: requirements_dev.txt
- run: script/setup --dev
- run: |
test $(script/run --version) = $(cat wyoming_piper/VERSION)
- run: script/lint
- run: script/test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ htmlcov
/.venv/
.mypy_cache/
__pycache__/
/local/
.tox/

/dist/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 1.5.0

- Send speakers in `info` message
- Update voices.json with new voices
- Add tests to CI

## 1.4.0

- Fix use of UTF-8 characters in URLs
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@

[Source](https://github.com/home-assistant/addons/tree/master/piper)

## Local Install

Clone the repository and set up Python virtual environment:

``` sh
git clone https://github.com/rhasspy/wyoming-piper.git
cd wyoming-piper
script/setup
```

Install Piper
```sh
curl -L -s "https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz" | tar -zxvf - -C /usr/share
```

Run a server that anyone can connect to:

``` sh
script/run --piper '/usr/share/piper/piper' --voice en_US-lessac-medium --uri 'tcp://0.0.0.0:10200' --data-dir /data --download-dir /data
```

## Docker Image

``` sh
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
wyoming==1.1.0
wyoming==1.5.3
6 changes: 6 additions & 0 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ flake8==6.0.0
isort==5.11.3
mypy==0.991
pylint==2.15.9
pytest==7.4.4
pytest-asyncio==0.23.3
tox>=4,<5
scipy>=1.10,<2
numpy>=1.20,<2
python-speech-features==0.6
7 changes: 5 additions & 2 deletions script/format
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_piper"
_TESTS_DIR = _PROGRAM_DIR / "tests"

_FORMAT_DIRS = [_MODULE_DIR, _TESTS_DIR]

context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "black", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "black"] + _FORMAT_DIRS)
subprocess.check_call([context.env_exe, "-m", "isort"] + _FORMAT_DIRS)
13 changes: 8 additions & 5 deletions script/lint
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_piper"
_TESTS_DIR = _PROGRAM_DIR / "tests"

_LINT_DIRS = [_MODULE_DIR, _TESTS_DIR]

context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "black", str(_MODULE_DIR), "--check"])
subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR), "--check"])
subprocess.check_call([context.env_exe, "-m", "flake8", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "pylint", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "mypy", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "black"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "isort"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "flake8"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "pylint"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "mypy"] + _LINT_DIRS)
10 changes: 10 additions & 0 deletions script/setup
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
import argparse
import subprocess
import venv
from pathlib import Path
Expand All @@ -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)
Expand All @@ -20,3 +24,9 @@ subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])

# Install requirements
subprocess.check_call(pip + ["install", "-r", str(_PROGRAM_DIR / "requirements.txt")])

if args.dev:
# Install dev requirements
subprocess.check_call(
pip + ["install", "-r", str(_PROGRAM_DIR / "requirements_dev.txt")]
)
13 changes: 13 additions & 0 deletions script/test
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import subprocess
import sys
import venv
from pathlib import Path

_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_TEST_DIR = _PROGRAM_DIR / "tests"

context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "pytest", _TEST_DIR] + sys.argv[1:])
21 changes: 14 additions & 7 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,32 @@
from setuptools import setup

this_dir = Path(__file__).parent
module_dir = this_dir / "wyoming_piper"

requirements = []
requirements_path = this_dir / "requirements.txt"
if requirements_path.is_file():
with open(requirements_path, "r", encoding="utf-8") as requirements_file:
requirements = requirements_file.read().splitlines()

data_files = [module_dir / "voices.json"]
module_name = "wyoming_piper"
module_dir = this_dir / module_name
version_path = module_dir / "VERSION"
version = version_path.read_text(encoding="utf-8").strip()

data_files = [module_dir / "voices.json", version_path]

# -----------------------------------------------------------------------------

setup(
name="wyoming_piper",
version="1.4.0",
name=module_name,
version=version,
description="Wyoming Server for Piper",
url="https://github.com/rhasspy/wyoming-piper",
author="Michael Hansen",
author_email="[email protected]",
license="MIT",
packages=setuptools.find_packages(),
package_data={
"wyoming_piper": [str(p.relative_to(module_dir)) for p in data_files]
},
package_data={module_name: [str(p.relative_to(module_dir)) for p in data_files]},
install_requires=requirements,
classifiers=[
"Development Status :: 3 - Alpha",
Expand All @@ -42,4 +44,9 @@
"Programming Language :: Python :: 3.11",
],
keywords="rhasspy wyoming piper tts",
entry_points={
'console_scripts': [
'wyoming-piper = wyoming_piper:__main__.run'
]
},
)
Empty file added tests/__init__.py
Empty file.
43 changes: 43 additions & 0 deletions tests/dtw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import math

import numpy as np
import scipy


def compute_optimal_path(x: np.ndarray, y: np.ndarray) -> float:
"""Computes optimal path between x and y."""
m = len(x)
n = len(y)

# Need 2-D arrays for distance calculation
if len(x.shape) == 1:
x = x.reshape(-1, 1)

if len(y.shape) == 1:
y = y.reshape(-1, 1)

distance_matrix = scipy.spatial.distance.cdist(x, y, metric="cosine")

cost_matrix = np.full(shape=(m, n), fill_value=math.inf, dtype=float)
cost_matrix[0][0] = distance_matrix[0][0]

for row in range(1, m):
cost = distance_matrix[row, 0]
cost_matrix[row][0] = cost + cost_matrix[row - 1][0]

for col in range(1, n):
cost = distance_matrix[0, col]
cost_matrix[0][col] = cost + cost_matrix[0][col - 1]

for row in range(1, m):
for col in range(1, n):
cost = distance_matrix[row, col]
cost_matrix[row][col] = cost + min(
cost_matrix[row - 1][col], # insertion
cost_matrix[row][col - 1], # deletion
cost_matrix[row - 1][col - 1], # match
)

distance = cost_matrix[m - 1][n - 1]

return distance
123 changes: 123 additions & 0 deletions tests/test_piper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Tests for wyoming-piper"""
import asyncio
import sys
import tarfile
import wave
from asyncio.subprocess import PIPE
from pathlib import Path
from urllib.request import urlopen

import numpy as np
import pytest
import python_speech_features
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.event import async_read_event, async_write_event
from wyoming.info import Describe, Info
from wyoming.tts import Synthesize, SynthesizeVoice

from .dtw import compute_optimal_path

_DIR = Path(__file__).parent
_LOCAL_DIR = _DIR.parent / "local"
_PIPER_URL = (
"https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz"
)
_TIMEOUT = 60


def download_piper() -> None:
"""Downloads a binary version of Piper."""
piper_path = _LOCAL_DIR / "piper"
if piper_path.exists():
return

_LOCAL_DIR.mkdir(parents=True, exist_ok=True)
with urlopen(_PIPER_URL) as response:
with tarfile.open(fileobj=response, mode="r|*") as piper_file:
piper_file.extractall(_LOCAL_DIR)


@pytest.mark.asyncio
async def test_piper() -> None:
download_piper()

proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"wyoming_piper",
"--uri",
"stdio://",
"--piper",
str(_LOCAL_DIR / "piper" / "piper"),
"--voice",
"en_US-ryan-low",
"--data-dir",
str(_LOCAL_DIR),
stdin=PIPE,
stdout=PIPE,
)
assert proc.stdin is not None
assert proc.stdout is not None

# Check info
await async_write_event(Describe().event(), proc.stdin)
while True:
event = await asyncio.wait_for(async_read_event(proc.stdout), timeout=_TIMEOUT)
assert event is not None

if not Info.is_type(event.type):
continue

info = Info.from_event(event)
assert len(info.tts) == 1, "Expected one tts service"
tts = info.tts[0]
assert len(tts.voices) > 0, "Expected at least one voice"
voice_model = next((v for v in tts.voices if v.name == "en_US-ryan-low"), None)
assert voice_model is not None, "Expected ryan voice"
break

# Synthesize text
await async_write_event(
Synthesize("This is a test.", voice=SynthesizeVoice("en_US-ryan-low")).event(),
proc.stdin,
)

event = await asyncio.wait_for(async_read_event(proc.stdout), timeout=_TIMEOUT)
assert event is not None
assert AudioStart.is_type(event.type)
audio_start = AudioStart.from_event(event)

with wave.open(str(_DIR / "this_is_a_test.wav"), "rb") as wav_file:
assert audio_start.rate == wav_file.getframerate()
assert audio_start.width == wav_file.getsampwidth()
assert audio_start.channels == wav_file.getnchannels()
expected_audio = wav_file.readframes(wav_file.getnframes())
expected_array = np.frombuffer(expected_audio, dtype=np.int16)

actual_audio = bytes()
while True:
event = await asyncio.wait_for(async_read_event(proc.stdout), timeout=_TIMEOUT)
assert event is not None
if AudioStop.is_type(event.type):
break

if AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event)
assert chunk.rate == audio_start.rate
assert chunk.width == audio_start.width
assert chunk.channels == audio_start.channels
actual_audio += chunk.audio

actual_array = np.frombuffer(actual_audio, dtype=np.int16)

# Less than 20% difference in length
assert (
abs(len(actual_array) - len(expected_array))
/ max(len(actual_array), len(expected_array))
< 0.2
)

# Compute dynamic time warping (DTW) distance of MFCC features
expected_mfcc = python_speech_features.mfcc(expected_array, winstep=0.02)
actual_mfcc = python_speech_features.mfcc(actual_array, winstep=0.02)
assert compute_optimal_path(actual_mfcc, expected_mfcc) < 10
Binary file added tests/this_is_a_test.wav
Binary file not shown.
Loading