// 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 sound import ( "bytes" _ "embed" "errors" "fmt" "io" "os" "path/filepath" "slices" "strings" "time" "github.com/pelletier/go-toml/v2" "git.stormux.org/storm/fenrir/internal/presentation" ) //go:embed soundicons.toml var defaultIcons []byte const sampleRate = 48000 const maxDuration = 10 * time.Second type clip struct { samples []float32 finished chan struct{} // shutdown sample submission notification *notificationPlayback } type iconMap struct { Icons map[presentation.Cue]string `toml:"icons"` } // Validate_theme accepts a single pack name or an absolute pack directory. func Validate_theme(theme string) error { if strings.TrimSpace(theme) == "" || strings.ContainsAny(theme, "\x00\r\n") || theme == "." || theme == ".." || (!filepath.IsAbs(theme) && filepath.Base(theme) != theme) { return fmt.Errorf("sound theme must be a name or an absolute directory") } return nil } func Theme_paths(home, theme string) []string { if filepath.IsAbs(theme) { return []string{theme} } paths := []string{filepath.Join(home, ".local/stormux/fenrir/sounds", theme)} if executable, err := os.Executable(); err == nil { paths = append(paths, filepath.Join(filepath.Dir(executable), "config", "sound", theme)) } return append(paths, filepath.Join("/usr/share/sounds/fenrir", theme), filepath.Join("/usr/share/sounds/fenrirscreenreader", theme), ) } // Theme_names is used by the settings worker, never by the input/audio loop. // Limit directory enumeration; retain saved choices separately in the form. func Theme_names(home string) []string { names := map[string]bool{"default": true} for _, path := range Theme_paths(home, "default") { directory, err := os.Open(filepath.Dir(path)) if err != nil { continue } entries, _ := directory.ReadDir(256) directory.Close() for _, entry := range entries { if entry.IsDir() && Validate_theme(entry.Name()) == nil { names[entry.Name()] = true } } } result := []string{"default"} for name := range names { if name != "default" { result = append(result, name) } } slices.Sort(result[1:]) return result } // Load and resample once, before input grabs. No disk or decoding work belongs // on the input loop or the audio callback. Python soundicons.conf is not read. func load_theme(roots []string) (map[presentation.Cue]*clip, error) { var root string for _, candidate := range roots { info, err := os.Stat(candidate) if err == nil && info.IsDir() { root = candidate break } if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, err } } if root == "" { return nil, fmt.Errorf("selected Fenrir sound pack not found") } file, err := os.Open(filepath.Join(root, "soundicons.toml")) var data []byte if errors.Is(err, os.ErrNotExist) { data, err = defaultIcons, nil } else if err == nil { data, err = io.ReadAll(io.LimitReader(file, (64<<10)+1)) err = errors.Join(err, file.Close()) } if err != nil { return nil, err } if len(data) > 64<<10 { return nil, fmt.Errorf("soundicons.toml exceeds 64 KiB") } var icons iconMap if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&icons); err != nil { return nil, fmt.Errorf("soundicons.toml: %w", err) } var known iconMap if err := toml.Unmarshal(defaultIcons, &known); err != nil { return nil, err } clips := make(map[presentation.Cue]*clip) var problems []error for cue, name := range icons.Icons { if _, ok := known.Icons[cue]; !ok { return nil, fmt.Errorf("unknown sound cue %q", cue) } if name == "" || filepath.Base(name) != name || name == "." || name == ".." { return nil, fmt.Errorf("sound cue %q requires a filename in the theme directory", cue) } item, err := decode_file(filepath.Join(root, name)) if err != nil { problems = append(problems, fmt.Errorf("%s: %w", cue, err)) continue } clips[cue] = item } return clips, errors.Join(problems...) } func decode_file(path string) (*clip, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() info, err := file.Stat() if err != nil { return nil, err } if !info.Mode().IsRegular() || info.Size() > 4<<20 { return nil, fmt.Errorf("sound must be a regular WAV file at most 4 MiB") } return decode_wav(io.LimitReader(file, (4<<20)+1)) }