docs: update README with detailed desktop features and setup requirements

This commit is contained in:
2026-07-17 14:41:10 +03:00
parent 55fbd357b0
commit 54fb100d3c
35 changed files with 3469 additions and 162 deletions
+215
View File
@@ -0,0 +1,215 @@
package app
import (
"context"
"errors"
"io"
"sync"
"time"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/output"
)
type State string
const (
StateStopped State = "stopped"
StateStarting State = "starting"
StateListening State = "listening"
StateProcessing State = "processing"
StateRestarting State = "restarting"
StateStopping State = "stopping"
StateError State = "error"
)
type Status struct {
State State
Mode config.Mode
Source string
SpeechActive bool
OverloadedAt time.Time
LastError string
UpdatedAt time.Time
}
type sessionRunner func(context.Context, config.Settings, output.Sink, io.Writer, RuntimeObserver) error
type Controller struct {
mu sync.Mutex
runner sessionRunner
sink output.Sink
diagnostics io.Writer
cancel context.CancelFunc
done chan error
status Status
subscribers map[chan Status]struct{}
}
func NewController(sink output.Sink, diagnostics io.Writer) *Controller {
return newControllerWithRunner(sink, diagnostics, RunObserved)
}
func newControllerWithRunner(sink output.Sink, diagnostics io.Writer, runner sessionRunner) *Controller {
return &Controller{
runner: runner,
sink: sink,
diagnostics: diagnostics,
status: Status{State: StateStopped, UpdatedAt: time.Now()},
subscribers: make(map[chan Status]struct{}),
}
}
func (c *Controller) Start(settings config.Settings) error {
if err := settings.Validate(); err != nil {
return err
}
c.mu.Lock()
if c.cancel != nil {
c.mu.Unlock()
return errors.New("caption processing is already running")
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
c.cancel = cancel
c.done = done
c.status = Status{State: StateStarting, Mode: settings.Mode, UpdatedAt: time.Now()}
c.publishLocked()
c.mu.Unlock()
go func() {
err := c.runner(ctx, settings, c.sink, c.diagnostics, c.observe)
c.mu.Lock()
if c.done == done {
c.cancel = nil
c.done = nil
if err != nil {
c.status.State = StateError
c.status.LastError = err.Error()
} else {
c.status.State = StateStopped
c.status.SpeechActive = false
}
c.status.UpdatedAt = time.Now()
c.publishLocked()
}
c.mu.Unlock()
done <- err
close(done)
}()
return nil
}
func (c *Controller) Stop(ctx context.Context) error {
c.mu.Lock()
if c.cancel == nil {
c.mu.Unlock()
return nil
}
if c.status.State != StateRestarting {
c.status.State = StateStopping
}
c.status.UpdatedAt = time.Now()
c.publishLocked()
cancel := c.cancel
done := c.done
c.mu.Unlock()
cancel()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (c *Controller) Restart(ctx context.Context, settings config.Settings) error {
c.mu.Lock()
if c.cancel != nil {
c.status.State = StateRestarting
c.status.UpdatedAt = time.Now()
c.publishLocked()
}
c.mu.Unlock()
if err := c.Stop(ctx); err != nil {
return err
}
return c.Start(settings)
}
func (c *Controller) Status() Status {
c.mu.Lock()
defer c.mu.Unlock()
return c.status
}
func (c *Controller) Subscribe(buffer int) (<-chan Status, func()) {
if buffer < 1 {
buffer = 1
}
updates := make(chan Status, buffer)
c.mu.Lock()
c.subscribers[updates] = struct{}{}
updates <- c.status
c.mu.Unlock()
return updates, func() {
c.mu.Lock()
if _, ok := c.subscribers[updates]; ok {
delete(c.subscribers, updates)
close(updates)
}
c.mu.Unlock()
}
}
func (c *Controller) observe(event RuntimeEvent) {
c.mu.Lock()
switch event.Kind {
case RuntimeStarting:
c.status.State = StateStarting
c.status.LastError = ""
case RuntimeListening:
c.status.State = StateListening
c.status.LastError = ""
case RuntimeProcessing:
c.status.State = StateProcessing
case RuntimeRestarting:
c.status.State = StateRestarting
case RuntimeStopped:
c.status.State = StateStopped
c.status.SpeechActive = false
case RuntimeError:
if event.Err != nil {
c.status.LastError = event.Err.Error()
}
case RuntimeSource:
c.status.Source = event.Value
case RuntimeSpeech:
c.status.SpeechActive = event.Active
case RuntimeOverloaded:
c.status.OverloadedAt = event.At
}
c.status.UpdatedAt = event.At
if c.status.UpdatedAt.IsZero() {
c.status.UpdatedAt = time.Now()
}
c.publishLocked()
c.mu.Unlock()
}
func (c *Controller) publishLocked() {
for subscriber := range c.subscribers {
select {
case subscriber <- c.status:
default:
select {
case <-subscriber:
default:
}
select {
case subscriber <- c.status:
default:
}
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package app
import (
"context"
"io"
"sync"
"testing"
"time"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/output"
)
func TestControllerStartObserveAndStop(t *testing.T) {
started := make(chan struct{})
runner := func(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, observer RuntimeObserver) error {
observe(observer, RuntimeEvent{Kind: RuntimeSource, Value: "monitor-source"})
observe(observer, RuntimeEvent{Kind: RuntimeListening})
observe(observer, RuntimeEvent{Kind: RuntimeSpeech, Active: true})
close(started)
<-ctx.Done()
observe(observer, RuntimeEvent{Kind: RuntimeStopped})
return nil
}
controller := newControllerWithRunner(nil, io.Discard, runner)
settings := config.DefaultSettings()
if err := controller.Start(settings); err != nil {
t.Fatalf("Start() error = %v", err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("runner did not start")
}
status := controller.Status()
if status.State != StateListening || status.Source != "monitor-source" || !status.SpeechActive {
t.Fatalf("Status() = %#v", status)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := controller.Stop(ctx); err != nil {
t.Fatalf("Stop() error = %v", err)
}
status = controller.Status()
if status.State != StateStopped || status.SpeechActive {
t.Fatalf("Status() after stop = %#v", status)
}
}
func TestControllerRestartUsesNewSettings(t *testing.T) {
var mu sync.Mutex
var modes []config.Mode
started := make(chan struct{}, 2)
runner := func(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, observer RuntimeObserver) error {
mu.Lock()
modes = append(modes, settings.Mode)
mu.Unlock()
observe(observer, RuntimeEvent{Kind: RuntimeListening})
started <- struct{}{}
<-ctx.Done()
return nil
}
controller := newControllerWithRunner(nil, io.Discard, runner)
settings := config.DefaultSettings()
if err := controller.Start(settings); err != nil {
t.Fatal(err)
}
<-started
settings.Mode = config.ModeFixed
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := controller.Restart(ctx, settings); err != nil {
t.Fatalf("Restart() error = %v", err)
}
<-started
mu.Lock()
defer mu.Unlock()
if len(modes) != 2 || modes[0] != config.ModeVAD || modes[1] != config.ModeFixed {
t.Fatalf("runner modes = %v", modes)
}
}
+29 -5
View File
@@ -15,9 +15,13 @@ import (
)
type directHooks struct {
ready func() error
busy func() error
idle func() error
ready func() error
busy func() error
idle func() error
source func(string) error
speech func(bool) error
recognition func(bool) error
overloaded func() error
}
func runDirect(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, hooks directHooks) error {
@@ -45,8 +49,17 @@ func runDirect(ctx context.Context, settings config.Settings, sink output.Sink,
return fmt.Errorf("find default output monitor: %w", err)
}
fmt.Fprintf(diagnostics, "Capturing from: %s\n", monitorSource)
if hooks.source != nil {
if err := hooks.source(monitorSource); err != nil {
return err
}
}
packets, waitCapture, err := capture.Packets(ctx, monitorSource)
packets, waitCapture, err := capture.PacketsObserved(ctx, monitorSource, diagnostics, func() {
if hooks.overloaded != nil {
_ = hooks.overloaded()
}
})
if err != nil {
return fmt.Errorf("start system-audio capture: %w", err)
}
@@ -56,7 +69,18 @@ func runDirect(ctx context.Context, settings config.Settings, sink output.Sink,
return err
}
processor := captions.New(settings, resources, sink.Publish)
processor := captions.NewWithActivity(settings, resources, sink.Publish, captions.Activity{
SpeechChanged: func(active bool) {
if hooks.speech != nil {
_ = hooks.speech(active)
}
},
RecognitionChanged: func(active bool) {
if hooks.recognition != nil {
_ = hooks.recognition(active)
}
},
})
var processErr error
for packet := range packets {
if err := hooks.busy(); err != nil {
+37
View File
@@ -0,0 +1,37 @@
package app
import "time"
type RuntimeEventKind string
const (
RuntimeStarting RuntimeEventKind = "starting"
RuntimeListening RuntimeEventKind = "listening"
RuntimeProcessing RuntimeEventKind = "processing"
RuntimeRestarting RuntimeEventKind = "restarting"
RuntimeStopped RuntimeEventKind = "stopped"
RuntimeError RuntimeEventKind = "error"
RuntimeSource RuntimeEventKind = "source"
RuntimeSpeech RuntimeEventKind = "speech"
RuntimeOverloaded RuntimeEventKind = "overloaded"
)
type RuntimeEvent struct {
Kind RuntimeEventKind
At time.Time
Value string
Active bool
Err error
}
type RuntimeObserver func(RuntimeEvent)
func observe(observer RuntimeObserver, event RuntimeEvent) {
if observer == nil {
return
}
if event.At.IsZero() {
event.At = time.Now()
}
observer(event)
}
+36 -1
View File
@@ -72,10 +72,22 @@ func (h workerHealth) timeoutError(now time.Time, timeout time.Duration) error {
// Presentation stays in this process, so the native model can be replaced
// without restarting the terminal or GTK main loop.
func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer) error {
return RunObserved(ctx, settings, sink, diagnostics, nil)
}
func RunObserved(
ctx context.Context,
settings config.Settings,
sink output.Sink,
diagnostics io.Writer,
observer RuntimeObserver,
) error {
if err := capture.ValidatePrograms(); err != nil {
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
return err
}
if err := models.Validate(settings); err != nil {
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
return err
}
@@ -84,24 +96,32 @@ func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagno
signal.Notify(reload, syscall.SIGHUP)
defer signal.Stop(reload)
observe(observer, RuntimeEvent{Kind: RuntimeStarting})
for {
result, err := runWorkerAttempt(ctx, settings, sink, diagnostics, reload)
result, err := runWorkerAttempt(ctx, settings, sink, diagnostics, reload, observer)
if ctx.Err() != nil {
observe(observer, RuntimeEvent{Kind: RuntimeStopped})
return nil
}
switch result {
case attemptStopped:
observe(observer, RuntimeEvent{Kind: RuntimeStopped})
return nil
case attemptFatal:
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
return err
case attemptReload:
fmt.Fprintln(diagnostics, "Reloading model worker.")
observe(observer, RuntimeEvent{Kind: RuntimeRestarting})
continue
case attemptFailed:
if !settings.ModelAutoRestart {
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
return err
}
fmt.Fprintf(diagnostics, "warning: %v; restarting model worker in %s\n", err, modelRestartDelay)
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
observe(observer, RuntimeEvent{Kind: RuntimeRestarting})
}
timer := time.NewTimer(modelRestartDelay)
@@ -116,6 +136,7 @@ func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagno
<-timer.C
}
fmt.Fprintln(diagnostics, "Reloading model worker.")
observe(observer, RuntimeEvent{Kind: RuntimeRestarting})
case <-timer.C:
}
}
@@ -127,6 +148,7 @@ func runWorkerAttempt(
sink output.Sink,
diagnostics io.Writer,
reload <-chan os.Signal,
observer RuntimeObserver,
) (attemptResult, error) {
cmd, protocol, err := startModelWorker(settings, diagnostics)
if err != nil {
@@ -236,8 +258,21 @@ func runWorkerAttempt(
switch message.Type {
case messageReady:
fmt.Fprintln(diagnostics, "Model worker ready.")
observe(observer, RuntimeEvent{Kind: RuntimeListening})
case messageBusy, messageIdle:
case messageHeartbeat:
case messageSource:
observe(observer, RuntimeEvent{Kind: RuntimeSource, Value: message.Value})
case messageSpeech:
observe(observer, RuntimeEvent{Kind: RuntimeSpeech, Active: message.Active})
case messageRecognition:
kind := RuntimeListening
if message.Active {
kind = RuntimeProcessing
}
observe(observer, RuntimeEvent{Kind: kind, Active: message.Active})
case messageOverloaded:
observe(observer, RuntimeEvent{Kind: RuntimeOverloaded})
case messageCaption:
if message.Event == nil {
beginStop(attemptFailed, errors.New("model worker sent an empty caption event"))
+22 -12
View File
@@ -24,18 +24,24 @@ const (
)
const (
messageReady = "ready"
messageBusy = "busy"
messageIdle = "idle"
messageCaption = "caption"
messageFailure = "failure"
messageHeartbeat = "heartbeat"
messageReady = "ready"
messageBusy = "busy"
messageIdle = "idle"
messageCaption = "caption"
messageFailure = "failure"
messageHeartbeat = "heartbeat"
messageSource = "source"
messageSpeech = "speech"
messageRecognition = "recognition"
messageOverloaded = "overloaded"
)
type workerMessage struct {
Type string `json:"type"`
Event *captions.Event `json:"event,omitempty"`
Error string `json:"error,omitempty"`
Type string `json:"type"`
Event *captions.Event `json:"event,omitempty"`
Error string `json:"error,omitempty"`
Value string `json:"value,omitempty"`
Active bool `json:"active,omitempty"`
}
type workerReporter struct {
@@ -110,9 +116,13 @@ func RunModelWorker(diagnostics io.Writer) int {
reporter := &workerReporter{encoder: json.NewEncoder(protocol)}
hooks := directHooks{
ready: func() error { return reporter.send(workerMessage{Type: messageReady}) },
busy: func() error { return reporter.send(workerMessage{Type: messageBusy}) },
idle: func() error { return reporter.send(workerMessage{Type: messageIdle}) },
ready: func() error { return reporter.send(workerMessage{Type: messageReady}) },
busy: func() error { return reporter.send(workerMessage{Type: messageBusy}) },
idle: func() error { return reporter.send(workerMessage{Type: messageIdle}) },
source: func(value string) error { return reporter.send(workerMessage{Type: messageSource, Value: value}) },
speech: func(active bool) error { return reporter.send(workerMessage{Type: messageSpeech, Active: active}) },
recognition: func(active bool) error { return reporter.send(workerMessage{Type: messageRecognition, Active: active}) },
overloaded: func() error { return reporter.send(workerMessage{Type: messageOverloaded}) },
}
heartbeatInterval := time.Second