Skip to content

TTS

Source: tts/

Pronunciation backend: provider contract, gTTS implementation, content-addressed MP3 cache, live-set phrase collection, and TtsService. Cache layout and GC rules are in Data and storage. Playback stays on App session (speak); the language and auto-speak used for a set are its effective overrides, with TTS preferences as the app default. Screens must not construct Audio or GttsProvider.

Provider

TTS provider contract and error types.

UI and TtsService depend only on TtsProvider. Swap GttsProvider for ElevenLabs or OpenAI later without changing cache or playback.

TtsError

Bases: Exception

Raised when speech cannot be synthesized.

Source code in src/learning_app/tts/provider.py
class TtsError(Exception):
    """Raised when speech cannot be synthesized."""

TtsNetworkError

Bases: TtsError

Raised when a provider cannot reach its remote API.

Source code in src/learning_app/tts/provider.py
class TtsNetworkError(TtsError):
    """Raised when a provider cannot reach its remote API."""

TtsProvider

Bases: ABC

Backend that turns text into MP3 bytes.

name and voice are part of the on-disk cache key so a later provider does not replay audio generated by an older one.

Source code in src/learning_app/tts/provider.py
class TtsProvider(ABC):
    """Backend that turns text into MP3 bytes.

    ``name`` and ``voice`` are part of the on-disk cache key so a later
    provider does not replay audio generated by an older one.
    """

    name: str
    voice: str = ""

    @abstractmethod
    async def synthesize(self, text: str, language: str) -> bytes:
        """Return MP3 bytes for ``text`` spoken in ``language``.

        Args:
            text: Already-normalized phrase to speak.
            language: IETF language tag such as ``en`` or ``pl``.

        Returns:
            MP3 audio bytes.

        Raises:
            TtsNetworkError: The remote service could not be reached.
            TtsError: Synthesis failed for another reason.
        """

synthesize abstractmethod async

synthesize(text: str, language: str) -> bytes

Return MP3 bytes for text spoken in language.

Parameters:

Name Type Description Default
text str

Already-normalized phrase to speak.

required
language str

IETF language tag such as en or pl.

required

Returns:

Type Description
bytes

MP3 audio bytes.

Raises:

Type Description
TtsNetworkError

The remote service could not be reached.

TtsError

Synthesis failed for another reason.

Source code in src/learning_app/tts/provider.py
@abstractmethod
async def synthesize(self, text: str, language: str) -> bytes:
    """Return MP3 bytes for ``text`` spoken in ``language``.

    Args:
        text: Already-normalized phrase to speak.
        language: IETF language tag such as ``en`` or ``pl``.

    Returns:
        MP3 audio bytes.

    Raises:
        TtsNetworkError: The remote service could not be reached.
        TtsError: Synthesis failed for another reason.
    """

gTTS implementation of TtsProvider (Google Translate unofficial TTS).

GttsProvider

Bases: TtsProvider

Generate MP3 audio through the gTTS library.

Calls are offloaded with asyncio.to_thread so Flet handlers stay responsive. lang_check is disabled to avoid an extra network round trip per word.

Source code in src/learning_app/tts/gtts_provider.py
class GttsProvider(TtsProvider):
    """Generate MP3 audio through the gTTS library.

    Calls are offloaded with ``asyncio.to_thread`` so Flet handlers stay
    responsive. ``lang_check`` is disabled to avoid an extra network round
    trip per word.
    """

    name = "gtts"
    voice = ""

    def __init__(self, *, timeout: float = 10.0, tld: str = "com"):
        """Configure request timeout and Google Translate TLD.

        Args:
            timeout: Seconds to wait for the gTTS HTTP response.
            tld: Google Translate host TLD (``com``, ``co.uk``, …).
        """
        self.timeout = timeout
        self.tld = tld

    async def synthesize(self, text: str, language: str) -> bytes:
        """Return MP3 bytes from gTTS in a worker thread.

        Args:
            text: Normalized phrase to speak.
            language: IETF language tag accepted by gTTS (for example ``en``).

        Returns:
            MP3 audio bytes.

        Raises:
            TtsNetworkError: The Translate endpoint failed or timed out.
            TtsError: gTTS rejected the input or returned empty audio.
        """
        return await asyncio.to_thread(self._synthesize_sync, text, language)

    def _synthesize_sync(self, text: str, language: str) -> bytes:
        from gtts import gTTS
        from gtts.tts import gTTSError

        buffer = BytesIO()
        try:
            speech = gTTS(
                text=text,
                lang=language,
                lang_check=False,
                tld=self.tld,
                timeout=self.timeout,
            )
            speech.write_to_fp(buffer)
        except gTTSError as error:
            raise TtsNetworkError("gTTS could not generate audio.") from error
        except OSError as error:
            raise TtsNetworkError("gTTS network request failed.") from error
        except ValueError as error:
            raise TtsError("gTTS rejected the text or language.") from error
        except Exception as error:
            raise TtsError("gTTS could not generate audio.") from error

        data = buffer.getvalue()
        if not data:
            raise TtsError("gTTS returned empty audio.")
        return data

synthesize async

synthesize(text: str, language: str) -> bytes

Return MP3 bytes from gTTS in a worker thread.

Parameters:

Name Type Description Default
text str

Normalized phrase to speak.

required
language str

IETF language tag accepted by gTTS (for example en).

required

Returns:

Type Description
bytes

MP3 audio bytes.

Raises:

Type Description
TtsNetworkError

The Translate endpoint failed or timed out.

TtsError

gTTS rejected the input or returned empty audio.

Source code in src/learning_app/tts/gtts_provider.py
async def synthesize(self, text: str, language: str) -> bytes:
    """Return MP3 bytes from gTTS in a worker thread.

    Args:
        text: Normalized phrase to speak.
        language: IETF language tag accepted by gTTS (for example ``en``).

    Returns:
        MP3 audio bytes.

    Raises:
        TtsNetworkError: The Translate endpoint failed or timed out.
        TtsError: gTTS rejected the input or returned empty audio.
    """
    return await asyncio.to_thread(self._synthesize_sync, text, language)

Cache and texts

Content-addressed MP3 cache for generated pronunciation audio.

TtsCache

Store MP3 files keyed by provider, voice, language, and text.

Files live under FilePathManager.get_tts_cache_dir(). Each object is <hash[:2]>/<hash>.mp3 plus a JSON sidecar used by garbage collection so recordings stay while some set still speaks that phrase in that language.

Source code in src/learning_app/tts/cache.py
class TtsCache:
    """Store MP3 files keyed by provider, voice, language, and text.

    Files live under ``FilePathManager.get_tts_cache_dir()``. Each object is
    ``<hash[:2]>/<hash>.mp3`` plus a JSON sidecar used by garbage collection
    so recordings stay while some set still speaks that phrase in that language.
    """

    def __init__(self, root: Path | str | None = None):
        """Create a cache rooted at ``root`` or the app TTS directory.

        Args:
            root: Cache directory. ``None`` uses ``get_tts_cache_dir()``.
        """
        self.root = Path(root) if root is not None else Path(FilePathManager.get_tts_cache_dir())
        self.root.mkdir(parents=True, exist_ok=True)

    @staticmethod
    def make_key(provider: str, language: str, text: str, voice: str = "") -> str:
        """Return a hex digest for the cache object.

        Args:
            provider: Provider id such as ``gtts``.
            language: IETF language tag.
            text: Phrase to speak (will be normalized).
            voice: Optional voice id; empty for providers without voices.

        Returns:
            SHA-256 hex digest used as the filename stem.
        """
        normalized = normalize_text(text)
        payload = "\0".join((provider, voice, language, normalized)).encode("utf-8")
        return hashlib.sha256(payload).hexdigest()

    def get_path(self, key: str) -> Path | None:
        """Return the MP3 path when a non-empty file and sidecar exist.

        Args:
            key: Digest from ``make_key``.

        Returns:
            Path to the MP3, or ``None`` on a cache miss or empty file.
        """
        mp3_path = self._mp3_path(key)
        if (
            mp3_path.is_file()
            and mp3_path.stat().st_size > 0
            and self._sidecar_path(key).is_file()
        ):
            return mp3_path
        return None

    def put(
        self,
        key: str,
        data: bytes,
        *,
        text: str,
        language: str,
        provider: str,
        voice: str = "",
    ) -> Path:
        """Write MP3 bytes and a sidecar describing the phrase.

        Args:
            key: Digest from ``make_key``.
            data: MP3 bytes.
            text: Normalized phrase stored for garbage collection.
            language: IETF language tag.
            provider: Provider id that produced ``data``.
            voice: Optional voice id.

        Returns:
            Path to the written MP3.

        Raises:
            ValueError: If ``data`` is empty.
        """
        if not data:
            raise ValueError("Cannot cache empty audio.")

        mp3_path = self._mp3_path(key)
        mp3_path.parent.mkdir(parents=True, exist_ok=True)
        mp3_path.write_bytes(data)
        sidecar = {
            "text": normalize_text(text),
            "language": language,
            "provider": provider,
            "voice": voice,
        }
        self._sidecar_path(key).write_text(
            json.dumps(sidecar, ensure_ascii=False),
            encoding=_SIDECAR_ENCODING,
        )
        return mp3_path

    def garbage_collect(self, live_texts: Iterable[tuple[str, str]]) -> int:
        """Delete recordings whose phrase and language are no longer live.

        Provider and voice are ignored: if a set still speaks that phrase in
        that language, every cached rendering of the pair is kept. A recording
        in another language is removed even when the same spelling remains.

        Args:
            live_texts: ``(phrase, language)`` pairs still spoken by a set.

        Returns:
            Number of MP3 files removed.
        """
        live: set[tuple[str, str]] = set()
        for text, language in live_texts:
            normalized = normalize_text(text)
            if normalized and isinstance(language, str) and language.strip():
                live.add((normalized, language.strip()))
        removed = 0

        for sidecar_path in self.root.rglob("*.json"):
            mp3_path = sidecar_path.with_suffix(".mp3")
            phrase = self._phrase_from_sidecar(sidecar_path)
            if phrase is not None and phrase in live:
                continue
            if mp3_path.is_file():
                mp3_path.unlink()
                removed += 1
            sidecar_path.unlink(missing_ok=True)

        for mp3_path in self.root.rglob("*.mp3"):
            if not mp3_path.with_suffix(".json").is_file():
                mp3_path.unlink()
                removed += 1

        return removed

    def _mp3_path(self, key: str) -> Path:
        return self.root / key[:2] / f"{key}.mp3"

    def _sidecar_path(self, key: str) -> Path:
        return self.root / key[:2] / f"{key}.json"

    @staticmethod
    def _phrase_from_sidecar(path: Path) -> tuple[str, str] | None:
        try:
            payload = json.loads(path.read_text(encoding=_SIDECAR_ENCODING))
        except (OSError, UnicodeError, json.JSONDecodeError):
            return None
        if not isinstance(payload, dict):
            return None
        text = payload.get("text")
        language = payload.get("language")
        if not isinstance(text, str) or not isinstance(language, str):
            return None
        normalized = normalize_text(text)
        language = language.strip()
        if not normalized or not language:
            return None
        return normalized, language

make_key staticmethod

make_key(
    provider: str, language: str, text: str, voice: str = ""
) -> str

Return a hex digest for the cache object.

Parameters:

Name Type Description Default
provider str

Provider id such as gtts.

required
language str

IETF language tag.

required
text str

Phrase to speak (will be normalized).

required
voice str

Optional voice id; empty for providers without voices.

''

Returns:

Type Description
str

SHA-256 hex digest used as the filename stem.

Source code in src/learning_app/tts/cache.py
@staticmethod
def make_key(provider: str, language: str, text: str, voice: str = "") -> str:
    """Return a hex digest for the cache object.

    Args:
        provider: Provider id such as ``gtts``.
        language: IETF language tag.
        text: Phrase to speak (will be normalized).
        voice: Optional voice id; empty for providers without voices.

    Returns:
        SHA-256 hex digest used as the filename stem.
    """
    normalized = normalize_text(text)
    payload = "\0".join((provider, voice, language, normalized)).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()

get_path

get_path(key: str) -> Path | None

Return the MP3 path when a non-empty file and sidecar exist.

Parameters:

Name Type Description Default
key str

Digest from make_key.

required

Returns:

Type Description
Path | None

Path to the MP3, or None on a cache miss or empty file.

Source code in src/learning_app/tts/cache.py
def get_path(self, key: str) -> Path | None:
    """Return the MP3 path when a non-empty file and sidecar exist.

    Args:
        key: Digest from ``make_key``.

    Returns:
        Path to the MP3, or ``None`` on a cache miss or empty file.
    """
    mp3_path = self._mp3_path(key)
    if (
        mp3_path.is_file()
        and mp3_path.stat().st_size > 0
        and self._sidecar_path(key).is_file()
    ):
        return mp3_path
    return None

put

put(
    key: str,
    data: bytes,
    *,
    text: str,
    language: str,
    provider: str,
    voice: str = "",
) -> Path

Write MP3 bytes and a sidecar describing the phrase.

Parameters:

Name Type Description Default
key str

Digest from make_key.

required
data bytes

MP3 bytes.

required
text str

Normalized phrase stored for garbage collection.

required
language str

IETF language tag.

required
provider str

Provider id that produced data.

required
voice str

Optional voice id.

''

Returns:

Type Description
Path

Path to the written MP3.

Raises:

Type Description
ValueError

If data is empty.

Source code in src/learning_app/tts/cache.py
def put(
    self,
    key: str,
    data: bytes,
    *,
    text: str,
    language: str,
    provider: str,
    voice: str = "",
) -> Path:
    """Write MP3 bytes and a sidecar describing the phrase.

    Args:
        key: Digest from ``make_key``.
        data: MP3 bytes.
        text: Normalized phrase stored for garbage collection.
        language: IETF language tag.
        provider: Provider id that produced ``data``.
        voice: Optional voice id.

    Returns:
        Path to the written MP3.

    Raises:
        ValueError: If ``data`` is empty.
    """
    if not data:
        raise ValueError("Cannot cache empty audio.")

    mp3_path = self._mp3_path(key)
    mp3_path.parent.mkdir(parents=True, exist_ok=True)
    mp3_path.write_bytes(data)
    sidecar = {
        "text": normalize_text(text),
        "language": language,
        "provider": provider,
        "voice": voice,
    }
    self._sidecar_path(key).write_text(
        json.dumps(sidecar, ensure_ascii=False),
        encoding=_SIDECAR_ENCODING,
    )
    return mp3_path

garbage_collect

garbage_collect(
    live_texts: Iterable[tuple[str, str]],
) -> int

Delete recordings whose phrase and language are no longer live.

Provider and voice are ignored: if a set still speaks that phrase in that language, every cached rendering of the pair is kept. A recording in another language is removed even when the same spelling remains.

Parameters:

Name Type Description Default
live_texts Iterable[tuple[str, str]]

(phrase, language) pairs still spoken by a set.

required

Returns:

Type Description
int

Number of MP3 files removed.

Source code in src/learning_app/tts/cache.py
def garbage_collect(self, live_texts: Iterable[tuple[str, str]]) -> int:
    """Delete recordings whose phrase and language are no longer live.

    Provider and voice are ignored: if a set still speaks that phrase in
    that language, every cached rendering of the pair is kept. A recording
    in another language is removed even when the same spelling remains.

    Args:
        live_texts: ``(phrase, language)`` pairs still spoken by a set.

    Returns:
        Number of MP3 files removed.
    """
    live: set[tuple[str, str]] = set()
    for text, language in live_texts:
        normalized = normalize_text(text)
        if normalized and isinstance(language, str) and language.strip():
            live.add((normalized, language.strip()))
    removed = 0

    for sidecar_path in self.root.rglob("*.json"):
        mp3_path = sidecar_path.with_suffix(".mp3")
        phrase = self._phrase_from_sidecar(sidecar_path)
        if phrase is not None and phrase in live:
            continue
        if mp3_path.is_file():
            mp3_path.unlink()
            removed += 1
        sidecar_path.unlink(missing_ok=True)

    for mp3_path in self.root.rglob("*.mp3"):
        if not mp3_path.with_suffix(".json").is_file():
            mp3_path.unlink()
            removed += 1

    return removed

Normalize phrases and collect live set texts for TTS cache GC.

normalize_text

normalize_text(text: str) -> str

Strip and collapse whitespace so cache keys stay stable.

Parameters:

Name Type Description Default
text str

Raw cell value from a learning set.

required

Returns:

Type Description
str

Normalized phrase, or an empty string when nothing remains.

Source code in src/learning_app/tts/texts.py
def normalize_text(text: str) -> str:
    """Strip and collapse whitespace so cache keys stay stable.

    Args:
        text: Raw cell value from a learning set.

    Returns:
        Normalized phrase, or an empty string when nothing remains.
    """
    return " ".join(str(text).split())

speakable_phrases

speakable_phrases(text: str) -> list[str]

Split a cell on / and return each normalized member.

Empty members are dropped. Order is preserved and duplicates are removed. A cell such as go/goes/went becomes three phrases. A cell without slashes is a one-item list.

Parameters:

Name Type Description Default
text str

Raw cell value from a learning set.

required

Returns:

Type Description
list[str]

Distinct normalized phrases to synthesize and keep in the TTS cache.

Source code in src/learning_app/tts/texts.py
def speakable_phrases(text: str) -> list[str]:
    """Split a cell on ``/`` and return each normalized member.

    Empty members are dropped. Order is preserved and duplicates are removed.
    A cell such as ``go/goes/went`` becomes three phrases. A cell without
    slashes is a one-item list.

    Args:
        text: Raw cell value from a learning set.

    Returns:
        Distinct normalized phrases to synthesize and keep in the TTS cache.
    """
    phrases: list[str] = []
    seen: set[str] = set()
    for part in str(text).split("/"):
        normalized = normalize_text(part)
        if normalized and normalized not in seen:
            seen.add(normalized)
            phrases.append(normalized)
    return phrases

collect_speakable_texts

collect_speakable_texts() -> set[tuple[str, str]]

Return phrases still spoken, paired with each set's TTS language.

Statistics columns and empty cells are ignored. Unreadable set files are skipped so GC can still run after a partial catalog failure. The language is the set's tts_language override, or the app language when that cell is empty. The same spelling in two languages yields two pairs.

Returns:

Type Description
set[tuple[str, str]]

Distinct (phrase, language) pairs from *_words.csv and

set[tuple[str, str]]

*_definitions.csv under the CSV directory.

Source code in src/learning_app/tts/texts.py
def collect_speakable_texts() -> set[tuple[str, str]]:
    """Return phrases still spoken, paired with each set's TTS language.

    Statistics columns and empty cells are ignored. Unreadable set files are
    skipped so GC can still run after a partial catalog failure. The language
    is the set's ``tts_language`` override, or the app language when that
    cell is empty. The same spelling in two languages yields two pairs.

    Returns:
        Distinct ``(phrase, language)`` pairs from ``*_words.csv`` and
        ``*_definitions.csv`` under the CSV directory.
    """
    from learning_app.data.set_settings import language_for_set_file

    csv_dir = Path(FilePathManager.get_csv_dir())
    texts: set[tuple[str, str]] = set()
    if not csv_dir.is_dir():
        return texts

    for path in csv_dir.iterdir():
        if not path.name.endswith(_SET_SUFFIXES):
            continue
        language = language_for_set_file(path.name)
        for phrase in _texts_from_set_csv(path):
            texts.add((phrase, language))
    return texts

Service

Orchestrate TTS cache lookup, synthesis, and garbage collection.

TtsService

Resolve a phrase to a cached MP3 path, generating it when missing.

One service instance should be reused for the app session. Playback stays outside this class so a later UI layer can feed the path into Flet Audio.

Source code in src/learning_app/tts/service.py
class TtsService:
    """Resolve a phrase to a cached MP3 path, generating it when missing.

    One service instance should be reused for the app session. Playback stays
    outside this class so a later UI layer can feed the path into Flet Audio.
    """

    def __init__(self, provider: TtsProvider, cache: TtsCache | None = None):
        """Bind a provider and optional cache.

        Args:
            provider: Backend used on a cache miss.
            cache: Disk cache. ``None`` uses the default app TTS directory.
        """
        self.provider = provider
        self.cache = cache if cache is not None else TtsCache()
        self._locks: dict[str, asyncio.Lock] = {}
        self._locks_guard = asyncio.Lock()

    async def resolve_path(self, text: str, language: str) -> Path:
        """Return the MP3 path for ``text``, synthesizing on a cache miss.

        Args:
            text: Phrase to speak. Whitespace is normalized.
            language: IETF language tag passed to the provider.

        Returns:
            Path to the MP3 on disk.

        Raises:
            TtsError: ``text`` is empty after normalization, contains more
                than one slash-separated member (use ``resolve_paths``), or
                synthesis fails.
        """
        phrases = speakable_phrases(text)
        if not phrases:
            raise TtsError("Cannot synthesize empty text.")
        if len(phrases) > 1:
            raise TtsError("Slash-separated cells need resolve_paths().")

        normalized = phrases[0]
        key = self.cache.make_key(
            provider=self.provider.name,
            voice=self.provider.voice,
            language=language,
            text=normalized,
        )
        cached = self.cache.get_path(key)
        if cached is not None:
            return cached

        lock = await self._lock_for(key)
        async with lock:
            cached = self.cache.get_path(key)
            if cached is not None:
                return cached
            data = await self.provider.synthesize(normalized, language)
            return self.cache.put(
                key,
                data,
                text=normalized,
                language=language,
                provider=self.provider.name,
                voice=self.provider.voice,
            )

    async def resolve_paths(self, text: str, language: str) -> list[Path]:
        """Return an MP3 path for every slash-separated member of ``text``.

        ``go/goes/went`` yields three cached files so the UI can play each
        form in order. Members share the cache with the same phrase in other
        cells.

        Args:
            text: Cell value, possibly containing ``/``.
            language: IETF language tag passed to the provider.

        Returns:
            Paths in speak order, one per distinct member.

        Raises:
            TtsError: ``text`` has no speakable members, or synthesis fails.
        """
        phrases = speakable_phrases(text)
        if not phrases:
            raise TtsError("Cannot synthesize empty text.")
        return [await self.resolve_path(phrase, language) for phrase in phrases]

    async def get_mp3_bytes(self, text: str, language: str) -> bytes:
        """Return MP3 bytes for ``text``, using the cache when possible.

        Args:
            text: Phrase to speak.
            language: IETF language tag.

        Returns:
            MP3 audio bytes.
        """
        path = await self.resolve_path(text, language)
        return path.read_bytes()

    def garbage_collect(self) -> int:
        """Remove cached audio for phrase/language pairs no set still speaks.

        Returns:
            Number of MP3 files removed.
        """
        return garbage_collect_tts_cache(self.cache)

    async def _lock_for(self, key: str) -> asyncio.Lock:
        async with self._locks_guard:
            lock = self._locks.get(key)
            if lock is None:
                lock = asyncio.Lock()
                self._locks[key] = lock
            return lock

resolve_path async

resolve_path(text: str, language: str) -> Path

Return the MP3 path for text, synthesizing on a cache miss.

Parameters:

Name Type Description Default
text str

Phrase to speak. Whitespace is normalized.

required
language str

IETF language tag passed to the provider.

required

Returns:

Type Description
Path

Path to the MP3 on disk.

Raises:

Type Description
TtsError

text is empty after normalization, contains more than one slash-separated member (use resolve_paths), or synthesis fails.

Source code in src/learning_app/tts/service.py
async def resolve_path(self, text: str, language: str) -> Path:
    """Return the MP3 path for ``text``, synthesizing on a cache miss.

    Args:
        text: Phrase to speak. Whitespace is normalized.
        language: IETF language tag passed to the provider.

    Returns:
        Path to the MP3 on disk.

    Raises:
        TtsError: ``text`` is empty after normalization, contains more
            than one slash-separated member (use ``resolve_paths``), or
            synthesis fails.
    """
    phrases = speakable_phrases(text)
    if not phrases:
        raise TtsError("Cannot synthesize empty text.")
    if len(phrases) > 1:
        raise TtsError("Slash-separated cells need resolve_paths().")

    normalized = phrases[0]
    key = self.cache.make_key(
        provider=self.provider.name,
        voice=self.provider.voice,
        language=language,
        text=normalized,
    )
    cached = self.cache.get_path(key)
    if cached is not None:
        return cached

    lock = await self._lock_for(key)
    async with lock:
        cached = self.cache.get_path(key)
        if cached is not None:
            return cached
        data = await self.provider.synthesize(normalized, language)
        return self.cache.put(
            key,
            data,
            text=normalized,
            language=language,
            provider=self.provider.name,
            voice=self.provider.voice,
        )

resolve_paths async

resolve_paths(text: str, language: str) -> list[Path]

Return an MP3 path for every slash-separated member of text.

go/goes/went yields three cached files so the UI can play each form in order. Members share the cache with the same phrase in other cells.

Parameters:

Name Type Description Default
text str

Cell value, possibly containing /.

required
language str

IETF language tag passed to the provider.

required

Returns:

Type Description
list[Path]

Paths in speak order, one per distinct member.

Raises:

Type Description
TtsError

text has no speakable members, or synthesis fails.

Source code in src/learning_app/tts/service.py
async def resolve_paths(self, text: str, language: str) -> list[Path]:
    """Return an MP3 path for every slash-separated member of ``text``.

    ``go/goes/went`` yields three cached files so the UI can play each
    form in order. Members share the cache with the same phrase in other
    cells.

    Args:
        text: Cell value, possibly containing ``/``.
        language: IETF language tag passed to the provider.

    Returns:
        Paths in speak order, one per distinct member.

    Raises:
        TtsError: ``text`` has no speakable members, or synthesis fails.
    """
    phrases = speakable_phrases(text)
    if not phrases:
        raise TtsError("Cannot synthesize empty text.")
    return [await self.resolve_path(phrase, language) for phrase in phrases]

get_mp3_bytes async

get_mp3_bytes(text: str, language: str) -> bytes

Return MP3 bytes for text, using the cache when possible.

Parameters:

Name Type Description Default
text str

Phrase to speak.

required
language str

IETF language tag.

required

Returns:

Type Description
bytes

MP3 audio bytes.

Source code in src/learning_app/tts/service.py
async def get_mp3_bytes(self, text: str, language: str) -> bytes:
    """Return MP3 bytes for ``text``, using the cache when possible.

    Args:
        text: Phrase to speak.
        language: IETF language tag.

    Returns:
        MP3 audio bytes.
    """
    path = await self.resolve_path(text, language)
    return path.read_bytes()

garbage_collect

garbage_collect() -> int

Remove cached audio for phrase/language pairs no set still speaks.

Returns:

Type Description
int

Number of MP3 files removed.

Source code in src/learning_app/tts/service.py
def garbage_collect(self) -> int:
    """Remove cached audio for phrase/language pairs no set still speaks.

    Returns:
        Number of MP3 files removed.
    """
    return garbage_collect_tts_cache(self.cache)

garbage_collect_tts_cache

garbage_collect_tts_cache(
    cache: TtsCache | None = None,
) -> int

Remove cached MP3s whose phrase and language no longer appear in any set.

Uses the default app TTS directory when cache is omitted. Safe to call after deleting or rewriting set content. Do not call after progress-only saves.

Parameters:

Name Type Description Default
cache TtsCache | None

Cache to prune. None uses FilePathManager.get_tts_cache_dir().

None

Returns:

Type Description
int

Number of MP3 files removed.

Source code in src/learning_app/tts/service.py
def garbage_collect_tts_cache(cache: TtsCache | None = None) -> int:
    """Remove cached MP3s whose phrase and language no longer appear in any set.

    Uses the default app TTS directory when ``cache`` is omitted. Safe to call
    after deleting or rewriting set content. Do not call after progress-only
    saves.

    Args:
        cache: Cache to prune. ``None`` uses ``FilePathManager.get_tts_cache_dir()``.

    Returns:
        Number of MP3 files removed.
    """
    store = cache if cache is not None else TtsCache()
    return store.garbage_collect(collect_speakable_texts())