chore: add Apache License 2.0 and ignore build directory

This commit is contained in:
2026-07-16 17:24:50 +03:00
parent 45e4bce2a0
commit 7affa0dbc8
23 changed files with 1814 additions and 157 deletions
+61
View File
@@ -0,0 +1,61 @@
// Package app coordinates capture, transcription, and caption presentation.
package app
import (
"context"
"errors"
"fmt"
"io"
"tea.chunkbyte.com/kato/captioneer/src/captions"
"tea.chunkbyte.com/kato/captioneer/src/capture"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/models"
"tea.chunkbyte.com/kato/captioneer/src/output"
)
func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer) error {
if err := capture.ValidatePrograms(); err != nil {
return err
}
resources, err := models.New(settings)
if err != nil {
return err
}
defer resources.Close()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
monitorSource, err := capture.DefaultMonitorSource(ctx)
if err != nil {
return fmt.Errorf("find default output monitor: %w", err)
}
fmt.Fprintf(diagnostics, "Capturing from: %s\n", monitorSource)
fmt.Fprintln(diagnostics, "Press Ctrl+C to stop.")
packets, waitCapture, err := capture.Packets(ctx, monitorSource)
if err != nil {
return fmt.Errorf("start system-audio capture: %w", err)
}
processor := captions.New(settings, resources, sink.Publish)
var processErr error
for packet := range packets {
if err := processor.Accept(packet); err != nil {
processErr = fmt.Errorf("publish caption: %w", err)
cancel()
break
}
}
if processErr == nil {
processErr = processor.Flush()
}
captureErr := waitCapture()
if captureErr != nil && ctx.Err() == nil {
captureErr = fmt.Errorf("audio capture stopped unexpectedly: %w", captureErr)
} else {
captureErr = nil
}
return errors.Join(processErr, captureErr)
}
+4
View File
@@ -31,6 +31,10 @@ func DurationSamples(duration time.Duration) int {
return int(duration * SampleRate / time.Second)
}
func SamplesDuration(samples int) time.Duration {
return time.Duration(samples) * time.Second / SampleRate
}
func RMSDBFS(samples []float32) float64 {
if len(samples) == 0 {
return math.Inf(-1)
+25
View File
@@ -0,0 +1,25 @@
package captions
import "time"
type EventKind uint8
const (
Provisional EventKind = iota + 1
Final
Hide
)
type Event struct {
Kind EventKind
Text string
StartedAt time.Duration
EndedAt time.Duration
}
type Transcriber interface {
Decode(samples []float32) string
AcceptVAD(samples []float32)
FlushVAD()
NextSpeechSegment() (start int, samples []float32, ok bool)
}
+58 -51
View File
@@ -1,120 +1,127 @@
// Package captions turns audio packets into fixed or speech-aware captions.
// Package captions turns audio packets into presentation-neutral caption events.
package captions
import (
"fmt"
"io"
"strings"
"tea.chunkbyte.com/kato/captioneer/src/audio"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/models"
)
type Processor struct {
settings config.Settings
resources *models.Resources
output io.Writer
settings config.Settings
transcriber Transcriber
emit func(Event) error
fixedSamples []float32
previewSamples []float32
previewActive bool
previewVisible bool
previewStart int
nextPreviewAt int
totalSamples int
}
func New(settings config.Settings, resources *models.Resources, output io.Writer) *Processor {
func New(settings config.Settings, transcriber Transcriber, emit func(Event) error) *Processor {
return &Processor{
settings: settings,
resources: resources,
output: output,
transcriber: transcriber,
emit: emit,
nextPreviewAt: audio.DurationSamples(settings.ChunkDuration),
}
}
func (p *Processor) Accept(samples []float32) {
func (p *Processor) Accept(samples []float32) error {
if len(samples) == 0 {
return
return nil
}
if p.settings.Mode == config.ModeFixed {
p.acceptFixed(samples)
return
return p.acceptFixed(samples)
}
p.acceptVAD(samples)
return p.acceptVAD(samples)
}
func (p *Processor) Flush() {
func (p *Processor) Flush() error {
if p.settings.Mode == config.ModeFixed {
if len(p.fixedSamples) > 0 {
p.emitCommitted(p.totalSamples, p.totalSamples+len(p.fixedSamples), p.fixedSamples)
return p.emitFinal(p.totalSamples, p.totalSamples+len(p.fixedSamples), p.fixedSamples)
}
return
return nil
}
p.resources.FlushVAD()
p.drainVAD()
p.clearPreview()
p.transcriber.FlushVAD()
return p.drainVAD()
}
func (p *Processor) acceptFixed(samples []float32) {
func (p *Processor) acceptFixed(samples []float32) error {
p.fixedSamples = append(p.fixedSamples, samples...)
chunkSize := audio.DurationSamples(p.settings.ChunkDuration)
for len(p.fixedSamples) >= chunkSize {
p.emitCommitted(p.totalSamples, p.totalSamples+chunkSize, p.fixedSamples[:chunkSize])
if err := p.emitFinal(p.totalSamples, p.totalSamples+chunkSize, p.fixedSamples[:chunkSize]); err != nil {
return err
}
p.fixedSamples = p.fixedSamples[chunkSize:]
p.totalSamples += chunkSize
}
return nil
}
func (p *Processor) acceptVAD(samples []float32) {
func (p *Processor) acceptVAD(samples []float32) error {
p.totalSamples += len(samples)
p.resources.AcceptVAD(samples)
p.transcriber.AcceptVAD(samples)
if p.previewActive || audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB {
p.previewActive = true
if !p.previewActive {
p.previewActive = true
p.previewStart = p.totalSamples - len(samples)
}
p.previewSamples = append(p.previewSamples, samples...)
if len(p.previewSamples) >= p.nextPreviewAt {
p.emitPreview(p.previewSamples)
if err := p.emitProvisional(); err != nil {
return err
}
p.nextPreviewAt += audio.DurationSamples(p.settings.ChunkDuration)
}
}
p.drainVAD()
return p.drainVAD()
}
func (p *Processor) drainVAD() {
func (p *Processor) drainVAD() error {
for {
segment, ok := p.resources.NextSpeechSegment()
start, samples, ok := p.transcriber.NextSpeechSegment()
if !ok {
return
return nil
}
if err := p.emitFinal(start, start+len(samples), samples); err != nil {
return err
}
p.clearPreview()
p.emitCommitted(segment.Start, segment.Start+len(segment.Samples), segment.Samples)
p.previewSamples = nil
p.previewActive = false
p.previewStart = 0
p.nextPreviewAt = audio.DurationSamples(p.settings.ChunkDuration)
}
}
func (p *Processor) emitPreview(samples []float32) {
text := p.resources.Decode(samples)
func (p *Processor) emitProvisional() error {
text := strings.TrimSpace(p.transcriber.Decode(p.previewSamples))
if text == "" {
return
return nil
}
fmt.Fprintf(p.output, "\r\033[2K… %s", text)
p.previewVisible = true
return p.emit(Event{
Kind: Provisional,
Text: text,
StartedAt: audio.SamplesDuration(p.previewStart),
EndedAt: audio.SamplesDuration(p.previewStart + len(p.previewSamples)),
})
}
func (p *Processor) emitCommitted(start, end int, samples []float32) {
text := p.resources.Decode(samples)
func (p *Processor) emitFinal(start, end int, samples []float32) error {
text := strings.TrimSpace(p.transcriber.Decode(samples))
if text == "" {
return
}
p.clearPreview()
fmt.Fprintf(p.output, "[%s-%s] %s\n", audio.FormatTime(start), audio.FormatTime(end), text)
}
func (p *Processor) clearPreview() {
if p.previewVisible {
fmt.Fprint(p.output, "\r\033[2K")
p.previewVisible = false
return p.emit(Event{Kind: Hide})
}
return p.emit(Event{
Kind: Final,
Text: text,
StartedAt: audio.SamplesDuration(start),
EndedAt: audio.SamplesDuration(end),
})
}
+136
View File
@@ -0,0 +1,136 @@
package captions
import (
"testing"
"time"
"tea.chunkbyte.com/kato/captioneer/src/audio"
"tea.chunkbyte.com/kato/captioneer/src/config"
)
type fakeSegment struct {
start int
samples []float32
}
type fakeTranscriber struct {
text string
decodeTexts []string
segments []fakeSegment
flushed bool
}
func (f *fakeTranscriber) Decode([]float32) string {
if len(f.decodeTexts) == 0 {
return f.text
}
text := f.decodeTexts[0]
f.decodeTexts = f.decodeTexts[1:]
return text
}
func (f *fakeTranscriber) AcceptVAD([]float32) {}
func (f *fakeTranscriber) FlushVAD() { f.flushed = true }
func (f *fakeTranscriber) NextSpeechSegment() (int, []float32, bool) {
if len(f.segments) == 0 {
return 0, nil, false
}
segment := f.segments[0]
f.segments = f.segments[1:]
return segment.start, segment.samples, true
}
func TestFixedModeEmitsFinalCaption(t *testing.T) {
transcriber := &fakeTranscriber{text: "hello"}
var events []Event
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(event Event) error {
events = append(events, event)
return nil
})
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Kind != Final || events[0].Text != "hello" {
t.Fatalf("unexpected events: %#v", events)
}
if events[0].StartedAt != 0 || events[0].EndedAt != time.Second {
t.Fatalf("unexpected timestamps: %#v", events[0])
}
}
func TestFixedModeFlushesFinalPartialChunkWithExactTimestamps(t *testing.T) {
transcriber := &fakeTranscriber{decodeTexts: []string{"first", "partial"}}
var events []Event
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(event Event) error {
events = append(events, event)
return nil
})
if err := processor.Accept(make([]float32, audio.SampleRate+audio.SampleRate/2)); err != nil {
t.Fatal(err)
}
if err := processor.Flush(); err != nil {
t.Fatal(err)
}
if len(events) != 2 {
t.Fatalf("event count = %d, want 2", len(events))
}
if events[1].Text != "partial" || events[1].StartedAt != time.Second || events[1].EndedAt != 1500*time.Millisecond {
t.Fatalf("unexpected partial event: %#v", events[1])
}
}
func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
transcriber := &fakeTranscriber{text: "speech"}
var events []Event
processor := New(config.Settings{
Mode: config.ModeVAD,
ChunkDuration: time.Second,
PreviewThresholdDB: -45,
}, transcriber, func(event Event) error {
events = append(events, event)
return nil
})
loud := make([]float32, audio.SampleRate)
for i := range loud {
loud[i] = 0.5
}
if err := processor.Accept(loud); err != nil {
t.Fatal(err)
}
transcriber.segments = []fakeSegment{{start: 0, samples: loud}}
if err := processor.Accept(make([]float32, audio.SamplesPerPacket())); err != nil {
t.Fatal(err)
}
if len(events) != 2 || events[0].Kind != Provisional || events[1].Kind != Final {
t.Fatalf("unexpected events: %#v", events)
}
}
func TestEmptyFinalEmitsHide(t *testing.T) {
transcriber := &fakeTranscriber{text: " \n\t "}
var event Event
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(got Event) error {
event = got
return nil
})
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
t.Fatal(err)
}
if event.Kind != Hide {
t.Fatalf("event kind = %d, want Hide", event.Kind)
}
}
func TestFlushFlushesVAD(t *testing.T) {
transcriber := &fakeTranscriber{}
processor := New(config.Settings{Mode: config.ModeVAD, ChunkDuration: time.Second}, transcriber, func(Event) error { return nil })
if err := processor.Flush(); err != nil {
t.Fatal(err)
}
if !transcriber.flushed {
t.Fatal("VAD was not flushed")
}
}
+77
View File
@@ -0,0 +1,77 @@
//go:build gtk
package main
import (
"context"
"errors"
"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"
)
func main() {
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)
}
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)
}
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)
}
}
+6 -32
View File
@@ -2,16 +2,14 @@ package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"tea.chunkbyte.com/kato/captioneer/src/captions"
"tea.chunkbyte.com/kato/captioneer/src/capture"
"tea.chunkbyte.com/kato/captioneer/src/app"
"tea.chunkbyte.com/kato/captioneer/src/config"
"tea.chunkbyte.com/kato/captioneer/src/models"
"tea.chunkbyte.com/kato/captioneer/src/output/terminal"
)
func main() {
@@ -19,37 +17,13 @@ func main() {
if err := settings.Validate(); err != nil {
log.Fatal(err)
}
if err := capture.ValidatePrograms(); err != nil {
log.Fatal(err)
}
resources, err := models.New(settings)
if err != nil {
log.Fatal(err)
}
defer resources.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
monitorSource, err := capture.DefaultMonitorSource(ctx)
if err != nil {
log.Fatalf("find default output monitor: %v", err)
}
fmt.Fprintf(os.Stderr, "Capturing from: %s\n", monitorSource)
fmt.Fprintln(os.Stderr, "Press Ctrl+C to stop.")
packets, waitCapture, err := capture.Packets(ctx, monitorSource)
if err != nil {
log.Fatalf("start system-audio capture: %v", err)
}
processor := captions.New(settings, resources, os.Stdout)
for packet := range packets {
processor.Accept(packet)
}
processor.Flush()
if err := waitCapture(); err != nil && ctx.Err() == nil {
log.Fatalf("audio capture stopped unexpectedly: %v", err)
sink := terminal.New(os.Stdout)
defer sink.Close()
if err := app.Run(ctx, settings, sink, os.Stderr); err != nil {
log.Fatal(err)
}
}
+91
View File
@@ -37,6 +37,66 @@ func Parse() Settings {
return settings
}
type OutputMode string
const (
OutputTerminal OutputMode = "terminal"
OutputOverlay OutputMode = "overlay"
OutputBoth OutputMode = "both"
)
type OverlayBackend string
const (
BackendAuto OverlayBackend = "auto"
BackendWayland OverlayBackend = "wayland"
BackendX11 OverlayBackend = "x11"
)
type OverlaySettings struct {
Backend OverlayBackend
Opacity float64
FontSize int
BottomMargin int
MaxWidth int
Monitor string
FinalTimeout time.Duration
ClickThrough bool
}
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 threshold used to begin VAD previews")
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.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, "maximum caption width in logical pixels")
flags.StringVar(&desktop.Overlay.Monitor, "overlay-monitor", "auto", "monitor selection: auto, numeric index, or connector name")
flags.DurationVar(&desktop.Overlay.FinalTimeout, "overlay-final-timeout", 4*time.Second, "how long a final caption remains; zero waits for replacement")
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")
@@ -49,3 +109,34 @@ func (s Settings) Validate() error {
}
return nil
}
func (s DesktopSettings) Validate() error {
if err := s.Settings.Validate(); err != nil {
return err
}
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 errors.New("--overlay-backend must be auto, wayland, or x11")
}
if s.Overlay.Opacity < 0 || s.Overlay.Opacity > 1 {
return errors.New("--overlay-opacity must be between 0 and 1")
}
if s.Overlay.FontSize <= 0 {
return errors.New("--overlay-font-size must be positive")
}
if s.Overlay.BottomMargin < 0 {
return errors.New("--overlay-bottom-margin cannot be negative")
}
if s.Overlay.MaxWidth <= 0 {
return errors.New("--overlay-max-width must be positive")
}
if s.Overlay.FinalTimeout < 0 {
return errors.New("--overlay-final-timeout cannot be negative")
}
if s.Overlay.Monitor == "" {
return errors.New("--overlay-monitor cannot be empty")
}
return nil
}
+41
View File
@@ -14,3 +14,44 @@ func TestSettingsValidate(t *testing.T) {
t.Fatal("missing mode unexpectedly passed")
}
}
func validDesktopSettings() DesktopSettings {
return DesktopSettings{
Settings: Settings{Mode: ModeVAD, ChunkDuration: time.Second, Threads: 2},
Output: OutputOverlay,
Overlay: OverlaySettings{
Backend: BackendAuto, Opacity: 0.9, FontSize: 28, BottomMargin: 100,
MaxWidth: 1200, Monitor: "auto", FinalTimeout: 4 * time.Second, ClickThrough: true,
},
}
}
func TestDesktopSettingsValidate(t *testing.T) {
if err := validDesktopSettings().Validate(); err != nil {
t.Fatalf("valid desktop settings failed: %v", err)
}
tests := []struct {
name string
change func(*DesktopSettings)
}{
{"missing output", func(s *DesktopSettings) { s.Output = "" }},
{"invalid backend", func(s *DesktopSettings) { s.Overlay.Backend = "other" }},
{"negative opacity", func(s *DesktopSettings) { s.Overlay.Opacity = -0.1 }},
{"large opacity", func(s *DesktopSettings) { s.Overlay.Opacity = 1.1 }},
{"zero font", func(s *DesktopSettings) { s.Overlay.FontSize = 0 }},
{"negative margin", func(s *DesktopSettings) { s.Overlay.BottomMargin = -1 }},
{"zero width", func(s *DesktopSettings) { s.Overlay.MaxWidth = 0 }},
{"empty monitor", func(s *DesktopSettings) { s.Overlay.Monitor = "" }},
{"negative timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = -time.Second }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
settings := validDesktopSettings()
test.change(&settings)
if err := settings.Validate(); err == nil {
t.Fatal("invalid settings unexpectedly passed")
}
})
}
}
+3 -8
View File
@@ -12,11 +12,6 @@ import (
"tea.chunkbyte.com/kato/captioneer/src/config"
)
type SpeechSegment struct {
Start int
Samples []float32
}
type modelPaths struct {
encoder string
decoder string
@@ -96,13 +91,13 @@ func (r *Resources) FlushVAD() {
r.vad.Flush()
}
func (r *Resources) NextSpeechSegment() (SpeechSegment, bool) {
func (r *Resources) NextSpeechSegment() (int, []float32, bool) {
if r.vad.IsEmpty() {
return SpeechSegment{}, false
return 0, nil, false
}
segment := r.vad.Front()
r.vad.Pop()
return SpeechSegment{Start: segment.Start, Samples: segment.Samples}, true
return segment.Start, segment.Samples, true
}
func validatePaths(modelsDir string, mode config.Mode) (modelPaths, error) {
+575
View File
@@ -0,0 +1,575 @@
//go:build gtk
// Package overlay renders captions in a GTK4 desktop overlay.
package overlay
/*
#cgo pkg-config: gtk4 gtk4-x11 gtk4-wayland gtk4-layer-shell-0
#cgo LDFLAGS: -lX11
#include <gtk/gtk.h>
#include <gtk4-layer-shell.h>
#include <gdk/x11/gdkx.h>
#include <gdk/wayland/gdkwayland.h>
#include <X11/Xlib.h>
#include <X11/Xatom.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
// GTK4 deprecates direct Xlib access, but it remains the supported escape hatch
// for the EWMH hints needed by this deliberately isolated X11 fallback.
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
typedef struct {
GMainLoop *loop;
GtkWindow *window;
GtkWidget *bubble;
GtkWidget *final_label;
GtkWidget *preview_label;
GdkMonitor *monitor;
gboolean final_visible;
gboolean preview_visible;
gboolean x11;
} CaptioneerOverlay;
typedef struct {
CaptioneerOverlay *overlay;
char *final_text;
char *preview_text;
} CaptioneerUpdate;
static void captioneer_append_warning(GString *warnings, const char *message) {
if (warnings->len > 0) {
g_string_append(warnings, " ");
}
g_string_append(warnings, message);
}
static GdkMonitor *captioneer_monitor_by_index(GdkDisplay *display, guint index) {
GListModel *monitors = gdk_display_get_monitors(display);
if (index >= g_list_model_get_n_items(monitors)) {
return NULL;
}
return GDK_MONITOR(g_list_model_get_item(monitors, index));
}
static GdkMonitor *captioneer_monitor_by_connector(GdkDisplay *display, const char *connector) {
GListModel *monitors = gdk_display_get_monitors(display);
for (guint i = 0; i < g_list_model_get_n_items(monitors); i++) {
GdkMonitor *monitor = GDK_MONITOR(g_list_model_get_item(monitors, i));
const char *candidate = gdk_monitor_get_connector(monitor);
if (candidate != NULL && g_strcmp0(candidate, connector) == 0) {
return monitor;
}
g_object_unref(monitor);
}
return NULL;
}
static GdkMonitor *captioneer_x11_cursor_monitor(GdkDisplay *display) {
Display *xdisplay = GDK_DISPLAY_XDISPLAY(display);
Window root = DefaultRootWindow(xdisplay);
Window root_return, child_return;
int root_x = 0, root_y = 0, win_x = 0, win_y = 0;
unsigned int mask = 0;
if (!XQueryPointer(xdisplay, root, &root_return, &child_return,
&root_x, &root_y, &win_x, &win_y, &mask)) {
return NULL;
}
GListModel *monitors = gdk_display_get_monitors(display);
for (guint i = 0; i < g_list_model_get_n_items(monitors); i++) {
GdkMonitor *monitor = GDK_MONITOR(g_list_model_get_item(monitors, i));
GdkRectangle geometry;
gdk_monitor_get_geometry(monitor, &geometry);
if (root_x >= geometry.x && root_x < geometry.x + geometry.width &&
root_y >= geometry.y && root_y < geometry.y + geometry.height) {
return monitor;
}
g_object_unref(monitor);
}
return NULL;
}
static GdkMonitor *captioneer_select_monitor(
GdkDisplay *display,
const char *selection,
gboolean x11,
char **error_message
) {
if (g_strcmp0(selection, "auto") == 0) {
GdkMonitor *monitor = x11 ? captioneer_x11_cursor_monitor(display) : NULL;
if (monitor != NULL) {
return monitor;
}
return captioneer_monitor_by_index(display, 0);
}
errno = 0;
char *end = NULL;
long index = strtol(selection, &end, 10);
if (errno == 0 && end != selection && *end == '\0' && index >= 0) {
GdkMonitor *monitor = captioneer_monitor_by_index(display, (guint)index);
if (monitor == NULL) {
*error_message = g_strdup_printf("monitor index %ld does not exist", index);
}
return monitor;
}
GdkMonitor *monitor = captioneer_monitor_by_connector(display, selection);
if (monitor == NULL) {
*error_message = g_strdup_printf("monitor connector %s does not exist", selection);
}
return monitor;
}
static void captioneer_set_input_passthrough(CaptioneerOverlay *overlay, GString *warnings) {
if (!gdk_display_supports_input_shapes(gtk_widget_get_display(GTK_WIDGET(overlay->window)))) {
captioneer_append_warning(warnings, "This display backend does not support pointer click-through.");
return;
}
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
cairo_region_t *empty = cairo_region_create();
gdk_surface_set_input_region(surface, empty);
cairo_region_destroy(empty);
}
static void captioneer_apply_x11_hints(CaptioneerOverlay *overlay) {
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
Display *display = GDK_SURFACE_XDISPLAY(surface);
Window window = GDK_SURFACE_XID(surface);
Atom type_property = XInternAtom(display, "_NET_WM_WINDOW_TYPE", False);
Atom notification = XInternAtom(display, "_NET_WM_WINDOW_TYPE_NOTIFICATION", False);
XChangeProperty(display, window, type_property, XA_ATOM, 32, PropModeReplace,
(unsigned char *)&notification, 1);
Atom state_property = XInternAtom(display, "_NET_WM_STATE", False);
Atom states[] = {
XInternAtom(display, "_NET_WM_STATE_ABOVE", False),
XInternAtom(display, "_NET_WM_STATE_SKIP_TASKBAR", False),
XInternAtom(display, "_NET_WM_STATE_SKIP_PAGER", False),
XInternAtom(display, "_NET_WM_STATE_STICKY", False),
};
XChangeProperty(display, window, state_property, XA_ATOM, 32, PropModeReplace,
(unsigned char *)states, G_N_ELEMENTS(states));
Atom desktop_property = XInternAtom(display, "_NET_WM_DESKTOP", False);
unsigned long all_desktops = 0xFFFFFFFFul;
XChangeProperty(display, window, desktop_property, XA_CARDINAL, 32, PropModeReplace,
(unsigned char *)&all_desktops, 1);
Atom user_time_property = XInternAtom(display, "_NET_WM_USER_TIME", False);
unsigned long user_time = 0;
XChangeProperty(display, window, user_time_property, XA_CARDINAL, 32, PropModeReplace,
(unsigned char *)&user_time, 1);
XWMHints *hints = XAllocWMHints();
if (hints != NULL) {
hints->flags = InputHint;
hints->input = False;
XSetWMHints(display, window, hints);
XFree(hints);
}
XFlush(display);
}
static gboolean captioneer_reposition_x11(gpointer data) {
CaptioneerOverlay *overlay = data;
if (!overlay->x11 || overlay->monitor == NULL) {
return G_SOURCE_REMOVE;
}
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
if (surface == NULL) {
return G_SOURCE_REMOVE;
}
GdkRectangle geometry;
gdk_monitor_get_geometry(overlay->monitor, &geometry);
int width = gdk_surface_get_width(surface);
int height = gdk_surface_get_height(surface);
int bottom_margin = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(overlay->window), "bottom-margin"));
int x = geometry.x + (geometry.width - width) / 2;
int y = geometry.y + geometry.height - height - bottom_margin;
XMoveWindow(GDK_SURFACE_XDISPLAY(surface), GDK_SURFACE_XID(surface), x, y);
return G_SOURCE_REMOVE;
}
static void captioneer_surface_size_changed(GObject *object, GParamSpec *spec, gpointer data) {
(void)object;
(void)spec;
g_idle_add(captioneer_reposition_x11, data);
}
static void captioneer_update_window_visibility(CaptioneerOverlay *overlay) {
gboolean visible = overlay->final_visible || overlay->preview_visible;
gtk_widget_set_visible(overlay->bubble, visible);
gtk_widget_set_visible(GTK_WIDGET(overlay->window), visible);
if (visible && overlay->x11) {
g_idle_add(captioneer_reposition_x11, overlay);
}
}
static gboolean captioneer_apply_update(gpointer data) {
CaptioneerUpdate *update = data;
CaptioneerOverlay *overlay = update->overlay;
overlay->final_visible = update->final_text[0] != '\0';
gtk_label_set_text(GTK_LABEL(overlay->final_label), update->final_text);
gtk_widget_set_visible(overlay->final_label, overlay->final_visible);
overlay->preview_visible = update->preview_text[0] != '\0';
if (overlay->preview_visible) {
char *preview = g_strdup_printf("… %s", update->preview_text);
gtk_label_set_text(GTK_LABEL(overlay->preview_label), preview);
g_free(preview);
} else {
gtk_label_set_text(GTK_LABEL(overlay->preview_label), "");
}
gtk_widget_set_visible(overlay->preview_label, overlay->preview_visible);
captioneer_update_window_visibility(overlay);
g_free(update->final_text);
g_free(update->preview_text);
g_free(update);
return G_SOURCE_REMOVE;
}
static gboolean captioneer_quit_on_main(gpointer data) {
g_main_loop_quit(((CaptioneerOverlay *)data)->loop);
return G_SOURCE_REMOVE;
}
static gboolean captioneer_window_close(GtkWindow *window, gpointer data) {
(void)window;
g_main_loop_quit(((CaptioneerOverlay *)data)->loop);
return TRUE;
}
static CaptioneerOverlay *captioneer_overlay_new(
const char *backend,
double opacity,
int font_size,
int bottom_margin,
int max_width,
const char *monitor_selection,
int click_through,
char **warning_message,
char **error_message
) {
*warning_message = NULL;
*error_message = NULL;
g_set_prgname("com.chunkbyte.captioneer.overlay");
if (g_strcmp0(backend, "auto") != 0) {
g_setenv("GDK_BACKEND", backend, TRUE);
}
if (!gtk_init_check()) {
*error_message = g_strdup("GTK could not connect to the requested display backend");
return NULL;
}
GdkDisplay *display = gdk_display_get_default();
gboolean wayland = GDK_IS_WAYLAND_DISPLAY(display);
gboolean x11 = GDK_IS_X11_DISPLAY(display);
if (!wayland && !x11) {
*error_message = g_strdup("GTK selected an unsupported display backend");
return NULL;
}
GString *warnings = g_string_new(NULL);
CaptioneerOverlay *overlay = g_new0(CaptioneerOverlay, 1);
overlay->loop = g_main_loop_new(NULL, FALSE);
overlay->x11 = x11;
char *monitor_error = NULL;
if (wayland && g_strcmp0(monitor_selection, "auto") == 0) {
overlay->monitor = NULL;
} else {
overlay->monitor = captioneer_select_monitor(display, monitor_selection, x11, &monitor_error);
if (overlay->monitor == NULL) {
*error_message = monitor_error != NULL ? monitor_error : g_strdup("no monitor is available");
g_main_loop_unref(overlay->loop);
g_free(overlay);
g_string_free(warnings, TRUE);
return NULL;
}
}
overlay->window = GTK_WINDOW(gtk_window_new());
gtk_window_set_title(overlay->window, "Captioneer Overlay");
gtk_window_set_decorated(overlay->window, FALSE);
gtk_window_set_resizable(overlay->window, FALSE);
gtk_widget_set_focusable(GTK_WIDGET(overlay->window), FALSE);
gtk_widget_add_css_class(GTK_WIDGET(overlay->window), "captioneer-overlay");
g_object_set_data(G_OBJECT(overlay->window), "bottom-margin", GINT_TO_POINTER(bottom_margin));
g_signal_connect(overlay->window, "close-request", G_CALLBACK(captioneer_window_close), overlay);
if (wayland && gtk_layer_is_supported()) {
gtk_layer_init_for_window(overlay->window);
gtk_layer_set_namespace(overlay->window, "captioneer");
gtk_layer_set_layer(overlay->window, GTK_LAYER_SHELL_LAYER_OVERLAY);
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, TRUE);
gtk_layer_set_margin(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, bottom_margin);
gtk_layer_set_exclusive_zone(overlay->window, 0);
gtk_layer_set_keyboard_mode(overlay->window, GTK_LAYER_SHELL_KEYBOARD_MODE_NONE);
if (overlay->monitor != NULL) {
gtk_layer_set_monitor(overlay->window, overlay->monitor);
}
} else if (wayland) {
captioneer_append_warning(warnings,
"The Wayland compositor does not support Layer Shell; using a normal window without guaranteed always-on-top, placement, taskbar, or fullscreen behavior.");
}
GdkMonitor *size_monitor = overlay->monitor;
if (size_monitor == NULL) {
size_monitor = captioneer_monitor_by_index(display, 0);
}
int effective_width = max_width;
if (size_monitor != NULL) {
GdkRectangle geometry;
gdk_monitor_get_geometry(size_monitor, &geometry);
int relative_width = (int)(geometry.width * 0.8);
if (relative_width < effective_width) {
effective_width = relative_width;
}
if (size_monitor != overlay->monitor) {
g_object_unref(size_monitor);
}
}
int max_chars = (int)(effective_width / (font_size * 0.58));
if (max_chars < 20) max_chars = 20;
if (max_chars > 120) max_chars = 120;
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
overlay->bubble = box;
gtk_widget_add_css_class(box, "captioneer-bubble");
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
overlay->final_label = gtk_label_new("");
overlay->preview_label = gtk_label_new("");
GtkWidget *labels[] = {overlay->final_label, overlay->preview_label};
for (guint i = 0; i < G_N_ELEMENTS(labels); i++) {
gtk_label_set_justify(GTK_LABEL(labels[i]), GTK_JUSTIFY_CENTER);
gtk_label_set_xalign(GTK_LABEL(labels[i]), 0.5f);
gtk_label_set_wrap(GTK_LABEL(labels[i]), TRUE);
gtk_label_set_wrap_mode(GTK_LABEL(labels[i]), PANGO_WRAP_WORD_CHAR);
gtk_label_set_lines(GTK_LABEL(labels[i]), 2);
gtk_label_set_ellipsize(GTK_LABEL(labels[i]), PANGO_ELLIPSIZE_END);
gtk_label_set_max_width_chars(GTK_LABEL(labels[i]), max_chars);
gtk_widget_set_halign(labels[i], GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(box), labels[i]);
gtk_widget_set_visible(labels[i], FALSE);
}
gtk_widget_add_css_class(overlay->final_label, "captioneer-final");
gtk_widget_add_css_class(overlay->preview_label, "captioneer-preview");
gtk_window_set_child(overlay->window, box);
char *css = g_strdup_printf(
"window.captioneer-overlay { background-color: transparent; box-shadow: none; }"
".captioneer-bubble { background-color: rgba(0, 0, 0, %.3f); border-radius: 12px; padding: 14px 22px; }"
".captioneer-final { color: rgba(255, 255, 255, 1.0); font-size: %dpx; }"
".captioneer-preview { color: rgba(255, 255, 255, 0.78); font-size: %dpx; }",
opacity, font_size, font_size);
GtkCssProvider *provider = gtk_css_provider_new();
gtk_css_provider_load_from_string(provider, css);
gtk_style_context_add_provider_for_display(display, GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(provider);
g_free(css);
gtk_widget_realize(GTK_WIDGET(overlay->window));
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
if (click_through) {
captioneer_set_input_passthrough(overlay, warnings);
}
if (x11) {
captioneer_apply_x11_hints(overlay);
g_signal_connect(surface, "notify::width", G_CALLBACK(captioneer_surface_size_changed), overlay);
g_signal_connect(surface, "notify::height", G_CALLBACK(captioneer_surface_size_changed), overlay);
}
gtk_widget_set_visible(GTK_WIDGET(overlay->window), FALSE);
if (warnings->len > 0) {
*warning_message = g_string_free(warnings, FALSE);
} else {
g_string_free(warnings, TRUE);
}
return overlay;
}
static void captioneer_overlay_post(
CaptioneerOverlay *overlay,
const char *final_text,
const char *preview_text
) {
CaptioneerUpdate *update = g_new0(CaptioneerUpdate, 1);
update->overlay = overlay;
update->final_text = g_strdup(final_text == NULL ? "" : final_text);
update->preview_text = g_strdup(preview_text == NULL ? "" : preview_text);
g_idle_add(captioneer_apply_update, update);
}
static void captioneer_overlay_run(CaptioneerOverlay *overlay) {
g_main_loop_run(overlay->loop);
}
static void captioneer_overlay_quit(CaptioneerOverlay *overlay) {
g_idle_add(captioneer_quit_on_main, overlay);
}
static void captioneer_overlay_free(CaptioneerOverlay *overlay) {
while (g_main_context_pending(NULL)) {
g_main_context_iteration(NULL, FALSE);
}
gtk_window_destroy(overlay->window);
if (overlay->monitor != NULL) {
g_object_unref(overlay->monitor);
}
g_main_loop_unref(overlay->loop);
g_free(overlay);
}
static void captioneer_string_free(char *value) {
g_free(value);
}
*/
import "C"
import (
"errors"
"sync"
"time"
"unsafe"
"tea.chunkbyte.com/kato/captioneer/src/captions"
"tea.chunkbyte.com/kato/captioneer/src/config"
)
type Sink struct {
native *C.CaptioneerOverlay
finalTimeout time.Duration
finalTimer *time.Timer
state viewState
mu sync.RWMutex
closed bool
}
func New(settings config.OverlaySettings) (*Sink, string, error) {
backend := C.CString(string(settings.Backend))
monitor := C.CString(settings.Monitor)
defer C.free(unsafe.Pointer(backend))
defer C.free(unsafe.Pointer(monitor))
var warningMessage *C.char
var errorMessage *C.char
native := C.captioneer_overlay_new(
backend,
C.double(settings.Opacity),
C.int(settings.FontSize),
C.int(settings.BottomMargin),
C.int(settings.MaxWidth),
monitor,
boolInt(settings.ClickThrough),
&warningMessage,
&errorMessage,
)
warning := takeString(warningMessage)
if native == nil {
message := takeString(errorMessage)
if message == "" {
message = "unknown GTK initialization failure"
}
return nil, warning, errors.New(message)
}
return &Sink{native: native, finalTimeout: settings.FinalTimeout}, warning, nil
}
func (s *Sink) Publish(event captions.Event) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return errors.New("overlay is closed")
}
s.state.Apply(event, time.Now(), s.finalTimeout)
if event.Kind == captions.Final || event.Kind == captions.Hide {
s.stopFinalTimer()
}
if event.Kind == captions.Final && s.finalTimeout > 0 {
s.finalTimer = time.AfterFunc(s.finalTimeout, s.expireFinal)
}
s.postState()
return nil
}
func (s *Sink) expireFinal() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed || !s.state.ExpireFinal(time.Now()) {
return
}
s.finalTimer = nil
s.postState()
}
func (s *Sink) postState() {
finalText := C.CString(s.state.Final)
previewText := C.CString(s.state.Provisional)
defer C.free(unsafe.Pointer(finalText))
defer C.free(unsafe.Pointer(previewText))
C.captioneer_overlay_post(s.native, finalText, previewText)
}
func (s *Sink) stopFinalTimer() {
if s.finalTimer != nil {
s.finalTimer.Stop()
s.finalTimer = nil
}
}
func (s *Sink) Run() error {
s.mu.RLock()
if s.closed {
s.mu.RUnlock()
return errors.New("overlay is closed")
}
native := s.native
s.mu.RUnlock()
C.captioneer_overlay_run(native)
return nil
}
func (s *Sink) Quit() {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.closed {
C.captioneer_overlay_quit(s.native)
}
}
func (s *Sink) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.stopFinalTimer()
C.captioneer_overlay_free(s.native)
s.native = nil
s.closed = true
return nil
}
func boolInt(value bool) C.int {
if value {
return 1
}
return 0
}
func takeString(value *C.char) string {
if value == nil {
return ""
}
result := C.GoString(value)
C.captioneer_string_free(value)
return result
}
+39
View File
@@ -0,0 +1,39 @@
package overlay
import (
"strings"
"time"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
type viewState struct {
Final string
Provisional string
FinalExpiresAt time.Time
}
func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration) {
switch event.Kind {
case captions.Provisional:
s.Provisional = strings.TrimSpace(event.Text)
case captions.Final:
s.Final = strings.TrimSpace(event.Text)
s.Provisional = ""
s.FinalExpiresAt = time.Time{}
if finalTimeout > 0 {
s.FinalExpiresAt = now.Add(finalTimeout)
}
case captions.Hide:
*s = viewState{}
}
}
func (s *viewState) ExpireFinal(now time.Time) bool {
if s.FinalExpiresAt.IsZero() || now.Before(s.FinalExpiresAt) {
return false
}
s.Final = ""
s.FinalExpiresAt = time.Time{}
return true
}
+50
View File
@@ -0,0 +1,50 @@
package overlay
import (
"testing"
"time"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
func TestViewStateKeepsFinalAboveNewProvisional(t *testing.T) {
now := time.Unix(100, 0)
var state viewState
state.Apply(captions.Event{Kind: captions.Provisional, Text: " first draft "}, now, 4*time.Second)
state.Apply(captions.Event{Kind: captions.Final, Text: " final "}, now, 4*time.Second)
state.Apply(captions.Event{Kind: captions.Provisional, Text: " next draft "}, now, 4*time.Second)
if state.Final != "final" || state.Provisional != "next draft" {
t.Fatalf("unexpected rows: %#v", state)
}
state.Apply(captions.Event{Kind: captions.Hide}, now, 4*time.Second)
if state.Final != "" || state.Provisional != "" {
t.Fatalf("hide did not clear rows: %#v", state)
}
}
func TestViewStateFinalTimeout(t *testing.T) {
now := time.Unix(100, 0)
var state viewState
state.Apply(captions.Event{Kind: captions.Final, Text: "final"}, now, 4*time.Second)
state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 4*time.Second)
if state.ExpireFinal(now.Add(3999 * time.Millisecond)) {
t.Fatal("final caption expired early")
}
if !state.ExpireFinal(now.Add(4 * time.Second)) {
t.Fatal("final caption did not expire")
}
if state.Final != "" || state.Provisional != "draft" {
t.Fatalf("expiry changed the wrong row: %#v", state)
}
}
func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
now := time.Unix(100, 0)
var state viewState
state.Apply(captions.Event{Kind: captions.Final, Text: "final"}, now, 0)
if state.ExpireFinal(now.Add(24 * time.Hour)) {
t.Fatal("zero-timeout caption expired")
}
}
+33
View File
@@ -0,0 +1,33 @@
// Package output defines caption presentation sinks.
package output
import (
"errors"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
type Sink interface {
Publish(captions.Event) error
Close() error
}
type MultiSink []Sink
func (sinks MultiSink) Publish(event captions.Event) error {
var publishErr error
for _, sink := range sinks {
if err := sink.Publish(event); err != nil {
publishErr = errors.Join(publishErr, err)
}
}
return publishErr
}
func (sinks MultiSink) Close() error {
var closeErr error
for i := len(sinks) - 1; i >= 0; i-- {
closeErr = errors.Join(closeErr, sinks[i].Close())
}
return closeErr
}
+49
View File
@@ -0,0 +1,49 @@
package output
import (
"errors"
"testing"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
type recordingSink struct {
events []captions.Event
closed bool
err error
}
func (s *recordingSink) Publish(event captions.Event) error {
s.events = append(s.events, event)
return s.err
}
func (s *recordingSink) Close() error { s.closed = true; return s.err }
func TestMultiSinkPublishesAndClosesAll(t *testing.T) {
first := &recordingSink{}
second := &recordingSink{}
sinks := MultiSink{first, second}
event := captions.Event{Kind: captions.Final, Text: "hello"}
if err := sinks.Publish(event); err != nil {
t.Fatal(err)
}
if len(first.events) != 1 || len(second.events) != 1 {
t.Fatalf("event was not dispatched: %d, %d", len(first.events), len(second.events))
}
if err := sinks.Close(); err != nil {
t.Fatal(err)
}
if !first.closed || !second.closed {
t.Fatal("not all sinks were closed")
}
}
func TestMultiSinkStillPublishesToOtherSinksAfterError(t *testing.T) {
want := errors.New("publish failed")
first := &recordingSink{err: want}
second := &recordingSink{}
err := (MultiSink{first, second}).Publish(captions.Event{Kind: captions.Hide})
if !errors.Is(err, want) || len(second.events) != 1 {
t.Fatalf("unexpected result: err=%v second=%d", err, len(second.events))
}
}
+54
View File
@@ -0,0 +1,54 @@
// Package terminal renders captions using terminal control sequences.
package terminal
import (
"fmt"
"io"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
type Sink struct {
writer io.Writer
previewVisible bool
}
func New(writer io.Writer) *Sink {
return &Sink{writer: writer}
}
func (s *Sink) Publish(event captions.Event) error {
switch event.Kind {
case captions.Provisional:
_, err := fmt.Fprintf(s.writer, "\r\033[2K… %s", event.Text)
if err == nil {
s.previewVisible = true
}
return err
case captions.Final:
if err := s.clearPreview(); err != nil {
return err
}
_, err := fmt.Fprintf(s.writer, "[%.2fs-%.2fs] %s\n", event.StartedAt.Seconds(), event.EndedAt.Seconds(), event.Text)
return err
case captions.Hide:
return s.clearPreview()
default:
return fmt.Errorf("unsupported caption event kind %d", event.Kind)
}
}
func (s *Sink) Close() error {
return s.clearPreview()
}
func (s *Sink) clearPreview() error {
if !s.previewVisible {
return nil
}
_, err := fmt.Fprint(s.writer, "\r\033[2K")
if err == nil {
s.previewVisible = false
}
return err
}
+37
View File
@@ -0,0 +1,37 @@
package terminal
import (
"bytes"
"testing"
"time"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
func TestTerminalProvisionalAndFinalOutput(t *testing.T) {
var output bytes.Buffer
sink := New(&output)
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: "done", StartedAt: time.Second, EndedAt: 2500 * time.Millisecond,
}); err != nil {
t.Fatal(err)
}
want := "\r\033[2K… draft\r\033[2K[1.00s-2.50s] done\n"
if output.String() != want {
t.Fatalf("output = %q, want %q", output.String(), want)
}
}
func TestTerminalHideClearsOnlyVisiblePreview(t *testing.T) {
var output bytes.Buffer
sink := New(&output)
_ = sink.Publish(captions.Event{Kind: captions.Hide})
_ = sink.Publish(captions.Event{Kind: captions.Provisional, Text: "draft"})
_ = sink.Publish(captions.Event{Kind: captions.Hide})
if got := output.String(); got != "\r\033[2K… draft\r\033[2K" {
t.Fatalf("unexpected output %q", got)
}
}