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
|
||||
|
||||
Reference in New Issue
Block a user