// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package speech import ( "context" "fmt" ) // Driver binds a stable file/CLI ID to its implementation. Availability must // be a cheap dependency/hardware check, never connect, speak or start a service. // Connection failures are reported by Open and never select another driver. type Driver struct { ID, Label string Serial bool Hardware bool Check func() error Open func(Options) (Transport, error) Modules func(Options) ([]string, error) Catalog func(Options) (Catalog, error) Voices func(Options) ([]Voice, error) } func (d Driver) Availability() error { if d.Check != nil { return d.Check() } return nil } var drivers = append([]Driver{{ ID: "speech-dispatcher", Label: "Speech Dispatcher", Open: func(options Options) (Transport, error) { return Open(options) }, Modules: discover_modules, Catalog: Discover, Voices: discover_voices, }}, hardware_drivers()...) func hardware_drivers() []Driver { var result []Driver for _, entry := range []struct{ id, label string }{ {"dectalk", "DECtalk"}, {"dectalk-usb", "DECtalk USB"}, {"litetalk", "LiteTalk"}, {"doubletalk", "DoubleTalk LT"}, {"tripletalk", "TripleTalk"}, } { driver := Driver{ID: entry.id, Label: entry.label, Serial: entry.id != "dectalk-usb", Hardware: true, Open: func(options Options) (Transport, error) { return open_hardware(entry.id, options) }, } if entry.id == "dectalk-usb" { driver.Check = usb_available } result = append(result, driver) } return result } // Drivers returns a copy; registering a completed implementation here supplies // settings validation, UI choices, discovery and startup from the same entry. func Drivers() []Driver { return append([]Driver(nil), drivers...) } func Find_driver(id string) (Driver, error) { for _, driver := range drivers { if driver.ID == id { return driver, nil } } return Driver{}, fmt.Errorf("unknown speech driver %q", id) } func Open_driver(id string, options Options) (Transport, error) { return Open_driver_context(context.Background(), id, options) } // Open_driver_context bounds connection setup without binding the live client to ctx. func Open_driver_context(ctx context.Context, id string, options Options) (Transport, error) { options.context = ctx driver, err := Find_driver(id) if err != nil { return nil, err } if err := Validate_driver_options(id, options); err != nil { return nil, err } if err = driver.Availability(); err != nil { return nil, fmt.Errorf("speech driver %s unavailable: %w", id, err) } client, err := driver.Open(options) if err != nil { return nil, err } return client, nil } // discover_modules uses a separate, bounded connection. It never changes the // active speaking client or cancels speech. Only Prepare permits daemon startup. func discover_modules(options Options) ([]string, error) { wire, err := connect_options(options) if err != nil { return nil, err } defer wire.close() modules, err := wire.command("LIST OUTPUT_MODULES") if err != nil { return nil, err } seen := map[string]bool{} for _, module := range modules { if !valid_module(module) || module == "" || seen[module] { return nil, fmt.Errorf("invalid Speech Dispatcher module list") } seen[module] = true } return modules, nil } func valid_module(module string) bool { for _, char := range module { if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '.' || char == '_' || char == '+' || char == '-') { return false } } return true }