docs: update README with detailed desktop features and setup requirements
This commit is contained in:
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = ©
|
||||
}
|
||||
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:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user