"""
Configuration for speaker-helper.
Module summary
--------------
speaker-helper reads a small, typed configuration describing where the
backend reads, and which voice / language / mode to use. Values come
from (in increasing precedence) dataclass defaults, an optional
``settings.yaml``, and ``SPEAKER_HELPER_*`` environment variables. Strings
spelled ``${VAR}`` inside the YAML are expanded from the environment at load
time so hosts and secrets never sit in a committed file.
See ``settings.yaml.example`` and ``speaker_config.json.example`` at the
repository root for a documented template.
Usage example
-------------
>>> from speaker_helper.config import Settings
>>> s = Settings.from_yaml_text("backend: kokoro\\nlanguage: fr\\nvoice: ff_siwis")
>>> s.backend, s.voice
('kokoro', 'ff_siwis')
Author
------
Warith HARCHAOUI — https://linkedin.com/in/warith-harchaoui
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import Any
import os_helper as osh
import yaml
_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
def _expand_env(value: Any) -> Any:
"""Recursively expand ``${VAR}`` references from the environment.
Parameters
----------
value : Any
A scalar, list, or dict loaded from YAML.
Returns
-------
Any
The same structure with ``${VAR}`` replaced by ``os.environ[VAR]``
(empty string when the variable is unset — the loader stays permissive).
"""
# Scalars: substitute every ${VAR} with its environment value (empty when
# unset — the loader stays permissive rather than raising on missing vars).
if isinstance(value, str):
return _ENV_RE.sub(lambda m: os.environ.get(m.group(1), ""), value)
# Containers: recurse so ${VAR} inside nested lists/dicts is expanded too.
if isinstance(value, list):
return [_expand_env(v) for v in value]
if isinstance(value, dict):
return {k: _expand_env(v) for k, v in value.items()}
# Non-string leaves (int, bool, None) pass through untouched.
return value
def _from_mapping(cls: type, mapping: dict[str, Any] | None) -> Any:
"""Build a dataclass from a mapping, ignoring unknown keys."""
# Keep only keys that are real dataclass fields, so a stray/extra YAML key
# is silently ignored instead of raising a TypeError on construction.
known = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in (mapping or {}).items() if k in known})
[docs]
@dataclass
class Settings:
"""Top-level speaker-helper configuration.
Parameters
----------
backend : str
Which TTS backend to drive. ``"kokoro"`` (default) runs in this
process and needs no server; ``"openvoice"`` and ``"chatterbox"``
clone a voice from a recording; ``"mock"`` is deterministic and
serverless, for tests and the evaluation layer. The backend is an
implementation detail — the rest of the package talks only to the
:class:`~speaker_helper.engine.TTSEngine` protocol. See
:func:`speaker_helper.engine.available_backends`.
engine : str
A label carried on results, for callers that record which model
produced what. It does not select the backend; ``backend`` does.
voice : str
Which built-in voice reads. Empty (default) means "read the text,
detect the language, and pick the voice that speaks it" — which is
not a convenience: the detected language is what the phonemiser
works from, and French read as English is not French in an American
accent, it is an American spelling the words out.
voice_sample : str
Path to a reference recording, for the cloning backends. Kokoro
refuses one rather than ignoring it.
voice_id : str
Deprecated spelling of ``voice``, kept because configuration files in
the wild use it. ``voice`` wins when both are set.
language : str
ISO-639-1 target language (e.g. ``"fr"``).
normalize : bool
Loudness-normalise the output where the backend can.
mode : str
``"offline"`` or ``"streaming"``.
first_chunk_sentences : int
Streaming only: sentences in the first emitted chunk (1 → lowest
time-to-first-audio, which is the number a conversation feels).
Notes
-----
Precedence, lowest to highest: dataclass defaults → ``settings.yaml`` →
``SPEAKER_HELPER_*`` environment variables.
"""
backend: str = "kokoro"
engine: str = "kokoro"
voice: str = ""
voice_sample: str = ""
voice_id: str = ""
language: str = "fr"
normalize: bool = True
mode: str = "offline"
first_chunk_sentences: int = 1
# Backend-specific knobs for the ``mock`` engine (rtf, chars_per_sec,
# sample_rate, amplitude, frequency_hz). Ignored by other backends.
mock: dict[str, Any] = field(default_factory=dict)
# Voice-cloning configuration. Empty (default) means "use a preset voice".
# When non-empty, synthesis uses a cloned voice built from reference audio;
# keys: name, audio, reference_text, samples. See
# :mod:`speaker_helper.cloning`. A missing transcript is derived with
# ``vocal-helper``; an empty ``audio`` falls back to the bundled ref-fr-female.
clone: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Let ``voice_id`` stand in for ``voice``.
``voice_id`` is what the REST-engine era called it, and settings files
written then still say it. Silently ignoring those would start the
wrong voice with no error anywhere — the worst shape of a rename.
"""
if not self.voice and self.voice_id:
self.voice = self.voice_id
# ----- constructors ---------------------------------------------------
[docs]
@classmethod
def from_mapping(cls, mapping: dict[str, Any] | None) -> Settings:
"""Build settings from a plain mapping (unknown keys ignored).
Parameters
----------
mapping : dict or None
Nested mapping mirroring the dataclass fields.
Returns
-------
Settings
The populated settings, with environment overrides applied.
"""
mapping = _expand_env(mapping or {})
# A `voicebox:` section is dropped rather than rejected: settings files
# written for the REST engine are still on disk, and refusing to load
# one would be a worse welcome than ignoring a key that now means
# nothing. `_from_mapping` already drops unknown keys; this comment is
# here so the silence is deliberate rather than accidental.
settings = _from_mapping(cls, mapping)
settings._apply_env_overrides()
return settings
[docs]
@classmethod
def from_yaml_text(cls, text: str) -> Settings:
"""Build settings from a YAML document string.
Parameters
----------
text : str
YAML source.
Returns
-------
Settings
The populated settings.
"""
return cls.from_mapping(yaml.safe_load(text) or {})
[docs]
@classmethod
def load(cls, path: str | Path | None = None) -> Settings:
"""Load settings from a YAML file if present, else defaults + env.
Parameters
----------
path : str or Path or None
Path to a ``settings.yaml``. When ``None``, ``./settings.yaml`` is
used if it exists; otherwise dataclass defaults apply. Environment
overrides are always applied last.
Returns
-------
Settings
The populated settings.
"""
if path is None:
default = Path("settings.yaml")
path = default if osh.file_exists(str(default)) else None
if path is None:
return cls.from_mapping({})
return cls.from_yaml_text(Path(path).read_text(encoding="utf-8"))
def _apply_env_overrides(self) -> None:
"""Override selected fields from ``SPEAKER_HELPER_*`` env variables."""
# Environment variables are the highest-precedence source, applied last
# so they win over both dataclass defaults and any YAML file. Each key is
# optional; only present ones override, keeping the rest untouched.
env = os.environ
if "SPEAKER_HELPER_VOICE" in env:
self.voice = env["SPEAKER_HELPER_VOICE"]
if "SPEAKER_HELPER_VOICE_SAMPLE" in env:
self.voice_sample = env["SPEAKER_HELPER_VOICE_SAMPLE"]
if "SPEAKER_HELPER_BACKEND" in env:
self.backend = env["SPEAKER_HELPER_BACKEND"]
if "SPEAKER_HELPER_ENGINE" in env:
self.engine = env["SPEAKER_HELPER_ENGINE"]
if "SPEAKER_HELPER_VOICE_ID" in env:
self.voice_id = env["SPEAKER_HELPER_VOICE_ID"]
if not self.voice:
self.voice = self.voice_id
if "SPEAKER_HELPER_LANGUAGE" in env:
self.language = env["SPEAKER_HELPER_LANGUAGE"]