Compare commits
7
Commits
fea9161b3c
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76bc3d8185 | ||
|
|
449626bffa | ||
|
|
13ddebbf98 | ||
|
|
2f5ca41ac3 | ||
|
|
192666dd43 | ||
|
|
bd2fc175a8 | ||
|
|
55181d6397 |
@@ -0,0 +1,115 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
code: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests before release
|
||||
runs-on: ubuntu-latest
|
||||
container: registry.fedoraproject.org/fedora:44
|
||||
steps:
|
||||
- name: Validate release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
[[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
printf 'Tag must match vMAJOR.MINOR.BUG; got %s\n' "${GITHUB_REF_NAME}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: >-
|
||||
dnf install -y
|
||||
bzip2 curl gcc gcc-c++ git gtk4-devel gtk4-layer-shell-devel
|
||||
jq libX11-devel libXfixes-devel nodejs24 nodejs24-bin pkgconf-pkg-config
|
||||
pulseaudio-utils ShellCheck tar gzip binutils
|
||||
|
||||
- name: Check out repository
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Install the Go version from go.mod
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
go_version="$(awk '$1 == "go" { print $2 }' go.mod)"
|
||||
curl --fail --location --silent --show-error \
|
||||
"https://go.dev/dl/go${go_version}.linux-amd64.tar.gz" \
|
||||
--output /tmp/go.tar.gz
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
go version
|
||||
|
||||
- name: Run tests and static checks
|
||||
run: ./scripts/ci/test.sh
|
||||
|
||||
publish:
|
||||
name: Build and publish release
|
||||
needs: test
|
||||
permissions:
|
||||
code: read
|
||||
releases: write
|
||||
runs-on: ubuntu-latest
|
||||
container: registry.fedoraproject.org/fedora:44
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: >-
|
||||
dnf install -y
|
||||
bzip2 curl gcc gcc-c++ git gtk4-devel gtk4-layer-shell-devel
|
||||
jq libX11-devel libXfixes-devel nodejs24 nodejs24-bin pkgconf-pkg-config
|
||||
pulseaudio-utils ShellCheck tar gzip binutils
|
||||
|
||||
- name: Check out repository and tags
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the Go version from go.mod
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
go_version="$(awk '$1 == "go" { print $2 }' go.mod)"
|
||||
curl --fail --location --silent --show-error \
|
||||
"https://go.dev/dl/go${go_version}.linux-amd64.tar.gz" \
|
||||
--output /tmp/go.tar.gz
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
go version
|
||||
|
||||
- name: Build portable release archive
|
||||
run: ./scripts/ci/package-release.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Generate release notes
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" --match 'v[0-9]*' 2>/dev/null || true)"
|
||||
{
|
||||
printf 'Automated Captioneer release for `%s`.\n\n' "${GITHUB_REF_NAME}"
|
||||
printf 'The Linux x86_64 archive contains both Captioneer commands and their Sherpa/ONNX native libraries. Models are downloaded separately with `scripts/download-models.sh`.\n'
|
||||
if [[ -n "$previous_tag" ]]; then
|
||||
printf '\nChanges since `%s`:\n\n' "$previous_tag"
|
||||
git log --no-merges --pretty='- %s (`%h`)' "${previous_tag}..${GITHUB_REF_NAME}"
|
||||
fi
|
||||
} > dist/release-notes.md
|
||||
|
||||
- name: Publish Gitea release
|
||||
env:
|
||||
GITEA_API_URL: ${{ gitea.api_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||
RELEASE_NOTES: dist/release-notes.md
|
||||
run: >-
|
||||
./scripts/ci/publish-release.sh
|
||||
"dist/captioneer-${GITHUB_REF_NAME}-linux-amd64.tar.gz"
|
||||
"dist/captioneer-${GITHUB_REF_NAME}-linux-amd64.tar.gz.sha256"
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
permissions:
|
||||
code: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Go and GTK tests
|
||||
runs-on: ubuntu-latest
|
||||
container: registry.fedoraproject.org/fedora:44
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: >-
|
||||
dnf install -y
|
||||
bzip2 curl gcc gcc-c++ git gtk4-devel gtk4-layer-shell-devel
|
||||
jq libX11-devel libXfixes-devel nodejs24 nodejs24-bin pkgconf-pkg-config
|
||||
pulseaudio-utils ShellCheck tar gzip binutils
|
||||
|
||||
- name: Check out repository
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Install the Go version from go.mod
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
go_version="$(awk '$1 == "go" { print $2 }' go.mod)"
|
||||
curl --fail --location --silent --show-error \
|
||||
"https://go.dev/dl/go${go_version}.linux-amd64.tar.gz" \
|
||||
--output /tmp/go.tar.gz
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
go version
|
||||
|
||||
- name: Run tests and static checks
|
||||
run: ./scripts/ci/test.sh
|
||||
@@ -5,3 +5,4 @@ models/silero_vad_v5.onnx
|
||||
# Locally built executable.
|
||||
/captioneer
|
||||
/build/
|
||||
/dist/
|
||||
|
||||
@@ -64,7 +64,9 @@ 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 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,18 +107,19 @@ 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`. |
|
||||
| `--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:
|
||||
|
||||
```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
|
||||
@@ -198,8 +201,23 @@ distrobox enter --no-workdir captioneer-dev -- bash -lc '
|
||||
|
||||
Run this from the repository root. The build and run helpers resolve that path automatically.
|
||||
|
||||
## Gitea Actions and releases
|
||||
|
||||
The `Tests` workflow runs the normal and GTK-tagged Go tests, `go vet`, and ShellCheck for every pull request and branch push. To make it mandatory for pull requests, open the repository's **Settings → Branches**, edit the protection rule for `master`, enable required status checks, and select **Tests / Go and GTK tests** after its first run. Workflow files can report the check, but the branch-protection rule is what prevents merging when it fails.
|
||||
|
||||
Pushing a tag that exactly follows `vMAJOR.MINOR.BUG` runs the same test suite first, then builds and publishes a Gitea release:
|
||||
|
||||
```bash
|
||||
git tag v1.3.519
|
||||
git push origin v1.3.519
|
||||
```
|
||||
|
||||
The release contains a checksummed Linux x86_64 archive with `captioneer`, `captioneer-desktop`, the required Sherpa/ONNX shared libraries, the model download script, the README, and the license. The desktop command still requires GTK4 and GTK4 Layer Shell runtime libraries on the destination system.
|
||||
|
||||
The release workflow uses Gitea's built-in `GITEA_TOKEN`; no personal token is required. In **Settings → Actions → General**, allow the workflow token `Read` access to code and `Write` access to releases. Gitea clamps workflow permissions to those repository settings, so a more restrictive maximum produces an actionable HTTP 403 during publishing.
|
||||
|
||||
## License
|
||||
|
||||
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.
|
||||
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
cd -- "$repo_root"
|
||||
|
||||
tag=${1:-}
|
||||
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
printf 'captioneer: release tag must match vMAJOR.MINOR.BUG, got %q\n' "$tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
platform=linux-amd64
|
||||
archive_name="captioneer-${tag}-${platform}.tar.gz"
|
||||
package_name="captioneer-${tag}-${platform}"
|
||||
package_dir="dist/$package_name"
|
||||
|
||||
rm -rf -- "$package_dir"
|
||||
rm -f -- "dist/$archive_name" "dist/$archive_name.sha256"
|
||||
mkdir -p -- "$package_dir/lib" "$package_dir/scripts"
|
||||
|
||||
# The Sherpa Go module embeds an absolute build-machine RPATH. Add a portable
|
||||
# fallback so the bundled native libraries resolve beside the extracted tools.
|
||||
# shellcheck disable=SC2016 # $ORIGIN must remain literal for the ELF loader.
|
||||
export CGO_LDFLAGS='-Wl,-rpath,$ORIGIN/lib'
|
||||
go build -trimpath -o "$package_dir/captioneer" ./src/cmd/captioneer
|
||||
go build -trimpath -tags gtk -o "$package_dir/captioneer-desktop" ./src/cmd/captioneer-desktop
|
||||
|
||||
sherpa_dir="$(go list -m -f '{{.Dir}}' github.com/k2-fsa/sherpa-onnx-go-linux)"
|
||||
sherpa_lib_dir="$sherpa_dir/lib/x86_64-unknown-linux-gnu"
|
||||
cp -- "$sherpa_lib_dir/libsherpa-onnx-c-api.so" "$package_dir/lib/"
|
||||
cp -- "$sherpa_lib_dir/libonnxruntime.so" "$package_dir/lib/"
|
||||
cp -- README.md LICENSE "$package_dir/"
|
||||
cp -- scripts/download-models.sh "$package_dir/scripts/"
|
||||
|
||||
for executable in captioneer captioneer-desktop; do
|
||||
# shellcheck disable=SC2016 # Verify the literal ELF-loader token.
|
||||
if ! readelf -d "$package_dir/$executable" | grep -Fq '$ORIGIN/lib'; then
|
||||
printf 'captioneer: %s is missing the portable native-library RPATH\n' "$executable" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
tar -C dist -czf "dist/$archive_name" "$package_name"
|
||||
(
|
||||
cd -- dist
|
||||
sha256sum "$archive_name" > "$archive_name.sha256"
|
||||
)
|
||||
|
||||
printf '%s\n' "$repo_root/dist/$archive_name"
|
||||
printf '%s\n' "$repo_root/dist/$archive_name.sha256"
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
: "${GITEA_API_URL:?GITEA_API_URL is required}"
|
||||
: "${GITEA_REPOSITORY:?GITEA_REPOSITORY is required}"
|
||||
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
|
||||
: "${RELEASE_TAG:?RELEASE_TAG is required}"
|
||||
: "${RELEASE_NOTES:?RELEASE_NOTES is required}"
|
||||
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
printf 'captioneer: release tag must match vMAJOR.MINOR.BUG, got %q\n' "$RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if (( $# == 0 )); then
|
||||
printf 'captioneer: at least one release asset is required\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for asset in "$@"; do
|
||||
[[ -f "$asset" ]] || { printf 'captioneer: release asset not found: %s\n' "$asset" >&2; exit 1; }
|
||||
done
|
||||
|
||||
api_base="${GITEA_API_URL%/}/repos/$GITEA_REPOSITORY"
|
||||
authorization="Authorization: token $GITEA_TOKEN"
|
||||
release_response="$(mktemp)"
|
||||
trap 'rm -f -- "$release_response"' EXIT
|
||||
|
||||
status="$(curl --silent --show-error --output "$release_response" --write-out '%{http_code}' \
|
||||
--header "$authorization" \
|
||||
"$api_base/releases/tags/$RELEASE_TAG")"
|
||||
|
||||
release_payload="$(jq -n \
|
||||
--arg tag "$RELEASE_TAG" \
|
||||
--arg name "Captioneer $RELEASE_TAG" \
|
||||
--rawfile body "$RELEASE_NOTES" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')"
|
||||
|
||||
case "$status" in
|
||||
200)
|
||||
release_id="$(jq -er '.id' "$release_response")"
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--request PATCH \
|
||||
--header "$authorization" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "$release_payload" \
|
||||
"$api_base/releases/$release_id" > "$release_response"
|
||||
;;
|
||||
404)
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--request POST \
|
||||
--header "$authorization" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "$release_payload" \
|
||||
"$api_base/releases" > "$release_response"
|
||||
release_id="$(jq -er '.id' "$release_response")"
|
||||
;;
|
||||
*)
|
||||
printf 'captioneer: Gitea release lookup failed with HTTP %s\n' "$status" >&2
|
||||
cat "$release_response" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
for asset in "$@"; do
|
||||
asset_name="$(basename -- "$asset")"
|
||||
while IFS= read -r attachment_id; do
|
||||
[[ -n "$attachment_id" ]] || continue
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--request DELETE \
|
||||
--header "$authorization" \
|
||||
"$api_base/releases/$release_id/assets/$attachment_id" >/dev/null
|
||||
done < <(jq -r --arg name "$asset_name" '.assets[]? | select(.name == $name) | .id' "$release_response")
|
||||
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--request POST \
|
||||
--header "$authorization" \
|
||||
--form "attachment=@$asset" \
|
||||
"$api_base/releases/$release_id/assets?name=$asset_name" >/dev/null
|
||||
printf 'Uploaded %s\n' "$asset_name"
|
||||
done
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
cd -- "$repo_root"
|
||||
|
||||
go test ./...
|
||||
go test -tags gtk ./...
|
||||
go vet ./...
|
||||
go vet -tags gtk ./...
|
||||
|
||||
shellcheck \
|
||||
scripts/download-models.sh \
|
||||
scripts/distrobox/common.sh \
|
||||
scripts/distrobox/build.sh \
|
||||
scripts/distrobox/run.sh \
|
||||
scripts/ci/test.sh \
|
||||
scripts/ci/package-release.sh \
|
||||
scripts/ci/publish-release.sh
|
||||
+15
-2
@@ -4,10 +4,15 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultModelsDir = "models"
|
||||
const (
|
||||
DefaultModelsDir = "models"
|
||||
DefaultOverlayFontFamily = "Sans"
|
||||
MinimumOverlayFinalLifetime = 3 * time.Second
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
@@ -57,6 +62,7 @@ type OverlaySettings struct {
|
||||
Backend OverlayBackend
|
||||
Opacity float64
|
||||
FontSize int
|
||||
FontFamily string
|
||||
BottomMargin int
|
||||
MaxWidth int
|
||||
Monitor string
|
||||
@@ -85,10 +91,11 @@ 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")
|
||||
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)
|
||||
@@ -126,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")
|
||||
}
|
||||
@@ -135,6 +145,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")
|
||||
}
|
||||
|
||||
@@ -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,10 +40,12 @@ 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 = "" }},
|
||||
{"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) {
|
||||
|
||||
@@ -132,6 +132,7 @@ func newVAD(model string, threads int) *sherpa.VoiceActivityDetector {
|
||||
SileroVad: sherpa.SileroVadModelConfig{
|
||||
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,
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
@@ -354,28 +371,39 @@ 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);
|
||||
// width-chars gives GTK a concrete allocation width, so wrapping happens
|
||||
// before a long sentence can widen the bubble beyond the screen.
|
||||
gtk_label_set_width_chars(GTK_LABEL(labels[i]), max_chars);
|
||||
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);
|
||||
// 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));
|
||||
@@ -445,10 +473,18 @@ import (
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
)
|
||||
|
||||
const (
|
||||
finalFadeRefreshInterval = 200 * time.Millisecond
|
||||
averageGlyphWidthFactor = 0.58
|
||||
minimumLineCharacters = 20
|
||||
maximumLineCharacters = 64
|
||||
)
|
||||
|
||||
type Sink struct {
|
||||
native *C.CaptioneerOverlay
|
||||
finalTimeout time.Duration
|
||||
finalTimer *time.Timer
|
||||
lineCharacters int
|
||||
state viewState
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
@@ -456,8 +492,10 @@ type Sink struct {
|
||||
|
||||
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
|
||||
@@ -466,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,
|
||||
@@ -481,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 {
|
||||
@@ -490,29 +533,42 @@ 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)
|
||||
if event.Kind == captions.Final || event.Kind == captions.Hide {
|
||||
now := time.Now()
|
||||
s.state.Apply(event, now, s.finalTimeout, s.lineCharacters)
|
||||
if event.Kind == captions.Final {
|
||||
s.stopFinalTimer()
|
||||
}
|
||||
if event.Kind == captions.Final && s.finalTimeout > 0 {
|
||||
s.finalTimer = time.AfterFunc(s.finalTimeout, s.expireFinal)
|
||||
s.scheduleFinalRefresh(now)
|
||||
}
|
||||
s.postState()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Sink) expireFinal() {
|
||||
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()
|
||||
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 +582,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 {
|
||||
|
||||
+204
-16
@@ -1,39 +1,227 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
type viewState struct {
|
||||
Final string
|
||||
Provisional string
|
||||
FinalExpiresAt time.Time
|
||||
const (
|
||||
maxVisibleFinalLines = 6
|
||||
maxVisiblePreviewLines = 3
|
||||
oldestLineBrightness = 0.65
|
||||
defaultLineFadeDuration = 3 * time.Second
|
||||
readingWordsPerSecond = 3.0
|
||||
)
|
||||
|
||||
type finalLine struct {
|
||||
CreatedAt time.Time
|
||||
Text string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration) {
|
||||
type viewState struct {
|
||||
Finals []finalLine
|
||||
Provisional string
|
||||
}
|
||||
|
||||
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)
|
||||
case captions.Final:
|
||||
s.Final = strings.TrimSpace(event.Text)
|
||||
s.Provisional = ""
|
||||
s.FinalExpiresAt = time.Time{}
|
||||
if finalTimeout > 0 {
|
||||
s.FinalExpiresAt = now.Add(finalTimeout)
|
||||
chunks := splitCaptionText(event.Text, maxLineCharacters)
|
||||
if len(chunks) > maxVisiblePreviewLines {
|
||||
chunks = chunks[len(chunks)-maxVisiblePreviewLines:]
|
||||
}
|
||||
s.Provisional = strings.Join(chunks, "\n")
|
||||
case captions.Final:
|
||||
chunks := splitCaptionText(event.Text, maxLineCharacters)
|
||||
if len(chunks) == 0 {
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
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))
|
||||
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 {
|
||||
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{}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -7,44 +7,123 @@ import (
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
func TestViewStateKeepsFinalAboveNewProvisional(t *testing.T) {
|
||||
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: " 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.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 != "" {
|
||||
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 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, 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(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, testLineCharacters)
|
||||
}
|
||||
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
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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, testLineCharacters)
|
||||
|
||||
if got := state.FinalMarkup(now, 3*time.Second); got != `<span foreground="#FFFFFF"><hello & goodbye></span>` {
|
||||
t.Fatalf("new line markup = %q", got)
|
||||
}
|
||||
if got := state.FinalMarkup(now.Add(3*time.Second), 3*time.Second); got != `<span foreground="#A6A6A6"><hello & goodbye></span>` {
|
||||
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