Skip to content

Commit

Permalink
Merge branch 'master' into fix-espeak-eventloop
Browse files Browse the repository at this point in the history
  • Loading branch information
willwade authored Nov 15, 2024
2 parents 49f9883 + 2c5043c commit a3e77df
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 39 deletions.
42 changes: 36 additions & 6 deletions pyttsx3/drivers/espeak.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

logger = logging.getLogger(__name__)


if platform.system() == "Windows":
import winsound

Expand All @@ -34,7 +35,12 @@ def __init__(self, proxy):
rate = _espeak.Initialize(_espeak.AUDIO_OUTPUT_RETRIEVAL, 1000)
if rate == -1:
raise RuntimeError("could not initialize espeak")
EspeakDriver._defaultVoice = "default"
current_voice = _espeak.GetCurrentVoice()
if current_voice and current_voice.contents.name:
EspeakDriver._defaultVoice = current_voice.contents.name.decode("utf-8")
else:
# Fallback to a known default if no voice is set
EspeakDriver._defaultVoice = "gmw/en" # Adjust this as needed
EspeakDriver._moduleInitialized = True
self._proxy = proxy
self._looping = False
Expand Down Expand Up @@ -77,7 +83,14 @@ def getProperty(name: str):
if name == "voices":
voices = []
for v in _espeak.ListVoices(None):
kwargs = {"id": v.name.decode("utf-8"), "name": v.name.decode("utf-8")}
# Use identifier as the unique ID
voice_id = v.identifier.decode(
"utf-8"
).lower() # Identifier corresponds to the "File" in espeak --voices
kwargs = {
"id": voice_id, # Use "identifier" as the ID
"name": v.name.decode("utf-8"), # Nice name
}
if v.languages:
try:
language_code_bytes = v.languages[1:]
Expand All @@ -87,14 +100,16 @@ def getProperty(name: str):
kwargs["languages"] = [language_code]
except UnicodeDecodeError:
kwargs["languages"] = ["Unknown"]
genders = [None, "male", "female"]
genders = [None, "Male", "Female"]
kwargs["gender"] = genders[v.gender]
kwargs["age"] = v.age or None
voices.append(Voice(**kwargs))
return voices
if name == "voice":
voice = _espeak.GetCurrentVoice()
return voice.contents.name.decode("utf-8") if voice.contents.name else None
if voice and voice.contents.name:
return voice.contents.identifier.decode("utf-8").lower()
return None
if name == "rate":
return _espeak.GetParameter(_espeak.RATE)
if name == "volume":
Expand All @@ -110,9 +125,24 @@ def setProperty(name: str, value):
return
try:
utf8Value = str(value).encode("utf-8")
_espeak.SetVoiceByName(utf8Value)
logging.debug(f"Attempting to set voice to: {value}")
result = _espeak.SetVoiceByName(utf8Value)
if result == 0: # EE_OK is 0
logging.debug(f"Successfully set voice to: {value}")
elif result == 1: # EE_BUFFER_FULL
raise ValueError(
f"SetVoiceByName failed: EE_BUFFER_FULL while setting voice to {value}"
)
elif result == 2: # EE_INTERNAL_ERROR
raise ValueError(
f"SetVoiceByName failed: EE_INTERNAL_ERROR while setting voice to {value}"
)
else:
raise ValueError(
f"SetVoiceByName failed with unknown return code {result} for voice: {value}"
)
except ctypes.ArgumentError as e:
raise ValueError(str(e))
raise ValueError(f"Invalid voice name: {value}, error: {e}")
elif name == "rate":
try:
_espeak.SetParameter(_espeak.RATE, value, 0)
Expand Down
34 changes: 33 additions & 1 deletion pyttsx3/drivers/sapi5.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import os
import time
import weakref
import locale

import pythoncom

Expand All @@ -34,6 +35,15 @@ def buildDriver(proxy):
return SAPI5Driver(proxy)


def lcid_to_locale(language_code):
primary, sub = language_code.split("-")
locale_id = (int(sub) << 10) | int(primary)
try:
return locale.windows_locale[locale_id].replace("_", "-")
except KeyError:
return f"Unknown Locale: {locale_id}"


# noinspection PyPep8Naming,PyShadowingNames
class SAPI5Driver:
def __init__(self, proxy):
Expand Down Expand Up @@ -88,7 +98,29 @@ def save_to_file(self, text, filename):

@staticmethod
def _toVoice(attr):
return Voice(attr.Id, attr.GetDescription())
# Retrieve voice ID and description (name)
voice_id = attr.Id
voice_name = attr.GetDescription()

# Retrieve and convert language code
language_attr = attr.GetAttribute("Language")
language_code = int(language_attr, 16)
primary_sub_code = f"{language_code & 0x3FF}-{(language_code >> 10) & 0x3FF}"
languages = [lcid_to_locale(primary_sub_code)]

# Retrieve gender
gender_attr = attr.GetAttribute("Gender")
gender_title_case = (gender_attr or "").title()
gender = gender_title_case if gender_title_case in {"Male", "Female"} else None

# Retrieve age
age_attr = attr.GetAttribute("Age")
age = age_attr if age_attr in {"Child", "Teen", "Adult", "Senior"} else None

# Create and return the Voice object with additional attributes
return Voice(
id=voice_id, name=voice_name, languages=languages, gender=gender, age=age
)

def _tokenFromId(self, id_):
tokens = self._tts.GetVoices()
Expand Down
70 changes: 42 additions & 28 deletions tests/test_engines.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,41 +41,55 @@ def test_espeak_voices(driver_name):
print(list(pyttsx3._activeEngines))
print(engine)
assert str(engine) == "espeak", "Expected engine name to be espeak"
voice = engine.getProperty("voice")
if voice: # eSpeak-NG Windows v1.52-dev returns None
assert (
voice == "English (Great Britain)"
), f"Expected {engine} default voice to be 'English (Great Britain)'"

# Verify initial voice
default_voice = engine.getProperty("voice")
print(f"Initial default voice ID: {default_voice}")
assert (
default_voice == "gmw/en"
), f"Expected default voice ID to be 'gmw/en', Got: {default_voice}"

# Get and validate the number of voices
voices = engine.getProperty("voices")
print(f"{engine} has {len(voices) = } voices.")
# Linux eSpeak-NG v1.50 has 109 voices,
# macOS eSpeak-NG v1.51 has 131 voices,
# Windows eSpeak-NG v1.52-dev has 221 voices.
assert len(voices) in {109, 131, 221}, f"Expected 109, 131, 221 voices in {engine}"
# print("\n".join(voice.id for voice in voices))
english_voices = [voice for voice in voices if voice.id.startswith("English")]
assert len(voices) in {109, 131, 221}, "Unexpected number of voices"

# Define the expected English voice IDs (excluding Caribbean for now as not in some envs
# Linux eSpeak-NG v1.50 has 7 English voices,
# macOS eSpeak-NG v1.51 and Windows eSpeak-NG v1.52-dev have 8 English voices.
assert len(english_voices) in {7, 8}, "Expected 7 or 8 English voices in {engine}"
names = []
for _voice in english_voices:
engine.setProperty("voice", _voice.id)
# English (America, New York City) --> America, New York City
name = _voice.id[9:-1]
names.append(name)
engine.say(f"{name} says hello")
engine.runAndWait() # TODO: Remove this line when multiple utterances work!
name_str = "|".join(names)
expected = (
"Caribbean|Great Britain|Scotland|Lancaster|West Midlands"
"|Received Pronunciation|America|America, New York City"
)
no_nyc = expected.rpartition("|")[0]
assert name_str in {expected, no_nyc}, f"Expected '{expected}' or '{no_nyc}'."
print(f"({name_str.replace('|', ' ; ')})", end=" ", flush=True)
engine.runAndWait()
engine.setProperty("voice", voice) # Reset voice to original value
engine.stop()
english_voice_ids = [
"gmw/en", # Great Britain
"gmw/en-GB-scotland", # Scotland
"gmw/en-GB-x-gbclan", # Lancaster
"gmw/en-GB-x-gbcwmd", # West Midlands
"gmw/en-GB-x-rp", # Received Pronunciation
"gmw/en-US", # America
"gmw/en-US-nyc", # America, New York City
]

for voice_id in english_voice_ids:
target_voice = next((v for v in voices if v.id == voice_id), None)
if not target_voice:
print(f"Voice with ID '{voice_id}' not found. Skipping.")
continue

print(f"Attempting to set voice to ID: {voice_id} (Name: {target_voice.name})")
engine.setProperty("voice", target_voice.id)

# Verify the change
current_voice = engine.getProperty("voice")
print(f"Current voice ID: {current_voice}")
if current_voice != target_voice.id:
print(
f"Voice change mismatch. Expected: {target_voice.id}, Got: {current_voice}. Skipping."
)
continue

engine.say(f"Hello, this is {target_voice.name}.")
engine.runAndWait()


@pytest.mark.parametrize("driver_name", pyttsx3.engine.engines_by_sys_platform())
Expand Down
6 changes: 2 additions & 4 deletions tests/test_pyttsx3.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import sys
import wave
from pathlib import Path
from unittest import mock

import pytest
Expand Down Expand Up @@ -124,10 +125,7 @@ def test_apple_nsss_voices(engine):
engine.setProperty("voice", voice) # Reset voice to original value


@pytest.mark.xfail(
sys.platform == "darwin", reason="TODO: Fix this test to pass on macOS"
)
def test_saving_to_file(engine, tmp_path):
def test_saving_to_file(engine, tmp_path: Path) -> None:
"""
Apple writes .aiff, not .wav. https://github.com/nateshmbhat/pyttsx3/issues/361
"""
Expand Down

0 comments on commit a3e77df

Please sign in to comment.