This file provides guidance to agents when working with code in this repository.
Mimora is a local, offline pronunciation trainer (Python 3.11/3.12, Tkinter GUI). It speaks an LLM-generated phrase aloud (TTS backend per language: Kokoro for English, Supertonic 3 for Spanish - see mimora/tts.py), records the user repeating it, then scores the attempt against the reference with the active pronunciation engine plus prosody. The engine is selected by config.ENGINE (settings.json "engine"; see mimora/engine.py): the default is phoneme (espeak reference phonemes vs a wav2vec2 phoneme recognizer, feature-weighted edit distance, calibrated 0–5 grade); the alternative acoustic engine uses Wav2Vec2-embedding cosine-DTW plus phoneme/word error rates; none disables scoring entirely (no recognizer model is loaded, every take is accepted, the GUI shows a neutral "scoring off" read-out - for slow machines). The user repeats the same phrase until the score passes, then generates the next one.
The practice language is data, not an assumption: every language is one entry in config.LANGUAGE_PROFILES (display name, FLORES-200 code, engines, variants with Kokoro/espeak wiring and voices, phrase-generation prompts, startup greeting, preview/warm-up phrases, default practice text and its unreadable-file fallback). The active language/variant comes from settings.json "practice_language" / "accent" (the legacy "english_accent" is read as a fallback and dropped on the next save); both apply after a restart. English (variants american/british) is fully calibrated; Spanish (variant castilian) runs the phoneme engine as experimental until pronunciation/phoneme/es_model_calibration.json lands (scoring falls back to the English calibration; the app logs a startup warning and the settings window shows a notice - see config.engine_experimental). The English-only acoustic engine is offered per profile (config.available_engines); an unavailable engine falls back to the language's first available one. Adding a language = one profile module in mimora/languages/ (a pure-data PROFILE dict) registered in the LANGUAGE_PROFILES assembly in config.py, plus an engine calibration, never a new if language == ... branch (see tasks/multilingual_support_task.md).
The pronunciation-scoring core in pronunciation/acoustic/ is adapted from OpenPronounce (MIT) and reused as a GUI-agnostic library.
# Root requirements.txt pulls in the subproject files via -r (llm_server,
# pronunciation/acoustic and pronunciation/phoneme), so one install covers all:
pip install -r requirements.txt
python main.pyAlso requires the native espeak-ng binary on PATH (used by phonemizer) and a GGUF chat model at config.EXTERNAL_MODEL_PATH.
Default LLM backend: local_server - llm_server/server.py is launched automatically as a subprocess.
Alternatives: set "llm_backend": "lm-studio" in config/settings.json and run LM Studio on http://localhost:1234 (or on another machine in the local network - set "lm_studio_host" to its address as host or host:port, port defaults to 1234; also editable in the settings window); or "llm_backend": "off" - no LLM is loaded or started at all, practice phrases are the source text's own sentences taken verbatim in order (mimora/phrase_source.py); the phrase-length choice is disabled in the settings window in this mode (sentences are never shortened into fragments).
main.py-PronunciationTrainerGUI: Tkinter GUI, recording, the Prompt→Record→Analyze→Feedback→Loop state machine, threading orchestration; the LLM-server subprocess lifecycle is delegated tomimora/llm_server_ctl.py.mimora/bootstrap.py- early process setup for the entry point, stdlib-only.early_init()runs BEFORE the heavy mimora.* imports inmain.py(UTF-8 console/env hints, HF symlink-warning suppression, library warning filters);setup_logging(log_file)runs AFTER them (basicConfig with force=True replaces handlers auto-installed during the imports). The two-phase split is load-bearing - do not merge or reorder the calls.mimora/session.py-SessionState: per-run score tally (distinct-phrase count, mean of per-phrase best scores with raw/graded formatting, the current phrase's attempt list for the ring's dot column) and the bounded attempt history with trend arrows. Pure data, no Tk;main.pyowns the instance and forwards its outputs to the view (update_session_stats/render_history). Unit-tested intests/test_session.py.mimora/playback.py-PlaybackController: the per-playback stop-event lifecycle plus the talking-mouth coupling.new_event()installs a fresh stop event per playback (Tk main thread only),stop()supersedes the current one,play_with_face()is the blocking chokepoint aroundTTSManager.play_arraythat also drives the FaceWidget's loudness track,play_async()the fire-and-forget wrapper,finished()the identity-guarded Ready-status restore. Composed bymain.pywith the Tk root, the view facade, the TTSManager and the shutdown event.mimora/ui.py-TrainerView: the view facademain.pycomposes. Owns the window chrome (header, status bar, tip line), the control row with the mic button, theenter_*intent methods and the feedback orchestration (show_feedback), and delegates panel-local work to per-panel classes:mimora/ui_practice.py(PracticePanel, collapsible source-text editor),mimora/ui_hero.py(HeroCard: phrase with clickable words, translation, and the score row - the FaceWidget on the left, then the SCORE column and WORK ON badges, and theProgressRingon the right - session average of per-phrase bests plus the current phrase's attempt dots),mimora/ui_prosody.py(ProsodyPanel, pitch/energy sparklines with the cached last result) andmimora/ui_history.py(HistoryPanel, scrollable attempt list). Shared palette/fonts/wheel helpers and the hover tooltip live inmimora/ui_theme.py(importing it also disables ttkbootstrap's classic-widget autostyle hook). The controller-facing API is unchanged by the split;ui.pyre-exportsTHEME,FONT_FAMILY,WHEEL_EVENTSandwheel_scroll_unitsforsettings_window.py.mimora/engine.py- engine dispatcher: binds the backend chosen byconfig.ENGINE(phonemedefault,acousticalternative,none= scoring off) and exposes oneanalyze(...)interface, somain.pyis engine-agnostic and only the selected engine's weights load.pronunciation/none/is a no-op engine returningPronunciationResult(scored=False, passed=True); the GUI renders unscored results neutrally (ui.py _show_unscored_feedback).pronunciation/phoneme/speech.py- default pronunciation engine (text-only reference): espeak reference phonemes vs a wav2vec2 phoneme recognizer, feature-weighted edit distance, mapped to a calibrated 0–5 grade. Model calibration lives inpronunciation/phoneme/<lang>_model_calibration.json(committed, selected by espeak language); a per-userphoneme_goodoverride inpronunciation/phoneme/calibration.json(gitignored, shaped{lang: {users: {name: ...}}}). Samples appended tologs/phoneme_samples.jsonl. No GUI dependency.pronunciation/acoustic/speech.py- alternative pronunciation engine (adapted from OpenPronounce). Single entry pointanalyze(user_audio, expected_text, reference_audio) -> PronunciationResult. Wav2Vec2 embeddings + per-step cosine DTW, phoneme/word error rates. Scoring uses a calibratable acoustic floor (pronunciation/acoustic/calibration.jsonoverridesconfig.PRONUNCIATION_ACOUSTIC_GOOD); every attempt's raw components are appended tologs/acoustic_samples.jsonl. No GUI/Tkinter dependency. Prosody is no longer computed here (returnsprosody={}) - seemimora/prosody.py.mimora/prosody.py- engine-agnostic prosody layer: F0/energy contour extraction (librosa/sklearn, no torch).main.pycallscompute_prosody(user, reference)afteranalyze(via_compute_prosody_safe, skipped entirely while the prosody block is collapsed (show_prosody) - pyin pitch tracking is expensive on slow machines) and fillsresult.prosody, so the pitch/energy charts work identically regardless of the active engine. Pure plotting helpers (to_semitones,resample_series) stay inmimora/prosody_utils.py.pronunciation/acoustic/calibrate.py- on-request semi-automatic calibration: fits the acoustic floor from collected samples and writespronunciation/acoustic/calibration.json(--dry-runto preview).mimora/audio_io.py- shared audio-device infrastructure depended on by both the mic and speaker paths (so neither depends on the other). Exportsreset_portaudio()(shared PortAudio reset used by recording and playback),uses_winsound()(single source of truth for which playback path is taken),WINSOUND_AVAILABLEand the winsound lead-in constants. The coordinatingAUDIO_LOCKand pipelineAUDIO_SAMPLE_RATEstay inconfig.pywith the otherAUDIO_*settings. (The synthesis rate is no longer a constant here: it is a property of the active TTS backend,TTSManager.sample_rate.)mimora/tts.py-TTSManager: facade over per-variant synthesis backends selected by profile data (config.TTS_BACKEND, registryTTS_BACKENDS):KokoroBackend(torch, 24 kHz; English) andSupertonicBackend(ONNX, 44.1 kHz; Spanish - weights OpenRAIL-M, cached inmodel_cache/supertonic3/viaSUPERTONIC_CACHE_DIR).synthesize()returns the waveform atTTSManager.sample_rate(the backend's native rate - main.py reads this property everywhere instead of a constant);play_array(waveform, sample_rate)plays any waveform. Also exportsloudness_envelope(waveform, sample_rate, fps)(per-frame RMS → 0..1 track that drives the talking mouth - seeface_widget.py). The PortAudio/winsound device helpers it uses live inmimora/audio_io.py.mimora/face_widget.py-FaceWidget: cartoon articulation face shown on a Tk Canvas. A filled talking mouth opens/closes during playback; a smiley (plus eyebrows and blinking) reflects the score while idle. Frames are rendered by Pillow at 4x supersampling (LANCZOS downscale = antialiasing Tk Canvas lacks) and cached per quantized state, so steady-state animation is an image swap. Deps: stdlibtkinter+ Pillow. The mouth is driven byplay_levels(levels, fps)- a pre-computed loudness track the widget's ownafter-loop replays by wall-clock - rather than a live audio callback (see the Windows-audio note below).mimora/progress_widget.py-ProgressRing: circular session-average gauge on the right of the hero score row (mirrors the face on the left), with a vertical attempt-dot column on its left flank - one dot per take of the current phrase, bottom-up, coloured by quality (theme good/warn/bad from the score fraction); only the last few fit (column height = ring), older dots drop off the bottom. Atk.Frameholding a dots Canvas, the ring Canvas and a count Label (grid, so the count centres under the ring). The ring (track + fill arc with round caps) and the dot column are rendered by Pillow at 4x supersampling (LANCZOS downscale for the antialiasing Tk Canvas lacks); the numbers (average, "/ max", phrase count) are plain Tk text in the app font, so no TrueType font file is needed. Driven byset_progress(value, maximum, count)andset_attempts(scores)(empty list clears the dots -ui.py enter_reference_playingdoes this per new phrase); starts empty ("0 phrases", no fill, no dots). The file is named generically on purpose -ProgressRingis the current shape; a later redesign swaps the class without moving the import. Deps: stdlibtkinter+ Pillow. This holds the session tally that previously sat in the status bar (seeui.py update_session_stats).mimora/lifecycle.py- process-exit helpers, Tk-free:hard_exit()(TerminateProcess on Windows to dodge the CUDA DLL-detach crash,os._exitelsewhere) andspawn_replacement()(detached self-relaunch for the settings restart).main.pykeeps the orchestration:quit_app/restart_apprun_shutdown_runtimefirst (playback, recorder, LLM-server subprocess), then call these.mimora/llm.py-LLMManager: OpenAI-compatible client.generate_phrase()produces one practice phrase per request (non-streaming).mimora/llm_server_ctl.py-LLMServerController: lifecycle of the local LLM-server subprocess (thelocal_serverbackend).start(llm_mgr)launchesllm_server/server.pyand blocks until it answers (readiness polled viaLLMManager.check_connection);shutdown()terminates it with a 5-second kill fallback and is safe to call from two threads at once (loader thread + Tk thread inquit_app).mimora/recorder.py-AudioRecorder: microphone capture thread with silence-based auto-stop, device selection, normalization and the diagnostic WAV dumps; returns the take as one 16 kHz mono array.mimora/translator.py-TranslatorManager: offline NLLB-200 translation of the practice phrase for the translation panel (loaded lazily, CPU by default -config.TRANSLATOR_DEVICE).mimora/loader.py- pure, stateless config-loading helpers (JSON parsing, setting validation, device probe) used byconfig.py. Unit-tested intests/test_loader.py.mimora/phoneme_examples.py- static IPA phoneme → example word table backing the "WORK ON" badges (tooltip text and the example word spoken on a badge click), per language.mimora/phrase_source.py-SourceTextPhraseProvider: the "off" LLM backend's phrase source, stdlib-only (importable without openai). Duck-typed drop-in forLLMManagerin the generation path (main.pyonly callsgenerate_phraseon it): returns the source text's sentences verbatim, sequentially with wraparound; a text edit (detected by hash) restarts from the first sentence. Also home of the sharedsplit_sentenceshelperLLMManager._split_sentencesdelegates to. Unit-tested intests/test_phrase_source.py.mimora/config.py- all configuration (device, model names, score threshold, practice-text path, phrase-generation settings, audio settings) plus the language model:LANGUAGE_PROFILES(language → profile with variants, engines, prompts, texts), assembled from the per-language pure-data modules inmimora/languages/(onePROFILEdict each, e.g.english.py,spanish.py), and the derived per-run constants (PRACTICE_LANGUAGE,ACCENT,TARGET_LANGUAGE,ESPEAK_LANGUAGE,TTS_*,SOURCE_FLORES_CODE,PHONEME_EXPERIMENTAL). Public helpers:language_choices(),accent_choices(language),accent_voices(accent, language),accent_default_voice(accent, language),default_accent(language),available_engines(language),engine_experimental(engine, language, accent),translation_targets(language). User overrides live inconfig/settings.json; UI themes inconfig/themes/. Unit-tested intests/test_language_profiles.py.mimora/settings_ctl.py-SettingsGlue: the persistence mechanics around settings.json -persist(with suppression during reset),persist_and_apply_live(table-driven live config attrs),sync_window(mirror a main-window change into the open settings dialog) andreset_to_defaults(the "Default" button, dispatching only defaults that differ from the live values). Owns theLIVE_CONFIG_ATTRS/SETTING_LIVE_ATTRStables; the dispatch itself (on_setting_changed, theon_*_changedhandlers) stays inmain.py, injected as callbacks. Unit-tested intests/test_settings_ctl.py.mimora/settings_window.py- settings dialog (gear button in the header). Declarative model: every editable settings.json key is oneField(kind, choices, range, restart flag) grouped intoSections;SettingsWindowrenders them into a scrollable column and stays passive - changes go tomain.py on_setting_changed, which applies live effects and persists once viamimora/settings_ctl.py(restart-only keys are just persisted). Restart-only changes surface a "Restart now" offer that relaunches the process (main.py restart_app). The Language field listsconfig.language_choices(); the Accent field follows the selected language and is hidden when it has a single variant; the Engine field offers onlyconfig.available_engines; an experimental notice appears when the selected engine+language runs uncalibrated (config.engine_experimental); "Random voice per phrase" is disabled when the running variant has fewer than two voices. Includes a per-voice "Listen" preview (running language/variant only), "Cancel" (reverts everything changed while the window was open, then closes) and "Default" (SettingsGlue.reset_to_defaults: removes every override from settings.json -config.USER_SETTING_DEFAULTS/reset_user_settings- and applies the built-in defaults live without re-persisting them).llm_server/server.py- standalone FastAPI server loading GGUF viallama_cpp; runs as a separate process to avoid GPU contention.texts/practice_text.txt- default source text loaded into the input panel at startup; the default path comes from the active language profile (texts/practice_text_es.txtfor Spanish).texts/holds additional practice texts.
- Prompt -
llm_mgr.generate_phrase(source_text)→tts_mgr.synthesize(phrase). The synthesized array is stored asself.reference_audioand played for the user. Phrase generation + synth + playback all run in one daemon thread (_generate_and_prompt). - Record - shared recording path (
AudioRecorder._record_loop→AudioRecorder.get_audio), 16 kHz mono. Gated by_can_record()(a phrase must be ready and nothing else busy). - Analyze -
_finalize_recording→analyze_recording(daemon thread) callsengine.analyze(...)- the dispatcher inmimora/engine.py, which selectspronunciation.acousticorpronunciation.phonemebyconfig.ENGINE. - Feedback -
_show_feedback(viaroot.after) fills the hero card (score, verdict, "WORK ON" phoneme badges, underlined problem words in the phrase) and appends the take to the attempt-history list; enables replay buttons. - Loop - the user decides when to move on: generate the next phrase, or record the same one again (the phrase/reference are retained until a new phrase is generated).
result.passedis not enforced yet - no code inmain.py/ui.pyreads it, so nothing auto-advances or forces a repeat based on the verdict. See the Pass-threshold note under Key Patterns & Gotchas.
-
Threading: Recording, analysis, model loading, phrase generation, and playback run in daemon threads. Always update the GUI via
root.after(); never read/write Tk widgets from a background thread. Source text is read on the main thread and passed into the worker. -
Reference audio is synthesized once (
mimora/tts.pysynthesize()): the same waveform is both played to the user and passed toanalyze()as the reference. Only ONE synthesis backend runs per session (the one the active variant selects); the backends never mix within a run. -
Sample rates: recording uses 16 kHz; the TTS backend outputs its native rate (Kokoro 24 kHz, Supertonic 44.1 kHz) - main.py reads it from
TTSManager.sample_rate, never a constant; Wav2Vec2 needs 16 kHz.engine.analyzetakesuser_srandreference_srand_prepare_waveformresamples to 16 kHz internally.play_arrayplays the reference at the backend rate and the user recording at 16 kHz. -
Pronunciation model lifecycle (
pronunciation/acoustic/speech.py): models load lazily;load_models()makes loading explicit (call in a background thread at startup) andwarm_up()removes first-call latency - mirroringmimora/tts.py. Device followsconfig.WAV2VEC2_DEVICE(defaults toconfig.DEVICE).speech.pyreads config viagetattr(..., default)so it stays usable without config edits. -
Pass threshold /
result.passedare currently inert (reserved for a future gating loop):config.PRONUNCIATION_SCORE_THRESHOLD(settings.json"pronunciation_score_threshold"; not exposed in the settings window) feeds each engine'sscore_thresholdand producesresult.passed- the acoustic engine always asscore >= threshold(pronunciation/acoustic/speech.py), the phoneme engine only as a fallback when no calibrated bucket exists (pronunciation/phoneme/speech.py; normallypassed = bucket >= PASS_BUCKET, so the threshold is bypassed on the default calibrated path). Butresult.passedis not consumed by the app:ui.py show_feedbackderives the score read-out, quality band, face and history from the score/bucket, not frompassed, and the phonemefeedbackstring ("(passed)"/"(try again)") is never rendered. So today the Pass-threshold value has no visible effect - it is computed and logged only, kept as a hook for a future pass/repeat gate. When that gate is implemented, wire it toresult.passed(and account for the phoneme calibrated-bucket path, where the raw threshold does not apply). -
GPU contention: Wav2Vec2, Kokoro, and
llama_cppcan compete for VRAM. Mitigations: the LLM runs in a separate process (llm_server/), and the loop's phases (LLM → Kokoro → Wav2Vec2) run sequentially. If VRAM is tight, setWAV2VEC2_DEVICE = "cpu"inmimora/config.py. -
Phrase generation (
mimora/llm.py):generate_phrase()is a stateless, non-streaming completion with its own system prompt (config.PHRASE_GEN_SYSTEM_PROMPT, a{min_words}/{max_words}format template); each request builds its messages from scratch (there is no conversational history). To keep phrases varied, the prompt only includes a sliding window of the source text (_current_window, advanced everyPHRASE_GEN_WINDOW_REPEATScalls) plus a random focus word and opening-style hint;_clean_phrasestrips quotes/list markers. A proficiency level 0-5 (config.PHRASE_GEN_LEVEL, settings.json"phrase_gen_level", live) selects per-language constraints from the profile'sphrase_gen["levels"](vocab/grammar hints folded into the system prompt - kept stable across calls for the llama.cpp prefix cache - word range, wordfreq Zipf floor); the generated phrase is validated against them (_fits_level) with at mostPHRASE_GEN_LEVEL_RETRIES(=1) regeneration, then accepted as-is (soft degradation; samples logged tologs/phrase_level_samples.jsonlfor threshold tuning). Seetasks/phrase_level_task.md(in prototypes-pronunciation). -
Audio normalization (
main.py): peaks are normalized before analysis; silence belowAUDIO_MIN_PEAK_THRESHOLD = 0.01skips gain adjustment. -
Windows audio: TTS/playback uses
winsoundto bypass PortAudio/MME issues; asounddevicefallback exists for other platforms. A ~150 ms silence lead-in is prepended to avoid clipping the first audio.config.AUDIO_LOCKserialises PortAudio init/teardown between the mic and speaker paths. -
Talking mouth without an audio callback:
winsound.PlaySoundplays the whole buffer with no per-frame hook, so the face cannot follow live amplitude on Windows. Instead the loudness envelope is pre-computed from the (fully known) waveform viatts.loudness_envelope()and the face replays it on its own wall-clockafter-loop (FaceWidget.play_levels).PlaybackController.play_with_face()(mimora/playback.py) is the single chokepoint wrapping everyplay_arraycall: it starts the track, plays, then closes the mouth - and prepends closed-mouth frames matchingTTSManager.playback_lead_in_seconds()so the animation lines up with the Windows warm-up silence. The same path is used on all platforms (no live-RMS branch). Because the envelope uses the samesample_rateas playback, the slowed-reference speed (lowered sample rate) stretches the mouth track automatically._rest_face_if_current()stops the mouth on finish/interrupt without clobbering a playback that superseded it (same identity-guard idea asPlaybackController.finished). -
LLM server subprocess (
mimora/llm_server_ctl.py): started fromload_components()viaLLMServerController.start(), which pollsLLMManager.check_connection()until ready; terminated viaLLMServerController.shutdown()(called from_shutdown_runtimeon quit/restart) with a 5-second kill fallback. -
Device detection (
mimora/config.py): CUDA auto-detected viatorch.cuda.is_available(). -
espeak-ng is a native binary dependency of
phonemizer(separate install, not pip) - required for phoneme extraction and word-error analysis.
python -m unittest discover -s tests -v # all fast unit tests, no model download
python tests/test_speech.py user.wav [ref.wav] # optional end-to-end (loads the model)- No linting/formatting config - follow PEP 8.
- Type hints used throughout (
from typing import Optional, List). - Logging via
logging:%(asctime)s [%(levelname)s] (%(threadName)s) %(message)s. - Use explicit
RuntimeErrorwith a descriptive message for runtime validation, notassert. - Library deprecation warnings are filtered in
mimora/bootstrap.py(early_init(), called at the top ofmain.pybefore the heavy imports).