Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/build-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ jobs:
uses: OpenVoiceOS/gh-automations/.github/workflows/build-tests.yml@dev
secrets: inherit
with:
python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
python_versions: '["3.11", "3.12", "3.13", "3.14"]'
install_extras: 'test'
test_path: 'tests'
6 changes: 5 additions & 1 deletion .github/workflows/license_check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ jobs:
# NOTE: pilosus matches the resolved requirement, so an anchored
# `^pycotovia$` never matches `pycotovia==0.1.0` / `pycotovia:0.1.0`.
# Allow either separator (the colon gotcha).
exclude_packages: '^pycotovia([=<>!~ @;:]|$)'
# phoonnx itself is excluded: the checker resolves it against the last
# setup.py-era PyPI upload (1.3.3), which carries no license metadata and
# is scored "Error". The check is about third-party dependencies; the
# repo's own license is Apache-2.0 in pyproject.toml.
exclude_packages: '^(pycotovia|phoonnx)([=<>!~ @;:]|$)'
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ synthesis_config = SynthesisConfig(
length_scale=1.0,
noise_w_scale=0.8,
enable_phonetic_spellings=True, # apply pronunciation fixes, see "locale" folder in this repo
add_diacritics=False # for arabic and hebrew
add_diacritics=False, # adds pronunciation marks: Arabic tashkeel, Hebrew niqqud, Russian/Ukrainian/Belarusian word stress
diacritics_model=None # optional model variant for backends that support it (e.g. "silero" or "ruaccent" for ru/uk/be via stressonnx)
)

# Synthesize audio from text
Expand Down Expand Up @@ -207,6 +208,7 @@ Individual languages greatly benefit from domain-specific knowledge, for conveni
- [uvigo/cotovia](https://github.com/TigreGotico/cotovia-mirror) for galician phonemization (pre-compiled binaries bundled)
- [mush42/mantoq](https://github.com/mush42/mantoq) for arabic phonemization
- [mush42/libtashkeel](https://github.com/mush42/libtashkeel) for arabic diacritics
- [stressonnx](https://github.com/TigreGotico/stressonnx) for Russian/Ukrainian/Belarusian word-stress accentuation (optional dependency; `add_diacritics` falls back gracefully when not installed)
- [scarletcho/KoG2P](https://github.com/scarletcho/KoG2P) for korean phonemization
- [stannam/hangul_to_ipa](https://github.com/stannam/hangul_to_ipa) a converter from Hangul to IPA
- [chorusai/arpa2ipa](https://github.com/chorusai/arpa2ipa) a converter from Arpabet to IPA
Expand Down
15 changes: 12 additions & 3 deletions phoonnx/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@

if self.add_diacritics is None:
self.add_diacritics = False
if self.lang_code and self.lang_code.startswith("ar"):
if self.lang_code and (
self.lang_code.startswith("ar")
or self.lang_code.startswith(("ru", "uk", "be"))
):
self.add_diacritics = True

self.lang_code = normalize_lang(self.lang_code or "und")
Expand Down Expand Up @@ -302,7 +305,7 @@

lang_code = lang_code or (config.get("language", {}).get("code") or
config.get("espeak", {}).get("voice"))
diacritics = lang_code.startswith("ar")
diacritics = lang_code.startswith(("ar", "ru", "uk", "be"))
phoneme_type = phoneme_type or config.get("phoneme_type", PhonemeType.ESPEAK)
if phoneme_type == "text":
phoneme_type = PhonemeType.UNICODE
Expand Down Expand Up @@ -541,16 +544,22 @@

enable_phonetic_spellings: bool = True

"""for arabic and hebrew models"""
"""for arabic, hebrew, and Slavic (ru/uk/be) models"""
add_diacritics: bool = True

diacritics_model: Optional[str] = None
"""Optional model variant for diacritization backends that support multiple
models. Currently used by the Russian/Ukrainian/Belarusian stress backend
(``stressonnx``) — e.g. ``"silero"`` or ``"ruaccent"``. Ignored by the
Arabic and Hebrew backends. ``None`` lets ``stressonnx`` use its default."""

# Engine-specific per-call params (d_factor, p_factor, e_factor, …)
extra_params: Dict[str, Any] = field(default_factory=dict)


def get_phonemizer(phoneme_type: PhonemeType,
alphabet: Alphabet = Alphabet.IPA,
model: Optional[str] = None) -> 'Phonemizer':

Check failure on line 562 in phoonnx/config.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (F821)

phoonnx/config.py:562:53: F821 Undefined name `Phonemizer`
"""
Create a phonemizer instance for the specified phonemeization strategy.

Expand Down
48 changes: 47 additions & 1 deletion phoonnx/phonemizers/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import abc
import logging
import re
import string
import unicodedata
Expand All @@ -11,6 +12,49 @@
from phoonnx.thirdparty.phonikud import PhonikudDiacritizer
from phoonnx.thirdparty.tashkeel import TashkeelDiacritizer

LOG = logging.getLogger(__name__)


def _add_slavic_stress(text: str, lang: str, model: Optional[str] = None) -> str:
"""Apply word-stress accentuation to Russian/Ukrainian/Belarusian text.

Delegates to ``stressonnx.stress`` when the package is available. If the
package is not installed, or the language is not supported by the installed
version, a warning is logged and the original text is returned unchanged —
matching the optional-backend pattern used by the Arabic tashkeel backend.

Parameters
----------
text:
Input text (Cyrillic).
lang:
BCP-47 language tag (``ru``, ``uk``, ``be``, or a longer tag starting
with one of those prefixes).
model:
Optional model variant forwarded to ``stressonnx.stress`` when the
function accepts a ``model`` keyword argument (e.g. ``"silero"`` or
``"ruaccent"`` for Russian). Ignored if the backend does not support it.
"""
try:
from stressonnx import stress # type: ignore[import]
import inspect
sig = inspect.signature(stress)
kwargs = {}
if model is not None and "model" in sig.parameters:
kwargs["model"] = model
return stress(text, lang, **kwargs)
except ImportError:
LOG.warning(
"stressonnx is not installed; add_diacritics has no effect for lang=%s. "
"Install stressonnx to enable word-stress accentuation.",
lang,
)
except Exception as exc:
LOG.warning(
"stressonnx failed for lang=%s (%s); returning text unchanged.", lang, exc
)
return text

# list of (substring, terminator, end_of_sentence) tuples.
TextChunks = List[Tuple[str, str, bool]]
# list of (phonemes, terminator, end_of_sentence) tuples.
Expand Down Expand Up @@ -48,11 +92,13 @@ def phonemize_string(self, text: str, lang: str) -> str:
def phonemize_to_list(self, text: str, lang: str) -> List[str]:
return list(self.phonemize_string(text, lang))

def add_diacritics(self, text: str, lang: str) -> str:
def add_diacritics(self, text: str, lang: str, model: Optional[str] = None) -> str:
if lang.startswith("he"):
return self.phonikud.diacritize(text)
elif lang.startswith("ar"):
return self.tashkeel.diacritize(text, self.taskeen_threshold)
elif lang.startswith(("ru", "uk", "be")):
return _add_slavic_stress(text, lang, model)
return text

def phonemize(self, text: str, lang: str) -> PhonemizedChunks:
Expand Down
3 changes: 2 additions & 1 deletion phoonnx/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,8 @@ def synthesize(
text = self.phonetic_spellings.apply(text)

if syn_config.add_diacritics:
text = self.phonemizer.add_diacritics(text, self.config.lang_code)
text = self.phonemizer.add_diacritics(text, self.config.lang_code,
model=syn_config.diacritics_model)
LOG.debug("text+diacritics=%s", text)

# All phonemization goes through the unified self.phonemize method
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ classifiers = [
]
authors = [{name = "JarbasAI", email = "jarbasai@mailfence.com"}]
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.11"
dependencies = [
"numpy",
"onnxruntime",
Expand Down
157 changes: 157 additions & 0 deletions tests/test_diacritics_stress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Tests for add_diacritics routing — Slavic stress (ru/uk/be) and existing ar/he backends."""
import sys
import types
import unittest
from unittest.mock import MagicMock, patch

from phoonnx.phonemizers.base import BasePhonemizer, GraphemePhonemizer


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_stub_stressonnx(*, has_model_kwarg: bool = True):
"""Return a minimal stressonnx stub module."""
mod = types.ModuleType("stressonnx")
if has_model_kwarg:
def stress(text, lang, model=None):
return f"[stressed:{lang}:{model}]{text}"
else:
def stress(text, lang):
return f"[stressed:{lang}]{text}"
mod.stress = stress
return mod


class TestAddDiacriticsRouting(unittest.TestCase):
"""add_diacritics dispatches to the correct per-language backend."""

def setUp(self):
self.phonemizer = GraphemePhonemizer()

# -- Russian / Ukrainian / Belarusian ------------------------------------

def test_ru_routes_to_stressonnx(self):
stub = _make_stub_stressonnx()
with patch.dict(sys.modules, {"stressonnx": stub}):
result = self.phonemizer.add_diacritics("замок", "ru")
self.assertEqual(result, "[stressed:ru:None]замок")

def test_uk_routes_to_stressonnx(self):
stub = _make_stub_stressonnx()
with patch.dict(sys.modules, {"stressonnx": stub}):
result = self.phonemizer.add_diacritics("замок", "uk")
self.assertIn("[stressed:uk:", result)

def test_be_routes_to_stressonnx(self):
stub = _make_stub_stressonnx()
with patch.dict(sys.modules, {"stressonnx": stub}):
result = self.phonemizer.add_diacritics("замак", "be")
self.assertIn("[stressed:be:", result)

def test_ru_region_tag(self):
"""ru-RU (BCP-47 region suffix) still routes to stress backend."""
stub = _make_stub_stressonnx()
with patch.dict(sys.modules, {"stressonnx": stub}):
result = self.phonemizer.add_diacritics("замок", "ru-RU")
self.assertIn("[stressed:ru-RU:", result)

def test_model_kwarg_forwarded(self):
"""When a model name is given it is passed to stressonnx.stress."""
stub = _make_stub_stressonnx(has_model_kwarg=True)
with patch.dict(sys.modules, {"stressonnx": stub}):
result = self.phonemizer.add_diacritics("замок", "ru", model="silero")
self.assertIn("silero", result)

def test_model_kwarg_omitted_when_backend_lacks_it(self):
"""If stressonnx.stress has no model param, it is not forwarded (no TypeError)."""
stub = _make_stub_stressonnx(has_model_kwarg=False)
with patch.dict(sys.modules, {"stressonnx": stub}):
# should not raise even though model= was provided
result = self.phonemizer.add_diacritics("замок", "ru", model="silero")
self.assertIn("[stressed:ru]", result)

def test_missing_stressonnx_returns_text_unchanged(self):
"""If stressonnx is not installed the original text is returned (best-effort)."""
# Remove stressonnx from sys.modules to simulate ImportError
with patch.dict(sys.modules, {"stressonnx": None}):
result = self.phonemizer.add_diacritics("замок", "ru")
self.assertEqual(result, "замок")

def test_stressonnx_exception_returns_text_unchanged(self):
"""If stressonnx.stress raises, the original text is returned."""
stub = types.ModuleType("stressonnx")
stub.stress = MagicMock(side_effect=RuntimeError("model load failed"))
with patch.dict(sys.modules, {"stressonnx": stub}):
result = self.phonemizer.add_diacritics("замок", "ru")
self.assertEqual(result, "замок")

# -- Arabic / Hebrew — existing backends still work ---------------------

def test_ar_routes_to_tashkeel(self):
mock_tashkeel = MagicMock()
mock_tashkeel.diacritize.return_value = "مَرْحَبًا"
self.phonemizer._tashkeel = mock_tashkeel
result = self.phonemizer.add_diacritics("مرحبا", "ar")
mock_tashkeel.diacritize.assert_called_once()
self.assertEqual(result, "مَرْحَبًا")

def test_he_routes_to_phonikud(self):
mock_phonikud = MagicMock()
mock_phonikud.diacritize.return_value = "שָׁלוֹם"
self.phonemizer._phonikud = mock_phonikud
result = self.phonemizer.add_diacritics("שלום", "he")
mock_phonikud.diacritize.assert_called_once()
self.assertEqual(result, "שָׁלוֹם")

def test_other_lang_passthrough(self):
"""Languages with no diacritization backend return text unchanged."""
result = self.phonemizer.add_diacritics("hello", "en")
self.assertEqual(result, "hello")

result = self.phonemizer.add_diacritics("hola", "es")
self.assertEqual(result, "hola")


class TestVoiceConfigAutoEnable(unittest.TestCase):
"""VoiceConfig.__post_init__ auto-enables add_diacritics for ru/uk/be."""

def _make_config(self, lang_code):
from phoonnx.config import VoiceConfig, PhonemeType, Alphabet, Engine
from phoonnx.tokenizer import TTSTokenizer, Vocabulary
tok = TTSTokenizer(Vocabulary(char2idx={"a": 0, "_": 1}),
add_blank_char=False, add_blank_word=False,
use_eos_bos=False, blank_at_end=False, blank_at_start=False)
return VoiceConfig(
num_symbols=2, num_speakers=1, num_langs=1,
sample_rate=22050, lang_code=lang_code,
phoneme_type=PhonemeType.GRAPHEMES,
alphabet=Alphabet.UNICODE,
phonemizer_model=None,
tokenizer=tok,
)

def test_ru_auto_enables(self):
cfg = self._make_config("ru")
self.assertTrue(cfg.add_diacritics)

def test_uk_auto_enables(self):
cfg = self._make_config("uk")
self.assertTrue(cfg.add_diacritics)

def test_be_auto_enables(self):
cfg = self._make_config("be")
self.assertTrue(cfg.add_diacritics)

def test_ar_still_auto_enables(self):
cfg = self._make_config("ar")
self.assertTrue(cfg.add_diacritics)

def test_en_does_not_auto_enable(self):
cfg = self._make_config("en")
self.assertFalse(cfg.add_diacritics)


if __name__ == "__main__":
unittest.main(verbosity=2)
Loading