diff --git a/README.md b/README.md index ed02d84..78e0acb 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Use `--output=both` to keep terminal output as well: ./scripts/distrobox/run.sh --mode=vad --output=both ``` -The overlay shows the latest final caption above the active provisional caption. Final captions remain for four seconds by default. The dark background is 90% opaque, text is centered and limited to two lines per row, and pointer input passes through the window by default. Empty completed captions hide the overlay. +In VAD mode, a 500 ms pause finalizes the current caption and starts the next line. The overlay keeps up to six completed lines above the active provisional caption; each completed line remains visible for at least three seconds. If a seventh line arrives sooner, the hard six-line cap removes the oldest. Text is left-aligned inside the centered, fixed-width dark bubble. Pointer input passes through the window by default. Empty completed captions hide the overlay. To build outside the helper script, install GTK4 and GTK4 Layer Shell development packages, then run: @@ -108,7 +108,7 @@ Layer Shell and X11 hints are requests, not universal guarantees. Same-layer win | `--overlay-bottom-margin=100` | `100` | Bottom margin in logical pixels. | | `--overlay-max-width=1200` | `1200` | Fixed caption-bubble width; it is capped at 80% of the selected monitor. | | `--overlay-monitor=auto` | `auto` | `auto`, a zero-based monitor index, or a connector name such as `DP-1`. | -| `--overlay-final-timeout=4s` | `4s` | Time before the final row disappears. `0s` keeps it until replaced or hidden. | +| `--overlay-final-timeout=3s` | `3s` | Completed-caption lifetime; it must be `0s` or at least `3s`. `0s` keeps lines until the six-line cap removes them. | | `--overlay-click-through=true` | `true` | Use an empty input region where the display backend supports it. | Examples: @@ -202,4 +202,4 @@ Run this from the repository root. The build and run helpers resolve that path a Captioneer is licensed under the [Apache License 2.0](LICENSE). Sherpa-Onnx is Apache-2.0 licensed, GTK4 is LGPL-2.1-or-later, and GTK4 Layer Shell is MIT licensed. Captioneer links to the system GTK libraries rather than copying their source into the project. -Version 1 intentionally has no animation, caption history, runtime dragging, background-free mode, capture exclusion, Flatpak, desktop entry, or autostart integration. +Version 1 intentionally has no animation, persisted caption history, runtime dragging, background-free mode, capture exclusion, Flatpak, desktop entry, or autostart integration. diff --git a/src/config/config.go b/src/config/config.go index c41840b..e35de52 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -7,7 +7,10 @@ import ( "time" ) -const DefaultModelsDir = "models" +const ( + DefaultModelsDir = "models" + MinimumOverlayFinalLifetime = 3 * time.Second +) type Mode string @@ -88,7 +91,7 @@ func ParseDesktop() DesktopSettings { flags.IntVar(&desktop.Overlay.BottomMargin, "overlay-bottom-margin", 100, "distance from the monitor bottom in logical pixels") flags.IntVar(&desktop.Overlay.MaxWidth, "overlay-max-width", 1200, "fixed caption width in logical pixels before the monitor safety cap") flags.StringVar(&desktop.Overlay.Monitor, "overlay-monitor", "auto", "monitor selection: auto, numeric index, or connector name") - flags.DurationVar(&desktop.Overlay.FinalTimeout, "overlay-final-timeout", 4*time.Second, "how long a final caption remains; zero waits for replacement") + flags.DurationVar(&desktop.Overlay.FinalTimeout, "overlay-final-timeout", MinimumOverlayFinalLifetime, "completed-caption lifetime; zero keeps lines until the six-line cap removes them") flags.BoolVar(&desktop.Overlay.ClickThrough, "overlay-click-through", true, "pass pointer input through the caption window") flag.Parse() desktop.Mode = Mode(mode) @@ -135,6 +138,9 @@ func (s DesktopSettings) Validate() error { if s.Overlay.FinalTimeout < 0 { return errors.New("--overlay-final-timeout cannot be negative") } + if s.Overlay.FinalTimeout > 0 && s.Overlay.FinalTimeout < MinimumOverlayFinalLifetime { + return errors.New("--overlay-final-timeout must be zero or at least 3s") + } if s.Overlay.Monitor == "" { return errors.New("--overlay-monitor cannot be empty") } diff --git a/src/config/config_test.go b/src/config/config_test.go index dad619e..bbc8e31 100644 --- a/src/config/config_test.go +++ b/src/config/config_test.go @@ -44,6 +44,7 @@ func TestDesktopSettingsValidate(t *testing.T) { {"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 }}, + {"short timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = time.Second }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/src/models/sherpa.go b/src/models/sherpa.go index d81a29b..5058e82 100644 --- a/src/models/sherpa.go +++ b/src/models/sherpa.go @@ -130,8 +130,9 @@ func validatePaths(modelsDir string, mode config.Mode) (modelPaths, error) { func newVAD(model string, threads int) *sherpa.VoiceActivityDetector { return sherpa.NewVoiceActivityDetector(&sherpa.VadModelConfig{ SileroVad: sherpa.SileroVadModelConfig{ - Model: model, - Threshold: 0.5, + Model: model, + Threshold: 0.5, + // A 500 ms pause ends the current caption line and starts the next one. MinSilenceDuration: 0.5, MinSpeechDuration: 0.25, WindowSize: 512, diff --git a/src/output/overlay/overlay_gtk.go b/src/output/overlay/overlay_gtk.go index fd0d05a..b5b42f6 100644 --- a/src/output/overlay/overlay_gtk.go +++ b/src/output/overlay/overlay_gtk.go @@ -354,13 +354,17 @@ static CaptioneerOverlay *captioneer_overlay_new( gtk_label_set_xalign(GTK_LABEL(labels[i]), 0.0f); 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_FILL); gtk_box_append(GTK_BOX(box), labels[i]); gtk_widget_set_visible(labels[i], FALSE); } + // Completed-caption history grows vertically for every visible entry. The + // view state, rather than GTK ellipsizing, enforces its six-entry limit. + gtk_label_set_lines(GTK_LABEL(overlay->final_label), -1); + gtk_label_set_ellipsize(GTK_LABEL(overlay->final_label), PANGO_ELLIPSIZE_NONE); + gtk_label_set_lines(GTK_LABEL(overlay->preview_label), 2); + gtk_label_set_ellipsize(GTK_LABEL(overlay->preview_label), PANGO_ELLIPSIZE_END); 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); @@ -490,12 +494,13 @@ func (s *Sink) Publish(event captions.Event) error { if s.closed { return errors.New("overlay is closed") } - s.state.Apply(event, time.Now(), s.finalTimeout) + now := time.Now() + s.state.Apply(event, 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) + if event.Kind == captions.Final { + s.scheduleFinalExpiry(now) } s.postState() return nil @@ -504,15 +509,20 @@ func (s *Sink) Publish(event captions.Event) error { func (s *Sink) expireFinal() { s.mu.Lock() defer s.mu.Unlock() - if s.closed || !s.state.ExpireFinal(time.Now()) { + if s.closed { return } + now := time.Now() + changed := s.state.ExpireFinal(now) s.finalTimer = nil - s.postState() + s.scheduleFinalExpiry(now) + if changed { + s.postState() + } } func (s *Sink) postState() { - finalText := C.CString(s.state.Final) + finalText := C.CString(s.state.FinalText()) previewText := C.CString(s.state.Provisional) defer C.free(unsafe.Pointer(finalText)) defer C.free(unsafe.Pointer(previewText)) @@ -526,6 +536,18 @@ func (s *Sink) stopFinalTimer() { } } +func (s *Sink) scheduleFinalExpiry(now time.Time) { + expiresAt := s.state.NextFinalExpiry() + if expiresAt.IsZero() { + return + } + delay := expiresAt.Sub(now) + if delay < 0 { + delay = 0 + } + s.finalTimer = time.AfterFunc(delay, s.expireFinal) +} + func (s *Sink) Run() error { s.mu.RLock() if s.closed { diff --git a/src/output/overlay/state.go b/src/output/overlay/state.go index 326658e..6740f1e 100644 --- a/src/output/overlay/state.go +++ b/src/output/overlay/state.go @@ -7,10 +7,16 @@ import ( "tea.chunkbyte.com/kato/captioneer/src/captions" ) +const maxVisibleFinalLines = 6 + +type finalLine struct { + Text string + ExpiresAt time.Time +} + type viewState struct { - Final string - Provisional string - FinalExpiresAt time.Time + Finals []finalLine + Provisional string } func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration) { @@ -18,22 +24,51 @@ func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time 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) + text := strings.TrimSpace(event.Text) + if text == "" { + return } + line := finalLine{Text: text} + if finalTimeout > 0 { + line.ExpiresAt = now.Add(finalTimeout) + } + s.Finals = append(s.Finals, line) + if len(s.Finals) > maxVisibleFinalLines { + s.Finals = s.Finals[len(s.Finals)-maxVisibleFinalLines:] + } + s.Provisional = "" case captions.Hide: *s = viewState{} } } func (s *viewState) ExpireFinal(now time.Time) bool { - if s.FinalExpiresAt.IsZero() || now.Before(s.FinalExpiresAt) { + firstVisible := 0 + for firstVisible < len(s.Finals) { + expiresAt := s.Finals[firstVisible].ExpiresAt + if expiresAt.IsZero() || now.Before(expiresAt) { + break + } + firstVisible++ + } + if firstVisible == 0 { return false } - s.Final = "" - s.FinalExpiresAt = time.Time{} + s.Finals = s.Finals[firstVisible:] return true } + +func (s viewState) FinalText() string { + lines := make([]string, len(s.Finals)) + for i, line := range s.Finals { + lines[i] = line.Text + } + return strings.Join(lines, "\n") +} + +func (s viewState) NextFinalExpiry() time.Time { + if len(s.Finals) == 0 { + return time.Time{} + } + return s.Finals[0].ExpiresAt +} diff --git a/src/output/overlay/state_test.go b/src/output/overlay/state_test.go index 0039843..3178e51 100644 --- a/src/output/overlay/state_test.go +++ b/src/output/overlay/state_test.go @@ -7,39 +7,52 @@ import ( "tea.chunkbyte.com/kato/captioneer/src/captions" ) -func TestViewStateKeepsFinalAboveNewProvisional(t *testing.T) { +func TestViewStateKeepsCompletedLinesAboveNewProvisional(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.Final, Text: " first final "}, now, 4*time.Second) + state.Apply(captions.Event{Kind: captions.Final, Text: " second 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" { + if state.FinalText() != "first final\nsecond 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 != "" { + if state.FinalText() != "" || state.Provisional != "" { t.Fatalf("hide did not clear rows: %#v", state) } } -func TestViewStateFinalTimeout(t *testing.T) { +func TestViewStateExpiresEachCompletedLineAfterItsOwnLifetime(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) + state.Apply(captions.Event{Kind: captions.Final, Text: "first"}, now, 3*time.Second) + state.Apply(captions.Event{Kind: captions.Final, Text: "second"}, now.Add(time.Second), 3*time.Second) + state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 3*time.Second) - if state.ExpireFinal(now.Add(3999 * time.Millisecond)) { - t.Fatal("final caption expired early") + if state.ExpireFinal(now.Add(2999 * time.Millisecond)) { + t.Fatal("completed line expired early") } - if !state.ExpireFinal(now.Add(4 * time.Second)) { - t.Fatal("final caption did not expire") + if !state.ExpireFinal(now.Add(3 * time.Second)) { + t.Fatal("first completed line did not expire") } - if state.Final != "" || state.Provisional != "draft" { + if state.FinalText() != "second" || state.Provisional != "draft" { t.Fatalf("expiry changed the wrong row: %#v", state) } } +func TestViewStateLimitsVisibleCompletedLines(t *testing.T) { + now := time.Unix(100, 0) + var state viewState + for i := 0; i <= maxVisibleFinalLines; i++ { + state.Apply(captions.Event{Kind: captions.Final, Text: string(rune('a' + i))}, now, 3*time.Second) + } + if len(state.Finals) != maxVisibleFinalLines || state.FinalText() != "b\nc\nd\ne\nf\ng" { + t.Fatalf("unexpected completed-line limit: %#v", state) + } +} + func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) { now := time.Unix(100, 0) var state viewState