2 Commits
Author SHA1 Message Date
kato bd2fc175a8 feat(overlay): add gradual fade effect for aging caption lines
Updated the overlay sink and state to apply a gradual fade from white to gray for completed caption lines over their display lifetime. The `FinalMarkup` method replaces plain `FinalText`, generating Pango markup with decreasing brightness. A periodic refresh timer (200 ms) recalculates colors and expiry, improving visual clarity of line recency. README updated to document the new fade behavior.
2026-07-16 18:03:23 +03:00
kato 55181d6397 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.
2026-07-16 18:00:11 +03:00
7 changed files with 189 additions and 39 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 and gradually fades from white to a restrained gray as it ages. 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,
+39 -9
View File
@@ -215,7 +215,7 @@ static gboolean captioneer_apply_update(gpointer 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_label_set_markup(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';
@@ -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);
@@ -445,6 +449,8 @@ import (
"tea.chunkbyte.com/kato/captioneer/src/config"
)
const finalFadeRefreshInterval = 200 * time.Millisecond
type Sink struct {
native *C.CaptioneerOverlay
finalTimeout time.Duration
@@ -490,29 +496,33 @@ 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.scheduleFinalRefresh(now)
}
s.postState()
return nil
}
func (s *Sink) expireFinal() {
func (s *Sink) refreshFinals() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed || !s.state.ExpireFinal(time.Now()) {
if s.closed {
return
}
now := time.Now()
s.state.ExpireFinal(now)
s.finalTimer = nil
s.scheduleFinalRefresh(now)
s.postState()
}
func (s *Sink) postState() {
finalText := C.CString(s.state.Final)
finalText := C.CString(s.state.FinalMarkup(time.Now(), s.finalTimeout))
previewText := C.CString(s.state.Provisional)
defer C.free(unsafe.Pointer(finalText))
defer C.free(unsafe.Pointer(previewText))
@@ -526,6 +536,26 @@ func (s *Sink) stopFinalTimer() {
}
}
func (s *Sink) scheduleFinalRefresh(now time.Time) {
if len(s.state.Finals) == 0 {
return
}
if s.state.NextFinalExpiry().IsZero() && !s.state.NeedsFadeRefresh(now, s.finalTimeout) {
return
}
nextRefresh := now.Add(finalFadeRefreshInterval)
expiresAt := s.state.NextFinalExpiry()
if !expiresAt.IsZero() && expiresAt.Before(nextRefresh) {
nextRefresh = expiresAt
}
delay := nextRefresh.Sub(now)
if delay < 0 {
delay = 0
}
s.finalTimer = time.AfterFunc(delay, s.refreshFinals)
}
func (s *Sink) Run() error {
s.mu.RLock()
if s.closed {
+97 -11
View File
@@ -1,16 +1,29 @@
package overlay
import (
"fmt"
"html"
"strings"
"time"
"tea.chunkbyte.com/kato/captioneer/src/captions"
)
const (
maxVisibleFinalLines = 6
oldestLineBrightness = 0.65
defaultLineFadeDuration = 3 * time.Second
)
type finalLine struct {
CreatedAt time.Time
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 +31,95 @@ 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{CreatedAt: now, 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) FinalMarkup(now time.Time, lineLifetime time.Duration) string {
fadeDuration := lineLifetime
if fadeDuration <= 0 {
fadeDuration = defaultLineFadeDuration
}
var markup strings.Builder
for i, line := range s.Finals {
if i > 0 {
markup.WriteByte('\n')
}
brightness := lineBrightness(now.Sub(line.CreatedAt), fadeDuration)
channel := int(brightness*255 + 0.5)
fmt.Fprintf(&markup, `<span foreground="#%02X%02X%02X">`, channel, channel, channel)
markup.WriteString(html.EscapeString(line.Text))
markup.WriteString("</span>")
}
return markup.String()
}
func (s viewState) NeedsFadeRefresh(now time.Time, lineLifetime time.Duration) bool {
fadeDuration := lineLifetime
if fadeDuration <= 0 {
fadeDuration = defaultLineFadeDuration
}
for _, line := range s.Finals {
if now.Sub(line.CreatedAt) < fadeDuration {
return true
}
}
return false
}
func (s viewState) NextFinalExpiry() time.Time {
if len(s.Finals) == 0 {
return time.Time{}
}
return s.Finals[0].ExpiresAt
}
func lineBrightness(age, fadeDuration time.Duration) float64 {
if age <= 0 || fadeDuration <= 0 {
return 1
}
progress := float64(age) / float64(fadeDuration)
if progress > 1 {
progress = 1
}
return 1 - progress*(1-oldestLineBrightness)
}
+38 -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
@@ -48,3 +61,16 @@ func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
t.Fatal("zero-timeout caption expired")
}
}
func TestViewStateFinalMarkupFadesAndEscapesText(t *testing.T) {
now := time.Unix(100, 0)
var state viewState
state.Apply(captions.Event{Kind: captions.Final, Text: "<hello & goodbye>"}, now, 3*time.Second)
if got := state.FinalMarkup(now, 3*time.Second); got != `<span foreground="#FFFFFF">&lt;hello &amp; goodbye&gt;</span>` {
t.Fatalf("new line markup = %q", got)
}
if got := state.FinalMarkup(now.Add(3*time.Second), 3*time.Second); got != `<span foreground="#A6A6A6">&lt;hello &amp; goodbye&gt;</span>` {
t.Fatalf("old line markup = %q", got)
}
}