mirror of
https://github.com/techieyann/frame-voice.git
synced 2026-10-17 14:01:33 +02:00
164 lines
8.1 KiB
Python
164 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
||
"""Installer frontends sharing settings and backend preparation."""
|
||
import curses
|
||
import importlib.util
|
||
import os
|
||
from pathlib import Path
|
||
import subprocess
|
||
|
||
|
||
def load(name):
|
||
spec = importlib.util.spec_from_file_location(name.replace('-', '_'), Path(__file__).with_name(name+'.py'))
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def desktop_environment():
|
||
environment = dict(os.environ, GSK_RENDERER='cairo')
|
||
if environment.get('DISPLAY') or environment.get('WAYLAND_DISPLAY'):
|
||
return environment
|
||
found = load('tray-session').discover(os.getuid())
|
||
if found:
|
||
environment.update(found[1])
|
||
return environment
|
||
return None
|
||
|
||
|
||
def choose_screen(screen, title, choices, selected=0):
|
||
selected = max(0, min(selected, len(choices)-1))
|
||
while True:
|
||
screen.erase()
|
||
height, width = screen.getmaxyx()
|
||
if height < 10 or width < 40: raise RuntimeError('Enlarge the terminal to at least 40 columns and 10 rows')
|
||
screen.addnstr(1, 2, title, width-4, curses.A_BOLD)
|
||
screen.addnstr(height-2, 2, '↑/↓ select · Enter edit/accept · Esc cancel', width-4)
|
||
visible = height-5
|
||
offset = max(0, min(selected-visible+1, len(choices)-visible))
|
||
for row, choice in enumerate(choices[offset:offset+visible], 3):
|
||
screen.addnstr(row, 2, choice, width-4, curses.A_REVERSE if row-3+offset == selected else 0)
|
||
screen.refresh()
|
||
key = screen.getch()
|
||
if key == curses.KEY_UP: selected = (selected-1) % len(choices)
|
||
elif key == curses.KEY_DOWN: selected = (selected+1) % len(choices)
|
||
elif key == curses.KEY_HOME: selected = 0
|
||
elif key == curses.KEY_END: selected = len(choices)-1
|
||
elif key in (10, 13, curses.KEY_ENTER): return selected
|
||
elif key == 27: raise RuntimeError('Setup canceled')
|
||
|
||
|
||
def entry(screen, title, initial='', secret=False):
|
||
value = initial
|
||
curses.curs_set(1)
|
||
try:
|
||
while True:
|
||
screen.erase()
|
||
height, width = screen.getmaxyx()
|
||
screen.addnstr(1, 2, title, width-4, curses.A_BOLD)
|
||
shown = '•'*len(value) if secret else value
|
||
screen.addnstr(3, 2, shown[-(width-5):], width-4)
|
||
screen.addnstr(height-2, 2, 'Enter accept · Esc cancel · Ctrl+U clear', width-4)
|
||
screen.move(3, min(2+len(value), width-3))
|
||
screen.refresh()
|
||
key = screen.get_wch()
|
||
if key in ('\n', '\r'): return value
|
||
if key == '\x1b': raise RuntimeError('Setup canceled')
|
||
if key == '\x15': value = ''
|
||
elif key in ('\x7f', '\b', curses.KEY_BACKSPACE): value = value[:-1]
|
||
elif isinstance(key, str) and key.isprintable(): value += key
|
||
finally:
|
||
curses.curs_set(0)
|
||
|
||
|
||
def tui_options(screen, backend, home, requested, language, key_file):
|
||
curses.curs_set(0)
|
||
values = backend.read_values(home)
|
||
mode = requested or 'groq'
|
||
if requested is None and values.get('VOICE_BACKEND') == 'local':
|
||
mode = 'local-balanced' if 'small' in values.get('VOICE_MODEL', '') else 'local-fast'
|
||
language = backend.normalize_language(language or values.get('VOICE_LANG', 'en'))
|
||
options = {}
|
||
specs = [spec for section in backend.SETTINGS.values() for spec in section]
|
||
specs.append(('VOICE_CLEANUP', 'Clean up transcript', 'bool', None, True))
|
||
specs.append(('GROQ_USAGE_NOTIFY', 'In-headset Groq notifications', 'bool', None, True))
|
||
specs.append(('VOICE_SLASH', 'Convert spoken slash to /', 'bool', None, False))
|
||
for spec in specs:
|
||
key, _, kind = spec[:3]
|
||
default = spec[4]
|
||
value = values.get(key, default)
|
||
options[key] = ('1' if str(value).lower() in ('1', 'true', 'yes', 'on') else '0') if kind == 'bool' else str(value)
|
||
api_key = ''
|
||
selected = 0
|
||
while True:
|
||
names = ['Transcription path: '+backend.MODES[mode], 'Language: '+('Automatic detection' if language == 'auto' else backend.LANGUAGES[language])]
|
||
cloud_keys = ('VOICE_CLEANUP', 'GROQ_USAGE_NOTIFY', 'VOICE_SLASH')
|
||
visible_specs = [spec for spec in specs if mode == 'groq' or spec[0] not in cloud_keys]
|
||
if mode == 'groq':
|
||
names.append('Groq API key: '+('Provided key file' if key_file else 'Saved/provided' if api_key or values.get('GROQ_API_KEY') else 'Not set'))
|
||
for spec in visible_specs:
|
||
value = options[spec[0]]
|
||
names.append(spec[1]+': '+(('On' if value == '1' else 'Off') if spec[2] == 'bool' else backend.option_label(value) if spec[2] == 'enum' else value))
|
||
names.append('Install and start Frame Voice')
|
||
selected = choose_screen(screen, 'Frame Voice installer', names, selected)
|
||
if selected == 0:
|
||
modes = list(backend.MODES)
|
||
mode = modes[choose_screen(screen, 'Transcription path', list(backend.MODES.values()), modes.index(mode))]
|
||
elif selected == 1:
|
||
codes = ['auto', 'en']+[code for code in backend.LANGUAGES if code != 'en']
|
||
labels = ['Automatic detection', 'English']+[backend.LANGUAGES[code] for code in codes[2:]]
|
||
language = codes[choose_screen(screen, 'Language', labels, codes.index(language))]
|
||
elif mode == 'groq' and selected == 2:
|
||
api_key = entry(screen, 'Groq API key (empty keeps the saved key)', api_key, secret=True)
|
||
elif selected == len(names)-1:
|
||
maximum = float(options['VOICE_MAX'])
|
||
if not 0.1 <= maximum <= 120: raise RuntimeError('Maximum recording seconds must be 0.1–120')
|
||
if mode != 'groq':
|
||
for key in cloud_keys: options.pop(key, None)
|
||
return mode, language, api_key, options
|
||
else:
|
||
spec = visible_specs[selected-(3 if mode == 'groq' else 2)]
|
||
key, label, kind = spec[:3]
|
||
if kind == 'bool': options[key] = '0' if options[key] == '1' else '1'
|
||
elif kind == 'enum':
|
||
choices = spec[3]
|
||
options[key] = choices[choose_screen(screen, label, [backend.option_label(value) for value in choices], choices.index(options[key]) if options[key] in choices else 0)]
|
||
else: options[key] = entry(screen, label, options[key])
|
||
|
||
|
||
def run_tui(backend, home, requested, language, key_file):
|
||
try:
|
||
terminal = open('/dev/tty', 'r+')
|
||
except OSError as error:
|
||
raise RuntimeError('The TUI needs a terminal. Agents should use --non-interactive --backend MODE --language CODE and --groq-key-file PATH for Groq.') from error
|
||
saved = [os.dup(fd) for fd in (0, 1)]
|
||
try:
|
||
for fd in (0, 1): os.dup2(terminal.fileno(), fd)
|
||
mode, language, key, options = curses.wrapper(tui_options, backend, home, requested, language, key_file)
|
||
finally:
|
||
for fd, original in zip((0, 1), saved):
|
||
os.dup2(original, fd)
|
||
os.close(original)
|
||
terminal.close()
|
||
prepared, _ = backend.prepare(home, mode, key_file=key_file, api_key=key, allow_prompt=False, language=language)
|
||
prepared.update(options)
|
||
backend.input_permissions(os.getuid())
|
||
backend.write_values(home/'.config/frame-voice/env', prepared)
|
||
|
||
|
||
def configure(backend, home, ui='auto', requested=None, language=None, key_file=None):
|
||
if ui == 'auto' and requested is not None:
|
||
backend.configure(home, requested, key_file, language=language)
|
||
return
|
||
environment = desktop_environment() if ui != 'tui' else None
|
||
if ui == 'gui' or ui == 'auto' and environment:
|
||
if not environment: raise RuntimeError('Open Desktop to use the GUI installer, or choose --tui')
|
||
command = ['python3', str(Path(__file__).with_name('config-gui.py')), '--setup']
|
||
if requested: command += ['--backend', requested]
|
||
if language: command += ['--language', language]
|
||
if key_file: command += ['--groq-key-file', str(key_file)]
|
||
result = subprocess.run(command, env=environment)
|
||
if result.returncode: raise RuntimeError('GUI setup canceled or failed')
|
||
backend.input_permissions(os.getuid(), gui=True)
|
||
else:
|
||
run_tui(backend, home, requested, language, key_file)
|