Files

174 lines
5.0 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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))
}