174 lines
5.0 KiB
Go
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))
|
|
}
|