chore: add Apache License 2.0 and ignore build directory
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 *)¬ification, 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user