Files
calliope/calliope/setup_wizard.py
2026-02-17 15:08:53 +01:00

148 lines
6.1 KiB
Python

"""First-run setup wizard — Rich TUI."""
import subprocess
import sys
import sounddevice as sd
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress
from rich.prompt import Confirm, IntPrompt, Prompt
from rich.table import Table
from calliope import config
console = Console()
def run() -> dict:
"""Run the interactive setup wizard and return the final config."""
cfg = dict(config.DEFAULTS)
# ── Welcome ──────────────────────────────────────────────────────
console.print(
Panel.fit(
"[bold magenta]Calliope[/bold magenta]\n"
"Voice-to-text for macOS — speak and type into any app.\n\n"
"This wizard will walk you through first-time setup.",
border_style="magenta",
)
)
# ── Permission checks ────────────────────────────────────────────
console.print("\n[bold]Permission checks[/bold]")
_check_accessibility()
_check_microphone()
console.print()
# ── Mic selection ────────────────────────────────────────────────
console.print("[bold]Microphone selection[/bold]")
devices = sd.query_devices()
table = Table(show_header=True)
table.add_column("#", style="cyan", width=4)
table.add_column("Device")
table.add_column("Inputs", justify="right")
input_indices: list[int] = []
for i, d in enumerate(devices):
if d["max_input_channels"] > 0:
input_indices.append(i)
marker = " (default)" if i == sd.default.device[0] else ""
table.add_row(str(i), f"{d['name']}{marker}", str(d["max_input_channels"]))
console.print(table)
default_dev = sd.default.device[0]
choice = Prompt.ask(
"Device index",
default=str(default_dev) if default_dev is not None else str(input_indices[0]),
)
cfg["device"] = int(choice) if choice else None
# ── Hotkey config ────────────────────────────────────────────────
console.print("\n[bold]Hotkey configuration[/bold]")
console.print(f" Push-to-talk : [cyan]{cfg['hotkeys']['ptt']}[/cyan]")
console.print(f" Toggle : [cyan]{cfg['hotkeys']['toggle']}[/cyan]")
if Confirm.ask("Keep defaults?", default=True):
pass
else:
cfg["hotkeys"]["ptt"] = Prompt.ask("Push-to-talk combo", default=cfg["hotkeys"]["ptt"])
cfg["hotkeys"]["toggle"] = Prompt.ask("Toggle combo", default=cfg["hotkeys"]["toggle"])
# ── Model download ───────────────────────────────────────────────
console.print("\n[bold]Model download[/bold]")
console.print(f" Default model: [cyan]{cfg['model']}[/cyan]")
if not Confirm.ask("Use default model?", default=True):
cfg["model"] = Prompt.ask("Whisper model")
console.print(f"Downloading [cyan]{cfg['model']}[/cyan] (this may take a while)...")
from calliope.transcriber import Transcriber
transcriber = Transcriber(model=cfg["model"])
with Progress() as progress:
task = progress.add_task("Loading model...", total=None)
transcriber.load()
progress.update(task, completed=100, total=100)
console.print("[green]Model ready.[/green]")
# ── Validation ───────────────────────────────────────────────────
if Confirm.ask("\nRecord a short test clip to verify everything works?", default=True):
console.print("Recording for 3 seconds...")
from calliope.recorder import Recorder
import time
rec = Recorder(device=cfg["device"])
rec.start()
time.sleep(3)
audio = rec.stop()
console.print("Transcribing...")
text = transcriber.transcribe(audio)
console.print(f"[green]Result:[/green] {text or '(no speech detected)'}")
# ── Save ─────────────────────────────────────────────────────────
config.save(cfg)
console.print(f"\n[green]Config saved to {config.CONFIG_PATH}[/green]")
console.print("Run [bold]calliope[/bold] to start. Enjoy! 🎤\n")
return cfg
def _check_accessibility() -> None:
try:
import ApplicationServices
trusted = ApplicationServices.AXIsProcessTrusted()
except Exception:
trusted = None
if trusted:
console.print(" [green]✓[/green] Accessibility access granted")
else:
console.print(" [red]✗[/red] Accessibility access — required for typing")
console.print(" Open: System Settings → Privacy & Security → Accessibility")
if Confirm.ask(" Open System Settings?", default=False):
subprocess.run(
["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"],
check=False,
)
def _check_microphone() -> None:
try:
import AVFoundation
status = AVFoundation.AVCaptureDevice.authorizationStatusForMediaType_(
AVFoundation.AVMediaTypeAudio
)
granted = status == 3 # AVAuthorizationStatusAuthorized
except Exception:
granted = None
if granted:
console.print(" [green]✓[/green] Microphone access granted")
else:
console.print(" [red]✗[/red] Microphone access — required for recording")
console.print(" Open: System Settings → Privacy & Security → Microphone")
if Confirm.ask(" Open System Settings?", default=False):
subprocess.run(
["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"],
check=False,
)