MIL-006: Spoken Malay word

Review record RC-028 (Go, autonomous mode, not independent).

Task: MIL-006#1
Task: MIL-006#2
Task: MIL-006#3
Task: MIL-006#4
Task: MIL-006#5
This commit is contained in:
2026-10-14 22:39:36 +08:00
parent 5900fda90a
commit b3c5116110
22 changed files with 566 additions and 20 deletions
@@ -0,0 +1,5 @@
"""Ports of the application layer.
@file __init__.py
@brief Holds the interfaces the use cases need from outside.
"""
@@ -0,0 +1,22 @@
"""Port for spoken words.
@file speech_port.py
@brief The SpeechPort interface the study session uses to say a word aloud.
"""
from typing import Protocol
class SpeechPort(Protocol):
"""Speaks a text aloud in a language."""
def speak(self, text: str, language_code: str) -> None:
"""Speak a text without blocking the caller for long.
An implementation does not raise: a failure is logged and ignored.
@param text The text to speak.
@param language_code Language of the text, for example "ms".
@return Nothing.
"""
...
@@ -6,6 +6,7 @@
from collections.abc import Callable, Sequence
from flash_card.application.ports.speech_port import SpeechPort
from flash_card.application.use_cases.study_session import StudySession
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
@@ -16,12 +17,14 @@ from flash_card.domain.repositories.vocabulary_repository import VocabularyRepos
def open_study_session(
vocabulary_repository: VocabularyRepository,
progress_repository: ProgressRepository,
speech: SpeechPort,
choose: Callable[[Sequence[Card]], Card],
) -> StudySession:
"""Build a session over the saved words to learn, else the whole word list.
@param vocabulary_repository Source of the delivered word list.
@param progress_repository Store of the words still to learn.
@param speech Speaks the front text of each drawn card.
@param choose Picks one card from a non-empty sequence.
@return The new session.
@throws VocabularyError When the saved progress, or the word list when no
@@ -30,4 +33,4 @@ def open_study_session(
deck = progress_repository.load()
if deck is None:
deck = Deck.from_vocabulary(vocabulary_repository.load())
return StudySession(deck, choose, progress_repository)
return StudySession(deck, choose, progress_repository, speech)
@@ -7,6 +7,7 @@
from collections.abc import Callable, Sequence
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.ports.speech_port import SpeechPort
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.repositories.progress_repository import ProgressRepository
@@ -20,13 +21,17 @@ class StudySession:
deck: Deck,
choose: Callable[[Sequence[Card]], Card],
progress: ProgressRepository,
speech: SpeechPort,
) -> None:
"""Create a session.
@param deck The cards still to learn.
@param choose Picks one card from a non-empty sequence.
@param progress Store of the words still to learn.
@param speech Speaks the front text of each drawn card.
"""
## @brief Speaks the front text of each drawn card.
self._speech = speech
## @brief Store of the words still to learn.
self._progress = progress
## @brief The cards still to learn.
@@ -60,8 +65,10 @@ class StudySession:
if self._deck.is_empty:
self._current = None
return None
self._current = self._deck.draw(self._choose, exclude=self._current)
return self._to_dto(self._current)
card = self._deck.draw(self._choose, exclude=self._current)
self._current = card
self._speech.speak(card.front, self._deck.language_pair.front_code)
return self._to_dto(card)
def mark_known(self) -> None:
"""Remove the current card from the deck and save the remaining cards.
-2
View File
@@ -51,8 +51,6 @@ CSV_ENCODING: Final[str] = "utf-8-sig"
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
## @brief File name of the words still to learn.
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
## @brief Language code used for speech.
SPEECH_LANGUAGE_CODE: Final[str] = "ms"
## @brief Root folder of the project.
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
@@ -0,0 +1,5 @@
"""Speech adapters.
@file __init__.py
@brief Holds the implementations of the speech port.
"""
@@ -0,0 +1,108 @@
"""Speech adapter over the gTTS service.
@file gtts_speech.py
@brief GttsSpeech, the SpeechPort that synthesises with gTTS and plays the file.
The packages gtts and playsound are optional and imported only when used.
"""
import importlib
import logging
import tempfile
import threading
from collections.abc import Callable
from pathlib import Path
## @brief Logger of the speech adapter.
_logger = logging.getLogger(__name__)
## @brief File name of the synthesised speech inside the temporary folder.
_SPEECH_FILE_NAME = "speech.mp3"
## @brief Writes an mp3 of a text and language to a path.
Synthesize = Callable[[str, str, Path], None]
## @brief Plays an audio file.
Play = Callable[[Path], None]
def _default_synthesize(text: str, language_code: str, path: Path) -> None:
"""Write an mp3 of the text with gTTS.
@param text The text to speak.
@param language_code Language of the text.
@param path File to write.
@return Nothing.
@throws ImportError When the gtts package is not installed.
"""
# Imported by name so the program starts without the optional extra.
module = importlib.import_module("gtts")
module.gTTS(text=text, lang=language_code).save(str(path))
def _default_play(path: Path) -> None:
"""Play an audio file and wait until it has been played.
@param path File to play.
@return Nothing.
@throws ImportError When the playsound package is not installed.
"""
# Imported by name so the program starts without the optional extra.
module = importlib.import_module("playsound")
module.playsound(str(path))
class GttsSpeech:
"""Speaks a text in a background thread; never raises."""
def __init__(
self,
synthesize: Synthesize | None = None,
play: Play | None = None,
) -> None:
"""Create the adapter.
@param synthesize Writes an mp3 of a text and language to a path, or
None for gTTS.
@param play Plays an audio file, or None for playsound.
"""
## @brief Writes the audio file.
self._synthesize = _default_synthesize if synthesize is None else synthesize
## @brief Plays the audio file.
self._play = _default_play if play is None else play
## @brief The thread of the latest speak call, or None.
self._thread: threading.Thread | None = None
def speak(self, text: str, language_code: str) -> None:
"""Start speaking and return at once.
@param text The text to speak.
@param language_code Language of the text.
@return Nothing.
"""
thread = threading.Thread(
target=self._run, args=(text, language_code), daemon=True
)
self._thread = thread
thread.start()
def wait(self) -> None:
"""Block until the latest speech has ended.
@return Nothing.
"""
if self._thread is not None:
self._thread.join()
def _run(self, text: str, language_code: str) -> None:
"""Synthesise and play in a temporary folder, then remove the file.
@param text The text to speak.
@param language_code Language of the text.
@return Nothing.
"""
try:
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / _SPEECH_FILE_NAME
self._synthesize(text, language_code, path)
self._play(path)
except Exception: # thread boundary: speech must never crash the program
_logger.warning("Speech failed for %r", text, exc_info=True)
@@ -0,0 +1,18 @@
"""Silent speech adapter.
@file null_speech.py
@brief NullSpeech, the SpeechPort used when speech is off or unavailable.
"""
class NullSpeech:
"""Speaks nothing."""
def speak(self, text: str, language_code: str) -> None:
"""Do nothing.
@param text The text, ignored.
@param language_code The language, ignored.
@return Nothing.
"""
return None
+31 -2
View File
@@ -5,6 +5,7 @@
"""
import argparse
import importlib.util
import logging
import random
import tkinter as tk
@@ -12,6 +13,7 @@ from collections.abc import Sequence
from pathlib import Path
from typing import NoReturn
from flash_card.application.ports.speech_port import SpeechPort
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.constants import (
DEFAULT_DATA_DIR,
@@ -25,6 +27,8 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
from flash_card.infrastructure.speech.null_speech import NullSpeech
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
@@ -33,20 +37,40 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
## @brief Logger of the entry point.
_logger = logging.getLogger(__name__)
## @brief Optional packages the spoken words need.
_SPEECH_PACKAGES = ("gtts", "playsound")
def build_speech(enabled: bool) -> SpeechPort:
"""Choose the speech adapter.
@param enabled False when the user switched speech off.
@return GttsSpeech when enabled and gtts and playsound are installed,
else NullSpeech.
"""
if enabled and all(
importlib.util.find_spec(name) is not None for name in _SPEECH_PACKAGES
):
return GttsSpeech()
return NullSpeech()
def create_application(
data_dir: Path | None = None,
speech_enabled: bool = True,
) -> tuple[tk.Tk, FlashCardView]:
"""Create the root window and the flash card view.
@param data_dir Folder of the data files, or None for the default folder.
@param speech_enabled False to run without spoken words.
@return The root window and the view built on it.
@throws VocabularyError When the word list or saved progress is unusable.
"""
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
session = open_study_session(repository, progress, random.choice)
speech = build_speech(speech_enabled)
session = open_study_session(repository, progress, speech, random.choice)
view_model = FlashCardViewModel(session)
root = tk.Tk()
view = FlashCardView(root, view_model)
@@ -66,9 +90,14 @@ def main(argv: Sequence[str] | None = None) -> int:
default=DEFAULT_DATA_DIR,
help="folder of the data files",
)
parser.add_argument(
"--no-speech",
action="store_true",
help="do not speak the words",
)
args = parser.parse_args(argv)
try:
root, _view = create_application(args.data_dir)
root, _view = create_application(args.data_dir, not args.no_speech)
except VocabularyError as error:
_logger.error("%s", error)
return 1