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
+215
View File
@@ -0,0 +1,215 @@
package app
import (
"context"
"errors"
"io"
"sync"
"time"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/output"
)
type State string
const (
StateStopped State = "stopped"
StateStarting State = "starting"
StateListening State = "listening"
StateProcessing State = "processing"
StateRestarting State = "restarting"
StateStopping State = "stopping"
StateError State = "error"
)
type Status struct {
State State
Mode config.Mode
Source string
SpeechActive bool
OverloadedAt time.Time
LastError string
UpdatedAt time.Time
}
type sessionRunner func(context.Context, config.Settings, output.Sink, io.Writer, RuntimeObserver) error
type Controller struct {
mu sync.Mutex
runner sessionRunner
sink output.Sink
diagnostics io.Writer
cancel context.CancelFunc
done chan error
status Status
subscribers map[chan Status]struct{}
}
func NewController(sink output.Sink, diagnostics io.Writer) *Controller {
return newControllerWithRunner(sink, diagnostics, RunObserved)
}
func newControllerWithRunner(sink output.Sink, diagnostics io.Writer, runner sessionRunner) *Controller {
return &Controller{
runner: runner,
sink: sink,
diagnostics: diagnostics,
status: Status{State: StateStopped, UpdatedAt: time.Now()},
subscribers: make(map[chan Status]struct{}),
}
}
func (c *Controller) Start(settings config.Settings) error {
if err := settings.Validate(); err != nil {
return err
}
c.mu.Lock()
if c.cancel != nil {
c.mu.Unlock()
return errors.New("caption processing is already running")
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
c.cancel = cancel
c.done = done
c.status = Status{State: StateStarting, Mode: settings.Mode, UpdatedAt: time.Now()}
c.publishLocked()
c.mu.Unlock()
go func() {
err := c.runner(ctx, settings, c.sink, c.diagnostics, c.observe)
c.mu.Lock()
if c.done == done {
c.cancel = nil
c.done = nil
if err != nil {
c.status.State = StateError
c.status.LastError = err.Error()
} else {
c.status.State = StateStopped
c.status.SpeechActive = false
}
c.status.UpdatedAt = time.Now()
c.publishLocked()
}
c.mu.Unlock()
done <- err
close(done)
}()
return nil
}
func (c *Controller) Stop(ctx context.Context) error {
c.mu.Lock()
if c.cancel == nil {
c.mu.Unlock()
return nil
}
if c.status.State != StateRestarting {
c.status.State = StateStopping
}
c.status.UpdatedAt = time.Now()
c.publishLocked()
cancel := c.cancel
done := c.done
c.mu.Unlock()
cancel()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (c *Controller) Restart(ctx context.Context, settings config.Settings) error {
c.mu.Lock()
if c.cancel != nil {
c.status.State = StateRestarting
c.status.UpdatedAt = time.Now()
c.publishLocked()
}
c.mu.Unlock()
if err := c.Stop(ctx); err != nil {
return err
}
return c.Start(settings)
}
func (c *Controller) Status() Status {
c.mu.Lock()
defer c.mu.Unlock()
return c.status
}
func (c *Controller) Subscribe(buffer int) (<-chan Status, func()) {
if buffer < 1 {
buffer = 1
}
updates := make(chan Status, buffer)
c.mu.Lock()
c.subscribers[updates] = struct{}{}
updates <- c.status
c.mu.Unlock()
return updates, func() {
c.mu.Lock()
if _, ok := c.subscribers[updates]; ok {
delete(c.subscribers, updates)
close(updates)
}
c.mu.Unlock()
}
}
func (c *Controller) observe(event RuntimeEvent) {
c.mu.Lock()
switch event.Kind {
case RuntimeStarting:
c.status.State = StateStarting
c.status.LastError = ""
case RuntimeListening:
c.status.State = StateListening
c.status.LastError = ""
case RuntimeProcessing:
c.status.State = StateProcessing
case RuntimeRestarting:
c.status.State = StateRestarting
case RuntimeStopped:
c.status.State = StateStopped
c.status.SpeechActive = false
case RuntimeError:
if event.Err != nil {
c.status.LastError = event.Err.Error()
}
case RuntimeSource:
c.status.Source = event.Value
case RuntimeSpeech:
c.status.SpeechActive = event.Active
case RuntimeOverloaded:
c.status.OverloadedAt = event.At
}
c.status.UpdatedAt = event.At
if c.status.UpdatedAt.IsZero() {
c.status.UpdatedAt = time.Now()
}
c.publishLocked()
c.mu.Unlock()
}
func (c *Controller) publishLocked() {
for subscriber := range c.subscribers {
select {
case subscriber <- c.status:
default:
select {
case <-subscriber:
default:
}
select {
case subscriber <- c.status:
default:
}
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package app
import (
"context"
"io"
"sync"
"testing"
"time"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/output"
)
func TestControllerStartObserveAndStop(t *testing.T) {
started := make(chan struct{})
runner := func(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, observer RuntimeObserver) error {
observe(observer, RuntimeEvent{Kind: RuntimeSource, Value: "monitor-source"})
observe(observer, RuntimeEvent{Kind: RuntimeListening})
observe(observer, RuntimeEvent{Kind: RuntimeSpeech, Active: true})
close(started)
<-ctx.Done()
observe(observer, RuntimeEvent{Kind: RuntimeStopped})
return nil
}
controller := newControllerWithRunner(nil, io.Discard, runner)
settings := config.DefaultSettings()
if err := controller.Start(settings); err != nil {
t.Fatalf("Start() error = %v", err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("runner did not start")
}
status := controller.Status()
if status.State != StateListening || status.Source != "monitor-source" || !status.SpeechActive {
t.Fatalf("Status() = %#v", status)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := controller.Stop(ctx); err != nil {
t.Fatalf("Stop() error = %v", err)
}
status = controller.Status()
if status.State != StateStopped || status.SpeechActive {
t.Fatalf("Status() after stop = %#v", status)
}
}
func TestControllerRestartUsesNewSettings(t *testing.T) {
var mu sync.Mutex
var modes []config.Mode
started := make(chan struct{}, 2)
runner := func(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, observer RuntimeObserver) error {
mu.Lock()
modes = append(modes, settings.Mode)
mu.Unlock()
observe(observer, RuntimeEvent{Kind: RuntimeListening})
started <- struct{}{}
<-ctx.Done()
return nil
}
controller := newControllerWithRunner(nil, io.Discard, runner)
settings := config.DefaultSettings()
if err := controller.Start(settings); err != nil {
t.Fatal(err)
}
<-started
settings.Mode = config.ModeFixed
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := controller.Restart(ctx, settings); err != nil {
t.Fatalf("Restart() error = %v", err)
}
<-started
mu.Lock()
defer mu.Unlock()
if len(modes) != 2 || modes[0] != config.ModeVAD || modes[1] != config.ModeFixed {
t.Fatalf("runner modes = %v", modes)
}
}
+29 -5
View File
@@ -15,9 +15,13 @@ import (
)
type directHooks struct {
ready func() error
busy func() error
idle func() error
ready func() error
busy func() error
idle func() error
source func(string) error
speech func(bool) error
recognition func(bool) error
overloaded func() error
}
func runDirect(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, hooks directHooks) error {
@@ -45,8 +49,17 @@ func runDirect(ctx context.Context, settings config.Settings, sink output.Sink,
return fmt.Errorf("find default output monitor: %w", err)
}
fmt.Fprintf(diagnostics, "Capturing from: %s\n", monitorSource)
if hooks.source != nil {
if err := hooks.source(monitorSource); err != nil {
return err
}
}
packets, waitCapture, err := capture.Packets(ctx, monitorSource)
packets, waitCapture, err := capture.PacketsObserved(ctx, monitorSource, diagnostics, func() {
if hooks.overloaded != nil {
_ = hooks.overloaded()
}
})
if err != nil {
return fmt.Errorf("start system-audio capture: %w", err)
}
@@ -56,7 +69,18 @@ func runDirect(ctx context.Context, settings config.Settings, sink output.Sink,
return err
}
processor := captions.New(settings, resources, sink.Publish)
processor := captions.NewWithActivity(settings, resources, sink.Publish, captions.Activity{
SpeechChanged: func(active bool) {
if hooks.speech != nil {
_ = hooks.speech(active)
}
},
RecognitionChanged: func(active bool) {
if hooks.recognition != nil {
_ = hooks.recognition(active)
}
},
})
var processErr error
for packet := range packets {
if err := hooks.busy(); err != nil {
+37
View File
@@ -0,0 +1,37 @@
package app
import "time"
type RuntimeEventKind string
const (
RuntimeStarting RuntimeEventKind = "starting"
RuntimeListening RuntimeEventKind = "listening"
RuntimeProcessing RuntimeEventKind = "processing"
RuntimeRestarting RuntimeEventKind = "restarting"
RuntimeStopped RuntimeEventKind = "stopped"
RuntimeError RuntimeEventKind = "error"
RuntimeSource RuntimeEventKind = "source"
RuntimeSpeech RuntimeEventKind = "speech"
RuntimeOverloaded RuntimeEventKind = "overloaded"
)
type RuntimeEvent struct {
Kind RuntimeEventKind
At time.Time
Value string
Active bool
Err error
}
type RuntimeObserver func(RuntimeEvent)
func observe(observer RuntimeObserver, event RuntimeEvent) {
if observer == nil {
return
}
if event.At.IsZero() {
event.At = time.Now()
}
observer(event)
}
+36 -1
View File
@@ -72,10 +72,22 @@ func (h workerHealth) timeoutError(now time.Time, timeout time.Duration) error {
// Presentation stays in this process, so the native model can be replaced
// without restarting the terminal or GTK main loop.
func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer) error {
return RunObserved(ctx, settings, sink, diagnostics, nil)
}
func RunObserved(
ctx context.Context,
settings config.Settings,
sink output.Sink,
diagnostics io.Writer,
observer RuntimeObserver,
) error {
if err := capture.ValidatePrograms(); err != nil {
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
return err
}
if err := models.Validate(settings); err != nil {
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
return err
}
@@ -84,24 +96,32 @@ func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagno
signal.Notify(reload, syscall.SIGHUP)
defer signal.Stop(reload)
observe(observer, RuntimeEvent{Kind: RuntimeStarting})
for {
result, err := runWorkerAttempt(ctx, settings, sink, diagnostics, reload)
result, err := runWorkerAttempt(ctx, settings, sink, diagnostics, reload, observer)
if ctx.Err() != nil {
observe(observer, RuntimeEvent{Kind: RuntimeStopped})
return nil
}
switch result {
case attemptStopped:
observe(observer, RuntimeEvent{Kind: RuntimeStopped})
return nil
case attemptFatal:
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
return err
case attemptReload:
fmt.Fprintln(diagnostics, "Reloading model worker.")
observe(observer, RuntimeEvent{Kind: RuntimeRestarting})
continue
case attemptFailed:
if !settings.ModelAutoRestart {
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
return err
}
fmt.Fprintf(diagnostics, "warning: %v; restarting model worker in %s\n", err, modelRestartDelay)
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
observe(observer, RuntimeEvent{Kind: RuntimeRestarting})
}
timer := time.NewTimer(modelRestartDelay)
@@ -116,6 +136,7 @@ func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagno
<-timer.C
}
fmt.Fprintln(diagnostics, "Reloading model worker.")
observe(observer, RuntimeEvent{Kind: RuntimeRestarting})
case <-timer.C:
}
}
@@ -127,6 +148,7 @@ func runWorkerAttempt(
sink output.Sink,
diagnostics io.Writer,
reload <-chan os.Signal,
observer RuntimeObserver,
) (attemptResult, error) {
cmd, protocol, err := startModelWorker(settings, diagnostics)
if err != nil {
@@ -236,8 +258,21 @@ func runWorkerAttempt(
switch message.Type {
case messageReady:
fmt.Fprintln(diagnostics, "Model worker ready.")
observe(observer, RuntimeEvent{Kind: RuntimeListening})
case messageBusy, messageIdle:
case messageHeartbeat:
case messageSource:
observe(observer, RuntimeEvent{Kind: RuntimeSource, Value: message.Value})
case messageSpeech:
observe(observer, RuntimeEvent{Kind: RuntimeSpeech, Active: message.Active})
case messageRecognition:
kind := RuntimeListening
if message.Active {
kind = RuntimeProcessing
}
observe(observer, RuntimeEvent{Kind: kind, Active: message.Active})
case messageOverloaded:
observe(observer, RuntimeEvent{Kind: RuntimeOverloaded})
case messageCaption:
if message.Event == nil {
beginStop(attemptFailed, errors.New("model worker sent an empty caption event"))
+22 -12
View File
@@ -24,18 +24,24 @@ const (
)
const (
messageReady = "ready"
messageBusy = "busy"
messageIdle = "idle"
messageCaption = "caption"
messageFailure = "failure"
messageHeartbeat = "heartbeat"
messageReady = "ready"
messageBusy = "busy"
messageIdle = "idle"
messageCaption = "caption"
messageFailure = "failure"
messageHeartbeat = "heartbeat"
messageSource = "source"
messageSpeech = "speech"
messageRecognition = "recognition"
messageOverloaded = "overloaded"
)
type workerMessage struct {
Type string `json:"type"`
Event *captions.Event `json:"event,omitempty"`
Error string `json:"error,omitempty"`
Type string `json:"type"`
Event *captions.Event `json:"event,omitempty"`
Error string `json:"error,omitempty"`
Value string `json:"value,omitempty"`
Active bool `json:"active,omitempty"`
}
type workerReporter struct {
@@ -110,9 +116,13 @@ func RunModelWorker(diagnostics io.Writer) int {
reporter := &workerReporter{encoder: json.NewEncoder(protocol)}
hooks := directHooks{
ready: func() error { return reporter.send(workerMessage{Type: messageReady}) },
busy: func() error { return reporter.send(workerMessage{Type: messageBusy}) },
idle: func() error { return reporter.send(workerMessage{Type: messageIdle}) },
ready: func() error { return reporter.send(workerMessage{Type: messageReady}) },
busy: func() error { return reporter.send(workerMessage{Type: messageBusy}) },
idle: func() error { return reporter.send(workerMessage{Type: messageIdle}) },
source: func(value string) error { return reporter.send(workerMessage{Type: messageSource, Value: value}) },
speech: func(active bool) error { return reporter.send(workerMessage{Type: messageSpeech, Active: active}) },
recognition: func(active bool) error { return reporter.send(workerMessage{Type: messageRecognition, Active: active}) },
overloaded: func() error { return reporter.send(workerMessage{Type: messageOverloaded}) },
}
heartbeatInterval := time.Second
+36 -2
View File
@@ -27,14 +27,27 @@ type Processor struct {
nextPreviewAt int
previewReceived int
totalSamples int
activity Activity
speechKnown bool
speechActive bool
}
type Activity struct {
SpeechChanged func(bool)
RecognitionChanged func(bool)
}
func New(settings config.Settings, transcriber Transcriber, emit func(Event) error) *Processor {
return NewWithActivity(settings, transcriber, emit, Activity{})
}
func NewWithActivity(settings config.Settings, transcriber Transcriber, emit func(Event) error, activity Activity) *Processor {
return &Processor{
settings: settings,
transcriber: transcriber,
emit: emit,
nextPreviewAt: audio.DurationSamples(settings.ChunkDuration),
activity: activity,
}
}
@@ -43,6 +56,7 @@ func (p *Processor) Accept(samples []float32) error {
return nil
}
if p.settings.Mode == config.ModeFixed {
p.setSpeechActive(audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB)
return p.acceptFixed(samples)
}
return p.acceptVAD(samples)
@@ -84,6 +98,7 @@ func (p *Processor) acceptVAD(samples []float32) error {
p.transcriber.AcceptVAD(samples)
speechActive := p.transcriber.SpeechActive()
p.setSpeechActive(speechActive)
if speechActive && (p.previewActive || audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB) {
if !p.previewActive {
p.previewActive = true
@@ -144,7 +159,7 @@ func (p *Processor) resetPreview() {
}
func (p *Processor) emitProvisional() error {
text := strings.TrimSpace(p.transcriber.Decode(p.previewSamples))
text := strings.TrimSpace(p.decode(p.previewSamples))
if text == "" {
return nil
}
@@ -157,7 +172,7 @@ func (p *Processor) emitProvisional() error {
}
func (p *Processor) emitFinal(start, end int, samples []float32) error {
text := strings.TrimSpace(p.transcriber.Decode(samples))
text := strings.TrimSpace(p.decode(samples))
if text == "" {
return p.emit(Event{Kind: Hide})
}
@@ -169,6 +184,25 @@ func (p *Processor) emitFinal(start, end int, samples []float32) error {
})
}
func (p *Processor) decode(samples []float32) string {
if p.activity.RecognitionChanged != nil {
p.activity.RecognitionChanged(true)
defer p.activity.RecognitionChanged(false)
}
return p.transcriber.Decode(samples)
}
func (p *Processor) setSpeechActive(active bool) {
if p.speechKnown && p.speechActive == active {
return
}
p.speechKnown = true
p.speechActive = active
if p.activity.SpeechChanged != nil {
p.activity.SpeechChanged(active)
}
}
func (p *Processor) emitFixedFinal(start, end int, samples []float32) error {
if audio.RMSDBFS(samples) < p.settings.PreviewThresholdDB {
return p.emit(Event{Kind: Hide})
+17 -5
View File
@@ -33,6 +33,15 @@ func DefaultMonitorSource(ctx context.Context) (string, error) {
}
func Packets(ctx context.Context, monitorSource string) (<-chan []float32, func() error, error) {
return PacketsObserved(ctx, monitorSource, os.Stderr, nil)
}
func PacketsObserved(
ctx context.Context,
monitorSource string,
diagnostics io.Writer,
overloaded func(),
) (<-chan []float32, func() error, error) {
cmd := exec.CommandContext(ctx, "parec",
"--device="+monitorSource,
"--format=s16le",
@@ -41,7 +50,7 @@ func Packets(ctx context.Context, monitorSource string) (<-chan []float32, func(
"--raw",
"--latency-msec=50",
)
cmd.Stderr = os.Stderr
cmd.Stderr = diagnostics
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -65,11 +74,11 @@ func Packets(ctx context.Context, monitorSource string) (<-chan []float32, func(
for {
n, readErr := io.ReadFull(stdout, packetBytes)
if n > 0 {
enqueueLatest(packets, audio.PCM16LEToFloat32(packetBytes[:n]))
enqueueLatest(packets, audio.PCM16LEToFloat32(packetBytes[:n]), diagnostics, overloaded)
}
if readErr != nil {
if ctx.Err() == nil && !errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrUnexpectedEOF) {
fmt.Fprintf(os.Stderr, "audio capture read error: %v\n", readErr)
fmt.Fprintf(diagnostics, "audio capture read error: %v\n", readErr)
}
return
}
@@ -78,7 +87,7 @@ func Packets(ctx context.Context, monitorSource string) (<-chan []float32, func(
return packets, wait, nil
}
func enqueueLatest(packets chan []float32, packet []float32) {
func enqueueLatest(packets chan []float32, packet []float32, diagnostics io.Writer, overloaded func()) {
select {
case packets <- packet:
return
@@ -93,5 +102,8 @@ func enqueueLatest(packets chan []float32, packet []float32) {
case packets <- packet:
default:
}
fmt.Fprintln(os.Stderr, "warning: caption processing overloaded; dropped 100 ms of oldest audio")
fmt.Fprintln(diagnostics, "warning: caption processing overloaded; dropped 100 ms of oldest audio")
if overloaded != nil {
overloaded()
}
}
+9 -57
View File
@@ -3,20 +3,15 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"runtime"
"syscall"
"tea.chunkbyte.com/kato/captioneer/src/app"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/output"
"tea.chunkbyte.com/kato/captioneer/src/output/overlay"
"tea.chunkbyte.com/kato/captioneer/src/output/terminal"
"tea.chunkbyte.com/kato/captioneer/src/gui"
)
func main() {
@@ -24,58 +19,15 @@ func main() {
os.Exit(app.RunModelWorker(os.Stderr))
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
settings := config.ParseDesktop()
if err := settings.Validate(); err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
terminalSink := terminal.New(os.Stdout)
if settings.Output == config.OutputTerminal {
defer terminalSink.Close()
if err := app.Run(ctx, settings.Settings, terminalSink, os.Stderr); err != nil {
log.Fatal(err)
}
launch, err := config.ParseDesktop(os.Args[1:], os.Stderr)
if errors.Is(err, flag.ErrHelp) {
return
}
overlaySink, warning, overlayErr := overlay.New(settings.Overlay)
if warning != "" {
fmt.Fprintf(os.Stderr, "warning: %s\n", warning)
}
if overlayErr != nil {
if settings.Output == config.OutputBoth {
fmt.Fprintf(os.Stderr, "warning: overlay unavailable: %v; continuing with terminal output\n", overlayErr)
defer terminalSink.Close()
if err := app.Run(ctx, settings.Settings, terminalSink, os.Stderr); err != nil {
log.Fatal(err)
}
return
}
log.Fatalf("initialize overlay: %v", overlayErr)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
var sink output.Sink = overlaySink
if settings.Output == config.OutputBoth {
sink = output.MultiSink{terminalSink, overlaySink}
}
workerDone := make(chan error, 1)
go func() {
workerDone <- app.Run(ctx, settings.Settings, sink, os.Stderr)
overlaySink.Quit()
}()
runErr := overlaySink.Run()
stop()
workerErr := <-workerDone
closeErr := sink.Close()
if err := errors.Join(runErr, workerErr, closeErr); err != nil {
log.Fatal(err)
}
runtime.LockOSThread()
os.Exit(gui.Run(launch, os.Stdout, os.Stderr))
}
+12 -2
View File
@@ -2,6 +2,9 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
@@ -17,10 +20,17 @@ func main() {
os.Exit(app.RunModelWorker(os.Stderr))
}
settings := config.Parse()
if err := settings.Validate(); err != nil {
launch, err := config.Parse(os.Args[1:], os.Stderr)
if errors.Is(err, flag.ErrHelp) {
return
}
if err != nil {
log.Fatal(err)
}
if launch.Warning != nil {
fmt.Fprintf(os.Stderr, "warning: %v; using built-in defaults\n", launch.Warning)
}
settings := launch.Config.Settings
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
+192
View File
@@ -0,0 +1,192 @@
package config
import (
"errors"
"flag"
"fmt"
"io"
"strings"
)
type LaunchConfig struct {
Config AppConfig
Path string
Warning error
}
func Parse(args []string, help io.Writer) (LaunchConfig, error) {
launch, err := loadLaunchConfig(args)
if err != nil {
return LaunchConfig{}, err
}
flags := flag.NewFlagSet("captioneer", flag.ContinueOnError)
flags.SetOutput(help)
registerConfigFlags(flags, &launch, args)
registerRecognitionFlags(flags, &launch.Config.Settings)
if err := flags.Parse(args); err != nil {
return LaunchConfig{}, err
}
if flags.NArg() != 0 {
return LaunchConfig{}, fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
}
if err := launch.Config.Settings.Validate(); err != nil {
return LaunchConfig{}, err
}
return launch, nil
}
func ParseDesktop(args []string, help io.Writer) (LaunchConfig, error) {
launch, err := loadLaunchConfig(args)
if err != nil {
return LaunchConfig{}, err
}
flags := flag.NewFlagSet("captioneer-desktop", flag.ContinueOnError)
flags.SetOutput(help)
registerConfigFlags(flags, &launch, args)
registerRecognitionFlags(flags, &launch.Config.Settings)
output := string(outputModeFor(launch.Config.Outputs))
backend := string(launch.Config.Overlay.Backend)
flags.StringVar(&output, "output", output, "caption output compatibility preset: terminal, overlay, or both")
flags.StringVar(&backend, "overlay-backend", backend, "display backend: auto, wayland, or x11")
flags.Float64Var(&launch.Config.Overlay.Opacity, "overlay-opacity", launch.Config.Overlay.Opacity, "caption background opacity from 0 to 1")
flags.IntVar(&launch.Config.Overlay.FontSize, "overlay-font-size", launch.Config.Overlay.FontSize, "caption font size in logical pixels")
flags.StringVar(&launch.Config.Overlay.FontFamily, "overlay-font-family", launch.Config.Overlay.FontFamily, "caption font family")
flags.IntVar(&launch.Config.Overlay.BottomMargin, "overlay-bottom-margin", launch.Config.Overlay.BottomMargin, "distance from the monitor bottom in logical pixels")
flags.IntVar(&launch.Config.Overlay.MaxWidth, "overlay-max-width", launch.Config.Overlay.MaxWidth, "fixed caption width in logical pixels before the monitor safety cap")
flags.StringVar(&launch.Config.Overlay.Monitor, "overlay-monitor", launch.Config.Overlay.Monitor, "monitor selection: auto, numeric index, or connector name")
flags.DurationVar(&launch.Config.Overlay.FinalTimeout, "overlay-final-timeout", launch.Config.Overlay.FinalTimeout, "completed-caption lifetime; zero keeps lines until the six-line cap removes them")
flags.BoolVar(&launch.Config.Overlay.ClickThrough, "overlay-click-through", launch.Config.Overlay.ClickThrough, "pass pointer input through the caption window")
flags.BoolVar(&launch.Config.Outputs.History, "gui-history", launch.Config.Outputs.History, "retain captions in the GUI history")
flags.BoolVar(&launch.Config.Outputs.StderrDiagnostics, "stderr-diagnostics", launch.Config.Outputs.StderrDiagnostics, "mirror diagnostics to stderr")
flags.BoolVar(&launch.Config.GUI.StartCaptions, "start-captions", launch.Config.GUI.StartCaptions, "start caption processing when the GUI opens")
flags.BoolVar(&launch.Config.GUI.MainWindowAlwaysOnTop, "main-window-always-on-top", launch.Config.GUI.MainWindowAlwaysOnTop, "request that the main window remain above other windows")
flags.BoolVar(&launch.Config.TranscriptLog.Enabled, "caption-log", launch.Config.TranscriptLog.Enabled, "save finalized captions to a timestamped transcript file")
flags.StringVar(&launch.Config.TranscriptLog.Directory, "caption-log-dir", launch.Config.TranscriptLog.Directory, "directory for timestamped caption transcript files")
if err := flags.Parse(args); err != nil {
return LaunchConfig{}, err
}
if flags.NArg() != 0 {
return LaunchConfig{}, fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
}
launch.Config.Overlay.Backend = OverlayBackend(backend)
if flagWasSet(flags, "output") {
switch OutputMode(output) {
case OutputTerminal:
launch.Config.Outputs.Overlay = false
launch.Config.Outputs.StdoutCaptions = true
case OutputOverlay:
launch.Config.Outputs.Overlay = true
launch.Config.Outputs.StdoutCaptions = false
case OutputBoth:
launch.Config.Outputs.Overlay = true
launch.Config.Outputs.StdoutCaptions = true
default:
return LaunchConfig{}, errors.New("--output must be terminal, overlay, or both")
}
}
if err := launch.Config.Validate(); err != nil {
return LaunchConfig{}, err
}
return launch, nil
}
func registerRecognitionFlags(flags *flag.FlagSet, settings *Settings) {
mode := string(settings.Mode)
flags.Var(modeValue{target: &settings.Mode, value: &mode}, "mode", "caption mode: vad or fixed")
flags.DurationVar(&settings.ChunkDuration, "chunk-duration", settings.ChunkDuration, "fixed chunk size or VAD preview refresh interval")
flags.StringVar(&settings.ModelsDir, "models-dir", settings.ModelsDir, "directory containing downloaded Sherpa models")
flags.IntVar(&settings.Threads, "threads", settings.Threads, "CPU threads used by recognition and VAD")
flags.Float64Var(&settings.PreviewThresholdDB, "preview-threshold-dbfs", settings.PreviewThresholdDB, "RMS dBFS gate below which recognition stays idle")
flags.Float64Var(&settings.VADThreshold, "vad-threshold", settings.VADThreshold, "speech detection sensitivity from 0 to 1; higher rejects more non-speech")
flags.BoolVar(&settings.ModelAutoRestart, "model-auto-restart", settings.ModelAutoRestart, "restart the isolated model worker after a crash or timeout")
flags.DurationVar(&settings.ModelTimeout, "model-timeout", settings.ModelTimeout, "maximum model startup or processing time; zero disables hang detection")
flags.DurationVar(&settings.ModelShutdownTimeout, "model-shutdown-timeout", settings.ModelShutdownTimeout, "time allowed for model flush and cleanup before force-killing the worker")
}
type modeValue struct {
target *Mode
value *string
}
func (v modeValue) String() string {
if v.value == nil {
return ""
}
return *v.value
}
func (v modeValue) Set(value string) error {
*v.value = value
*v.target = Mode(value)
return nil
}
func registerConfigFlags(flags *flag.FlagSet, launch *LaunchConfig, args []string) {
noConfig := hasNoConfig(args)
flags.StringVar(&launch.Path, "config", launch.Path, "configuration file path")
flags.BoolVar(&noConfig, "no-config", noConfig, "ignore the saved configuration for this launch")
}
func loadLaunchConfig(args []string) (LaunchConfig, error) {
path, err := ConfigPath()
if err != nil {
return LaunchConfig{}, err
}
if explicit := explicitConfigPath(args); explicit != "" {
path = explicit
}
launch := LaunchConfig{Config: DefaultAppConfig(), Path: path}
if hasNoConfig(args) {
return launch, nil
}
loaded, loadErr := Load(path)
if loadErr != nil {
launch.Warning = loadErr
return launch, nil
}
launch.Config = loaded
return launch, nil
}
func explicitConfigPath(args []string) string {
for index, argument := range args {
if strings.HasPrefix(argument, "--config=") {
return strings.TrimPrefix(argument, "--config=")
}
if argument == "--config" && index+1 < len(args) {
return args[index+1]
}
}
return ""
}
func hasNoConfig(args []string) bool {
for _, argument := range args {
if argument == "--no-config" || argument == "--no-config=true" {
return true
}
}
return false
}
func flagWasSet(flags *flag.FlagSet, name string) bool {
set := false
flags.Visit(func(candidate *flag.Flag) {
if candidate.Name == name {
set = true
}
})
return set
}
func outputModeFor(outputs OutputSettings) OutputMode {
switch {
case outputs.Overlay && outputs.StdoutCaptions:
return OutputBoth
case outputs.StdoutCaptions:
return OutputTerminal
default:
return OutputOverlay
}
}
+80
View File
@@ -0,0 +1,80 @@
package config
import (
"bytes"
"errors"
"flag"
"io"
"path/filepath"
"testing"
)
func TestCLIHelpDoesNotPanic(t *testing.T) {
var help bytes.Buffer
_, err := Parse([]string{"--help"}, &help)
if !errors.Is(err, flag.ErrHelp) {
t.Fatalf("Parse(--help) error = %v", err)
}
if !bytes.Contains(help.Bytes(), []byte("-mode")) {
t.Fatalf("help output did not contain mode flag:\n%s", help.String())
}
}
func TestCLIUsesSavedConfigThenExplicitFlags(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
path := filepath.Join(configHome, "captioneer", configFileName)
saved := DefaultAppConfig()
saved.Settings.Threads = 5
saved.Settings.ModelsDir = "saved-models"
if err := Save(path, saved); err != nil {
t.Fatal(err)
}
launch, err := Parse([]string{"--threads=7"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if launch.Config.Settings.Threads != 7 || launch.Config.Settings.ModelsDir != "saved-models" {
t.Fatalf("precedence result = %#v", launch.Config.Settings)
}
}
func TestCLINoConfigUsesBuiltInDefaults(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
path := filepath.Join(configHome, "captioneer", configFileName)
saved := DefaultAppConfig()
saved.Settings.Threads = 9
if err := Save(path, saved); err != nil {
t.Fatal(err)
}
launch, err := Parse([]string{"--no-config"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if launch.Config.Settings.Threads != DefaultSettings().Threads {
t.Fatalf("--no-config threads = %d", launch.Config.Settings.Threads)
}
}
func TestDesktopOutputFlagOverridesSavedCaptionDestinations(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
path := filepath.Join(configHome, "captioneer", configFileName)
saved := DefaultAppConfig()
saved.Outputs.Overlay = true
saved.Outputs.StdoutCaptions = false
if err := Save(path, saved); err != nil {
t.Fatal(err)
}
launch, err := ParseDesktop([]string{"--output=both"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if !launch.Config.Outputs.Overlay || !launch.Config.Outputs.StdoutCaptions {
t.Fatalf("output flags = %#v", launch.Config.Outputs)
}
}
+100 -57
View File
@@ -3,12 +3,12 @@ package config
import (
"errors"
"flag"
"strings"
"time"
)
const (
CurrentConfigVersion = 1
DefaultModelsDir = "models"
DefaultVADThreshold = 0.5
DefaultOverlayFontFamily = "Sans"
@@ -36,21 +36,18 @@ type Settings struct {
ModelShutdownTimeout time.Duration
}
func Parse() Settings {
var settings Settings
mode := ""
flag.StringVar(&mode, "mode", "", "caption mode: vad or fixed (required)")
flag.DurationVar(&settings.ChunkDuration, "chunk-duration", time.Second, "fixed chunk size or VAD preview refresh interval")
flag.StringVar(&settings.ModelsDir, "models-dir", DefaultModelsDir, "directory containing downloaded Sherpa models")
flag.IntVar(&settings.Threads, "threads", 2, "CPU threads used by recognition and VAD")
flag.Float64Var(&settings.PreviewThresholdDB, "preview-threshold-dbfs", -45, "RMS dBFS gate below which recognition stays idle")
flag.Float64Var(&settings.VADThreshold, "vad-threshold", DefaultVADThreshold, "speech detection sensitivity from 0 to 1; higher rejects more non-speech")
flag.BoolVar(&settings.ModelAutoRestart, "model-auto-restart", true, "restart the isolated model worker after a crash or timeout")
flag.DurationVar(&settings.ModelTimeout, "model-timeout", DefaultModelTimeout, "maximum model startup or processing time; zero disables hang detection")
flag.DurationVar(&settings.ModelShutdownTimeout, "model-shutdown-timeout", DefaultModelShutdownTimeout, "time allowed for model flush and cleanup before force-killing the worker")
flag.Parse()
settings.Mode = Mode(mode)
return settings
func DefaultSettings() Settings {
return Settings{
Mode: ModeVAD,
ChunkDuration: time.Second,
ModelsDir: DefaultModelsDir,
Threads: 2,
PreviewThresholdDB: -45,
VADThreshold: DefaultVADThreshold,
ModelAutoRestart: true,
ModelTimeout: DefaultModelTimeout,
ModelShutdownTimeout: DefaultModelShutdownTimeout,
}
}
type OutputMode string
@@ -81,44 +78,70 @@ type OverlaySettings struct {
ClickThrough bool
}
func DefaultOverlaySettings() OverlaySettings {
return OverlaySettings{
Backend: BackendAuto,
Opacity: 0.90,
FontSize: 28,
FontFamily: DefaultOverlayFontFamily,
BottomMargin: 100,
MaxWidth: 1200,
Monitor: "auto",
FinalTimeout: MinimumOverlayFinalLifetime,
ClickThrough: true,
}
}
type OutputSettings struct {
Overlay bool
History bool
StdoutCaptions bool
StderrDiagnostics bool
}
type GUISettings struct {
StartCaptions bool
MainWindowAlwaysOnTop bool
}
type TranscriptLogSettings struct {
Enabled bool
Directory string
}
type AppConfig struct {
Version int
Settings Settings
Overlay OverlaySettings
Outputs OutputSettings
GUI GUISettings
TranscriptLog TranscriptLogSettings
}
func DefaultAppConfig() AppConfig {
return AppConfig{
Version: CurrentConfigVersion,
Settings: DefaultSettings(),
Overlay: DefaultOverlaySettings(),
Outputs: OutputSettings{
Overlay: true,
History: true,
StdoutCaptions: false,
StderrDiagnostics: true,
},
GUI: GUISettings{StartCaptions: true},
TranscriptLog: TranscriptLogSettings{
Directory: DefaultTranscriptDirectory(),
},
}
}
type DesktopSettings struct {
Settings
Output OutputMode
Overlay OverlaySettings
}
func ParseDesktop() DesktopSettings {
flags := flag.CommandLine
var desktop DesktopSettings
var mode string
var output string
var backend string
flags.StringVar(&mode, "mode", "", "caption mode: vad or fixed (required)")
flags.DurationVar(&desktop.ChunkDuration, "chunk-duration", time.Second, "fixed chunk size or VAD preview refresh interval")
flags.StringVar(&desktop.ModelsDir, "models-dir", DefaultModelsDir, "directory containing downloaded Sherpa models")
flags.IntVar(&desktop.Threads, "threads", 2, "CPU threads used by recognition and VAD")
flags.Float64Var(&desktop.PreviewThresholdDB, "preview-threshold-dbfs", -45, "RMS dBFS gate below which recognition stays idle")
flags.Float64Var(&desktop.VADThreshold, "vad-threshold", DefaultVADThreshold, "speech detection sensitivity from 0 to 1; higher rejects more non-speech")
flags.BoolVar(&desktop.ModelAutoRestart, "model-auto-restart", true, "restart the isolated model worker after a crash or timeout")
flags.DurationVar(&desktop.ModelTimeout, "model-timeout", DefaultModelTimeout, "maximum model startup or processing time; zero disables hang detection")
flags.DurationVar(&desktop.ModelShutdownTimeout, "model-shutdown-timeout", DefaultModelShutdownTimeout, "time allowed for model flush and cleanup before force-killing the worker")
flags.StringVar(&output, "output", "", "caption output: terminal, overlay, or both (required)")
flags.StringVar(&backend, "overlay-backend", "auto", "display backend: auto, wayland, or x11")
flags.Float64Var(&desktop.Overlay.Opacity, "overlay-opacity", 0.90, "caption background opacity from 0 to 1")
flags.IntVar(&desktop.Overlay.FontSize, "overlay-font-size", 28, "caption font size in logical pixels")
flags.StringVar(&desktop.Overlay.FontFamily, "overlay-font-family", DefaultOverlayFontFamily, "caption font family")
flags.IntVar(&desktop.Overlay.BottomMargin, "overlay-bottom-margin", 100, "distance from the monitor bottom in logical pixels")
flags.IntVar(&desktop.Overlay.MaxWidth, "overlay-max-width", 1200, "fixed caption width in logical pixels before the monitor safety cap")
flags.StringVar(&desktop.Overlay.Monitor, "overlay-monitor", "auto", "monitor selection: auto, numeric index, or connector name")
flags.DurationVar(&desktop.Overlay.FinalTimeout, "overlay-final-timeout", MinimumOverlayFinalLifetime, "completed-caption lifetime; zero keeps lines until the six-line cap removes them")
flags.BoolVar(&desktop.Overlay.ClickThrough, "overlay-click-through", true, "pass pointer input through the caption window")
flag.Parse()
desktop.Mode = Mode(mode)
desktop.Output = OutputMode(output)
desktop.Overlay.Backend = OverlayBackend(backend)
return desktop
}
func (s Settings) Validate() error {
if s.Mode != ModeFixed && s.Mode != ModeVAD {
return errors.New("--mode is required and must be either fixed or vad")
@@ -148,32 +171,52 @@ func (s DesktopSettings) Validate() error {
if s.Output != OutputTerminal && s.Output != OutputOverlay && s.Output != OutputBoth {
return errors.New("--output is required and must be terminal, overlay, or both")
}
if s.Overlay.Backend != BackendAuto && s.Overlay.Backend != BackendWayland && s.Overlay.Backend != BackendX11 {
return s.Overlay.Validate()
}
func (s OverlaySettings) Validate() error {
if s.Backend != BackendAuto && s.Backend != BackendWayland && s.Backend != BackendX11 {
return errors.New("--overlay-backend must be auto, wayland, or x11")
}
if s.Overlay.Opacity < 0 || s.Overlay.Opacity > 1 {
if s.Opacity < 0 || s.Opacity > 1 {
return errors.New("--overlay-opacity must be between 0 and 1")
}
if s.Overlay.FontSize <= 0 {
if s.FontSize <= 0 {
return errors.New("--overlay-font-size must be positive")
}
if strings.TrimSpace(s.Overlay.FontFamily) == "" {
if strings.TrimSpace(s.FontFamily) == "" {
return errors.New("--overlay-font-family cannot be empty")
}
if s.Overlay.BottomMargin < 0 {
if s.BottomMargin < 0 {
return errors.New("--overlay-bottom-margin cannot be negative")
}
if s.Overlay.MaxWidth <= 0 {
if s.MaxWidth <= 0 {
return errors.New("--overlay-max-width must be positive")
}
if s.Overlay.FinalTimeout < 0 {
if s.FinalTimeout < 0 {
return errors.New("--overlay-final-timeout cannot be negative")
}
if s.Overlay.FinalTimeout > 0 && s.Overlay.FinalTimeout < MinimumOverlayFinalLifetime {
if s.FinalTimeout > 0 && s.FinalTimeout < MinimumOverlayFinalLifetime {
return errors.New("--overlay-final-timeout must be zero or at least 3s")
}
if s.Overlay.Monitor == "" {
if s.Monitor == "" {
return errors.New("--overlay-monitor cannot be empty")
}
return nil
}
func (c AppConfig) Validate() error {
if c.Version != CurrentConfigVersion {
return errors.New("unsupported configuration version")
}
if err := c.Settings.Validate(); err != nil {
return err
}
if err := c.Overlay.Validate(); err != nil {
return err
}
if c.TranscriptLog.Enabled && strings.TrimSpace(c.TranscriptLog.Directory) == "" {
return errors.New("caption log directory cannot be empty when logging is enabled")
}
return nil
}
+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
}
+77
View File
@@ -0,0 +1,77 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestConfigRoundTripUsesReadableDurations(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "config.json")
want := DefaultAppConfig()
want.Settings.ChunkDuration = 750 * time.Millisecond
want.Settings.ModelsDir = "models with spaces"
want.Outputs.StdoutCaptions = true
want.GUI.MainWindowAlwaysOnTop = true
want.TranscriptLog.Enabled = true
want.TranscriptLog.Directory = filepath.Join(t.TempDir(), "transcripts")
if err := Save(path, want); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"chunk_duration": "750ms"`) {
t.Fatalf("duration was not stored readably:\n%s", data)
}
got, err := Load(path)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("round trip = %#v, want %#v", got, want)
}
}
func TestMissingConfigUsesDefaults(t *testing.T) {
got, err := Load(filepath.Join(t.TempDir(), "missing.json"))
if err != nil {
t.Fatal(err)
}
want := DefaultAppConfig()
if got != want {
t.Fatalf("missing config = %#v, want %#v", got, want)
}
}
func TestPartialConfigKeepsOtherDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"recognition":{"threads":6}}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := Load(path)
if err != nil {
t.Fatal(err)
}
if got.Settings.Threads != 6 || got.Settings.Mode != ModeVAD || got.Overlay.FontSize != 28 {
t.Fatalf("partial config did not inherit defaults: %#v", got)
}
}
func TestInvalidStoredDurationReturnsDefaultsAndError(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"recognition":{"chunk_duration":"soon"}}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := Load(path)
if err == nil || !strings.Contains(err.Error(), "chunk duration") {
t.Fatalf("invalid duration error = %v", err)
}
if got != DefaultAppConfig() {
t.Fatalf("invalid config fallback = %#v", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package diagnostics provides bounded structured application logging.
package diagnostics
import (
"bytes"
"fmt"
"io"
"strings"
"sync"
"time"
)
type Severity string
const (
Debug Severity = "debug"
Info Severity = "info"
Warning Severity = "warning"
Error Severity = "error"
)
type Entry struct {
At time.Time
Severity Severity
Category string
Message string
}
type Log struct {
mu sync.Mutex
capacity int
entries []Entry
mirror io.Writer
subscribers map[chan Entry]struct{}
}
func New(capacity int, mirror io.Writer) *Log {
if capacity < 1 {
capacity = 1
}
if mirror == nil {
mirror = io.Discard
}
return &Log{
capacity: capacity,
mirror: mirror,
subscribers: make(map[chan Entry]struct{}),
}
}
func (l *Log) Add(severity Severity, category, message string) {
message = strings.TrimSpace(message)
if message == "" {
return
}
entry := Entry{At: time.Now(), Severity: severity, Category: category, Message: message}
l.mu.Lock()
l.entries = append(l.entries, entry)
if len(l.entries) > l.capacity {
copy(l.entries, l.entries[len(l.entries)-l.capacity:])
l.entries = l.entries[:l.capacity]
}
fmt.Fprintf(l.mirror, "%s %-7s [%s] %s\n", entry.At.Format(time.RFC3339), strings.ToUpper(string(severity)), category, message)
for subscriber := range l.subscribers {
select {
case subscriber <- entry:
default:
select {
case <-subscriber:
default:
}
select {
case subscriber <- entry:
default:
}
}
}
l.mu.Unlock()
}
func (l *Log) Entries() []Entry {
l.mu.Lock()
defer l.mu.Unlock()
return append([]Entry(nil), l.entries...)
}
func (l *Log) Clear() {
l.mu.Lock()
l.entries = nil
l.mu.Unlock()
}
func (l *Log) SetMirror(mirror io.Writer) {
if mirror == nil {
mirror = io.Discard
}
l.mu.Lock()
l.mirror = mirror
l.mu.Unlock()
}
func (l *Log) Subscribe(buffer int) (<-chan Entry, func()) {
if buffer < 1 {
buffer = 1
}
updates := make(chan Entry, buffer)
l.mu.Lock()
l.subscribers[updates] = struct{}{}
l.mu.Unlock()
return updates, func() {
l.mu.Lock()
if _, ok := l.subscribers[updates]; ok {
delete(l.subscribers, updates)
close(updates)
}
l.mu.Unlock()
}
}
func (l *Log) Writer(severity Severity, category string) io.Writer {
return &lineWriter{log: l, severity: severity, category: category}
}
type lineWriter struct {
mu sync.Mutex
log *Log
severity Severity
category string
buffer bytes.Buffer
}
func (w *lineWriter) Write(data []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.buffer.Write(data)
for {
line, err := w.buffer.ReadString('\n')
if err != nil {
w.buffer.WriteString(line)
break
}
severity := w.severity
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(trimmed), "warning:") {
severity = Warning
}
w.log.Add(severity, w.category, trimmed)
}
return len(data), nil
}
+20
View File
@@ -0,0 +1,20 @@
package diagnostics
import (
"bytes"
"testing"
)
func TestLogBoundsEntriesAndClassifiesWarningLines(t *testing.T) {
var mirror bytes.Buffer
log := New(2, &mirror)
writer := log.Writer(Info, "engine")
_, _ = writer.Write([]byte("first\nwarning: overloaded\nthird\n"))
entries := log.Entries()
if len(entries) != 2 || entries[0].Severity != Warning || entries[1].Message != "third" {
t.Fatalf("entries = %#v", entries)
}
if mirror.Len() == 0 {
t.Fatal("log was not mirrored")
}
}
+597
View File
@@ -0,0 +1,597 @@
//go:build gtk
// Package gui implements Captioneer's GTK4 desktop application.
package gui
import (
"context"
_ "embed"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"tea.chunkbyte.com/kato/captioneer/src/app"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
"tea.chunkbyte.com/kato/captioneer/src/output"
"tea.chunkbyte.com/kato/captioneer/src/output/history"
"tea.chunkbyte.com/kato/captioneer/src/output/overlay"
"tea.chunkbyte.com/kato/captioneer/src/output/terminal"
"tea.chunkbyte.com/kato/captioneer/src/output/transcript"
)
const (
applicationID = "com.chunkbyte.captioneer"
historyCapacity = 500
consoleCapacity = 2000
)
//go:embed style.css
var applicationCSS string
type Application struct {
launch config.LaunchConfig
config config.AppConfig
applied config.AppConfig
stdout io.Writer
stderr io.Writer
gtkApp *gtk.Application
window *gtk.ApplicationWindow
stack *gtk.Stack
router *output.Router
history *history.Sink
log *diagnostics.Log
controller *app.Controller
overlay *overlay.Sink
transcript *transcript.Sink
statusLabel *gtk.Label
statusDot *gtk.Label
startStopButton *gtk.Button
statusbarLabel *gtk.Label
captionBuffer *gtk.TextBuffer
captionView *gtk.TextView
captionScroll *gtk.ScrolledWindow
provisionalLabel *gtk.Label
consoleBuffer *gtk.TextBuffer
consoleView *gtk.TextView
consoleScroll *gtk.ScrolledWindow
settings settingsControls
settingsError *gtk.Label
applyButton *gtk.Button
lastShownError string
statusUpdates <-chan app.Status
stopStatus func()
historyUpdates <-chan history.Snapshot
stopHistory func()
logUpdates <-chan diagnostics.Entry
stopLog func()
uiDone chan struct{}
closingMu sync.Mutex
closing bool
}
func Run(launch config.LaunchConfig, stdout, stderr io.Writer) int {
application := newApplication(launch, stdout, stderr)
application.gtkApp.ConnectActivate(application.activate)
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(signals)
go func() {
select {
case <-signals:
glib.IdleAdd(application.shutdown)
case <-application.uiDone:
}
}()
return application.gtkApp.Run([]string{os.Args[0]})
}
func newApplication(launch config.LaunchConfig, stdout, stderr io.Writer) *Application {
mirror := io.Discard
if launch.Config.Outputs.StderrDiagnostics {
mirror = stderr
}
log := diagnostics.New(consoleCapacity, mirror)
router := output.NewRouter()
historySink := history.New(historyCapacity, launch.Config.Outputs.History)
_ = router.Set("history", historySink)
controller := app.NewController(router, log.Writer(diagnostics.Info, "engine"))
return &Application{
launch: launch,
config: launch.Config,
applied: launch.Config,
stdout: stdout,
stderr: stderr,
gtkApp: gtk.NewApplication(applicationID, gio.ApplicationFlagsNone),
router: router,
history: historySink,
log: log,
controller: controller,
uiDone: make(chan struct{}),
}
}
func (a *Application) activate() {
if a.window != nil {
a.window.Present()
return
}
a.installStyle()
a.buildWindow()
a.installActions()
a.subscribeModels()
if a.launch.Warning != nil {
a.log.Add(diagnostics.Warning, "configuration", fmt.Sprintf("%v; using built-in defaults", a.launch.Warning))
}
if err := a.configureOutputs(a.config, true); err != nil {
a.log.Add(diagnostics.Error, "outputs", err.Error())
}
a.window.Present()
a.applyAlwaysOnTop(a.config.GUI.MainWindowAlwaysOnTop)
if a.config.GUI.StartCaptions {
a.startCaptions()
}
}
func (a *Application) installStyle() {
provider := gtk.NewCSSProvider()
provider.LoadFromString(applicationCSS)
gtk.StyleContextAddProviderForDisplay(
gdk.DisplayGetDefault(),
provider,
gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
}
func (a *Application) buildWindow() {
a.window = gtk.NewApplicationWindow(a.gtkApp)
a.window.SetTitle("Captioneer")
a.window.SetDefaultSize(980, 680)
a.window.SetSizeRequest(720, 480)
a.window.AddCSSClass("captioneer-main")
a.window.ConnectCloseRequest(func() bool {
a.shutdown()
return true
})
header := gtk.NewHeaderBar()
header.AddCSSClass("captioneer-header")
header.SetShowTitleButtons(true)
brand := gtk.NewBox(gtk.OrientationHorizontal, 7)
if iconPath := findIconPath(); iconPath != "" {
icon := gtk.NewImageFromFile(iconPath)
icon.SetPixelSize(27)
brand.Append(icon)
}
title := gtk.NewLabel("CAPTIONEER")
title.AddCSSClass("captioneer-brand")
brand.Append(title)
header.PackStart(brand)
statusBox := gtk.NewBox(gtk.OrientationHorizontal, 6)
statusBox.SetHAlign(gtk.AlignCenter)
a.statusDot = gtk.NewLabel("●")
a.statusDot.AddCSSClass("status-dot")
a.statusLabel = gtk.NewLabel("STOPPED · VAD")
a.statusLabel.AddCSSClass("status-text")
statusBox.Append(a.statusDot)
statusBox.Append(a.statusLabel)
header.SetTitleWidget(statusBox)
a.startStopButton = gtk.NewButtonWithLabel("START")
a.startStopButton.AddCSSClass("primary-action")
a.startStopButton.SetTooltipText("Start or stop caption processing")
a.startStopButton.ConnectClicked(a.toggleCaptions)
header.PackEnd(a.startStopButton)
a.window.SetTitlebar(header)
root := gtk.NewBox(gtk.OrientationVertical, 0)
root.Append(a.buildMenuBar())
a.stack = gtk.NewStack()
a.stack.SetTransitionType(gtk.StackTransitionTypeNone)
a.stack.SetHExpand(true)
a.stack.SetVExpand(true)
tabs := gtk.NewStackSwitcher()
tabs.SetStack(a.stack)
tabs.AddCSSClass("captioneer-tabs")
tabs.SetHAlign(gtk.AlignFill)
root.Append(tabs)
a.stack.AddTitled(a.buildCaptionsPage(), "captions", "CAPTIONS")
a.stack.AddTitled(a.buildConsolePage(), "console", "CONSOLE")
a.stack.AddTitled(a.buildSettingsPage(), "settings", "SETTINGS")
root.Append(a.stack)
statusbar := gtk.NewBox(gtk.OrientationHorizontal, 8)
statusbar.AddCSSClass("statusbar")
a.statusbarLabel = gtk.NewLabel("Source: not connected · Speech: quiet · Outputs: preparing")
a.statusbarLabel.SetXAlign(0)
a.statusbarLabel.SetEllipsize(3)
statusbar.Append(a.statusbarLabel)
root.Append(statusbar)
a.window.SetChild(root)
}
func (a *Application) buildMenuBar() *gtk.PopoverMenuBar {
root := gio.NewMenu()
file := gio.NewMenu()
file.Append("Start Captions", "app.start")
file.Append("Stop Captions", "app.stop")
file.Append("Restart Captions", "app.restart")
file.Append("Clear Caption History", "app.clear-history")
file.Append("Exit", "app.exit")
root.AppendSubmenu("File", file)
view := gio.NewMenu()
view.Append("Captions", "app.view-captions")
view.Append("Console", "app.view-console")
view.Append("Settings", "app.view-settings")
root.AppendSubmenu("View", view)
help := gio.NewMenu()
help.Append("Copy Diagnostics", "app.copy-diagnostics")
help.Append("Open Configuration Folder", "app.open-config-folder")
help.Append("About Captioneer", "app.about")
root.AppendSubmenu("Help", help)
bar := gtk.NewPopoverMenuBarFromModel(root)
bar.AddCSSClass("captioneer-menu")
return bar
}
func (a *Application) installActions() {
a.addAction("start", a.startCaptions)
a.addAction("stop", a.stopCaptions)
a.addAction("restart", a.restartCaptions)
a.addAction("clear-history", a.history.Clear)
a.addAction("exit", a.shutdown)
a.addAction("view-captions", func() { a.stack.SetVisibleChildName("captions") })
a.addAction("view-console", func() { a.stack.SetVisibleChildName("console") })
a.addAction("view-settings", func() { a.stack.SetVisibleChildName("settings") })
a.addAction("copy-diagnostics", a.copyDiagnostics)
a.addAction("open-config-folder", a.openConfigFolder)
a.addAction("about", a.showAbout)
a.gtkApp.SetAccelsForAction("app.start", []string{"F5"})
a.gtkApp.SetAccelsForAction("app.stop", []string{"<Shift>F5"})
a.gtkApp.SetAccelsForAction("app.restart", []string{"<Primary>r"})
a.gtkApp.SetAccelsForAction("app.view-captions", []string{"<Alt>1"})
a.gtkApp.SetAccelsForAction("app.view-console", []string{"<Alt>2"})
a.gtkApp.SetAccelsForAction("app.view-settings", []string{"<Alt>3"})
a.gtkApp.SetAccelsForAction("app.exit", []string{"<Primary>q"})
}
func (a *Application) addAction(name string, callback func()) {
action := gio.NewSimpleAction(name, nil)
action.ConnectActivate(func(*glib.Variant) { callback() })
a.gtkApp.AddAction(action)
}
func (a *Application) subscribeModels() {
a.statusUpdates, a.stopStatus = a.controller.Subscribe(32)
a.historyUpdates, a.stopHistory = a.history.Subscribe(8)
a.logUpdates, a.stopLog = a.log.Subscribe(128)
go a.forwardStatus()
go a.forwardHistory()
go a.forwardLog()
}
func (a *Application) forwardStatus() {
for {
select {
case <-a.uiDone:
return
case status, ok := <-a.statusUpdates:
if !ok {
return
}
copy := status
glib.IdleAdd(func() { a.renderStatus(copy) })
}
}
}
func (a *Application) forwardHistory() {
for {
select {
case <-a.uiDone:
return
case snapshot, ok := <-a.historyUpdates:
if !ok {
return
}
copy := snapshot
glib.IdleAdd(func() { a.renderHistory(copy) })
}
}
}
func (a *Application) forwardLog() {
for {
select {
case <-a.uiDone:
return
case _, ok := <-a.logUpdates:
if !ok {
return
}
glib.IdleAdd(a.renderConsole)
}
}
}
func (a *Application) toggleCaptions() {
status := a.controller.Status()
switch status.State {
case app.StateStopped, app.StateError:
a.startCaptions()
default:
a.stopCaptions()
}
}
func (a *Application) startCaptions() {
if err := a.controller.Start(a.applied.Settings); err != nil {
a.log.Add(diagnostics.Error, "lifecycle", err.Error())
return
}
a.log.Add(diagnostics.Info, "lifecycle", "Starting caption processing")
}
func (a *Application) stopCaptions() {
go func() {
timeout := a.applied.Settings.ModelShutdownTimeout + time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := a.controller.Stop(ctx); err != nil {
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Stop captions: %v", err))
return
}
a.log.Add(diagnostics.Info, "lifecycle", "Caption processing stopped")
}()
}
func (a *Application) restartCaptions() {
go func() {
timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := a.controller.Restart(ctx, a.applied.Settings); err != nil {
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Restart captions: %v", err))
return
}
a.log.Add(diagnostics.Info, "lifecycle", "Caption processing restarted")
}()
}
func (a *Application) configureOutputs(next config.AppConfig, initial bool) error {
var outputErr error
a.history.SetEnabled(next.Outputs.History)
if next.Outputs.StdoutCaptions {
outputErr = joinError(outputErr, a.router.Set("stdout", terminal.New(a.stdout)))
} else {
outputErr = joinError(outputErr, a.router.Remove("stdout"))
}
if next.TranscriptLog.Enabled {
if initial || a.transcript == nil || next.TranscriptLog != a.applied.TranscriptLog {
sink, err := transcript.New(next.TranscriptLog.Directory)
if err != nil {
outputErr = joinError(outputErr, err)
} else {
a.transcript = sink
outputErr = joinError(outputErr, a.router.Set("transcript", sink))
a.log.Add(diagnostics.Info, "transcript", "Saving captions to "+sink.Path())
}
}
} else {
a.transcript = nil
outputErr = joinError(outputErr, a.router.Remove("transcript"))
}
if next.Outputs.Overlay {
settings := next.Overlay
if !initial && settings.Backend != a.applied.Overlay.Backend {
settings.Backend = a.applied.Overlay.Backend
}
if initial || a.overlay == nil || settings != a.applied.Overlay {
sink, warning, err := overlay.New(settings)
if warning != "" {
a.log.Add(diagnostics.Warning, "overlay", warning)
}
if err != nil {
outputErr = joinError(outputErr, err)
} else {
a.overlay = sink
outputErr = joinError(outputErr, a.router.Set("overlay", sink))
}
}
} else {
a.overlay = nil
outputErr = joinError(outputErr, a.router.Remove("overlay"))
}
if next.Outputs.StderrDiagnostics {
a.log.SetMirror(a.stderr)
} else {
a.log.SetMirror(io.Discard)
}
return outputErr
}
func (a *Application) renderStatus(status app.Status) {
a.updateApplyButton()
state := strings.ToUpper(string(status.State))
overloaded := !status.OverloadedAt.IsZero() && time.Since(status.OverloadedAt) < 5*time.Second
if overloaded {
state = "OVERLOADED"
}
mode := strings.ToUpper(string(status.Mode))
if mode == "" {
mode = strings.ToUpper(string(a.applied.Settings.Mode))
}
a.statusLabel.SetText(state + " · " + mode)
a.statusDot.RemoveCSSClass("status-stopped")
a.statusDot.RemoveCSSClass("status-running")
a.statusDot.RemoveCSSClass("status-warning")
a.statusDot.RemoveCSSClass("status-error")
active := status.State != app.StateStopped && status.State != app.StateError
switch {
case status.State == app.StateError:
a.statusDot.AddCSSClass("status-error")
case overloaded:
a.statusDot.AddCSSClass("status-warning")
case active:
a.statusDot.AddCSSClass("status-running")
default:
a.statusDot.AddCSSClass("status-stopped")
}
if active {
a.startStopButton.SetLabel("STOP")
a.startStopButton.RemoveCSSClass("primary-action")
a.startStopButton.AddCSSClass("danger-action")
} else {
a.startStopButton.SetLabel("START")
a.startStopButton.RemoveCSSClass("danger-action")
a.startStopButton.AddCSSClass("primary-action")
}
speech := "quiet"
if status.SpeechActive {
speech = "detected"
}
source := status.Source
if source == "" {
source = "not connected"
}
overload := ""
if overloaded {
overload = " · OVERLOADED"
}
a.statusbarLabel.SetText(fmt.Sprintf(
"Source: %s · Speech: %s · Outputs: %s%s",
source,
speech,
a.outputSummary(),
overload,
))
if status.LastError != "" && status.State == app.StateError && status.LastError != a.lastShownError {
a.log.Add(diagnostics.Error, "engine", status.LastError)
a.lastShownError = status.LastError
} else if status.LastError == "" {
a.lastShownError = ""
}
}
func (a *Application) outputSummary() string {
var active []string
if a.config.Outputs.Overlay {
active = append(active, "overlay")
}
if a.config.Outputs.History {
active = append(active, "history")
}
if a.config.Outputs.StdoutCaptions {
active = append(active, "stdout")
}
if a.config.TranscriptLog.Enabled {
active = append(active, "log file")
}
if len(active) == 0 {
return "none"
}
return strings.Join(active, ", ")
}
func (a *Application) copyDiagnostics() {
var text strings.Builder
status := a.controller.Status()
fmt.Fprintf(&text, "Captioneer diagnostics\nState: %s\nMode: %s\nSource: %s\nConfig: %s\n\n", status.State, status.Mode, status.Source, a.launch.Path)
for _, entry := range a.log.Entries() {
fmt.Fprintf(&text, "%s %-7s [%s] %s\n", entry.At.Format(time.RFC3339), strings.ToUpper(string(entry.Severity)), entry.Category, entry.Message)
}
gdk.DisplayGetDefault().Clipboard().SetText(text.String())
a.log.Add(diagnostics.Info, "gui", "Diagnostics copied to clipboard")
}
func (a *Application) openConfigFolder() {
if err := exec.Command("xdg-open", filepath.Dir(a.launch.Path)).Start(); err != nil {
a.log.Add(diagnostics.Error, "gui", fmt.Sprintf("Open configuration folder: %v", err))
}
}
func (a *Application) showAbout() {
dialog := gtk.NewAboutDialog()
dialog.SetTransientFor(&a.window.Window)
dialog.SetModal(true)
dialog.SetProgramName("Captioneer")
dialog.SetComments("Local live captions for Linux")
dialog.SetLicenseType(gtk.LicenseApache20)
dialog.SetWebsite("https://tea.chunkbyte.com/kato/captioneer")
dialog.Present()
}
func (a *Application) shutdown() {
a.closingMu.Lock()
if a.closing {
a.closingMu.Unlock()
return
}
a.closing = true
a.closingMu.Unlock()
a.log.Add(diagnostics.Info, "lifecycle", "Shutting down Captioneer")
go func() {
timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
_ = a.controller.Stop(ctx)
cancel()
glib.IdleAdd(func() {
close(a.uiDone)
if a.stopStatus != nil {
a.stopStatus()
a.stopHistory()
a.stopLog()
}
_ = a.router.Close()
a.gtkApp.Quit()
})
}()
}
func joinError(current, next error) error {
if current == nil {
return next
}
if next == nil {
return current
}
return fmt.Errorf("%v; %w", current, next)
}
func findIconPath() string {
candidates := []string{filepath.Join("assets", "icon", "icon.png")}
if executable, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(executable), "assets", "icon", "icon.png"))
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return ""
}
+208
View File
@@ -0,0 +1,208 @@
//go:build gtk
package gui
import (
"fmt"
"strings"
"time"
"github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"tea.chunkbyte.com/kato/captioneer/src/output/history"
)
func (a *Application) buildCaptionsPage() *gtk.Box {
page := gtk.NewBox(gtk.OrientationVertical, 7)
page.AddCSSClass("captioneer-content")
toolbar := gtk.NewBox(gtk.OrientationHorizontal, 5)
toolbar.AddCSSClass("toolbar")
toolbar.Append(labelWithClass("CAPTION HISTORY", "section-title"))
spacer := gtk.NewBox(gtk.OrientationHorizontal, 0)
spacer.SetHExpand(true)
toolbar.Append(spacer)
toolbar.Append(toolButton("Copy Selected", "Copy the selected caption text", func() {
copyBufferSelection(a.captionBuffer)
}))
toolbar.Append(toolButton("Copy Transcript", "Copy all final captions", func() {
gdk.DisplayGetDefault().Clipboard().SetText(a.history.Transcript())
}))
toolbar.Append(toolButton("Clear", "Clear visible caption history", a.history.Clear))
toolbar.Append(toolButton("Jump to Latest", "Resume at the newest caption", func() {
scrollTextViewToEnd(a.captionView, a.captionScroll)
}))
page.Append(toolbar)
a.captionBuffer = gtk.NewTextBuffer(nil)
a.captionView = gtk.NewTextViewWithBuffer(a.captionBuffer)
a.captionView.SetEditable(false)
a.captionView.SetCursorVisible(true)
a.captionView.SetWrapMode(gtk.WrapWordChar)
a.captionView.SetTopMargin(9)
a.captionView.SetBottomMargin(9)
a.captionView.SetLeftMargin(10)
a.captionView.SetRightMargin(10)
a.captionView.AddCSSClass("caption-view")
a.captionScroll = gtk.NewScrolledWindow()
a.captionScroll.SetPolicy(gtk.PolicyAutomatic, gtk.PolicyAutomatic)
a.captionScroll.SetHExpand(true)
a.captionScroll.SetVExpand(true)
a.captionScroll.SetChild(a.captionView)
a.captionScroll.AddCSSClass("inset-panel")
page.Append(a.captionScroll)
provisional := gtk.NewBox(gtk.OrientationHorizontal, 8)
provisional.AddCSSClass("provisional-row")
caption := labelWithClass("LIVE", "section-title")
caption.SetVAlign(gtk.AlignStart)
provisional.Append(caption)
a.provisionalLabel = gtk.NewLabel("Waiting for speech…")
a.provisionalLabel.SetXAlign(0)
a.provisionalLabel.SetWrap(true)
a.provisionalLabel.SetHExpand(true)
provisional.Append(a.provisionalLabel)
page.Append(provisional)
return page
}
func (a *Application) buildConsolePage() *gtk.Box {
page := gtk.NewBox(gtk.OrientationVertical, 7)
page.AddCSSClass("captioneer-content")
toolbar := gtk.NewBox(gtk.OrientationHorizontal, 5)
toolbar.AddCSSClass("toolbar")
toolbar.Append(labelWithClass("APPLICATION CONSOLE", "section-title"))
spacer := gtk.NewBox(gtk.OrientationHorizontal, 0)
spacer.SetHExpand(true)
toolbar.Append(spacer)
toolbar.Append(toolButton("Copy Selected", "Copy selected console output", func() {
copyBufferSelection(a.consoleBuffer)
}))
toolbar.Append(toolButton("Copy All", "Copy all buffered console output", func() {
copyBuffer(a.consoleBuffer)
}))
toolbar.Append(toolButton("Clear", "Clear the console display", func() {
a.log.Clear()
a.renderConsole()
}))
toolbar.Append(toolButton("Jump to Latest", "Resume at the newest message", func() {
scrollTextViewToEnd(a.consoleView, a.consoleScroll)
}))
page.Append(toolbar)
a.consoleBuffer = gtk.NewTextBuffer(nil)
a.consoleView = gtk.NewTextViewWithBuffer(a.consoleBuffer)
a.consoleView.SetEditable(false)
a.consoleView.SetCursorVisible(true)
a.consoleView.SetWrapMode(gtk.WrapWordChar)
a.consoleView.SetTopMargin(8)
a.consoleView.SetBottomMargin(8)
a.consoleView.SetLeftMargin(9)
a.consoleView.SetRightMargin(9)
a.consoleView.AddCSSClass("console-view")
a.consoleScroll = gtk.NewScrolledWindow()
a.consoleScroll.SetPolicy(gtk.PolicyAutomatic, gtk.PolicyAutomatic)
a.consoleScroll.SetHExpand(true)
a.consoleScroll.SetVExpand(true)
a.consoleScroll.SetChild(a.consoleView)
a.consoleScroll.AddCSSClass("inset-panel")
page.Append(a.consoleScroll)
return page
}
func (a *Application) renderHistory(snapshot history.Snapshot) {
follow := isAtBottom(a.captionScroll)
var text strings.Builder
for _, entry := range snapshot.Finals {
fmt.Fprintf(&text, "%s [%s%s] %s\n",
entry.CreatedAt.Format("15:04:05"),
formatCaptionTime(entry.StartedAt),
formatCaptionTime(entry.EndedAt),
entry.Text,
)
}
a.captionBuffer.SetText(text.String())
if snapshot.Provisional == nil || strings.TrimSpace(snapshot.Provisional.Text) == "" {
a.provisionalLabel.SetText("Waiting for speech…")
} else {
a.provisionalLabel.SetText(snapshot.Provisional.Text)
}
if follow {
scrollTextViewToEnd(a.captionView, a.captionScroll)
}
}
func (a *Application) renderConsole() {
follow := isAtBottom(a.consoleScroll)
var text strings.Builder
for _, entry := range a.log.Entries() {
fmt.Fprintf(&text, "%s %-7s %-14s %s\n",
entry.At.Format("15:04:05.000"),
strings.ToUpper(string(entry.Severity)),
"["+entry.Category+"]",
entry.Message,
)
}
a.consoleBuffer.SetText(text.String())
if follow {
scrollTextViewToEnd(a.consoleView, a.consoleScroll)
}
}
func formatCaptionTime(value time.Duration) string {
seconds := value.Seconds()
return fmt.Sprintf("%02d:%05.2f", int(seconds)/60, seconds-float64(int(seconds)/60*60))
}
func isAtBottom(scroll *gtk.ScrolledWindow) bool {
if scroll == nil {
return true
}
adjustment := scroll.VAdjustment()
return adjustment.Value()+adjustment.PageSize() >= adjustment.Upper()-4
}
func scrollTextViewToEnd(view *gtk.TextView, scroll *gtk.ScrolledWindow) {
if view == nil || scroll == nil {
return
}
view.ScrollToIter(view.Buffer().EndIter(), 0, false, 0, 1)
adjustment := scroll.VAdjustment()
adjustment.SetValue(adjustment.Upper())
}
func copyBufferSelection(buffer *gtk.TextBuffer) {
if buffer == nil {
return
}
start, end, ok := buffer.SelectionBounds()
if !ok {
return
}
gdk.DisplayGetDefault().Clipboard().SetText(buffer.Text(start, end, true))
}
func copyBuffer(buffer *gtk.TextBuffer) {
if buffer == nil {
return
}
start, end := buffer.Bounds()
gdk.DisplayGetDefault().Clipboard().SetText(buffer.Text(start, end, true))
}
func labelWithClass(text, class string) *gtk.Label {
label := gtk.NewLabel(text)
label.AddCSSClass(class)
label.SetXAlign(0)
return label
}
func toolButton(text, tooltip string, callback func()) *gtk.Button {
button := gtk.NewButtonWithLabel(text)
button.SetTooltipText(tooltip)
button.ConnectClicked(callback)
return button
}
+84
View File
@@ -0,0 +1,84 @@
//go:build gtk
package gui
/*
#cgo pkg-config: gtk4 gtk4-x11 gtk4-wayland x11
#include <gtk/gtk.h>
#include <gdk/x11/gdkx.h>
#include <gdk/wayland/gdkwayland.h>
#include <X11/Xlib.h>
#include <stdint.h>
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
// Returns 1 when the X11 hint was sent, 0 when the compositor controls this
// policy (Wayland), and -1 for an unavailable/unknown backend.
static int captioneer_set_main_window_above(GtkWindow *window, gboolean enabled) {
gtk_widget_realize(GTK_WIDGET(window));
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(window));
if (surface == NULL) {
return -1;
}
if (GDK_IS_WAYLAND_SURFACE(surface)) {
return 0;
}
if (!GDK_IS_X11_SURFACE(surface)) {
return -1;
}
Display *display = GDK_SURFACE_XDISPLAY(surface);
Window xid = GDK_SURFACE_XID(surface);
Window root = DefaultRootWindow(display);
Atom state = XInternAtom(display, "_NET_WM_STATE", False);
Atom above = XInternAtom(display, "_NET_WM_STATE_ABOVE", False);
XEvent event = {0};
event.xclient.type = ClientMessage;
event.xclient.window = xid;
event.xclient.message_type = state;
event.xclient.format = 32;
event.xclient.data.l[0] = enabled ? 1 : 0;
event.xclient.data.l[1] = above;
event.xclient.data.l[2] = None;
event.xclient.data.l[3] = 1;
XSendEvent(display, root, False,
SubstructureRedirectMask | SubstructureNotifyMask, &event);
XFlush(display);
return 1;
}
static int captioneer_set_main_window_above_native(uintptr_t native, gboolean enabled) {
return captioneer_set_main_window_above((GtkWindow *)native, enabled);
}
*/
import "C"
import (
"github.com/diamondburned/gotk4/pkg/glib/v2"
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
)
func (a *Application) applyAlwaysOnTop(enabled bool) {
if a.window == nil {
return
}
glib.IdleAdd(func() {
result := int(C.captioneer_set_main_window_above_native(
C.uintptr_t(a.window.Native()),
C.gboolean(boolToInt(enabled)),
))
if enabled && result == 0 {
a.log.Add(diagnostics.Warning, "window", "Always on top is compositor-controlled on Wayland and could not be forced")
} else if enabled && result < 0 {
a.log.Add(diagnostics.Warning, "window", "Always on top is unavailable on this display backend")
}
})
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
+470
View File
@@ -0,0 +1,470 @@
//go:build gtk
package gui
import (
"context"
"fmt"
"strings"
"time"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"tea.chunkbyte.com/kato/captioneer/src/app"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
)
type settingsControls struct {
startCaptions *gtk.Switch
alwaysOnTop *gtk.Switch
mode *gtk.ComboBoxText
chunkDuration *gtk.Entry
modelsDirectory *gtk.Entry
threads *gtk.SpinButton
previewThresholdDB *gtk.SpinButton
vadThreshold *gtk.SpinButton
overlayEnabled *gtk.Switch
historyEnabled *gtk.Switch
stdoutEnabled *gtk.Switch
stderrEnabled *gtk.Switch
transcriptEnabled *gtk.Switch
transcriptDirectory *gtk.Entry
overlayOpacity *gtk.SpinButton
overlayFont *gtk.FontButton
overlayFontSize *gtk.SpinButton
overlayBottomMargin *gtk.SpinButton
overlayMaxWidth *gtk.SpinButton
overlayMonitor *gtk.Entry
overlayFinalTimeout *gtk.Entry
overlayClickThrough *gtk.Switch
overlayBackend *gtk.ComboBoxText
modelAutoRestart *gtk.Switch
modelTimeout *gtk.Entry
modelShutdownTimeout *gtk.Entry
}
func (a *Application) buildSettingsPage() *gtk.Box {
page := gtk.NewBox(gtk.OrientationVertical, 7)
page.AddCSSClass("captioneer-content")
content := gtk.NewBox(gtk.OrientationHorizontal, 8)
content.SetHExpand(true)
content.SetVExpand(true)
settingsStack := gtk.NewStack()
settingsStack.SetTransitionType(gtk.StackTransitionTypeNone)
settingsStack.SetHExpand(true)
settingsStack.SetVExpand(true)
sidebar := gtk.NewStackSidebar()
sidebar.SetStack(settingsStack)
sidebar.SetSizeRequest(155, -1)
sidebar.AddCSSClass("settings-sidebar")
content.Append(sidebar)
a.createSettingsControls()
settingsStack.AddTitled(a.generalSettingsPage(), "general", "General")
settingsStack.AddTitled(a.recognitionSettingsPage(), "recognition", "Recognition")
settingsStack.AddTitled(a.outputSettingsPage(), "outputs", "Outputs")
settingsStack.AddTitled(a.overlaySettingsPage(), "overlay", "Caption Overlay")
settingsStack.AddTitled(a.advancedSettingsPage(), "advanced", "Advanced")
content.Append(settingsStack)
page.Append(content)
footer := gtk.NewBox(gtk.OrientationHorizontal, 7)
footer.AddCSSClass("toolbar")
a.settingsError = gtk.NewLabel("")
a.settingsError.SetXAlign(0)
a.settingsError.SetWrap(true)
a.settingsError.SetHExpand(true)
a.settingsError.AddCSSClass("validation-error")
footer.Append(a.settingsError)
reset := toolButton("Reset to Defaults", "Load defaults into the form without saving them", func() {
a.populateSettings(config.DefaultAppConfig())
a.updateApplyButton()
})
footer.Append(reset)
a.applyButton = gtk.NewButtonWithLabel("APPLY SETTINGS")
a.applyButton.AddCSSClass("primary-action")
a.applyButton.SetTooltipText("Validate and save all settings")
a.applyButton.ConnectClicked(a.applySettings)
footer.Append(a.applyButton)
page.Append(footer)
a.populateSettings(a.config)
a.connectSettingsChanged()
return page
}
func (a *Application) createSettingsControls() {
a.settings.startCaptions = gtk.NewSwitch()
a.settings.alwaysOnTop = gtk.NewSwitch()
a.settings.mode = combo([]option{{"vad", "Voice activity detection (VAD)"}, {"fixed", "Fixed chunks"}})
a.settings.chunkDuration = gtk.NewEntry()
a.settings.modelsDirectory = gtk.NewEntry()
a.settings.threads = spin(1, 128, 1, 0)
a.settings.previewThresholdDB = spin(-100, 0, 1, 1)
a.settings.vadThreshold = spin(0, 1, 0.01, 2)
a.settings.overlayEnabled = gtk.NewSwitch()
a.settings.historyEnabled = gtk.NewSwitch()
a.settings.stdoutEnabled = gtk.NewSwitch()
a.settings.stderrEnabled = gtk.NewSwitch()
a.settings.transcriptEnabled = gtk.NewSwitch()
a.settings.transcriptDirectory = gtk.NewEntry()
a.settings.overlayOpacity = spin(0, 1, 0.01, 2)
a.settings.overlayFont = gtk.NewFontButton()
a.settings.overlayFont.SetUseFont(true)
a.settings.overlayFontSize = spin(8, 96, 1, 0)
a.settings.overlayBottomMargin = spin(0, 2000, 5, 0)
a.settings.overlayMaxWidth = spin(100, 5000, 10, 0)
a.settings.overlayMonitor = gtk.NewEntry()
a.settings.overlayFinalTimeout = gtk.NewEntry()
a.settings.overlayClickThrough = gtk.NewSwitch()
a.settings.overlayBackend = combo([]option{{"auto", "Automatic"}, {"wayland", "Wayland"}, {"x11", "X11"}})
a.settings.modelAutoRestart = gtk.NewSwitch()
a.settings.modelTimeout = gtk.NewEntry()
a.settings.modelShutdownTimeout = gtk.NewEntry()
}
func (a *Application) generalSettingsPage() *gtk.ScrolledWindow {
box := settingsBox("GENERAL", "Desktop startup and main-window behavior.")
box.Append(settingRow("Start captions when the GUI opens", "The saved setting is used unless an explicit command-line flag overrides it.", a.settings.startCaptions))
box.Append(settingRow("Keep main window above others", "Best effort on X11. Wayland compositors normally do not allow applications to force this.", a.settings.alwaysOnTop))
return settingsScroller(box)
}
func (a *Application) recognitionSettingsPage() *gtk.ScrolledWindow {
box := settingsBox("RECOGNITION", "These settings require the caption pipeline to restart while it is running.")
box.Append(settingRow("Processing mode", "VAD waits for detected speech; fixed mode recognizes regular chunks.", a.settings.mode))
box.Append(settingRow("Chunk / preview interval", "Go duration such as 1s or 750ms.", a.settings.chunkDuration))
box.Append(pathSettingRow(a, "Models directory", "Directory containing the Sherpa-ONNX model files.", a.settings.modelsDirectory, "Select models directory"))
box.Append(settingRow("Recognition threads", "CPU threads used by the recognizer.", a.settings.threads))
box.Append(settingRow("Preview threshold (dBFS)", "Suppress fixed-mode previews below this level.", a.settings.previewThresholdDB))
box.Append(settingRow("VAD threshold", "Speech detector sensitivity from 0.00 to 1.00.", a.settings.vadThreshold))
return settingsScroller(box)
}
func (a *Application) outputSettingsPage() *gtk.ScrolledWindow {
box := settingsBox("OUTPUTS", "Caption destinations and diagnostic output are independent.")
box.Append(settingRow("Caption overlay", "Show captions in the desktop overlay.", a.settings.overlayEnabled))
box.Append(settingRow("GUI caption history", "Keep final captions and one live provisional row in this window.", a.settings.historyEnabled))
box.Append(settingRow("Caption text on stdout", "Useful when launching the desktop application from a terminal or script.", a.settings.stdoutEnabled))
box.Append(settingRow("Diagnostics on stderr", "Mirror Console messages to the launching terminal.", a.settings.stderrEnabled))
box.Append(settingRow("Save timestamped caption log", "Write final captions to a dated session file, flushing each line immediately.", a.settings.transcriptEnabled))
box.Append(pathSettingRow(a, "Caption log directory", "A new session file is created here each time Captioneer starts logging.", a.settings.transcriptDirectory, "Select caption log directory"))
return settingsScroller(box)
}
func (a *Application) overlaySettingsPage() *gtk.ScrolledWindow {
box := settingsBox("CAPTION OVERLAY", "Appearance changes apply without restarting recognition.")
box.Append(settingRow("Opacity", "Background opacity from 0.00 to 1.00.", a.settings.overlayOpacity))
box.Append(settingRow("Font family", "Font used for overlay captions.", a.settings.overlayFont))
box.Append(settingRow("Font size", "Caption text size in points.", a.settings.overlayFontSize))
box.Append(settingRow("Bottom margin", "Distance from the bottom edge in pixels.", a.settings.overlayBottomMargin))
box.Append(settingRow("Maximum width", "Maximum overlay width in pixels.", a.settings.overlayMaxWidth))
box.Append(settingRow("Monitor", "Use auto or the configured monitor connector/name.", a.settings.overlayMonitor))
box.Append(settingRow("Final caption lifetime", "Zero keeps final text visible; otherwise use 3s or longer.", a.settings.overlayFinalTimeout))
box.Append(settingRow("Click through overlay", "Allow pointer input to pass through the caption surface.", a.settings.overlayClickThrough))
return settingsScroller(box)
}
func (a *Application) advancedSettingsPage() *gtk.ScrolledWindow {
box := settingsBox("ADVANCED", "Low-level recovery and platform settings. Leave these at their defaults unless troubleshooting.")
expander := gtk.NewExpander("Show advanced settings")
expander.SetExpanded(false)
advanced := gtk.NewBox(gtk.OrientationVertical, 8)
advanced.SetMarginTop(8)
advanced.Append(settingRow("Overlay backend", "Changing the backend is saved now and takes effect after restarting the application.", a.settings.overlayBackend))
advanced.Append(settingRow("Restart model worker automatically", "Recover automatically if the isolated recognition worker crashes or stalls.", a.settings.modelAutoRestart))
advanced.Append(settingRow("Recognition timeout", "Maximum busy time before a stalled model worker is replaced; zero disables it.", a.settings.modelTimeout))
advanced.Append(settingRow("Worker shutdown timeout", "How long to wait for clean worker shutdown before forcing it.", a.settings.modelShutdownTimeout))
expander.SetChild(advanced)
box.Append(expander)
return settingsScroller(box)
}
func (a *Application) populateSettings(value config.AppConfig) {
a.settings.startCaptions.SetActive(value.GUI.StartCaptions)
a.settings.alwaysOnTop.SetActive(value.GUI.MainWindowAlwaysOnTop)
a.settings.mode.SetActiveID(string(value.Settings.Mode))
a.settings.chunkDuration.SetText(value.Settings.ChunkDuration.String())
a.settings.modelsDirectory.SetText(value.Settings.ModelsDir)
a.settings.threads.SetValue(float64(value.Settings.Threads))
a.settings.previewThresholdDB.SetValue(value.Settings.PreviewThresholdDB)
a.settings.vadThreshold.SetValue(value.Settings.VADThreshold)
a.settings.overlayEnabled.SetActive(value.Outputs.Overlay)
a.settings.historyEnabled.SetActive(value.Outputs.History)
a.settings.stdoutEnabled.SetActive(value.Outputs.StdoutCaptions)
a.settings.stderrEnabled.SetActive(value.Outputs.StderrDiagnostics)
a.settings.transcriptEnabled.SetActive(value.TranscriptLog.Enabled)
a.settings.transcriptDirectory.SetText(value.TranscriptLog.Directory)
a.settings.overlayOpacity.SetValue(value.Overlay.Opacity)
a.settings.overlayFont.SetFont(value.Overlay.FontFamily)
a.settings.overlayFontSize.SetValue(float64(value.Overlay.FontSize))
a.settings.overlayBottomMargin.SetValue(float64(value.Overlay.BottomMargin))
a.settings.overlayMaxWidth.SetValue(float64(value.Overlay.MaxWidth))
a.settings.overlayMonitor.SetText(value.Overlay.Monitor)
a.settings.overlayFinalTimeout.SetText(value.Overlay.FinalTimeout.String())
a.settings.overlayClickThrough.SetActive(value.Overlay.ClickThrough)
a.settings.overlayBackend.SetActiveID(string(value.Overlay.Backend))
a.settings.modelAutoRestart.SetActive(value.Settings.ModelAutoRestart)
a.settings.modelTimeout.SetText(value.Settings.ModelTimeout.String())
a.settings.modelShutdownTimeout.SetText(value.Settings.ModelShutdownTimeout.String())
}
func (a *Application) readSettings() (config.AppConfig, error) {
chunk, err := settingDuration("chunk / preview interval", a.settings.chunkDuration.Text())
if err != nil {
return config.AppConfig{}, err
}
finalTimeout, err := settingDuration("final caption lifetime", a.settings.overlayFinalTimeout.Text())
if err != nil {
return config.AppConfig{}, err
}
modelTimeout, err := settingDuration("recognition timeout", a.settings.modelTimeout.Text())
if err != nil {
return config.AppConfig{}, err
}
shutdownTimeout, err := settingDuration("worker shutdown timeout", a.settings.modelShutdownTimeout.Text())
if err != nil {
return config.AppConfig{}, err
}
next := config.AppConfig{
Version: config.CurrentConfigVersion,
GUI: config.GUISettings{
StartCaptions: a.settings.startCaptions.Active(),
MainWindowAlwaysOnTop: a.settings.alwaysOnTop.Active(),
},
Settings: config.Settings{
Mode: config.Mode(a.settings.mode.ActiveID()),
ChunkDuration: chunk,
ModelsDir: strings.TrimSpace(a.settings.modelsDirectory.Text()),
Threads: a.settings.threads.ValueAsInt(),
PreviewThresholdDB: a.settings.previewThresholdDB.Value(),
VADThreshold: a.settings.vadThreshold.Value(),
ModelAutoRestart: a.settings.modelAutoRestart.Active(),
ModelTimeout: modelTimeout,
ModelShutdownTimeout: shutdownTimeout,
},
Outputs: config.OutputSettings{
Overlay: a.settings.overlayEnabled.Active(),
History: a.settings.historyEnabled.Active(),
StdoutCaptions: a.settings.stdoutEnabled.Active(),
StderrDiagnostics: a.settings.stderrEnabled.Active(),
},
Overlay: config.OverlaySettings{
Backend: config.OverlayBackend(a.settings.overlayBackend.ActiveID()),
Opacity: a.settings.overlayOpacity.Value(),
FontSize: a.settings.overlayFontSize.ValueAsInt(),
FontFamily: a.settings.overlayFont.FontDesc().Family(),
BottomMargin: a.settings.overlayBottomMargin.ValueAsInt(),
MaxWidth: a.settings.overlayMaxWidth.ValueAsInt(),
Monitor: strings.TrimSpace(a.settings.overlayMonitor.Text()),
FinalTimeout: finalTimeout,
ClickThrough: a.settings.overlayClickThrough.Active(),
},
TranscriptLog: config.TranscriptLogSettings{
Enabled: a.settings.transcriptEnabled.Active(),
Directory: strings.TrimSpace(a.settings.transcriptDirectory.Text()),
},
}
if err := next.Validate(); err != nil {
return config.AppConfig{}, err
}
return next, nil
}
func (a *Application) applySettings() {
next, err := a.readSettings()
if err != nil {
a.settingsError.SetText(err.Error())
return
}
if err := config.Save(a.launch.Path, next); err != nil {
a.settingsError.SetText(err.Error())
a.log.Add(diagnostics.Error, "configuration", err.Error())
return
}
previous := a.applied
engineChanged := next.Settings != previous.Settings
backendChanged := next.Overlay.Backend != previous.Overlay.Backend
if err := a.configureOutputs(next, false); err != nil {
a.log.Add(diagnostics.Error, "outputs", err.Error())
}
a.config = next
a.applied = next
if backendChanged {
a.applied.Overlay.Backend = previous.Overlay.Backend
a.log.Add(diagnostics.Warning, "overlay", "Overlay backend change is saved and will apply after restarting Captioneer")
}
a.applyAlwaysOnTop(next.GUI.MainWindowAlwaysOnTop)
a.settingsError.SetText("")
a.log.Add(diagnostics.Info, "configuration", "Settings saved to "+a.launch.Path)
status := a.controller.Status()
if engineChanged && status.State != app.StateStopped && status.State != app.StateError {
go func(settings config.Settings) {
timeout := previous.Settings.ModelShutdownTimeout + 2*time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := a.controller.Restart(ctx, settings); err != nil {
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Apply and restart captions: %v", err))
}
}(next.Settings)
}
a.updateApplyButton()
a.renderStatus(a.controller.Status())
}
func (a *Application) updateApplyButton() {
if a.applyButton == nil {
return
}
next, err := a.readSettings()
if err != nil {
a.applyButton.SetLabel("APPLY SETTINGS")
return
}
status := a.controller.Status()
if next.Settings != a.applied.Settings && status.State != app.StateStopped && status.State != app.StateError {
a.applyButton.SetLabel("APPLY & RESTART CAPTIONS")
} else {
a.applyButton.SetLabel("APPLY SETTINGS")
}
}
func (a *Application) connectSettingsChanged() {
changed := a.updateApplyButton
entries := []*gtk.Entry{
a.settings.chunkDuration, a.settings.modelsDirectory, a.settings.transcriptDirectory,
a.settings.overlayMonitor, a.settings.overlayFinalTimeout, a.settings.modelTimeout,
a.settings.modelShutdownTimeout,
}
for _, entry := range entries {
entry.ConnectChanged(changed)
}
spins := []*gtk.SpinButton{
a.settings.threads, a.settings.previewThresholdDB, a.settings.vadThreshold,
a.settings.overlayOpacity, a.settings.overlayFontSize, a.settings.overlayBottomMargin,
a.settings.overlayMaxWidth,
}
for _, control := range spins {
control.ConnectValueChanged(changed)
}
combos := []*gtk.ComboBoxText{a.settings.mode, a.settings.overlayBackend}
for _, control := range combos {
control.ConnectChanged(changed)
}
a.settings.overlayFont.ConnectFontSet(changed)
switches := []*gtk.Switch{
a.settings.startCaptions, a.settings.alwaysOnTop, a.settings.overlayEnabled,
a.settings.historyEnabled, a.settings.stdoutEnabled, a.settings.stderrEnabled,
a.settings.transcriptEnabled, a.settings.overlayClickThrough, a.settings.modelAutoRestart,
}
for _, control := range switches {
control.ConnectStateSet(func(bool) bool {
glib.IdleAdd(changed)
return false
})
}
}
type option struct{ id, label string }
func combo(options []option) *gtk.ComboBoxText {
control := gtk.NewComboBoxText()
for _, option := range options {
control.Append(option.id, option.label)
}
return control
}
func spin(minimum, maximum, step float64, digits uint) *gtk.SpinButton {
control := gtk.NewSpinButtonWithRange(minimum, maximum, step)
control.SetDigits(digits)
control.SetNumeric(true)
return control
}
func settingsBox(title, description string) *gtk.Box {
box := gtk.NewBox(gtk.OrientationVertical, 8)
box.SetMarginTop(12)
box.SetMarginBottom(12)
box.SetMarginStart(12)
box.SetMarginEnd(12)
box.Append(labelWithClass(title, "section-title"))
intro := labelWithClass(description, "description")
intro.SetWrap(true)
box.Append(intro)
box.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
return box
}
func settingsScroller(child gtk.Widgetter) *gtk.ScrolledWindow {
scroll := gtk.NewScrolledWindow()
scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
scroll.SetHExpand(true)
scroll.SetVExpand(true)
scroll.SetChild(child)
scroll.AddCSSClass("settings-panel")
return scroll
}
func settingRow(title, description string, control gtk.Widgetter) *gtk.Box {
row := gtk.NewBox(gtk.OrientationHorizontal, 12)
row.AddCSSClass("setting-row")
text := gtk.NewBox(gtk.OrientationVertical, 2)
text.SetHExpand(true)
name := gtk.NewLabel(title)
name.SetXAlign(0)
name.SetMnemonicWidget(control)
text.Append(name)
detail := labelWithClass(description, "description")
detail.SetWrap(true)
text.Append(detail)
row.Append(text)
row.Append(control)
return row
}
func pathSettingRow(a *Application, title, description string, entry *gtk.Entry, dialogTitle string) *gtk.Box {
chooser := gtk.NewBox(gtk.OrientationHorizontal, 4)
entry.SetHExpand(true)
chooser.Append(entry)
browse := gtk.NewButtonWithLabel("Browse…")
browse.ConnectClicked(func() { a.selectFolder(dialogTitle, entry) })
chooser.Append(browse)
return settingRow(title, description, chooser)
}
func (a *Application) selectFolder(title string, entry *gtk.Entry) {
dialog := gtk.NewFileDialog()
dialog.SetTitle(title)
dialog.SelectFolder(context.Background(), &a.window.Window, func(result gio.AsyncResulter) {
folder, err := dialog.SelectFolderFinish(result)
if err != nil {
return
}
entry.SetText(folder.Path())
})
}
func settingDuration(name, value string) (time.Duration, error) {
duration, err := time.ParseDuration(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s must be a duration such as 1s or 750ms", name)
}
return duration, nil
}
+218
View File
@@ -0,0 +1,218 @@
window.captioneer-main {
background: #30372f;
color: #e0e5d7;
}
headerbar.captioneer-header {
min-height: 40px;
padding: 4px 7px;
background-image: linear-gradient(to bottom, #4a5543, #2b332c);
border-bottom: 1px solid #171d18;
box-shadow: inset 0 1px #748069;
}
.captioneer-brand {
color: #e8ebdf;
font-weight: 700;
letter-spacing: 1px;
}
.status-text {
color: #cbd3bd;
font-weight: 600;
}
.status-dot {
font-size: 16px;
}
.status-dot.status-running { color: #9eb96b; }
.status-dot.status-stopped { color: #7f8977; }
.status-dot.status-warning { color: #dfc078; }
.status-dot.status-error { color: #e58c7c; }
.captioneer-menu {
min-height: 24px;
padding: 0 4px;
background: #3a4337;
border-bottom: 1px solid #171d18;
}
.captioneer-tabs {
min-height: 34px;
padding: 3px 8px 0;
background: #353d33;
border-bottom: 1px solid #171d18;
}
.captioneer-tabs button {
min-height: 26px;
padding: 3px 16px;
border-radius: 2px 2px 0 0;
background-image: linear-gradient(to bottom, #56604d, #414a3d);
border: 1px solid #1d241e;
border-top-color: #748069;
border-left-color: #68745e;
color: #c9d0bf;
box-shadow: none;
}
.captioneer-tabs button:checked {
background: #515b49;
color: #f0f2ea;
border-bottom-color: #515b49;
}
.captioneer-content {
padding: 10px;
background: #515b49;
}
.inset-panel,
textview,
entry,
spinbutton,
combobox,
fontbutton {
background: #252c26;
color: #e0e5d7;
border: 1px solid #171d18;
border-bottom-color: #748069;
border-right-color: #68745e;
border-radius: 1px;
}
textview text {
background: #252c26;
color: #e0e5d7;
}
.console-view text {
background: #202621;
color: #c5cfb9;
font-family: Monospace;
font-size: 10pt;
}
.provisional-row {
min-height: 34px;
padding: 6px 10px;
background: #394237;
color: #cbd5b8;
border: 1px solid #1a201b;
font-style: italic;
}
.toolbar {
padding: 4px;
background: #424b3e;
border: 1px solid #202720;
}
button {
min-height: 24px;
padding: 3px 9px;
border-radius: 2px;
background-image: linear-gradient(to bottom, #657058, #46503f);
color: #edf0e6;
border: 1px solid #1a201b;
border-top-color: #7d896f;
border-left-color: #748069;
box-shadow: none;
}
button:hover {
background-image: linear-gradient(to bottom, #758064, #515c48);
}
button:active,
button:checked {
background: #354033;
border-top-color: #171d18;
border-left-color: #171d18;
border-bottom-color: #748069;
border-right-color: #68745e;
}
button:disabled,
switch:disabled,
entry:disabled,
spinbutton:disabled,
combobox:disabled {
color: #7f8977;
background: #363d35;
}
button:focus-visible,
entry:focus-visible,
spinbutton:focus-visible,
combobox:focus-visible,
switch:focus-visible,
textview:focus-visible {
outline: 2px solid #d5dea7;
outline-offset: -2px;
}
.primary-action {
background-image: linear-gradient(to bottom, #899b5f, #637344);
color: #ffffff;
font-weight: 700;
}
.danger-action {
background-image: linear-gradient(to bottom, #9e6556, #71463e);
}
.section-title {
color: #eef1e8;
font-weight: 700;
letter-spacing: 0.5px;
}
.description {
color: #adb7a1;
font-size: 9.5pt;
}
.validation-error {
color: #e5a192;
font-weight: 600;
}
.warning-text {
color: #dfc078;
}
.statusbar {
min-height: 25px;
padding: 3px 8px;
background: #343c32;
color: #adb7a1;
border-top: 1px solid #748069;
}
.settings-sidebar row {
min-width: 130px;
padding: 7px 10px;
border-radius: 1px;
}
.settings-panel {
background: #454f41;
border: 1px solid #1d241e;
}
.setting-row {
min-height: 48px;
padding: 6px 8px;
background: #4b5546;
border: 1px solid #252c26;
}
scrollbar slider {
min-width: 12px;
min-height: 12px;
border-radius: 1px;
background: #68745e;
border: 1px solid #222921;
}
+164
View File
@@ -0,0 +1,164 @@
// Package history retains a bounded in-memory caption transcript for the GUI.
package history
import (
"errors"
"strings"
"sync"
"time"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
type Entry struct {
CreatedAt time.Time
Text string
StartedAt time.Duration
EndedAt time.Duration
}
type Snapshot struct {
Finals []Entry
Provisional *Entry
}
type Sink struct {
mu sync.Mutex
capacity int
enabled bool
finals []Entry
provisional *Entry
subscribers map[chan Snapshot]struct{}
closed bool
}
func New(capacity int, enabled bool) *Sink {
if capacity < 1 {
capacity = 1
}
return &Sink{
capacity: capacity,
enabled: enabled,
subscribers: make(map[chan Snapshot]struct{}),
}
}
func (s *Sink) Publish(event captions.Event) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return errors.New("caption history is closed")
}
if !s.enabled {
return nil
}
now := time.Now()
switch event.Kind {
case captions.Provisional:
s.provisional = &Entry{CreatedAt: now, Text: event.Text, StartedAt: event.StartedAt, EndedAt: event.EndedAt}
case captions.Final:
s.provisional = nil
s.finals = append(s.finals, Entry{CreatedAt: now, Text: event.Text, StartedAt: event.StartedAt, EndedAt: event.EndedAt})
if len(s.finals) > s.capacity {
s.finals = append([]Entry(nil), s.finals[len(s.finals)-s.capacity:]...)
}
case captions.Hide:
s.provisional = nil
}
s.publishLocked()
return nil
}
func (s *Sink) SetEnabled(enabled bool) {
s.mu.Lock()
s.enabled = enabled
if !enabled {
s.provisional = nil
}
s.publishLocked()
s.mu.Unlock()
}
func (s *Sink) Clear() {
s.mu.Lock()
s.finals = nil
s.provisional = nil
s.publishLocked()
s.mu.Unlock()
}
func (s *Sink) Snapshot() Snapshot {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotLocked()
}
func (s *Sink) Transcript() string {
s.mu.Lock()
defer s.mu.Unlock()
lines := make([]string, len(s.finals))
for index, entry := range s.finals {
lines[index] = entry.Text
}
return strings.Join(lines, "\n")
}
func (s *Sink) Subscribe(buffer int) (<-chan Snapshot, func()) {
if buffer < 1 {
buffer = 1
}
updates := make(chan Snapshot, buffer)
s.mu.Lock()
s.subscribers[updates] = struct{}{}
updates <- s.snapshotLocked()
s.mu.Unlock()
return updates, func() {
s.mu.Lock()
if _, ok := s.subscribers[updates]; ok {
delete(s.subscribers, updates)
close(updates)
}
s.mu.Unlock()
}
}
func (s *Sink) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
for subscriber := range s.subscribers {
close(subscriber)
}
s.subscribers = nil
return nil
}
func (s *Sink) snapshotLocked() Snapshot {
snapshot := Snapshot{Finals: append([]Entry(nil), s.finals...)}
if s.provisional != nil {
copy := *s.provisional
snapshot.Provisional = &copy
}
return snapshot
}
func (s *Sink) publishLocked() {
snapshot := s.snapshotLocked()
for subscriber := range s.subscribers {
select {
case subscriber <- snapshot:
default:
select {
case <-subscriber:
default:
}
select {
case subscriber <- snapshot:
default:
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package history
import (
"testing"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
func TestHistoryKeepsFinalsBoundedAndProvisionalSeparate(t *testing.T) {
sink := New(2, true)
_ = sink.Publish(captions.Event{Kind: captions.Provisional, Text: "draft"})
if sink.Snapshot().Provisional == nil {
t.Fatal("provisional caption was not retained")
}
for _, text := range []string{"one", "two", "three"} {
_ = sink.Publish(captions.Event{Kind: captions.Final, Text: text})
}
snapshot := sink.Snapshot()
if snapshot.Provisional != nil || len(snapshot.Finals) != 2 || snapshot.Finals[0].Text != "two" {
t.Fatalf("snapshot = %#v", snapshot)
}
}
func TestDisabledHistoryKeepsExistingFinalsButStopsRecording(t *testing.T) {
sink := New(10, true)
_ = sink.Publish(captions.Event{Kind: captions.Final, Text: "kept"})
sink.SetEnabled(false)
_ = sink.Publish(captions.Event{Kind: captions.Final, Text: "ignored"})
if got := sink.Transcript(); got != "kept" {
t.Fatalf("transcript = %q", got)
}
}
+3 -1
View File
@@ -276,7 +276,9 @@ static CaptioneerOverlay *captioneer_overlay_new(
) {
*warning_message = NULL;
*error_message = NULL;
g_set_prgname("com.chunkbyte.captioneer.overlay");
if (g_get_prgname() == NULL) {
g_set_prgname("com.chunkbyte.captioneer.overlay");
}
if (g_strcmp0(backend, "auto") != 0) {
g_setenv("GDK_BACKEND", backend, TRUE);
}
+86
View File
@@ -0,0 +1,86 @@
package output
import (
"errors"
"sort"
"sync"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
type Router struct {
mu sync.RWMutex
sinks map[string]Sink
closed bool
}
func NewRouter() *Router {
return &Router{sinks: make(map[string]Sink)}
}
func (r *Router) Set(name string, sink Sink) error {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
if sink != nil {
_ = sink.Close()
}
return errors.New("output router is closed")
}
previous := r.sinks[name]
if sink == nil {
delete(r.sinks, name)
} else {
r.sinks[name] = sink
}
r.mu.Unlock()
if previous != nil && previous != sink {
return previous.Close()
}
return nil
}
func (r *Router) Remove(name string) error {
return r.Set(name, nil)
}
func (r *Router) Publish(event captions.Event) error {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.sinks))
for name := range r.sinks {
names = append(names, name)
}
sort.Strings(names)
var publishErr error
for _, name := range names {
publishErr = errors.Join(publishErr, r.sinks[name].Publish(event))
}
return publishErr
}
func (r *Router) Close() error {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return nil
}
r.closed = true
names := make([]string, 0, len(r.sinks))
for name := range r.sinks {
names = append(names, name)
}
sort.Sort(sort.Reverse(sort.StringSlice(names)))
sinks := make([]Sink, 0, len(names))
for _, name := range names {
sinks = append(sinks, r.sinks[name])
}
r.sinks = nil
r.mu.Unlock()
var closeErr error
for _, sink := range sinks {
closeErr = errors.Join(closeErr, sink.Close())
}
return closeErr
}
+30
View File
@@ -47,3 +47,33 @@ func TestMultiSinkStillPublishesToOtherSinksAfterError(t *testing.T) {
t.Fatalf("unexpected result: err=%v second=%d", err, len(second.events))
}
}
func TestRouterCanReplaceAndRemoveOutputsWhileRunning(t *testing.T) {
router := NewRouter()
first := &recordingSink{}
second := &recordingSink{}
if err := router.Set("history", first); err != nil {
t.Fatal(err)
}
if err := router.Publish(captions.Event{Kind: captions.Final, Text: "first"}); err != nil {
t.Fatal(err)
}
if err := router.Set("history", second); err != nil {
t.Fatal(err)
}
if !first.closed {
t.Fatal("replaced sink was not closed")
}
if err := router.Publish(captions.Event{Kind: captions.Final, Text: "second"}); err != nil {
t.Fatal(err)
}
if len(first.events) != 1 || len(second.events) != 1 {
t.Fatalf("unexpected routed events: first=%d second=%d", len(first.events), len(second.events))
}
if err := router.Remove("history"); err != nil {
t.Fatal(err)
}
if !second.closed {
t.Fatal("removed sink was not closed")
}
}
+102
View File
@@ -0,0 +1,102 @@
// Package transcript writes finalized captions to timestamped session logs.
package transcript
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
type Sink struct {
mu sync.Mutex
file *os.File
writer *bufio.Writer
now func() time.Time
closed bool
path string
}
func New(directory string) (*Sink, error) {
return newWithClock(directory, time.Now)
}
func newWithClock(directory string, now func() time.Time) (*Sink, error) {
if strings.TrimSpace(directory) == "" {
return nil, errors.New("transcript directory cannot be empty")
}
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, fmt.Errorf("create transcript directory: %w", err)
}
startedAt := now()
base := "captioneer-" + startedAt.Format("2006-01-02_15-04-05.000")
var file *os.File
var path string
for suffix := 0; suffix < 1000; suffix++ {
name := base + ".log"
if suffix > 0 {
name = fmt.Sprintf("%s-%d.log", base, suffix)
}
path = filepath.Join(directory, name)
candidate, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("create transcript: %w", err)
}
file = candidate
break
}
if file == nil {
return nil, errors.New("create transcript: too many files share this timestamp")
}
return &Sink{file: file, writer: bufio.NewWriter(file), now: now, path: path}, nil
}
func (s *Sink) Path() string {
return s.path
}
func (s *Sink) Publish(event captions.Event) error {
if event.Kind != captions.Final {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return errors.New("transcript is closed")
}
text := strings.Join(strings.Fields(event.Text), " ")
if text == "" {
return nil
}
_, err := fmt.Fprintf(
s.writer,
"%s [%.2fs-%.2fs] %s\n",
s.now().Format(time.RFC3339Nano),
event.StartedAt.Seconds(),
event.EndedAt.Seconds(),
text,
)
if err != nil {
return err
}
return s.writer.Flush()
}
func (s *Sink) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
return errors.Join(s.writer.Flush(), s.file.Close())
}
+40
View File
@@ -0,0 +1,40 @@
package transcript
import (
"os"
"strings"
"testing"
"time"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
func TestTranscriptWritesOnlyFinalCaptionsWithWallClockAndOffsets(t *testing.T) {
now := time.Date(2026, 7, 17, 12, 34, 56, 123000000, time.UTC)
sink, err := newWithClock(t.TempDir(), func() time.Time { return now })
if err != nil {
t.Fatal(err)
}
if err := sink.Publish(captions.Event{Kind: captions.Provisional, Text: "draft"}); err != nil {
t.Fatal(err)
}
if err := sink.Publish(captions.Event{
Kind: captions.Final, Text: "hello\nthere", StartedAt: time.Second, EndedAt: 2500 * time.Millisecond,
}); err != nil {
t.Fatal(err)
}
if err := sink.Close(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(sink.Path())
if err != nil {
t.Fatal(err)
}
want := "2026-07-17T12:34:56.123Z [1.00s-2.50s] hello there\n"
if string(data) != want {
t.Fatalf("transcript = %q, want %q", data, want)
}
if !strings.Contains(sink.Path(), "captioneer-2026-07-17_12-34-56.123.log") {
t.Fatalf("unexpected transcript path %q", sink.Path())
}
}