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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user