feat: add six-line caption cap and enforce 3s minimum final timeout

- Reduce default overlay-final-timeout from 4s to 3s and validate
  that non-zero timeout is at least 3s to prevent captions from
  disappearing too quickly.
- Limit completed captions to six lines and keep each visible for
  at least 3s, improving readability during fast speech.
- Update README to document new VAD behavior and changed defaults.
This commit is contained in:
2026-07-16 18:00:11 +03:00
parent fea9161b3c
commit 55181d6397
7 changed files with 116 additions and 38 deletions
+3 -3
View File
@@ -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.
+8 -2
View File
@@ -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")
}
+1
View File
@@ -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) {
+3 -2
View File
@@ -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,
+30 -8
View File
@@ -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 {
+46 -11
View File
@@ -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
}
+25 -12
View File
@@ -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