Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,11 +378,27 @@ All settings are in `.env`. See [`.env.example`](.env.example) for the full docu
| `neurascreen batch <folder>` | Generate videos from all scenarios in a folder |
| `neurascreen record <url>` | Record browser interactions → JSON scenario |
| `neurascreen list` | List available scenarios |
| `neurascreen voices list` | List configured TTS voices per provider |
| `neurascreen voices add <provider> <id> <name>` | Add a voice to a provider |
| `neurascreen voices remove <provider> <id>` | Remove a voice |
| `neurascreen voices set-default <provider> <id>` | 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`.

---
Expand Down
8 changes: 5 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
99 changes: 99 additions & 0 deletions neurascreen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""
Expand Down
19 changes: 19 additions & 0 deletions neurascreen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
Loading
Loading