Compare commits
2
Commits
192666dd43
...
13ddebbf98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ddebbf98 | ||
|
|
2f5ca41ac3 |
@@ -64,7 +64,9 @@ Use `--output=both` to keep terminal output as well:
|
||||
./scripts/distrobox/run.sh --mode=vad --output=both
|
||||
```
|
||||
|
||||
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 and wraps at its configured width instead of extending off-screen. Pointer input passes through the window by default. Empty completed captions hide the overlay.
|
||||
In VAD mode, a 500 ms pause finalizes the current speech segment. Captioneer splits that transcript again at sentence punctuation and at word-safe boundaries of at most 64 characters, so continuous speech does not become one enormous paragraph. The live provisional view keeps the latest three digestible chunks and grows vertically without ellipsizing them. Completed chunks remain visible for at least three seconds, receive extra time based on their word count, and gradually fade from white to a restrained gray as they age. When several chunks finalize together, their removal is staggered in reading order instead of deleting the whole block at once. Up to six completed chunks remain above the provisional text; if a seventh arrives, the oldest is removed. Blank recognition results never erase completed text before its reading lifetime expires.
|
||||
|
||||
Text is left-aligned inside the centered, fixed-width dark bubble and wraps at its configured width instead of extending off-screen. Pointer input passes through the window by default.
|
||||
|
||||
To build outside the helper script, install GTK4 and GTK4 Layer Shell development packages, then run:
|
||||
|
||||
@@ -105,6 +107,7 @@ Layer Shell and X11 hints are requests, not universal guarantees. Same-layer win
|
||||
| `--overlay-backend=auto|wayland|x11` | `auto` | Let GTK detect the backend, or require one explicitly. |
|
||||
| `--overlay-opacity=0.90` | `0.90` | Dark bubble opacity from `0` to `1`. |
|
||||
| `--overlay-font-size=28` | `28` | Font size in logical pixels. |
|
||||
| `--overlay-font-family=Sans` | `Sans` | GTK/Pango font family for final and provisional captions. |
|
||||
| `--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`. |
|
||||
@@ -114,9 +117,9 @@ Layer Shell and X11 hints are requests, not universal guarantees. Same-layer win
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Larger text, longer final-caption visibility
|
||||
# Larger text, a custom font, and longer final-caption visibility
|
||||
./scripts/distrobox/run.sh --mode=vad --output=overlay \
|
||||
--overlay-font-size=34 --overlay-final-timeout=6s
|
||||
--overlay-font-size=34 --overlay-font-family="Noto Sans" --overlay-final-timeout=6s
|
||||
|
||||
# Explicit XWayland/X11 route on the cursor's monitor
|
||||
./scripts/distrobox/run.sh --mode=vad --output=both --overlay-backend=x11
|
||||
|
||||
@@ -4,11 +4,13 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultModelsDir = "models"
|
||||
DefaultOverlayFontFamily = "Sans"
|
||||
MinimumOverlayFinalLifetime = 3 * time.Second
|
||||
)
|
||||
|
||||
@@ -60,6 +62,7 @@ type OverlaySettings struct {
|
||||
Backend OverlayBackend
|
||||
Opacity float64
|
||||
FontSize int
|
||||
FontFamily string
|
||||
BottomMargin int
|
||||
MaxWidth int
|
||||
Monitor string
|
||||
@@ -88,6 +91,7 @@ func ParseDesktop() DesktopSettings {
|
||||
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.StringVar(&desktop.Overlay.FontFamily, "overlay-font-family", DefaultOverlayFontFamily, "caption font family")
|
||||
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")
|
||||
@@ -129,6 +133,9 @@ func (s DesktopSettings) Validate() error {
|
||||
if s.Overlay.FontSize <= 0 {
|
||||
return errors.New("--overlay-font-size must be positive")
|
||||
}
|
||||
if strings.TrimSpace(s.Overlay.FontFamily) == "" {
|
||||
return errors.New("--overlay-font-family cannot be empty")
|
||||
}
|
||||
if s.Overlay.BottomMargin < 0 {
|
||||
return errors.New("--overlay-bottom-margin cannot be negative")
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func validDesktopSettings() DesktopSettings {
|
||||
Settings: Settings{Mode: ModeVAD, ChunkDuration: time.Second, Threads: 2},
|
||||
Output: OutputOverlay,
|
||||
Overlay: OverlaySettings{
|
||||
Backend: BackendAuto, Opacity: 0.9, FontSize: 28, BottomMargin: 100,
|
||||
Backend: BackendAuto, Opacity: 0.9, FontSize: 28, FontFamily: DefaultOverlayFontFamily, BottomMargin: 100,
|
||||
MaxWidth: 1200, Monitor: "auto", FinalTimeout: 4 * time.Second, ClickThrough: true,
|
||||
},
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func TestDesktopSettingsValidate(t *testing.T) {
|
||||
{"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 }},
|
||||
{"empty font family", func(s *DesktopSettings) { s.Overlay.FontFamily = " " }},
|
||||
{"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 = "" }},
|
||||
|
||||
@@ -246,10 +246,27 @@ static gboolean captioneer_window_close(GtkWindow *window, gpointer data) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static char *captioneer_font_family_rule(const char *font_family) {
|
||||
GString *rule = g_string_new("font-family: \"");
|
||||
for (const char *character = font_family; *character != '\0'; character++) {
|
||||
if (*character == '\\' || *character == '\"') {
|
||||
g_string_append_c(rule, '\\');
|
||||
}
|
||||
if (*character == '\n' || *character == '\r' || *character == '\t') {
|
||||
g_string_append_c(rule, ' ');
|
||||
} else {
|
||||
g_string_append_c(rule, *character);
|
||||
}
|
||||
}
|
||||
g_string_append(rule, "\";");
|
||||
return g_string_free(rule, FALSE);
|
||||
}
|
||||
|
||||
static CaptioneerOverlay *captioneer_overlay_new(
|
||||
const char *backend,
|
||||
double opacity,
|
||||
int font_size,
|
||||
const char *font_family,
|
||||
int bottom_margin,
|
||||
int max_width,
|
||||
const char *monitor_selection,
|
||||
@@ -366,23 +383,27 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
||||
// 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);
|
||||
// Provisional text is already split and bounded by the Go view state. Let
|
||||
// GTK allocate all of those lines instead of replacing overflow with "…".
|
||||
gtk_label_set_lines(GTK_LABEL(overlay->preview_label), -1);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(overlay->preview_label), PANGO_ELLIPSIZE_NONE);
|
||||
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 *font_family_rule = captioneer_font_family_rule(font_family);
|
||||
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);
|
||||
".captioneer-final { color: rgba(255, 255, 255, 1.0); font-size: %dpx; %s }"
|
||||
".captioneer-preview { color: rgba(255, 255, 255, 0.78); font-size: %dpx; %s }",
|
||||
opacity, font_size, font_family_rule, font_size, font_family_rule);
|
||||
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);
|
||||
g_free(font_family_rule);
|
||||
|
||||
gtk_widget_realize(GTK_WIDGET(overlay->window));
|
||||
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
|
||||
@@ -452,21 +473,29 @@ import (
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
)
|
||||
|
||||
const finalFadeRefreshInterval = 200 * time.Millisecond
|
||||
const (
|
||||
finalFadeRefreshInterval = 200 * time.Millisecond
|
||||
averageGlyphWidthFactor = 0.58
|
||||
minimumLineCharacters = 20
|
||||
maximumLineCharacters = 64
|
||||
)
|
||||
|
||||
type Sink struct {
|
||||
native *C.CaptioneerOverlay
|
||||
finalTimeout time.Duration
|
||||
finalTimer *time.Timer
|
||||
state viewState
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
native *C.CaptioneerOverlay
|
||||
finalTimeout time.Duration
|
||||
finalTimer *time.Timer
|
||||
lineCharacters int
|
||||
state viewState
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(settings config.OverlaySettings) (*Sink, string, error) {
|
||||
backend := C.CString(string(settings.Backend))
|
||||
fontFamily := C.CString(settings.FontFamily)
|
||||
monitor := C.CString(settings.Monitor)
|
||||
defer C.free(unsafe.Pointer(backend))
|
||||
defer C.free(unsafe.Pointer(fontFamily))
|
||||
defer C.free(unsafe.Pointer(monitor))
|
||||
|
||||
var warningMessage *C.char
|
||||
@@ -475,6 +504,7 @@ func New(settings config.OverlaySettings) (*Sink, string, error) {
|
||||
backend,
|
||||
C.double(settings.Opacity),
|
||||
C.int(settings.FontSize),
|
||||
fontFamily,
|
||||
C.int(settings.BottomMargin),
|
||||
C.int(settings.MaxWidth),
|
||||
monitor,
|
||||
@@ -490,7 +520,11 @@ func New(settings config.OverlaySettings) (*Sink, string, error) {
|
||||
}
|
||||
return nil, warning, errors.New(message)
|
||||
}
|
||||
return &Sink{native: native, finalTimeout: settings.FinalTimeout}, warning, nil
|
||||
return &Sink{
|
||||
native: native,
|
||||
finalTimeout: settings.FinalTimeout,
|
||||
lineCharacters: overlayLineCharacters(settings),
|
||||
}, warning, nil
|
||||
}
|
||||
|
||||
func (s *Sink) Publish(event captions.Event) error {
|
||||
@@ -500,17 +534,26 @@ func (s *Sink) Publish(event captions.Event) error {
|
||||
return errors.New("overlay is closed")
|
||||
}
|
||||
now := time.Now()
|
||||
s.state.Apply(event, now, s.finalTimeout)
|
||||
if event.Kind == captions.Final || event.Kind == captions.Hide {
|
||||
s.stopFinalTimer()
|
||||
}
|
||||
s.state.Apply(event, now, s.finalTimeout, s.lineCharacters)
|
||||
if event.Kind == captions.Final {
|
||||
s.stopFinalTimer()
|
||||
s.scheduleFinalRefresh(now)
|
||||
}
|
||||
s.postState()
|
||||
return nil
|
||||
}
|
||||
|
||||
func overlayLineCharacters(settings config.OverlaySettings) int {
|
||||
characters := int(float64(settings.MaxWidth) / (float64(settings.FontSize) * averageGlyphWidthFactor))
|
||||
if characters < minimumLineCharacters {
|
||||
return minimumLineCharacters
|
||||
}
|
||||
if characters > maximumLineCharacters {
|
||||
return maximumLineCharacters
|
||||
}
|
||||
return characters
|
||||
}
|
||||
|
||||
func (s *Sink) refreshFinals() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
+113
-11
@@ -3,16 +3,20 @@ package overlay
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
const (
|
||||
maxVisibleFinalLines = 6
|
||||
maxVisiblePreviewLines = 3
|
||||
oldestLineBrightness = 0.65
|
||||
defaultLineFadeDuration = 3 * time.Second
|
||||
readingWordsPerSecond = 3.0
|
||||
)
|
||||
|
||||
type finalLine struct {
|
||||
@@ -26,26 +30,39 @@ type viewState struct {
|
||||
Provisional string
|
||||
}
|
||||
|
||||
func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration) {
|
||||
func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration, maxLineCharacters int) {
|
||||
switch event.Kind {
|
||||
case captions.Provisional:
|
||||
s.Provisional = strings.TrimSpace(event.Text)
|
||||
chunks := splitCaptionText(event.Text, maxLineCharacters)
|
||||
if len(chunks) > maxVisiblePreviewLines {
|
||||
chunks = chunks[len(chunks)-maxVisiblePreviewLines:]
|
||||
}
|
||||
s.Provisional = strings.Join(chunks, "\n")
|
||||
case captions.Final:
|
||||
text := strings.TrimSpace(event.Text)
|
||||
if text == "" {
|
||||
chunks := splitCaptionText(event.Text, maxLineCharacters)
|
||||
if len(chunks) == 0 {
|
||||
return
|
||||
}
|
||||
line := finalLine{CreatedAt: now, Text: text}
|
||||
if finalTimeout > 0 {
|
||||
line.ExpiresAt = now.Add(finalTimeout)
|
||||
if len(chunks) > maxVisibleFinalLines {
|
||||
chunks = chunks[len(chunks)-maxVisibleFinalLines:]
|
||||
}
|
||||
expiresAt := now
|
||||
for _, text := range chunks {
|
||||
line := finalLine{CreatedAt: now, Text: text}
|
||||
if finalTimeout > 0 {
|
||||
expiresAt = expiresAt.Add(readingLifetime(text, finalTimeout))
|
||||
line.ExpiresAt = expiresAt
|
||||
}
|
||||
s.Finals = append(s.Finals, line)
|
||||
}
|
||||
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{}
|
||||
// A blank recognition result must not erase completed captions before
|
||||
// their individual reading lifetimes have elapsed.
|
||||
s.Provisional = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +101,11 @@ func (s viewState) FinalMarkup(now time.Time, lineLifetime time.Duration) string
|
||||
if i > 0 {
|
||||
markup.WriteByte('\n')
|
||||
}
|
||||
brightness := lineBrightness(now.Sub(line.CreatedAt), fadeDuration)
|
||||
lineFadeDuration := fadeDuration
|
||||
if !line.ExpiresAt.IsZero() {
|
||||
lineFadeDuration = line.ExpiresAt.Sub(line.CreatedAt)
|
||||
}
|
||||
brightness := lineBrightness(now.Sub(line.CreatedAt), lineFadeDuration)
|
||||
channel := int(brightness*255 + 0.5)
|
||||
fmt.Fprintf(&markup, `<span foreground="#%02X%02X%02X">`, channel, channel, channel)
|
||||
markup.WriteString(html.EscapeString(line.Text))
|
||||
@@ -99,13 +120,94 @@ func (s viewState) NeedsFadeRefresh(now time.Time, lineLifetime time.Duration) b
|
||||
fadeDuration = defaultLineFadeDuration
|
||||
}
|
||||
for _, line := range s.Finals {
|
||||
if now.Sub(line.CreatedAt) < fadeDuration {
|
||||
lineFadeDuration := fadeDuration
|
||||
if !line.ExpiresAt.IsZero() {
|
||||
lineFadeDuration = line.ExpiresAt.Sub(line.CreatedAt)
|
||||
}
|
||||
if now.Sub(line.CreatedAt) < lineFadeDuration {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitCaptionText(text string, maxCharacters int) []string {
|
||||
if maxCharacters < 1 {
|
||||
maxCharacters = 1
|
||||
}
|
||||
words := strings.Fields(text)
|
||||
if len(words) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var chunks []string
|
||||
var current strings.Builder
|
||||
currentCharacters := 0
|
||||
flush := func() {
|
||||
if currentCharacters == 0 {
|
||||
return
|
||||
}
|
||||
chunks = append(chunks, current.String())
|
||||
current.Reset()
|
||||
currentCharacters = 0
|
||||
}
|
||||
|
||||
for _, word := range words {
|
||||
wordCharacters := utf8.RuneCountInString(word)
|
||||
if wordCharacters > maxCharacters {
|
||||
flush()
|
||||
runes := []rune(word)
|
||||
for len(runes) > maxCharacters {
|
||||
chunks = append(chunks, string(runes[:maxCharacters]))
|
||||
runes = runes[maxCharacters:]
|
||||
}
|
||||
if len(runes) > 0 {
|
||||
current.WriteString(string(runes))
|
||||
currentCharacters = len(runes)
|
||||
}
|
||||
} else {
|
||||
separatorCharacters := 0
|
||||
if currentCharacters > 0 {
|
||||
separatorCharacters = 1
|
||||
}
|
||||
if currentCharacters+separatorCharacters+wordCharacters > maxCharacters {
|
||||
flush()
|
||||
separatorCharacters = 0
|
||||
}
|
||||
if separatorCharacters > 0 {
|
||||
current.WriteByte(' ')
|
||||
currentCharacters++
|
||||
}
|
||||
current.WriteString(word)
|
||||
currentCharacters += wordCharacters
|
||||
}
|
||||
|
||||
if endsSentence(word) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return chunks
|
||||
}
|
||||
|
||||
func endsSentence(word string) bool {
|
||||
trimmed := strings.TrimRight(word, `"'”’)]}`)
|
||||
return strings.HasSuffix(trimmed, ".") || strings.HasSuffix(trimmed, "!") || strings.HasSuffix(trimmed, "?")
|
||||
}
|
||||
|
||||
func readingLifetime(text string, minimum time.Duration) time.Duration {
|
||||
if minimum <= 0 {
|
||||
return 0
|
||||
}
|
||||
words := len(strings.Fields(text))
|
||||
readingSeconds := math.Ceil(float64(words) / readingWordsPerSecond)
|
||||
readingTime := time.Duration(readingSeconds * float64(time.Second))
|
||||
if readingTime < minimum {
|
||||
return minimum
|
||||
}
|
||||
return readingTime
|
||||
}
|
||||
|
||||
func (s viewState) NextFinalExpiry() time.Time {
|
||||
if len(s.Finals) == 0 {
|
||||
return time.Time{}
|
||||
|
||||
@@ -7,29 +7,31 @@ import (
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
const testLineCharacters = 80
|
||||
|
||||
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: " 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)
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: " first draft "}, now, 4*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: " first final "}, now, 4*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: " second final "}, now, 4*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: " next draft "}, now, 4*time.Second, testLineCharacters)
|
||||
|
||||
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.FinalText() != "" || state.Provisional != "" {
|
||||
t.Fatalf("hide did not clear rows: %#v", state)
|
||||
state.Apply(captions.Event{Kind: captions.Hide}, now, 4*time.Second, testLineCharacters)
|
||||
if state.FinalText() != "first final\nsecond final" || state.Provisional != "" {
|
||||
t.Fatalf("blank result erased completed captions: %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewStateExpiresEachCompletedLineAfterItsOwnLifetime(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
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)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "first"}, now, 3*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "second"}, now.Add(time.Second), 3*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 3*time.Second, testLineCharacters)
|
||||
|
||||
if state.ExpireFinal(now.Add(2999 * time.Millisecond)) {
|
||||
t.Fatal("completed line expired early")
|
||||
@@ -46,7 +48,7 @@ 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)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: string(rune('a' + i))}, now, 3*time.Second, testLineCharacters)
|
||||
}
|
||||
if len(state.Finals) != maxVisibleFinalLines || state.FinalText() != "b\nc\nd\ne\nf\ng" {
|
||||
t.Fatalf("unexpected completed-line limit: %#v", state)
|
||||
@@ -56,7 +58,7 @@ func TestViewStateLimitsVisibleCompletedLines(t *testing.T) {
|
||||
func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "final"}, now, 0)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "final"}, now, 0, testLineCharacters)
|
||||
if state.ExpireFinal(now.Add(24 * time.Hour)) {
|
||||
t.Fatal("zero-timeout caption expired")
|
||||
}
|
||||
@@ -65,7 +67,7 @@ func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
|
||||
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)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "<hello & goodbye>"}, now, 3*time.Second, testLineCharacters)
|
||||
|
||||
if got := state.FinalMarkup(now, 3*time.Second); got != `<span foreground="#FFFFFF"><hello & goodbye></span>` {
|
||||
t.Fatalf("new line markup = %q", got)
|
||||
@@ -74,3 +76,54 @@ func TestViewStateFinalMarkupFadesAndEscapesText(t *testing.T) {
|
||||
t.Fatalf("old line markup = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCaptionTextUsesSentencesAndWordBoundaries(t *testing.T) {
|
||||
got := splitCaptionText("Hello there. This sentence should wrap cleanly at words.", 18)
|
||||
want := []string{"Hello there.", "This sentence", "should wrap", "cleanly at words."}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("chunks = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("chunks = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionalCaptionKeepsOnlyLatestDigestibleChunks(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: "One. Two. Three. Four."}, now, 3*time.Second, testLineCharacters)
|
||||
if state.Provisional != "Two.\nThree.\nFour." {
|
||||
t.Fatalf("provisional text = %q", state.Provisional)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadingLifetimeGrowsWithWordCount(t *testing.T) {
|
||||
text := "one two three four five six seven eight nine ten eleven twelve"
|
||||
if got := readingLifetime(text, 3*time.Second); got != 4*time.Second {
|
||||
t.Fatalf("reading lifetime = %s, want 4s", got)
|
||||
}
|
||||
if got := readingLifetime("short caption", 3*time.Second); got != 3*time.Second {
|
||||
t.Fatalf("short reading lifetime = %s, want 3s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizedChunksExpireInReadingOrder(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "First sentence. Second sentence."}, now, 3*time.Second, testLineCharacters)
|
||||
|
||||
if len(state.Finals) != 2 {
|
||||
t.Fatalf("final chunks = %#v", state.Finals)
|
||||
}
|
||||
if state.Finals[0].ExpiresAt != now.Add(3*time.Second) {
|
||||
t.Fatalf("first expiry = %s", state.Finals[0].ExpiresAt)
|
||||
}
|
||||
if state.Finals[1].ExpiresAt != now.Add(6*time.Second) {
|
||||
t.Fatalf("second expiry = %s", state.Finals[1].ExpiresAt)
|
||||
}
|
||||
if !state.ExpireFinal(now.Add(3*time.Second)) || state.FinalText() != "Second sentence." {
|
||||
t.Fatalf("unexpected state after first reading window: %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user