docs: update README with detailed desktop features and setup requirements

This commit is contained in:
2026-07-17 14:41:10 +03:00
parent 55fbd357b0
commit 54fb100d3c
35 changed files with 3469 additions and 162 deletions
+235
View File
@@ -0,0 +1,235 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
const configFileName = "config.json"
type diskConfig struct {
Version int `json:"version"`
General struct {
StartCaptions bool `json:"start_captions"`
MainWindowAlwaysOnTop bool `json:"main_window_always_on_top"`
} `json:"general"`
Recognition struct {
Mode Mode `json:"mode"`
ChunkDuration string `json:"chunk_duration"`
ModelsDir string `json:"models_directory"`
Threads int `json:"threads"`
PreviewThresholdDB float64 `json:"preview_threshold_dbfs"`
VADThreshold float64 `json:"vad_threshold"`
ModelAutoRestart bool `json:"model_auto_restart"`
ModelTimeout string `json:"model_timeout"`
ModelShutdownTimeout string `json:"model_shutdown_timeout"`
} `json:"recognition"`
Outputs struct {
Overlay bool `json:"overlay"`
History bool `json:"history"`
StdoutCaptions bool `json:"stdout_captions"`
StderrDiagnostics bool `json:"stderr_diagnostics"`
} `json:"outputs"`
Overlay struct {
Backend OverlayBackend `json:"backend"`
Opacity float64 `json:"opacity"`
FontSize int `json:"font_size"`
FontFamily string `json:"font_family"`
BottomMargin int `json:"bottom_margin"`
MaxWidth int `json:"max_width"`
Monitor string `json:"monitor"`
FinalTimeout string `json:"final_timeout"`
ClickThrough bool `json:"click_through"`
} `json:"overlay"`
TranscriptLog struct {
Enabled bool `json:"enabled"`
Directory string `json:"directory"`
} `json:"transcript_log"`
}
func ConfigPath() (string, error) {
base, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("find user configuration directory: %w", err)
}
return filepath.Join(base, "captioneer", configFileName), nil
}
func DefaultTranscriptDirectory() string {
if stateHome := os.Getenv("XDG_STATE_HOME"); filepath.IsAbs(stateHome) {
return filepath.Join(stateHome, "captioneer", "transcripts")
}
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join("captioneer", "transcripts")
}
return filepath.Join(home, ".local", "state", "captioneer", "transcripts")
}
func Load(path string) (AppConfig, error) {
defaults := DefaultAppConfig()
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return defaults, nil
}
if err != nil {
return defaults, fmt.Errorf("read configuration: %w", err)
}
disk := diskFromConfig(defaults)
if err := json.Unmarshal(data, &disk); err != nil {
return defaults, fmt.Errorf("parse configuration: %w", err)
}
loaded, err := disk.toConfig()
if err != nil {
return defaults, err
}
if err := loaded.Validate(); err != nil {
return defaults, fmt.Errorf("validate configuration: %w", err)
}
return loaded, nil
}
func Save(path string, config AppConfig) error {
config.Version = CurrentConfigVersion
if err := config.Validate(); err != nil {
return fmt.Errorf("validate configuration: %w", err)
}
data, err := json.MarshalIndent(diskFromConfig(config), "", " ")
if err != nil {
return fmt.Errorf("encode configuration: %w", err)
}
data = append(data, '\n')
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0o700); err != nil {
return fmt.Errorf("create configuration directory: %w", err)
}
temporary, err := os.CreateTemp(directory, ".config-*.tmp")
if err != nil {
return fmt.Errorf("create temporary configuration: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return fmt.Errorf("secure temporary configuration: %w", err)
}
if _, err := temporary.Write(data); err != nil {
temporary.Close()
return fmt.Errorf("write temporary configuration: %w", err)
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return fmt.Errorf("sync temporary configuration: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close temporary configuration: %w", err)
}
if err := os.Rename(temporaryPath, path); err != nil {
return fmt.Errorf("replace configuration: %w", err)
}
return nil
}
func diskFromConfig(config AppConfig) diskConfig {
var disk diskConfig
disk.Version = config.Version
disk.General.StartCaptions = config.GUI.StartCaptions
disk.General.MainWindowAlwaysOnTop = config.GUI.MainWindowAlwaysOnTop
disk.Recognition.Mode = config.Settings.Mode
disk.Recognition.ChunkDuration = config.Settings.ChunkDuration.String()
disk.Recognition.ModelsDir = config.Settings.ModelsDir
disk.Recognition.Threads = config.Settings.Threads
disk.Recognition.PreviewThresholdDB = config.Settings.PreviewThresholdDB
disk.Recognition.VADThreshold = config.Settings.VADThreshold
disk.Recognition.ModelAutoRestart = config.Settings.ModelAutoRestart
disk.Recognition.ModelTimeout = config.Settings.ModelTimeout.String()
disk.Recognition.ModelShutdownTimeout = config.Settings.ModelShutdownTimeout.String()
disk.Outputs.Overlay = config.Outputs.Overlay
disk.Outputs.History = config.Outputs.History
disk.Outputs.StdoutCaptions = config.Outputs.StdoutCaptions
disk.Outputs.StderrDiagnostics = config.Outputs.StderrDiagnostics
disk.Overlay.Backend = config.Overlay.Backend
disk.Overlay.Opacity = config.Overlay.Opacity
disk.Overlay.FontSize = config.Overlay.FontSize
disk.Overlay.FontFamily = config.Overlay.FontFamily
disk.Overlay.BottomMargin = config.Overlay.BottomMargin
disk.Overlay.MaxWidth = config.Overlay.MaxWidth
disk.Overlay.Monitor = config.Overlay.Monitor
disk.Overlay.FinalTimeout = config.Overlay.FinalTimeout.String()
disk.Overlay.ClickThrough = config.Overlay.ClickThrough
disk.TranscriptLog.Enabled = config.TranscriptLog.Enabled
disk.TranscriptLog.Directory = config.TranscriptLog.Directory
return disk
}
func (disk diskConfig) toConfig() (AppConfig, error) {
chunkDuration, err := parseStoredDuration("chunk duration", disk.Recognition.ChunkDuration)
if err != nil {
return AppConfig{}, err
}
modelTimeout, err := parseStoredDuration("model timeout", disk.Recognition.ModelTimeout)
if err != nil {
return AppConfig{}, err
}
shutdownTimeout, err := parseStoredDuration("model shutdown timeout", disk.Recognition.ModelShutdownTimeout)
if err != nil {
return AppConfig{}, err
}
finalTimeout, err := parseStoredDuration("overlay final timeout", disk.Overlay.FinalTimeout)
if err != nil {
return AppConfig{}, err
}
return AppConfig{
Version: disk.Version,
GUI: GUISettings{
StartCaptions: disk.General.StartCaptions,
MainWindowAlwaysOnTop: disk.General.MainWindowAlwaysOnTop,
},
Settings: Settings{
Mode: disk.Recognition.Mode,
ChunkDuration: chunkDuration,
ModelsDir: disk.Recognition.ModelsDir,
Threads: disk.Recognition.Threads,
PreviewThresholdDB: disk.Recognition.PreviewThresholdDB,
VADThreshold: disk.Recognition.VADThreshold,
ModelAutoRestart: disk.Recognition.ModelAutoRestart,
ModelTimeout: modelTimeout,
ModelShutdownTimeout: shutdownTimeout,
},
Outputs: OutputSettings{
Overlay: disk.Outputs.Overlay,
History: disk.Outputs.History,
StdoutCaptions: disk.Outputs.StdoutCaptions,
StderrDiagnostics: disk.Outputs.StderrDiagnostics,
},
Overlay: OverlaySettings{
Backend: disk.Overlay.Backend,
Opacity: disk.Overlay.Opacity,
FontSize: disk.Overlay.FontSize,
FontFamily: disk.Overlay.FontFamily,
BottomMargin: disk.Overlay.BottomMargin,
MaxWidth: disk.Overlay.MaxWidth,
Monitor: disk.Overlay.Monitor,
FinalTimeout: finalTimeout,
ClickThrough: disk.Overlay.ClickThrough,
},
TranscriptLog: TranscriptLogSettings{
Enabled: disk.TranscriptLog.Enabled,
Directory: disk.TranscriptLog.Directory,
},
}, nil
}
func parseStoredDuration(name, value string) (time.Duration, error) {
duration, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("parse %s %q: %w", name, value, err)
}
return duration, nil
}