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
+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