Skip to content

Commit 89c3a95

Browse files
committed
✨ feat: feat(accessibility): add Kokoro TTS direct speech for livecd wizard
Replace ORCA/speech-dispatcher dependency with direct Kokoro TTS (koko CLI) for accessibility in the livecd setup wizard. Changes: - accessibility.py: Rewrite speak() to use koko directly (generate WAV + paplay) instead of spd-say. Add generation counter for cancellation, background thread playback, and --speed 1.5 for faster speech. Decouple announce() from speak() to prevent double-speaking. Add set_speak_voice() to update global voice/language settings. - app_window.py: Super+Alt+S activates accessibility with Kokoro TTS. Use keycode 39 with CAPTURE phase for reliable shortcut detection. - language_view.py: Always update global speak voice on language selection (even when voice preview is off). Add --speed 1.5 to preview generation. - keyboard_view.py: Add selected-children-changed signal handler. Fix race condition between _on_map announce and _select_first_item using _suppress_speak guard flag. Read title on screen entry, speak individual items on navigation. - base_view.py: Same race condition fix with _suppress_speak flag. Unified _select_first_and_announce method replaces separate _select_first_item + _on_map logic. - theme_view.py: Add speak() for JamesDSP/contrast toggle state changes. Announce toggle state on screen entry. Support both click and keyboard toggle with voice feedback.
1 parent 2dd70b9 commit 89c3a95

6 files changed

Lines changed: 334 additions & 86 deletions

File tree

Lines changed: 141 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
"""Accessibility utilities for ORCA screen reader support (AT-SPI2)."""
1+
"""Accessibility utilities — speech via Kokoro TTS (koko CLI)."""
22

3+
import os
34
import subprocess
5+
import tempfile
6+
import threading
47

58
import gi
69

@@ -12,12 +15,134 @@
1215

1316
_HAS_ANNOUNCE = hasattr(Gtk.Accessible, "announce")
1417

18+
# ── Kokoro TTS paths ────────────────────────────────────────
19+
_KOKO_BIN = "/usr/bin/koko"
20+
_KOKO_MODEL = "/usr/share/biglinux-kokoro-tts/model/model.onnx"
21+
_KOKO_VOICES = "/usr/share/biglinux-kokoro-tts/voices/voices.bin"
22+
23+
# ── Global accessibility state ──────────────────────────────
24+
_accessibility_enabled = False
25+
_speak_gen = 0
26+
_speak_lock = threading.Lock()
27+
_play_process = None
28+
29+
# Voice configuration set by the language screen selection
30+
_current_voice = "af_heart"
31+
_current_lang_code = "en-us"
32+
33+
34+
def is_accessibility_enabled() -> bool:
35+
return _accessibility_enabled
36+
37+
38+
def set_accessibility_enabled(enabled: bool) -> None:
39+
global _accessibility_enabled
40+
_accessibility_enabled = enabled
41+
logger.info(f"Accessibility {'enabled' if enabled else 'disabled'}")
42+
43+
44+
def set_speak_voice(voice: str, lang_code: str) -> None:
45+
"""Set the voice and language for speak() calls (called when user selects a language)."""
46+
global _current_voice, _current_lang_code
47+
_current_voice = voice
48+
_current_lang_code = lang_code
49+
50+
51+
def speak(text: str) -> None:
52+
"""Speak text using koko directly. Non-blocking, cancels previous speech."""
53+
if not _accessibility_enabled or not text:
54+
return
55+
logger.info(
56+
f"speak() called: '{text}' voice={_current_voice} lang={_current_lang_code}"
57+
)
58+
stop_speaking()
59+
global _speak_gen
60+
with _speak_lock:
61+
_speak_gen += 1
62+
gen = _speak_gen
63+
64+
def _do_speak():
65+
global _play_process
66+
tmpwav = None
67+
try:
68+
fd, tmpwav = tempfile.mkstemp(prefix="a11y-", suffix=".wav")
69+
os.close(fd)
70+
cmd = [
71+
_KOKO_BIN,
72+
"-m",
73+
_KOKO_MODEL,
74+
"-d",
75+
_KOKO_VOICES,
76+
"-l",
77+
_current_lang_code,
78+
"-s",
79+
_current_voice,
80+
"--force-style",
81+
"true",
82+
"--speed",
83+
"1.5",
84+
"text",
85+
text,
86+
"-o",
87+
tmpwav,
88+
]
89+
logger.info(f"speak() running koko: {' '.join(cmd)}")
90+
proc = subprocess.run(
91+
cmd,
92+
stdout=subprocess.PIPE,
93+
stderr=subprocess.PIPE,
94+
timeout=15,
95+
)
96+
if proc.returncode != 0:
97+
logger.info(
98+
f"speak() koko failed rc={proc.returncode} stderr={proc.stderr.decode()[:200]}"
99+
)
100+
return
101+
with _speak_lock:
102+
if gen != _speak_gen:
103+
logger.info("speak() discarded (gen changed)")
104+
return
105+
fsize = os.path.getsize(tmpwav) if os.path.isfile(tmpwav) else 0
106+
logger.info(f"speak() WAV generated: {tmpwav} size={fsize}")
107+
if fsize > 0:
108+
play = subprocess.Popen(
109+
["paplay", tmpwav],
110+
stdout=subprocess.DEVNULL,
111+
stderr=subprocess.PIPE,
112+
)
113+
with _speak_lock:
114+
_play_process = play
115+
play.wait(timeout=15)
116+
if play.returncode != 0:
117+
logger.info(
118+
f"speak() paplay failed rc={play.returncode} stderr={play.stderr.read().decode()[:200]}"
119+
)
120+
else:
121+
logger.info("speak() playback complete")
122+
except Exception as e:
123+
logger.info(f"speak() exception: {e}")
124+
finally:
125+
if tmpwav and os.path.isfile(tmpwav):
126+
try:
127+
os.unlink(tmpwav)
128+
except OSError:
129+
pass
130+
131+
threading.Thread(target=_do_speak, daemon=True).start()
132+
133+
134+
def stop_speaking() -> None:
135+
"""Cancel any ongoing speech playback."""
136+
global _speak_gen, _play_process
137+
with _speak_lock:
138+
_speak_gen += 1
139+
if _play_process and _play_process.poll() is None:
140+
_play_process.terminate()
141+
_play_process = None
142+
15143

16144
def announce(widget: Gtk.Accessible, message: str, assertive: bool = False) -> None:
17-
"""
18-
Announce a message to screen readers (ORCA) via AT-SPI2.
19-
Uses Gtk.Accessible.announce() on GTK 4.14+.
20-
"""
145+
"""Announce a message to screen readers via AT-SPI2 only (no TTS speak)."""
21146
if not message or not widget:
22147
return
23148
if _HAS_ANNOUNCE:
@@ -30,34 +155,6 @@ def announce(widget: Gtk.Accessible, message: str, assertive: bool = False) -> N
30155
widget.announce(message, priority)
31156
except Exception as e:
32157
logger.debug(f"announce() failed: {e}")
33-
else:
34-
logger.debug(f"a11y: {message}")
35-
36-
37-
def start_orca() -> bool:
38-
"""Start ORCA screen reader if not already running."""
39-
try:
40-
result = subprocess.run(
41-
["pgrep", "-x", "orca"],
42-
capture_output=True,
43-
timeout=5,
44-
)
45-
if result.returncode == 0:
46-
logger.info("ORCA is already running")
47-
return True
48-
subprocess.Popen(
49-
["orca"],
50-
stdout=subprocess.DEVNULL,
51-
stderr=subprocess.DEVNULL,
52-
)
53-
logger.info("Started ORCA screen reader")
54-
return True
55-
except FileNotFoundError:
56-
logger.warning("ORCA not found on this system")
57-
return False
58-
except subprocess.TimeoutExpired:
59-
logger.warning("Timeout checking for ORCA process")
60-
return False
61158

62159

63160
def ensure_orca_disabled() -> None:
@@ -73,4 +170,15 @@ def ensure_orca_disabled() -> None:
73170
stdout=subprocess.DEVNULL,
74171
stderr=subprocess.DEVNULL,
75172
)
173+
autostart_dir = os.path.expanduser("~/.config/autostart")
174+
override_file = os.path.join(autostart_dir, "orca-autostart.desktop")
175+
if not os.path.isfile(override_file):
176+
try:
177+
os.makedirs(autostart_dir, exist_ok=True)
178+
with open(override_file, "w") as f:
179+
f.write(
180+
"[Desktop Entry]\nType=Application\nName=Orca Screen Reader\nHidden=true\n"
181+
)
182+
except OSError:
183+
pass
76184
logger.info("Ensured ORCA is disabled at startup")

biglinux-livecd/usr/share/biglinux/livecd/ui/app_window.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111
from ui.keyboard_view import KeyboardView
1212
from ui.desktop_view import DesktopView
1313
from ui.theme_view import ThemeView
14-
from accessibility import announce, start_orca, ensure_orca_disabled
14+
from accessibility import (
15+
announce,
16+
ensure_orca_disabled,
17+
set_accessibility_enabled,
18+
speak,
19+
set_speak_voice,
20+
)
1521
from logging_config import get_logger
1622
import os
1723

@@ -120,8 +126,10 @@ def __init__(self, system_service: SystemService, **kwargs):
120126
self.set_content(self._build_ui())
121127
self._update_header_state()
122128

123-
# Create and add a Gtk.EventControllerKey for global key events
129+
# Create and add a Gtk.EventControllerKey for global key events (CAPTURE phase
130+
# so it runs before child widgets like the search entry consume keys)
124131
key_controller = Gtk.EventControllerKey.new()
132+
key_controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
125133
key_controller.connect("key-pressed", self._on_key_press_event)
126134
self.add_controller(key_controller)
127135

@@ -562,13 +570,18 @@ def _on_simple_theme_selected(self, view, theme):
562570
logger.error(f"ERROR in _on_simple_theme_selected: {e}", exc_info=True)
563571

564572
def _on_key_press_event(self, controller, keyval, keycode, state):
565-
# Super+Alt+S: start ORCA screen reader (standard GNOME shortcut)
573+
# Super+Alt+S: enable accessibility (speech via speech-dispatcher + Kokoro)
574+
# Use keycode 39 (physical 'S' key) so it works on any keyboard layout
566575
if (
567-
keyval == Gdk.KEY_s
576+
keycode == 39
568577
and state & Gdk.ModifierType.SUPER_MASK
569578
and state & Gdk.ModifierType.ALT_MASK
570579
):
571-
start_orca()
580+
set_accessibility_enabled(True)
581+
lang_view = self.stack.get_child_by_name("language")
582+
if isinstance(lang_view, LanguageView):
583+
lang_view.enable_voice_preview()
584+
speak(_("Accessibility enabled. Use arrow keys to navigate."))
572585
return True
573586
current_view = self.stack.get_visible_child()
574587
if isinstance(current_view, LanguageView):

biglinux-livecd/usr/share/biglinux/livecd/ui/base_view.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from gi.repository import Adw, Gdk, GLib, GObject, Gtk
1010
from services import SystemService
1111
from translations import _
12-
from accessibility import announce
12+
from accessibility import announce, speak, is_accessibility_enabled
1313
from logging_config import get_logger
1414

1515
logger = get_logger()
@@ -37,6 +37,9 @@ def _on_map(self, *args):
3737
self.load_items()
3838
self.items_loaded = True
3939
self.grab_focus()
40+
# Suppress selection-changed speak during initial selection
41+
self._suppress_speak = True
42+
GLib.idle_add(self._select_first_and_announce)
4043

4144
def _build_ui(self):
4245
scrolled_window = Gtk.ScrolledWindow(
@@ -87,6 +90,7 @@ def _build_ui(self):
8790

8891
self.flow_box.connect("child-activated", self._on_child_activated)
8992
self.flow_box.connect("activate-cursor-child", self._on_activate_cursor_child)
93+
self.flow_box.connect("selected-children-changed", self._on_selection_changed)
9094

9195
key_controller = Gtk.EventControllerKey.new()
9296
key_controller.connect("key-pressed", self._on_key_pressed)
@@ -136,8 +140,7 @@ def load_items(self):
136140

137141
self.flow_box.append(flow_child)
138142

139-
if items:
140-
GLib.idle_add(self._select_first_item)
143+
# Selection and announce are done in _select_first_and_announce via _on_map
141144

142145
def grab_focus(self):
143146
self.flow_box.grab_focus()
@@ -171,6 +174,29 @@ def _on_flow_leave(self, controller, *args):
171174
def _on_item_enter(self, controller, x, y, flow_child):
172175
self.flow_box.select_child(flow_child)
173176

177+
def _on_selection_changed(self, flow_box):
178+
"""Speak the selected item name when selection changes (mouse or keyboard)."""
179+
if getattr(self, "_suppress_speak", False):
180+
return
181+
selected = flow_box.get_selected_children()
182+
if selected and is_accessibility_enabled():
183+
child = selected[0]
184+
if hasattr(child, "item_data"):
185+
speak(child.item_data.name)
186+
187+
def _select_first_and_announce(self):
188+
"""Select first item and announce page title (without race condition)."""
189+
first_child = self.flow_box.get_first_child()
190+
if first_child:
191+
self.flow_box.select_child(first_child)
192+
self.grab_focus()
193+
self._suppress_speak = False
194+
if is_accessibility_enabled():
195+
text = self.get_title() or ""
196+
if text:
197+
speak(text)
198+
return GLib.SOURCE_REMOVE
199+
174200
def _on_activate_cursor_child(self, flow_box):
175201
selected = flow_box.get_selected_children()
176202
if selected:

biglinux-livecd/usr/share/biglinux/livecd/ui/keyboard_view.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
gi.require_version("Adw", "1")
77
from gi.repository import Gtk, Adw, GObject, GLib, Gdk
88
from translations import _
9-
from accessibility import announce
9+
from accessibility import announce, speak, is_accessibility_enabled
1010
import os
1111

1212
ASSETS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets"))
@@ -72,6 +72,7 @@ def _build_ui(self):
7272
# Connect to FlowBox-level signals
7373
self.flow_box.connect("child-activated", self._on_child_activated)
7474
self.flow_box.connect("activate-cursor-child", self._on_activate_cursor_child)
75+
self.flow_box.connect("selected-children-changed", self._on_selection_changed)
7576

7677
# Add key controller for Enter key handling
7778
key_controller = Gtk.EventControllerKey.new()
@@ -127,7 +128,7 @@ def load_layouts(self):
127128
child = self._create_layout_child(name, layout_id)
128129
self.flow_box.append(child)
129130

130-
GLib.idle_add(self._select_first_item)
131+
# Selection and announce are handled by _select_first_and_announce via _on_map
131132

132133
def _create_layout_child(self, name, layout_id):
133134
"""Create a FlowBoxChild with a keyboard layout card."""
@@ -204,13 +205,41 @@ def _on_item_enter(self, controller, x, y, child):
204205
"""Select the item when the mouse pointer enters it."""
205206
self.flow_box.select_child(child)
206207

208+
def _on_selection_changed(self, flow_box):
209+
"""Speak the selected layout name when selection changes (mouse or keyboard)."""
210+
if getattr(self, "_suppress_speak", False):
211+
return
212+
selected = flow_box.get_selected_children()
213+
if selected and is_accessibility_enabled():
214+
child = selected[0]
215+
if hasattr(child, "layout_data"):
216+
speak(child.layout_data["name"])
217+
207218
def _on_flow_leave(self, controller, *args):
208219
"""Clear selection when the mouse pointer leaves the flow box."""
209220
self.flow_box.unselect_all()
210221

211222
def _on_map(self, *args):
223+
from logging_config import get_logger
224+
225+
logger = get_logger()
226+
logger.info(
227+
f"KeyboardView._on_map called, accessibility={is_accessibility_enabled()}"
228+
)
212229
self.grab_focus()
213-
GLib.idle_add(self._select_first_item)
230+
# Suppress selection-changed speak during initial selection
231+
self._suppress_speak = True
232+
GLib.idle_add(self._select_first_and_announce)
233+
234+
def _select_first_and_announce(self):
235+
"""Select first item and announce page title (without race condition)."""
236+
child = self.flow_box.get_first_child()
237+
if child:
238+
self.flow_box.select_child(child)
239+
self._suppress_speak = False
240+
if is_accessibility_enabled():
241+
speak(_("Choose Your Keyboard Layout"))
242+
return GLib.SOURCE_REMOVE
214243

215244
def update_primary_layout(self, new_layout):
216245
self.primary_layout = new_layout

0 commit comments

Comments
 (0)