diff --git a/CONTEXT.md b/CONTEXT.md index 04b35d5..af2cee9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -97,3 +97,15 @@ A Piper TTS-generated MP3 of one unit of **Input Language** text: the of a **Q&A Pair**. Paths stored as `sentence_input_language_audio_path`, `question_input_language_audio_path`, `answer_input_language_audio_path`. _Avoid_: native audio, input audio + +### User Config +Per-user YAML file at `~/.config/efunk_lingo/lingoDiary/{username}/config.yaml` +containing language settings, Piper TTS voice selections, OpenAI API key, output +directory path, and processing options (repeat counts, pause durations, template +markers for diary parsing). + +### Piper Voice +A TTS voice identifier recognized by the `ovos-tts-plugin-piper` package, +organized by language code (e.g. `en`, `no`) and gender (`male` or `female`). +Examples: `alan-low` (English male), `talesyntese-medium` (Norwegian female). +Voice availability is auto-discovered and stored in `scripts/piper_voices.yaml`. diff --git a/README.md b/README.md index 19cf191..bc41c2a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,60 @@ services: SECRET_KEY: "change-me-to-a-random-string" ``` -Then create the directories and config files the container expects (replace `myuser` with your username): +### Creating user configs + +**Option A: Automated setup tool** (recommended) + +Use the interactive `manage-config.py` script to create and manage user configurations: + +```bash +# From the repo directory +python scripts/manage-config.py add-user +``` + +The script will guide you through: +1. **Username** — your login name +2. **Password** — stored with bcrypt hashing +3. **Primary Language** — language you write your diary in (menu: english, norwegian, french, etc.) +4. **Study Language** — language you're learning (menu: english, norwegian, french, etc.) +5. **Gender** — for grammatical output (male/female) +6. **Voice selections** — TTS voices for study language and primary language +7. **OpenAI API key** — for translation and tips generation + +This automatically creates: +- `~/.config/lingoDiary/users.yaml` (if not exists) +- `~/.config/lingoDiary/{username}/config.yaml` + +**Option B: Custom config directory** + +If you use a different config location: + +```bash +python scripts/manage-config.py add-user -o ~/my-lingodiary-config +``` + +**Option C: Custom docker-compose.yml path** + +```bash +python scripts/manage-config.py add-user -i /path/to/docker-compose.yml +``` + +### Managing users + +```bash +# List all registered users +python scripts/manage-config.py list-users + +# Modify an existing user's config +python scripts/manage-config.py modify-user myuser + +# Delete a user (backs up config automatically) +python scripts/manage-config.py delete-user myuser +``` + +### Manual setup (if not using the tool) + +If you prefer to create configs manually, replace `myuser` with your username: ```bash mkdir -p ~/.config/lingoDiary/myuser @@ -50,14 +103,18 @@ users: Create `~/.config/lingoDiary/myuser/config.yaml`: ```yaml +output_dir: "/data/myuser/" + +create_diary_answers_auto: true + openai: key: "sk-..." model: "gpt-4o-mini" languages: - primary_language: "english" # the language you write your diary in + primary_language: "english" primary_language_code: "en" - study_language: "norwegian" # the language you are learning + study_language: "norwegian" study_language_code: "no" gender: "male" @@ -65,116 +122,17 @@ gender: "male" tts: model: "piper" piper: - voice: "talesyntese-medium" # study language voice - piper_length_scale_tprs: 2 # speech speed (higher = slower) + piper_length_scale_tprs: 2 piper_length_scale_diary: 2 + voice: "talesyntese-medium" piper_input_language: - voice: "en_GB-alan-medium" # native language voice (enables Drive Mode) + voice: "alan-low" repeat_sentence_tprs: 2 repeat_sentence_diary: 2 pause_between_sentences_duration: 600 answer_silence_duration: 5000 ``` -
-Available Piper voices - -Use the **short name** (e.g. `talesyntese-medium`, `alan-medium`, `ryan-medium`). Both short form and full `lang_REGION-name-quality` form are accepted. Qualities: `x_low` < `low` < `medium` < `high`. - -**Albanian** — `edon-medium` - -**Arabic** — `kareem-low`, `kareem-medium` - -**Basque** — `antton-medium`, `maider-medium` - -**Bulgarian** — `dimitar-medium` - -**Catalan** — `upc_ona-medium`, `upc_ona-x_low`, `upc_pau-x_low` - -**Chinese** — `huayan-medium`, `huayan-x_low` - -**Czech** — `jirka-low`, `jirka-medium` - -**Danish** — `talesyntese-medium` - -**Dutch (BE)** — `nathalie-medium`, `nathalie-x_low`, `rdh-medium`, `rdh-x_low` - -**Dutch (NL)** — `alex-medium`, `mls-medium`, `pim-medium`, `ronnie-medium` - -**English (GB)** — `alan-low`, `alan-medium`, `alba-medium`, `aru-medium`, `cori-high`, `cori-medium`, `jenny_dioco-medium`, `northern_english_male-medium`, `semaine-medium`, `southern_english_female-low`, `vctk-medium` - -**English (US)** — `amy-low`, `amy-medium`, `arctic-medium`, `bryce-medium`, `danny-low`, `hfc_female-medium`, `hfc_male-medium`, `joe-medium`, `john-medium`, `kathleen-low`, `kristin-medium`, `kusal-medium`, `lessac-high`, `lessac-low`, `lessac-medium`, `libritts-high`, `libritts_r-medium`, `ljspeech-high`, `ljspeech-medium`, `norman-medium`, `ryan-high`, `ryan-low`, `ryan-medium` - -**Farsi** — `amir-medium`, `ganji-medium`, `gyro-medium` - -**Finnish** — `harri-low`, `harri-medium` - -**French** — `gilles-low`, `mls-medium`, `mls_1840-low`, `siwis-low`, `siwis-medium`, `tom-medium`, `upmc-medium` - -**Georgian** — `natia-medium` - -**German** — `eva_k-x_low`, `karlsson-low`, `kerstin-low`, `pavoque-low`, `ramona-low`, `thorsten-high`, `thorsten-low`, `thorsten-medium`, `thorsten_emotional-medium` - -**Greek** — `rapunzelina-low`, `rapunzelina-medium` - -**Hindi** — `pratham-medium`, `priyamvada-medium`, `rohan-medium` - -**Hungarian** — `anna-medium`, `berta-medium`, `imre-medium` - -**Icelandic** — `bui-medium`, `salka-medium`, `steinn-medium`, `ugla-medium` - -**Indonesian** — `news_tts-medium` - -**Italian** — `paola-medium`, `riccardo-x_low` - -**Kazakh** — `iseke-x_low`, `issai-high`, `raya-x_low` - -**Luxembourgish** — `marylux-medium` - -**Nepali** — `chitwan-medium`, `google-medium`, `google-x_low` - -**Norwegian** — `nvcc-medium`, `talesyntese-medium` - -**Polish** — `bass-high`, `darkman-medium`, `gosia-medium`, `mc_speech-medium`, `mls_6892-low` - -**Portuguese (BR)** — `cadu-medium`, `edresson-low`, `faber-medium`, `jeff-medium` - -**Portuguese (PT)** — `tugão-medium` - -**Romanian** — `mihai-medium` - -**Russian** — `denis-medium`, `dmitri-medium`, `irina-medium`, `ruslan-medium` - -**Serbian** — `serbski_institut-medium` - -**Slovak** — `lili-medium` - -**Slovenian** — `artur-medium` - -**Spanish (AR)** — `daniela-high` - -**Spanish (MX)** — `ald-medium`, `claude-high` - -**Spanish (ES)** — `carlfm-x_low`, `davefx-medium`, `mls_10246-low`, `mls_9972-low`, `sharvard-medium` - -**Swahili** — `lanfrica-medium` - -**Swedish** — `alma-medium`, `lisa-medium`, `nst-medium` - -**Telugu** — `maya-medium`, `padmavathi-medium`, `venkatesh-medium` - -**Turkish** — `dfki-medium` - -**Ukrainian** — `lada-x_low`, `mykyta-high`, `oleksa-high`, `tetiana-high`, `ukrainian_tts-medium` - -**Urdu** — `fasih-medium` - -**Vietnamese** — `25hours_single-low`, `vais1000-medium`, `vivos-x_low` - -**Welsh** — `bu_tts-medium`, `gwryw_gogleddol-medium` - -
- Pull the image and start: ```bash @@ -220,3 +178,23 @@ To build the Android APK locally: cd android_app && flutter build apk --release # APK: android_app/build/app/outputs/flutter-apk/app-release.apk ``` + +--- + +## Developer: Managing Piper Voices + +If you add new Piper TTS voice models to the Docker image, you must regenerate the voice list: + +```bash +# 1. Start the container +docker compose up -d + +# 2. Run voice discovery inside the container +docker compose exec lingo-diary python scripts/discover-voices.py + +# 3. Commit the updated voice list +git add scripts/piper_voices.yaml +git commit -m "chore: update available Piper voices" +``` + +This creates `scripts/piper_voices.yaml` which is used by the configuration tool (`scripts/manage-config.py`) to help users select voices when creating accounts. diff --git a/docs/CONFIG_MANAGEMENT.md b/docs/CONFIG_MANAGEMENT.md new file mode 100644 index 0000000..91c104a --- /dev/null +++ b/docs/CONFIG_MANAGEMENT.md @@ -0,0 +1,265 @@ +# User Configuration Management + +LingoDiary provides two tools for managing user configs and Piper TTS voices: + +## Tools + +### `scripts/discover-voices.py` + +Discovers available Piper TTS voices and saves them to `scripts/piper_voices.yaml`. + +**Usage:** +```bash +python scripts/discover-voices.py +``` + +**How it works:** +1. Connects to the `lingo-diary` Docker container via `docker compose exec` +2. Scans the Piper models directory (`~/.local/share/piper/models/`) +3. Falls back to a built-in list of known Piper voices if models are not found +4. Organizes voices by language code and gender (male/female) +5. Saves to `scripts/piper_voices.yaml` for use by `manage-config.py` + +**Output:** +```yaml +languages: + en: + male: + - alan-low + - en-US-lessac-low + female: + - en-US-ryan-high + no: + male: + - talesyntese-male + female: + - talesyntese-medium + # ... more languages +``` + +### `scripts/manage-config.py` + +Interactive CLI tool for creating, updating, and deleting user configurations. + +**Config directory resolution** (in priority order): +1. `-o/--output` flag (overrides everything) +2. Parse `docker-compose.yml` to extract `/app/.config/lingoDiary/` volume mount +3. Fallback: `~/.config/efunk_lingo/lingoDiary/` + +**Usage (interactive menu):** +```bash +python scripts/manage-config.py +``` + +**Usage (with custom config directory):** +```bash +python scripts/manage-config.py -o /path/to/config add-user +python scripts/manage-config.py -i /path/to/docker-compose.yml add-user +``` + +**Usage (direct commands):** +```bash +python scripts/manage-config.py add-user +python scripts/manage-config.py modify-user [username] +python scripts/manage-config.py delete-user [username] +python scripts/manage-config.py list-users + +# With options: +python scripts/manage-config.py -o ~/.config/custom add-user +python scripts/manage-config.py -i docker-compose.yml list-users +``` + +**Options:** +- `-i, --input FILE` — Path to docker-compose.yml (default: ../docker-compose.yml) +- `-o, --output DIR` — Config directory (overrides docker-compose.yml volume) + +## Configuration Files + +### `users.yaml` + +Located at: `~/.config/efunk_lingo/lingoDiary/users.yaml` + +System-wide user registry with password hashes: +```yaml +users: + laurent: + password: "$2b$12$..." # bcrypt hash + language: "en" # interface language + johanne: + password: "$2b$12$..." + language: "fr" +``` + +**Managed by:** `manage-config.py add-user`, `modify-user`, `delete-user` + +### Per-User Config + +Located at: `~/.config/efunk_lingo/lingoDiary/{username}/config.yaml` + +Per-user settings for language learning, TTS, and content generation: +```yaml +output_dir: "/data/laurent/" +gender: "male" + +languages: + primary_language: "English" + primary_language_code: "en" + study_language: "Norwegian" + study_language_code: "no" + +tts: + piper: + voice: "talesyntese-medium" # output language voice + piper_input_language: + voice: "alan-low" # input language voice (Drive Mode) + +openai: + key: "sk-..." # OpenAI API key +``` + +**Managed by:** `manage-config.py add-user`, `modify-user` + +## Workflow + +### Adding a New User + +```bash +python scripts/manage-config.py add-user +``` + +Prompts for: +1. **Username** — unique identifier +2. **Password** — bcrypt hashed before storage +3. **Primary Language** — language they write in (e.g., "English (en)") +4. **Study Language** — language they're learning (e.g., "Norwegian (no)") +5. **Gender** — male/female (used for grammar output) +6. **Study Language Voice** — Piper voice for output language audio +7. **Primary Language Voice** — Piper voice for input language audio (Drive Mode) +8. **OpenAI API Key** — for translation and Q&A generation + +Creates: +- Entry in `users.yaml` with password hash +- Directory `~/.config/efunk_lingo/lingoDiary/{username}/` +- Config file `{username}/config.yaml` with default values + +### Modifying a User + +```bash +python scripts/manage-config.py modify-user [username] +``` + +Shows current config values and lets you update any field by pressing Enter to skip. + +### Deleting a User + +```bash +python scripts/manage-config.py delete-user [username] +``` + +1. **Backs up** the user's config to `~/.config/efunk_lingo/lingoDiary/backups/{username}_{timestamp}.tar.gz` +2. **Removes** the config directory +3. **Deletes** the user entry from `users.yaml` + +Confirm deletion by typing the username. + +## Security Notes + +### Password Storage +- Passwords are hashed using **bcrypt** with salt +- Hashes are stored in `users.yaml` +- Never commit real passwords to version control + +### OpenAI API Keys +- Keys are stored in plaintext in `config.yaml` +- File permissions are set to `0o600` (owner only) +- **Keep file permissions tight** — run `chmod 600 ~/.config/efunk_lingo/lingoDiary/*/config.yaml` +- **Rotate keys periodically** if exposed +- **Never commit real keys** to version control + +### File Permissions +After setup, ensure restrictive permissions: +```bash +chmod 600 ~/.config/efunk_lingo/lingoDiary/users.yaml +chmod 600 ~/.config/efunk_lingo/lingoDiary/*/config.yaml +``` + +## Example: Adding Laurent's Config + +```bash +$ python scripts/manage-config.py add-user + +Username: laurent +Password: •••••••• +Confirm password: •••••••• + +Primary Language []: English (en) +Study Language []: Norwegian (no) +Gender []: + 1. male + 2. female +Enter choice: 1 + +Study Language Voice (male voices for no): + 1. talesyntese-male +Enter choice: 1 + +Primary Language Voice (male voices for en): + 1. alan-low + 2. en-US-artic-neural-8000hz + ... +Enter choice: 1 + +OpenAI Key: sk-proj-... + +✓ User 'laurent' added to users.yaml +✓ Config created: /home/user/.config/efunk_lingo/lingoDiary/laurent/config.yaml +✓ User 'laurent' set up successfully! +``` + +## Regenerating Voice List (Developer Task) + +After installing new Piper voice models in the Docker container, you must regenerate the voice list so users can select them: + +```bash +# 1. Start the container +docker compose up -d + +# 2. Run voice discovery inside the container +docker compose exec lingo-diary python scripts/discover-voices.py + +# 3. Commit the updated voice list +git add scripts/piper_voices.yaml +git commit -m "chore: update available Piper voices" +``` + +This updates `scripts/piper_voices.yaml`, which is checked into the repository and used by `manage-config.py` for voice selection during user setup. + +## Troubleshooting + +### "No voices found" when running discover-voices + +**Possible causes:** +- Docker container not running: `docker compose up -d` +- Piper models not installed in container (expected on first run) + +**Solution:** The tool uses a fallback list of known Piper voices. To get specific voices, download them into the container's `~/.local/share/piper/models/` directory. + +### "User config not found" when starting the app + +**Cause:** User added to `users.yaml` but config file is missing + +**Solution:** +```bash +python scripts/manage-config.py modify-user username +``` +Then save (press Enter on all fields to use defaults). + +### OpenAI key errors in logs + +**Cause:** Key missing or invalid + +**Solution:** +```bash +python scripts/manage-config.py modify-user username +``` +Then update the OpenAI key when prompted. diff --git a/scripts/discover-voices.py b/scripts/discover-voices.py new file mode 100755 index 0000000..58dff51 --- /dev/null +++ b/scripts/discover-voices.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +""" +Discover available Piper TTS voices in the installed ovos-tts-plugin-piper. + +This script queries the Piper plugin's available voices and organizes them by +language and gender (male/female). The output is saved to scripts/piper_voices.yaml. + +Usage: + # From repo root: python scripts/discover-voices.py + # It will use docker compose exec to query voices inside the container +""" + +import sys +import subprocess +import yaml +import json +from pathlib import Path +from collections import defaultdict + + +def extract_language_and_gender(voice_name: str) -> tuple[str, str]: + """ + Extract language code and gender from a Piper voice name. + + Expected format: {lang-REGION}[-{gender}[-{variant}[-{speed}]]] + Examples: + - "en-US" -> (en, unknown) + - "en-US-male" -> (en, male) + - "en-US-female-medium" -> (en, female) + - "talesyntese-medium" -> (no, unknown) [Norwegian specific] + - "alan-low" -> (en, male) [assumed from context] + + Returns: + Tuple of (language_code, gender) where gender is "male", "female", or "unknown" + """ + parts = voice_name.split("-") + + # Language is always the first part (e.g., "en" from "en-US-male") + lang = parts[0].lower() + + # Check if there's a gender indicator (typically 2nd or 3rd part) + gender = "unknown" + for part in parts[1:]: + if part.lower() in ("male", "female"): + gender = part.lower() + break + + # Heuristic fixes for known voices: + # "alan-low" is English male + if voice_name.lower() == "alan-low": + lang = "en" + gender = "male" + # "talesyntese-medium" is Norwegian (female by default in Piper) + elif "talesyntese" in voice_name.lower(): + lang = "no" + if gender == "unknown": + gender = "female" + + return lang, gender + + +def query_piper_via_docker() -> dict: + """Query Piper voices by running a script inside the Docker container.""" + print("Querying Piper voices from Docker container...") + + # Create a simple script that scans the Piper models directory + query_script = """ +import json +from pathlib import Path +try: + # Scan the Piper models directory + model_paths = [ + Path.home() / ".local" / "share" / "piper" / "models", + Path("/tmp/piper_models"), + Path("/app/.local/share/piper/models"), + ] + + voices_dict = {} + for model_path in model_paths: + if model_path.exists(): + for model_file in model_path.glob("*.onnx"): + voice_name = model_file.stem + voices_dict[voice_name] = str(model_file) + if voices_dict: + break # Found models, stop searching + + # Also try to use Piper's voice listing if available + if not voices_dict: + try: + import piper_tts + piper_models = piper_tts.piper_models.get_available_languages() + if isinstance(piper_models, dict): + for lang, variants in piper_models.items(): + if isinstance(variants, dict): + for variant in variants.keys(): + voice_name = f"{lang}-{variant}" + voices_dict[voice_name] = voice_name + except: + pass + + if voices_dict: + print(json.dumps({"voices": sorted(voices_dict.keys()), "error": None})) + else: + print(json.dumps({"voices": [], "error": "No Piper models found"})) +except Exception as e: + import traceback + print(json.dumps({"voices": [], "error": str(e)})) +""" + + try: + result = subprocess.run( + [ + "docker", + "compose", + "exec", + "-T", + "lingo-diary", + "python", + "-c", + query_script, + ], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, # Run from repo root + ) + + if result.returncode != 0: + print(f"⚠ Docker query failed, using fallback voice list...") + return None + + # Parse JSON output + output = result.stdout.strip() + try: + data = json.loads(output) + if data.get("error"): + print(f"⚠ {data['error']}, using fallback voice list...") + return None + return {"voices": data.get("voices", [])} + except json.JSONDecodeError: + print(f"⚠ Could not parse Docker output, using fallback voice list...") + return None + + except FileNotFoundError: + print("⚠ docker compose not found, using fallback voice list...") + return None + except Exception as e: + print(f"⚠ {e}, using fallback voice list...") + return None + + +# Canonical language code to name mapping (lowercase) +LANGUAGE_NAMES = { + "en": "english", + "no": "norwegian", + "fr": "french", + "de": "german", + "es": "spanish", + "it": "italian", + "pt": "portuguese", + "ru": "russian", + "zh": "chinese", + "ja": "japanese", + "ko": "korean", +} + +# Known Piper voices as fallback (from Hugging Face Piper repository) +FALLBACK_VOICES = { + "en": { + "name": "english", + "male": [ + "alan-low", + "en-US-artic-neural-8000hz", + "en-US-glow-tts", + "en-US-hfc-neural", + "en-US-lessac-low", + "en-US-lessac-medium", + "en-US-libritts", + "en-US-ljspeech", + "en-US-rms-low", + "en-GB-alba-medium", + "en-GB-alan-medium", + ], + "female": [ + "en-US-amy-medium", + "en-US-azure-neural", + "en-US-lessac-high", + "en-US-libritts-high", + "en-US-ljspeech-high", + "en-US-ryan-high", + "en-US-ryan-low", + "en-US-ryan-medium", + ], + }, + "no": { + "name": "norwegian", + "female": ["talesyntese-medium"], + "male": ["talesyntese-male"], + }, + "fr": { + "name": "french", + "male": ["fr-FR-gilles-low"], + "female": ["fr-FR-siwis-medium"], + }, + "de": { + "name": "german", + "male": ["de-DE-karlsson-low", "de-DE-thorsten-high"], + "female": ["de-DE-eva_k-x-low", "de-DE-eva_k-medium", "de-DE-kerstin-low"], + }, + "es": { + "name": "spanish", + "male": ["es-ES-carlos-low", "es-MX-carlos-low"], + "female": ["es-ES-tania-medium"], + }, + "it": { + "name": "italian", + "male": ["it-IT-riccardo-x-low"], + "female": ["it-IT-riccardo_riccardo-x-low"], + }, + "pt": { + "name": "portuguese", + "male": ["pt-BR-faber-medium"], + "female": ["pt-PT-fernanda-medium"], + }, + "ru": { + "name": "russian", + "male": ["ru-RU-igor-medium"], + "female": ["ru-RU-natasha-medium"], + }, + "zh": {"name": "chinese", "female": ["zh-CN-huayan-x-low"]}, + "ja": {"name": "japanese", "female": ["ja-JP-kokoro-medium"]}, + "ko": {"name": "korean", "female": ["ko-KR-kss-medium"]}, +} + + +def discover_voices(): + """Query Piper plugin and build voice index organized by language and gender.""" + print("=" * 70) + print("Piper Voice Discovery Tool") + print("=" * 70) + + result = query_piper_via_docker() + + if result is None or not result.get("voices"): + print("\n→ Using fallback voice list (known Piper voices)") + # Use fallback voices directly (already organized by language/gender) + organized = FALLBACK_VOICES.copy() + else: + voices_list = result["voices"] + print(f"✓ Found {len(voices_list)} voices from Docker container") + + # Organize by language and gender + languages = defaultdict(lambda: {"male": [], "female": [], "unknown": []}) + + for voice_name in voices_list: + lang_code, gender = extract_language_and_gender(voice_name) + if gender == "unknown": + # Try to infer from voice name patterns + if "female" in voice_name.lower() or "woman" in voice_name.lower(): + gender = "female" + elif "male" in voice_name.lower() or "man" in voice_name.lower(): + gender = "male" + + languages[lang_code][gender].append(voice_name) + + # Remove "unknown" gender category if empty, add language names + organized = {} + for lang_code in sorted(languages.keys()): + lang_data = languages[lang_code] + organized[lang_code] = {"name": LANGUAGE_NAMES.get(lang_code, lang_code)} + for gender in ["male", "female", "unknown"]: + if lang_data[gender]: + organized[lang_code][gender] = sorted(lang_data[gender]) + + # Print processing log + print("\nVoices organized by language and gender:") + for lang_code, genders in sorted(organized.items()): + # Skip "name" field in display + display_genders = {k: v for k, v in genders.items() if k != "name"} + for gender, voice_names in sorted(display_genders.items()): + for voice_name in voice_names: + print(f" {voice_name:40} → {lang_code} / {gender}") + + return organized + + +def save_voices_config(voices: dict): + """Save the discovered voices to scripts/piper_voices.yaml.""" + # Get the scripts directory (same directory as this file) + script_dir = Path(__file__).parent + output_file = script_dir / "piper_voices.yaml" + + config = { + "languages": voices, + "_metadata": { + "description": "Available Piper TTS voices, organized by language and gender.", + "note": "Regenerate this file by running: python scripts/discover-voices.py", + }, + } + + try: + with open(output_file, "w") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + print(f"\n✓ Voices saved to: {output_file}") + return output_file + except Exception as e: + print(f"ERROR: Failed to save voices: {e}") + sys.exit(1) + + +if __name__ == "__main__": + voices = discover_voices() + + if voices: + output_file = save_voices_config(voices) + + print("\nVoice summary:") + for lang, genders in sorted(voices.items()): + total = sum(len(voices) for voices in genders.values()) + print(f" {lang}: {total} voices") + for gender, voice_list in sorted(genders.items()): + if voice_list: + print(f" {gender}: {len(voice_list)} voices") + + print("\n✓ Discovery complete!") + else: + sys.exit(1) diff --git a/scripts/manage-config.py b/scripts/manage-config.py new file mode 100755 index 0000000..1dc7ae5 --- /dev/null +++ b/scripts/manage-config.py @@ -0,0 +1,802 @@ +#!/usr/bin/env python3 +""" +Interactive user configuration manager for LingoDiary. + +Manage users and their configurations: + - add-user: Create a new user with interactive prompts + - modify-user: Update an existing user's configuration + - delete-user: Remove a user and back up their config + +Usage: + python scripts/manage-config.py [command] [options] + +Options: + -i, --input FILE Path to docker-compose.yml (default: ../docker-compose.yml) + -o, --output DIR Config directory (overrides docker-compose.yml volume) + +Examples: + python scripts/manage-config.py add-user + python scripts/manage-config.py add-user -o ~/.config/lingoDiary + python scripts/manage-config.py add-user -i /path/to/docker-compose.yml +""" + +import sys +import os +import yaml +import getpass +import tarfile +import argparse +from pathlib import Path +from datetime import datetime +from typing import Dict, Optional, Any +from platformdirs import user_config_dir + +try: + import bcrypt +except ImportError: + print("ERROR: bcrypt is not installed. Install with: pip install bcrypt") + sys.exit(1) + + +def resolve_config_dir( + input_file: Optional[str] = None, output_dir: Optional[str] = None +) -> Path: + """ + Resolve the configuration directory using the priority: + 1. -o/--output flag (overrides everything) + 2. Parse docker-compose.yml to extract /app/.config/lingoDiary/ volume mount + 3. Fallback to ~/.config/efunk_lingo/lingoDiary/ + + Args: + input_file: Path to docker-compose.yml (from -i flag) + output_dir: Config directory (from -o flag) + + Returns: + Path object pointing to the config directory + """ + # Option 1: -o flag overrides everything + if output_dir: + config_dir = Path(output_dir).expanduser() + return config_dir + + # Option 2: Parse docker-compose.yml + docker_compose_path = None + + if input_file: + docker_compose_path = Path(input_file) + else: + # Default: look in parent directory of this script + default_path = Path(__file__).parent.parent / "docker-compose.yml" + if default_path.exists(): + docker_compose_path = default_path + + if docker_compose_path and docker_compose_path.exists(): + try: + config_dir = extract_config_from_docker_compose(docker_compose_path) + if config_dir: + return config_dir + except Exception as e: + print(f"⚠ Could not parse {docker_compose_path}: {e}") + + # Option 3: Fallback + fallback = Path.home() / ".config" / "efunk_lingo" / "lingoDiary" + print(f"ℹ Using config directory: {fallback}") + return fallback + + +def extract_config_from_docker_compose(compose_path: Path) -> Optional[Path]: + """ + Extract the host path for /app/.config/lingoDiary/ from docker-compose.yml. + + Example: + volumes: + - ~/.config/efunk_lingo/lingoDiary/:/app/.config/lingoDiary/ + + Returns: + Path object pointing to the host directory, or None if not found + """ + try: + with open(compose_path, "r") as f: + compose = yaml.safe_load(f) + + if not compose: + return None + + # Look for lingo-diary service + services = compose.get("services", {}) + lingo_service = services.get("lingo-diary", {}) + volumes = lingo_service.get("volumes", []) + + # Find volume entry with /app/.config/lingoDiary/ + for volume_entry in volumes: + if isinstance(volume_entry, str): + # Format: "host_path:/container_path" or "named_volume:/container_path" + if ":/app/.config/lingoDiary/" in volume_entry: + host_path = volume_entry.split(":")[0] + resolved_path = Path(host_path).expanduser() + print( + f"ℹ Found config directory in docker-compose.yml: {resolved_path}" + ) + return resolved_path + + return None + except Exception as e: + print(f"⚠ Error parsing docker-compose.yml: {e}") + return None + + +APP_NAME = "lingoDiary" +CONFIG_DIR = Path(user_config_dir(APP_NAME)) +USERS_FILE = CONFIG_DIR / "users.yaml" +BACKUPS_DIR = CONFIG_DIR / "backups" +VOICES_FILE = Path(__file__).parent / "piper_voices.yaml" + +# Default config template +DEFAULT_CONFIG_TEMPLATE = """# LingoDiary User Configuration +# Generated by manage-config.py + +output_dir: "/data/{username}/" + +# Content generation +create_diary_answers_auto: true + +# OpenAI Configuration +openai: + key: "{openai_key}" + model: "gpt-4o-mini" + +# Grammar output +gender: "{gender}" + +# Languages +languages: + primary_language: "{primary_language}" + primary_language_code: "{primary_language_code}" + study_language: "{study_language}" + study_language_code: "{study_language_code}" + +# Text-to-Speech (Piper) +tts: + model: "piper" + piper: + piper_length_scale_diary: 2 + piper_length_scale_tprs: 2 + voice: "{study_voice}" + piper_input_language: + voice: "{primary_voice}" + repeat_sentence_tprs: 2 + repeat_sentence_diary: 2 + pause_between_sentences_duration: 600 + answer_silence_duration: 5000 +""" + + +class ConfigManager: + def __init__(self, config_dir: Path): + self.config_dir = config_dir + self.users_file = self.config_dir / "users.yaml" + self.backups_dir = self.config_dir / "backups" + self.voices_file = Path(__file__).parent / "piper_voices.yaml" + + # Ensure directories exist + self.config_dir.mkdir(parents=True, exist_ok=True) + self.backups_dir.mkdir(parents=True, exist_ok=True) + + # Ensure users.yaml exists + if not self.users_file.exists(): + self._initialize_users_file() + + # Load users and voices + self.users = self._load_users() + self.voices = self._load_voices() + + def _initialize_users_file(self): + """Create an empty users.yaml if it doesn't exist.""" + try: + with open(self.users_file, "w") as f: + yaml.dump({"users": {}}, f) + os.chmod(self.users_file, 0o600) + except Exception as e: + print(f"ERROR: Failed to create users.yaml: {e}") + sys.exit(1) + + def _load_users(self) -> Dict[str, Any]: + """Load users.yaml or return empty dict if not found.""" + if not self.users_file.exists(): + return {"users": {}} + try: + with open(self.users_file, "r") as f: + return yaml.safe_load(f) or {"users": {}} + except Exception as e: + print(f"ERROR: Failed to load users file: {e}") + sys.exit(1) + + def _save_users(self): + """Save users.yaml.""" + try: + with open(self.users_file, "w") as f: + yaml.dump(self.users, f, default_flow_style=False) + # Restrict file permissions to owner only (security) + os.chmod(self.users_file, 0o600) + except Exception as e: + print(f"ERROR: Failed to save users file: {e}") + sys.exit(1) + + def _load_voices(self) -> Dict[str, Any]: + """Load piper_voices.yaml or return empty dict if not found.""" + if not self.voices_file.exists(): + print(f"⚠ WARNING: Voices file not found: {self.voices_file}") + print( + " Run: docker compose exec lingo-diary python scripts/discover-voices.py" + ) + return {} + try: + with open(self.voices_file, "r") as f: + config = yaml.safe_load(f) or {} + return config.get("languages", {}) + except Exception as e: + print(f"ERROR: Failed to load voices file: {e}") + sys.exit(1) + + def prompt_for_username(self, existing_only=False, exclude=None) -> str: + """Prompt for username with optional validation.""" + while True: + username = input("\nUsername: ").strip() + + if not username: + print("ERROR: Username cannot be empty") + continue + + if exclude and username in exclude: + print(f"ERROR: Username '{username}' not allowed") + continue + + exists = username in self.users.get("users", {}) + + if existing_only and not exists: + print(f"ERROR: User '{username}' does not exist") + continue + + if not existing_only and exists: + print(f"ERROR: User '{username}' already exists") + continue + + return username + + def prompt_for_password(self, confirm=True) -> str: + """Prompt for password with optional confirmation.""" + while True: + password = getpass.getpass("Password: ") + + if not password: + print("ERROR: Password cannot be empty") + continue + + if confirm: + confirm_password = getpass.getpass("Confirm password: ") + if password != confirm_password: + print("ERROR: Passwords do not match") + continue + + return password + + def prompt_for_language( + self, label: str, current: Optional[str] = None + ) -> tuple[str, str]: + """Prompt for language selection from available languages. Returns (name, code).""" + # self.voices is already the languages dict (loaded from _load_voices) + language_codes = [ + code for code in self.voices.keys() if code not in ("_metadata",) + ] + + if not language_codes: + print("ERROR: No languages available in piper_voices.yaml") + return "english", "en" + + # Map code → name from voices file + language_map = {} + for code in sorted(language_codes): + lang_data = self.voices[code] + name = lang_data.get("name", code) + language_map[code] = name + + while True: + if current: + print(f"\n{label} [{current}]:") + current_code = None + for code in sorted(language_codes): + name = language_map[code] + marker = "●" if f"{name} ({code})" == current else " " + print(f" {marker} {code}. {name}") + if f"{name} ({code})" == current: + current_code = code + + response = input("Enter choice (or press Enter to keep): ").strip() + if not response: + # Extract code from current format "english (en)" + if "(" in current and ")" in current: + code = current.split("(")[1].rstrip(")") + name = current.split("(")[0].strip() + return name, code + return current, current_code or current.lower()[:2] + else: + print(f"\n{label}:") + sorted_codes = sorted(language_codes) + for i, code in enumerate(sorted_codes, 1): + name = language_map[code] + print(f" {i}. {name} ({code})") + + response = input("Enter choice (number or code): ").strip() + + if not response: + print("ERROR: Language cannot be empty") + continue + + # Try to match the response to a language code + if response in language_codes: + name = language_map[response] + return name, response + + # Try to match by index (1-based) + try: + idx = int(response) - 1 + sorted_codes = sorted(language_codes) + if 0 <= idx < len(sorted_codes): + code = sorted_codes[idx] + name = language_map[code] + return name, code + except ValueError: + pass + + print( + f"ERROR: Invalid choice '{response}'. Enter a number (1-{len(language_codes)}) or language code (e.g., 'en')." + ) + + def prompt_for_gender(self, current: Optional[str] = None) -> str: + """Prompt for gender (male/female).""" + choices = ["male", "female"] + + if current: + print(f"\nGender [{current}]:") + for i, choice in enumerate(choices, 1): + marker = "●" if choice == current else " " + print(f" {marker} {i}. {choice}") + response = input("Enter choice (or press Enter to keep): ").strip() + if not response: + return current + else: + print("\nGender:") + for i, choice in enumerate(choices, 1): + print(f" {i}. {choice}") + response = input("Enter choice: ").strip() + + try: + idx = int(response) - 1 + if 0 <= idx < len(choices): + return choices[idx] + except (ValueError, IndexError): + pass + + print("ERROR: Invalid choice") + return self.prompt_for_gender(current) + + def prompt_for_voice( + self, label: str, lang_code: str, gender: str, current: Optional[str] = None + ) -> str: + """Prompt for voice selection by language and gender.""" + available_voices = self.voices.get(lang_code, {}).get(gender, []) + + if not available_voices: + print(f"\n⚠ WARNING: No {gender} voices available for {lang_code}") + response = input(f"{label} (enter manually): ").strip() + if not response and current: + print(f"Keeping current: {current}") + return current + return response + + print(f"\n{label} ({gender} voices for {lang_code}):") + for i, voice in enumerate(available_voices, 1): + marker = "●" if voice == current else " " + print(f" {marker} {i:2}. {voice}") + + while True: + response = input("Enter choice (or press Enter to keep current): ").strip() + + if not response: + if current: + print(f"Keeping current: {current}") + return current + print("ERROR: Must select a voice") + continue + + try: + idx = int(response) - 1 + if 0 <= idx < len(available_voices): + return available_voices[idx] + except ValueError: + pass + + print(f"ERROR: Invalid choice (1-{len(available_voices)})") + + def prompt_for_openai_key(self, current: Optional[str] = None) -> str: + """Prompt for OpenAI API key with security warnings.""" + print("\n" + "=" * 70) + print("OpenAI API Key Security Warning") + print("=" * 70) + print("The OpenAI key will be stored in plaintext in config.yaml.") + print("IMPORTANT:") + print(" 1. Ensure file permissions are restricted (chmod 600)") + print(" 2. Do NOT commit this file to version control") + print(" 3. Keep this key secret — anyone with it can use your API quota") + print(" 4. Consider rotating keys periodically") + print("=" * 70) + + if current: + masked = ( + current[:10] + "..." + current[-4:] if len(current) > 14 else "*" * 8 + ) + response = input(f"\nOpenAI Key [{masked}] (press Enter to keep): ").strip() + if not response: + return current + else: + response = input("\nOpenAI Key: ").strip() + + if not response: + print("ERROR: OpenAI key cannot be empty") + return self.prompt_for_openai_key(current) + + return response + + def add_user(self): + """Interactive flow to add a new user.""" + print("\n" + "=" * 70) + print("Add New User") + print("=" * 70) + + username = self.prompt_for_username(existing_only=False) + password = self.prompt_for_password(confirm=True) + primary_lang_name, primary_lang_code = self.prompt_for_language( + "Primary Language (e.g., 'English (en)')" + ) + study_lang_name, study_lang_code = self.prompt_for_language( + "Study Language (e.g., 'Norwegian (no)')" + ) + gender = self.prompt_for_gender() + study_voice = self.prompt_for_voice( + "Study Language Voice", study_lang_code, gender + ) + primary_voice = self.prompt_for_voice( + "Primary Language Voice", primary_lang_code, gender + ) + openai_key = self.prompt_for_openai_key() + + # Hash password + password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + # Add user to users.yaml + if "users" not in self.users: + self.users["users"] = {} + + self.users["users"][username] = { + "password": password_hash, + "language": primary_lang_code, + } + + self._save_users() + print(f"✓ User '{username}' added to users.yaml") + + # Create user config directory and config.yaml + user_config_dir_path = self.config_dir / username + user_config_dir_path.mkdir(exist_ok=True) + + config_content = DEFAULT_CONFIG_TEMPLATE.format( + username=username, + openai_key=openai_key, + gender=gender, + primary_language=primary_lang_name, + primary_language_code=primary_lang_code, + study_language=study_lang_name, + study_language_code=study_lang_code, + study_voice=study_voice, + primary_voice=primary_voice, + ) + + config_file = user_config_dir_path / "config.yaml" + try: + with open(config_file, "w") as f: + f.write(config_content) + os.chmod(config_file, 0o600) # Restrict permissions for security + print(f"✓ Config created: {config_file}") + except Exception as e: + print(f"ERROR: Failed to create config: {e}") + # Clean up the user entry + del self.users["users"][username] + self._save_users() + sys.exit(1) + + print(f"\n✓ User '{username}' set up successfully!") + + def modify_user(self, username: Optional[str] = None): + """Interactive flow to modify an existing user's configuration.""" + if not username: + username = self.prompt_for_username(existing_only=True) + + print(f"\n" + "=" * 70) + print(f"Modify User: {username}") + print("=" * 70) + + user_config_file = self.config_dir / username / "config.yaml" + + if not user_config_file.exists(): + print(f"ERROR: Config file not found: {user_config_file}") + sys.exit(1) + + try: + with open(user_config_file, "r") as f: + current_config = yaml.safe_load(f) or {} + except Exception as e: + print(f"ERROR: Failed to load config: {e}") + sys.exit(1) + + # Extract current values + current_gender = current_config.get("gender", "male") + current_primary_lang = current_config.get("languages", {}).get( + "primary_language", "English" + ) + current_primary_code = current_config.get("languages", {}).get( + "primary_language_code", "en" + ) + current_study_lang = current_config.get("languages", {}).get( + "study_language", "Norwegian" + ) + current_study_code = current_config.get("languages", {}).get( + "study_language_code", "no" + ) + current_study_voice = ( + current_config.get("tts", {}).get("piper", {}).get("voice", "") + ) + current_primary_voice = ( + current_config.get("tts", {}) + .get("piper_input_language", {}) + .get("voice", "") + ) + current_openai_key = current_config.get("openai", {}).get("key", "") + + # Prompt for changes + print("\nPress Enter to keep current value, or enter a new value.") + + primary_lang_name, primary_lang_code = self.prompt_for_language( + f"Primary Language", f"{current_primary_lang} ({current_primary_code})" + ) + study_lang_name, study_lang_code = self.prompt_for_language( + f"Study Language", f"{current_study_lang} ({current_study_code})" + ) + gender = self.prompt_for_gender(current_gender) + study_voice = self.prompt_for_voice( + "Study Language Voice", study_lang_code, gender, current_study_voice + ) + primary_voice = self.prompt_for_voice( + "Primary Language Voice", primary_lang_code, gender, current_primary_voice + ) + openai_key = self.prompt_for_openai_key(current_openai_key) + + # Update config + current_config["gender"] = gender + current_config["languages"]["primary_language"] = primary_lang_name + current_config["languages"]["primary_language_code"] = primary_lang_code + current_config["languages"]["study_language"] = study_lang_name + current_config["languages"]["study_language_code"] = study_lang_code + current_config["tts"]["piper"]["voice"] = study_voice + current_config["tts"]["piper_input_language"]["voice"] = primary_voice + current_config["openai"]["key"] = openai_key + + # Save updated config + try: + with open(user_config_file, "w") as f: + yaml.dump(current_config, f, default_flow_style=False) + os.chmod(user_config_file, 0o600) + print(f"\n✓ Config updated: {user_config_file}") + except Exception as e: + print(f"ERROR: Failed to save config: {e}") + sys.exit(1) + + print(f"✓ User '{username}' configuration updated successfully!") + + def delete_user(self, username: Optional[str] = None): + """Interactive flow to delete a user (with backup).""" + if not username: + username = self.prompt_for_username(existing_only=True) + + print(f"\n" + "=" * 70) + print(f"Delete User: {username}") + print("=" * 70) + print( + f"\nWARNING: This will back up and remove all configuration for '{username}'." + ) + + confirm = input(f"Type '{username}' to confirm deletion: ").strip() + if confirm != username: + print("Deletion cancelled.") + return + + user_config_dir_path = self.config_dir / username + + if user_config_dir_path.exists(): + # Create backup + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_file = self.backups_dir / f"{username}_{timestamp}.tar.gz" + + try: + with tarfile.open(backup_file, "w:gz") as tar: + tar.add(user_config_dir_path, arcname=username) + print(f"✓ Config backed up to: {backup_file}") + except Exception as e: + print(f"ERROR: Failed to create backup: {e}") + print("Deletion cancelled to preserve data.") + sys.exit(1) + + # Remove config directory + import shutil + + try: + shutil.rmtree(user_config_dir_path) + print(f"✓ Config directory removed: {user_config_dir_path}") + except Exception as e: + print(f"ERROR: Failed to remove config directory: {e}") + sys.exit(1) + + # Remove user from users.yaml + if username in self.users.get("users", {}): + del self.users["users"][username] + self._save_users() + print(f"✓ User removed from users.yaml") + + print(f"\n✓ User '{username}' deleted successfully!") + + def list_users(self): + """Display all users and their interface languages.""" + users = self.users.get("users", {}) + + if not users: + print("No users found.") + return + + print("\n" + "=" * 70) + print("Registered Users") + print("=" * 70) + for username, data in sorted(users.items()): + lang = data.get("language", "unknown") + config_file = self.config_dir / username / "config.yaml" + exists = "✓" if config_file.exists() else "✗" + print(f" {exists} {username:20} (interface: {lang})") + + def show_menu(self): + """Display interactive menu.""" + while True: + print("\n" + "=" * 70) + print("LingoDiary User Configuration Manager") + print("=" * 70) + print("\nOptions:") + print(" 1. Add User") + print(" 2. Modify User") + print(" 3. Delete User") + print(" 4. List Users") + print(" 5. Exit") + + choice = input("\nEnter choice (1-5): ").strip() + + if choice == "1": + self.add_user() + elif choice == "2": + self.modify_user() + elif choice == "3": + self.delete_user() + elif choice == "4": + self.list_users() + elif choice == "5": + print("Goodbye!") + break + else: + print("Invalid choice. Please try again.") + + +def main(): + """Main entry point.""" + # Parse command-line arguments + parser = argparse.ArgumentParser( + prog="manage-config.py", + description="LingoDiary User Configuration Manager", + epilog=""" +RESOLUTION ORDER (config directory): + 1. -o/--output DIR (if provided, highest priority) + 2. Extract from -i/--input docker-compose.yml (look for /app/.config/lingoDiary/) + 3. Auto-detect ../docker-compose.yml from script location + 4. Fallback: ~/.config/efunk_lingo/lingoDiary/ + +EXAMPLES: + # Interactive mode (auto-detect docker-compose.yml) + python manage-config.py + + # Add user with custom config directory + python manage-config.py add-user -o /etc/lingoDiary + + # Add user from custom docker-compose.yml + python manage-config.py add-user -i /path/to/docker-compose.yml + + # Modify user with explicit config directory + python manage-config.py modify-user john -o ~/.config/custom + + # List users from custom location + python manage-config.py list-users -o /data/lingoDiary +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument( + "-i", + "--input", + metavar="FILE", + help="Path to docker-compose.yml file to parse for config directory " + "(default: auto-detect ../docker-compose.yml)", + default=None, + ) + parser.add_argument( + "-o", + "--output", + metavar="DIR", + help="Configuration directory where users.yaml and user configs are stored " + "(overrides docker-compose.yml volume mount)", + default=None, + ) + parser.add_argument( + "command", + nargs="?", + metavar="COMMAND", + help="Command to run: add-user, modify-user, delete-user, list-users", + ) + parser.add_argument( + "username", + nargs="?", + metavar="USERNAME", + help="Username (required for: modify-user, delete-user)", + ) + + args = parser.parse_args() + + # Resolve config directory + config_dir = resolve_config_dir(args.input, args.output) + + # Create manager with resolved config directory + manager = ConfigManager(config_dir) + + if args.command: + command = args.command.lower() + + if command == "add-user": + manager.add_user() + elif command == "modify-user": + manager.modify_user(args.username) + elif command == "delete-user": + manager.delete_user(args.username) + elif command == "list-users": + manager.list_users() + else: + print(f"Unknown command: {command}") + print("\nUsage:") + print(" python scripts/manage-config.py [command] [options]") + print("\nCommands:") + print(" add-user Add a new user") + print(" modify-user Modify a user's configuration") + print(" delete-user Delete a user") + print(" list-users List all users") + print("\nOptions:") + print(" -i, --input FILE Path to docker-compose.yml") + print(" -o, --output DIR Config directory") + sys.exit(1) + else: + # Interactive menu + manager.show_menu() + + +if __name__ == "__main__": + main() diff --git a/scripts/piper_voices.yaml b/scripts/piper_voices.yaml new file mode 100644 index 0000000..1777cfa --- /dev/null +++ b/scripts/piper_voices.yaml @@ -0,0 +1,85 @@ +languages: + en: + name: english + male: + - alan-low + - en-US-artic-neural-8000hz + - en-US-glow-tts + - en-US-hfc-neural + - en-US-lessac-low + - en-US-lessac-medium + - en-US-libritts + - en-US-ljspeech + - en-US-rms-low + - en-GB-alba-medium + - en-GB-alan-medium + female: + - en-US-amy-medium + - en-US-azure-neural + - en-US-lessac-high + - en-US-libritts-high + - en-US-ljspeech-high + - en-US-ryan-high + - en-US-ryan-low + - en-US-ryan-medium + 'no': + name: norwegian + female: + - talesyntese-medium + male: + - talesyntese-male + fr: + name: french + male: + - fr-FR-gilles-low + female: + - fr-FR-siwis-medium + de: + name: german + male: + - de-DE-karlsson-low + - de-DE-thorsten-high + female: + - de-DE-eva_k-x-low + - de-DE-eva_k-medium + - de-DE-kerstin-low + es: + name: spanish + male: + - es-ES-carlos-low + - es-MX-carlos-low + female: + - es-ES-tania-medium + it: + name: italian + male: + - it-IT-riccardo-x-low + female: + - it-IT-riccardo_riccardo-x-low + pt: + name: portuguese + male: + - pt-BR-faber-medium + female: + - pt-PT-fernanda-medium + ru: + name: russian + male: + - ru-RU-igor-medium + female: + - ru-RU-natasha-medium + zh: + name: chinese + female: + - zh-CN-huayan-x-low + ja: + name: japanese + female: + - ja-JP-kokoro-medium + ko: + name: korean + female: + - ko-KR-kss-medium +_metadata: + description: Available Piper TTS voices, organized by language and gender. + note: 'Regenerate this file by running: python scripts/discover-voices.py'