diff --git a/README.md b/README.md index fe15cf3..44d6eff 100644 --- a/README.md +++ b/README.md @@ -378,11 +378,27 @@ All settings are in `.env`. See [`.env.example`](.env.example) for the full docu | `neurascreen batch ` | Generate videos from all scenarios in a folder | | `neurascreen record ` | Record browser interactions → JSON scenario | | `neurascreen list` | List available scenarios | +| `neurascreen voices list` | List configured TTS voices per provider | +| `neurascreen voices add ` | Add a voice to a provider | +| `neurascreen voices remove ` | Remove a voice | +| `neurascreen voices set-default ` | Set default voice for a provider | | `neurascreen --version` | Show version | | `neurascreen gui` | Launch desktop GUI (requires `[gui]` extra) | Options: `--verbose` / `-v` for debug output, `--headless` for headless mode, `--srt` for subtitle generation, `--chapters` for YouTube chapter markers. +### Voice management + +Voices are stored per provider in `~/.neurascreen/voices.json` (shared with the GUI). If `TTS_VOICE_ID` or `TTS_MODEL` are not set in `.env`, the CLI uses defaults from `voices.json`. + +```bash +neurascreen voices list # List all voices +neurascreen voices list -p openai # Filter by provider +neurascreen voices add gradium abc123 "My voice" # Add a voice +neurascreen voices set-default openai nova # Set default +neurascreen voices remove gradium abc123 # Remove +``` + You can also use `python -m neurascreen` instead of `neurascreen`. --- diff --git a/ROADMAP.md b/ROADMAP.md index 956c57d..e0fa882 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,10 +56,12 @@ Optional PySide6 desktop interface: `pip install neurascreen[gui]` - [x] Scenario diff: compare two scenarios side by side (#32) - [x] Autosave & recovery: periodic save, recovery on startup (#32) -## v1.6 — CLI Enhancements (planned) +## v1.6 — CLI Enhancements (done) -- [ ] CLI support for voices.json per-provider voice configuration (#33) -- [ ] `neurascreen voices list/add/remove/set-default` commands +- [x] CLI support for voices.json per-provider voice configuration (#33) +- [x] `neurascreen voices list/add/remove/set-default` commands +- [x] Config.load() fallback to voices.json defaults when .env voice/model empty +- [x] `validate` warns if voice not found in voices.json ## Future Ideas diff --git a/neurascreen/cli.py b/neurascreen/cli.py index 2213b99..27c9f37 100644 --- a/neurascreen/cli.py +++ b/neurascreen/cli.py @@ -405,6 +405,87 @@ def gui() -> None: sys.exit(launch_gui(sys.argv[:1])) +@cli.group("voices") +def voices_group() -> None: + """Manage TTS voice configuration (~/.neurascreen/voices.json).""" + pass + + +@voices_group.command("list") +@click.option("--provider", "-p", default=None, help="Filter by provider name") +def voices_list(provider: str | None) -> None: + """List configured voices per provider.""" + from .gui.tts.voices import load_voices, PROVIDER_NAMES + + configs = load_voices() + providers = [provider] if provider else PROVIDER_NAMES + + for name in providers: + cfg = configs.get(name) + if cfg is None: + continue + + default_marker = lambda vid: " (default)" if vid == cfg.default_voice else "" + click.echo(f"\n{name}") + click.echo(f" Model: {cfg.default_model or '(none)'}") + if cfg.voices: + for v in cfg.voices: + click.echo(f" {v.id:<30} {v.name}{default_marker(v.id)}") + else: + click.echo(f" (no voices configured)") + + +@voices_group.command("add") +@click.argument("provider") +@click.argument("voice_id") +@click.argument("name") +def voices_add(provider: str, voice_id: str, name: str) -> None: + """Add a voice to a provider.""" + from .gui.tts.voices import load_voices, save_voices, add_voice + + configs = load_voices() + if add_voice(configs, provider, voice_id, name): + save_voices(configs) + click.echo(f"Added voice '{voice_id}' ({name}) to {provider}") + else: + click.echo(f"Voice '{voice_id}' already exists in {provider}", err=True) + sys.exit(1) + + +@voices_group.command("remove") +@click.argument("provider") +@click.argument("voice_id") +def voices_remove(provider: str, voice_id: str) -> None: + """Remove a voice from a provider.""" + from .gui.tts.voices import load_voices, save_voices, remove_voice + + configs = load_voices() + if remove_voice(configs, provider, voice_id): + save_voices(configs) + click.echo(f"Removed voice '{voice_id}' from {provider}") + else: + click.echo(f"Voice '{voice_id}' not found in {provider}", err=True) + sys.exit(1) + + +@voices_group.command("set-default") +@click.argument("provider") +@click.argument("voice_id") +def voices_set_default(provider: str, voice_id: str) -> None: + """Set the default voice for a provider.""" + from .gui.tts.voices import load_voices, save_voices + + configs = load_voices() + cfg = configs.get(provider) + if cfg is None: + click.echo(f"Unknown provider: {provider}", err=True) + sys.exit(1) + + cfg.default_voice = voice_id + save_voices(configs) + click.echo(f"Default voice for {provider} set to '{voice_id}'") + + @cli.command() @click.argument("scenario_path", type=click.Path(exists=True)) def validate(scenario_path: str) -> None: @@ -426,6 +507,24 @@ def validate(scenario_path: str) -> None: steps = len(data.get("steps", [])) click.echo(f"Valid scenario: {data.get('title', 'Untitled')} ({steps} steps)") + # Check voice config against voices.json (warning only) + try: + from .gui.tts.voices import load_voices + config = Config.load() + if config.tts_voice_id and config.tts_provider: + configs = load_voices() + provider_cfg = configs.get(config.tts_provider) + if provider_cfg and provider_cfg.voices: + known_ids = {v.id for v in provider_cfg.voices} + if config.tts_voice_id not in known_ids: + click.echo( + f" Warning: voice '{config.tts_voice_id}' not found in " + f"voices.json for provider '{config.tts_provider}'", + err=True, + ) + except ImportError: + pass + def main() -> None: """Entry point.""" diff --git a/neurascreen/config.py b/neurascreen/config.py index 3991827..08f8b9a 100644 --- a/neurascreen/config.py +++ b/neurascreen/config.py @@ -126,8 +126,27 @@ def load(cls, env_path: str | None = None) -> "Config": config.logs_dir.mkdir(parents=True, exist_ok=True) config.scenarios_dir.mkdir(parents=True, exist_ok=True) + # Fallback to voices.json defaults if voice/model not set in .env + config._apply_voices_defaults() + return config + def _apply_voices_defaults(self) -> None: + """Fill empty tts_voice_id/tts_model from voices.json defaults.""" + if self.tts_voice_id and self.tts_model: + return + try: + from .gui.tts.voices import load_voices + configs = load_voices() + provider_cfg = configs.get(self.tts_provider) + if provider_cfg: + if not self.tts_voice_id and provider_cfg.default_voice: + self.tts_voice_id = provider_cfg.default_voice + if not self.tts_model and provider_cfg.default_model: + self.tts_model = provider_cfg.default_model + except ImportError: + pass + def validate(self) -> list[str]: """Validate required configuration. Returns list of errors.""" errors = [] diff --git a/tests/test_cli_voices.py b/tests/test_cli_voices.py new file mode 100644 index 0000000..4b55f88 --- /dev/null +++ b/tests/test_cli_voices.py @@ -0,0 +1,254 @@ +"""Tests for CLI voices commands and Config voices.json integration.""" + +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + + +def _make_config(provider="openai", voice_id="alloy"): + """Create a minimal Config for testing without loading .env.""" + from neurascreen.config import Config + tmp = Path(tempfile.mkdtemp()) + return Config( + app_url="http://localhost", app_email="", app_password="", + login_url="/login", output_dir=tmp, temp_dir=tmp, + logs_dir=tmp, scenarios_dir=tmp, + browser_headless=False, video_width=1920, video_height=1080, video_fps=30, + capture_screen=0, capture_display="", browser_screen_offset=0, + tts_provider=provider, tts_api_key="test", + tts_voice_id=voice_id, tts_model="default", + login_email_selector="", login_password_selector="", login_submit_selector="", + selector_draggable="", selector_canvas="", selector_delete_button="", + selector_close_modal="", selector_zoom_out="", selector_fit_view="", + ) + + +class TestVoicesList: + """Test neurascreen voices list command.""" + + def test_list_all_providers(self): + from neurascreen.cli import cli + runner = CliRunner() + result = runner.invoke(cli, ["voices", "list"], obj={}) + assert result.exit_code == 0 + assert "openai" in result.output + assert "gradium" in result.output + + def test_list_single_provider(self): + from neurascreen.cli import cli + runner = CliRunner() + result = runner.invoke(cli, ["voices", "list", "-p", "openai"], obj={}) + assert result.exit_code == 0 + assert "openai" in result.output + assert "alloy" in result.output + assert "gradium" not in result.output + + +class TestVoicesAdd: + """Test neurascreen voices add command.""" + + def test_add_voice(self): + from neurascreen.cli import cli + from neurascreen.gui.tts.voices import load_voices, VOICES_FILE + import neurascreen.gui.tts.voices as mod + + tmp = Path(tempfile.mkdtemp()) / "voices.json" + original = mod.VOICES_FILE + mod.VOICES_FILE = tmp + + try: + runner = CliRunner() + result = runner.invoke( + cli, ["voices", "add", "gradium", "test123", "Test Voice"], obj={} + ) + assert result.exit_code == 0 + assert "Added" in result.output + + # Verify it was saved + configs = load_voices(tmp) + ids = [v.id for v in configs["gradium"].voices] + assert "test123" in ids + finally: + mod.VOICES_FILE = original + + def test_add_duplicate(self): + from neurascreen.cli import cli + import neurascreen.gui.tts.voices as mod + + tmp = Path(tempfile.mkdtemp()) / "voices.json" + original = mod.VOICES_FILE + mod.VOICES_FILE = tmp + + try: + runner = CliRunner() + # Add once + runner.invoke(cli, ["voices", "add", "openai", "alloy", "Alloy"], obj={}) + # Add again — should fail (already exists in builtins) + result = runner.invoke( + cli, ["voices", "add", "openai", "alloy", "Alloy"], obj={} + ) + assert result.exit_code == 1 + assert "already exists" in result.output + finally: + mod.VOICES_FILE = original + + +class TestVoicesRemove: + """Test neurascreen voices remove command.""" + + def test_remove_voice(self): + from neurascreen.cli import cli + import neurascreen.gui.tts.voices as mod + + tmp = Path(tempfile.mkdtemp()) / "voices.json" + original = mod.VOICES_FILE + mod.VOICES_FILE = tmp + + try: + runner = CliRunner() + # Add then remove + runner.invoke(cli, ["voices", "add", "gradium", "xyz", "XYZ"], obj={}) + result = runner.invoke(cli, ["voices", "remove", "gradium", "xyz"], obj={}) + assert result.exit_code == 0 + assert "Removed" in result.output + finally: + mod.VOICES_FILE = original + + def test_remove_nonexistent(self): + from neurascreen.cli import cli + import neurascreen.gui.tts.voices as mod + + tmp = Path(tempfile.mkdtemp()) / "voices.json" + original = mod.VOICES_FILE + mod.VOICES_FILE = tmp + + try: + runner = CliRunner() + result = runner.invoke( + cli, ["voices", "remove", "gradium", "nope"], obj={} + ) + assert result.exit_code == 1 + assert "not found" in result.output + finally: + mod.VOICES_FILE = original + + +class TestVoicesSetDefault: + """Test neurascreen voices set-default command.""" + + def test_set_default(self): + from neurascreen.cli import cli + from neurascreen.gui.tts.voices import load_voices + import neurascreen.gui.tts.voices as mod + + tmp = Path(tempfile.mkdtemp()) / "voices.json" + original = mod.VOICES_FILE + mod.VOICES_FILE = tmp + + try: + runner = CliRunner() + result = runner.invoke( + cli, ["voices", "set-default", "openai", "nova"], obj={} + ) + assert result.exit_code == 0 + assert "nova" in result.output + + configs = load_voices(tmp) + assert configs["openai"].default_voice == "nova" + finally: + mod.VOICES_FILE = original + + def test_set_default_unknown_provider(self): + from neurascreen.cli import cli + import neurascreen.gui.tts.voices as mod + + tmp = Path(tempfile.mkdtemp()) / "voices.json" + original = mod.VOICES_FILE + mod.VOICES_FILE = tmp + + try: + runner = CliRunner() + result = runner.invoke( + cli, ["voices", "set-default", "nonexistent", "v1"], obj={} + ) + assert result.exit_code == 1 + assert "Unknown provider" in result.output + finally: + mod.VOICES_FILE = original + + +class TestConfigVoicesFallback: + """Test Config._apply_voices_defaults() logic.""" + + def test_fallback_fills_empty_voice(self): + from neurascreen.config import Config + + # Build config directly (bypass .env loading) + config = Config( + app_url="http://localhost", app_email="", app_password="", + login_url="/login", output_dir=Path(tempfile.mkdtemp()), + temp_dir=Path(tempfile.mkdtemp()), logs_dir=Path(tempfile.mkdtemp()), + scenarios_dir=Path(tempfile.mkdtemp()), + browser_headless=False, video_width=1920, video_height=1080, video_fps=30, + capture_screen=0, capture_display="", browser_screen_offset=0, + tts_provider="openai", tts_api_key="test", + tts_voice_id="", tts_model="", + login_email_selector="", login_password_selector="", login_submit_selector="", + selector_draggable="", selector_canvas="", selector_delete_button="", + selector_close_modal="", selector_zoom_out="", selector_fit_view="", + ) + config._apply_voices_defaults() + assert config.tts_voice_id == "alloy" + assert config.tts_model == "tts-1-hd" + + def test_no_fallback_when_set(self): + from neurascreen.config import Config + + config = Config( + app_url="http://localhost", app_email="", app_password="", + login_url="/login", output_dir=Path(tempfile.mkdtemp()), + temp_dir=Path(tempfile.mkdtemp()), logs_dir=Path(tempfile.mkdtemp()), + scenarios_dir=Path(tempfile.mkdtemp()), + browser_headless=False, video_width=1920, video_height=1080, video_fps=30, + capture_screen=0, capture_display="", browser_screen_offset=0, + tts_provider="openai", tts_api_key="test", + tts_voice_id="custom_voice", tts_model="custom_model", + login_email_selector="", login_password_selector="", login_submit_selector="", + selector_draggable="", selector_canvas="", selector_delete_button="", + selector_close_modal="", selector_zoom_out="", selector_fit_view="", + ) + config._apply_voices_defaults() + assert config.tts_voice_id == "custom_voice" + assert config.tts_model == "custom_model" + + +class TestValidateVoiceWarning: + """Test that validate command warns about unknown voices.""" + + def test_validate_warns_unknown_voice(self): + from neurascreen.cli import cli + import os + + scenario = { + "title": "Test", + "description": "test", + "resolution": {"width": 1920, "height": 1080}, + "steps": [{"action": "wait", "duration": 1000}], + } + tmp_dir = Path(tempfile.mkdtemp()) + tmp = tmp_dir / "test.json" + tmp.write_text(json.dumps(scenario)) + + # Create a minimal .env to avoid polluting os.environ with the real .env + env_file = tmp_dir / ".env" + env_file.write_text("APP_URL=http://localhost\nTTS_PROVIDER=openai\nTTS_VOICE_ID=nonexistent_xyz\n") + + runner = CliRunner(env={"DOTENV_PATH": str(env_file)}) + # Use patch to make Config.load use our temp .env + with patch("neurascreen.cli.Config.load", return_value=_make_config("openai", "nonexistent_xyz")): + result = runner.invoke(cli, ["validate", str(tmp)], obj={}) + assert result.exit_code == 0 + assert "Valid scenario" in result.output