2026-07-17 14:41:10 +03:00
|
|
|
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"`
|
2026-07-17 19:55:00 +03:00
|
|
|
PositionX *int `json:"position_x,omitempty"`
|
|
|
|
|
PositionY *int `json:"position_y,omitempty"`
|
2026-07-17 14:41:10 +03:00
|
|
|
} `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
|
2026-07-17 19:55:00 +03:00
|
|
|
positionX := config.Overlay.PositionX
|
|
|
|
|
positionY := config.Overlay.PositionY
|
|
|
|
|
disk.Overlay.PositionX = &positionX
|
|
|
|
|
disk.Overlay.PositionY = &positionY
|
2026-07-17 14:41:10 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-17 19:55:00 +03:00
|
|
|
positionX, positionY := -1, -1
|
|
|
|
|
if disk.Overlay.PositionX != nil {
|
|
|
|
|
positionX = *disk.Overlay.PositionX
|
|
|
|
|
}
|
|
|
|
|
if disk.Overlay.PositionY != nil {
|
|
|
|
|
positionY = *disk.Overlay.PositionY
|
|
|
|
|
}
|
2026-07-17 14:41:10 +03:00
|
|
|
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,
|
2026-07-17 19:55:00 +03:00
|
|
|
PositionX: positionX,
|
|
|
|
|
PositionY: positionY,
|
2026-07-17 14:41:10 +03:00
|
|
|
},
|
|
|
|
|
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
|
|
|
|
|
}
|