148 lines
6.1 KiB
Python
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,
|
|
)
|