6 Commits
Author SHA1 Message Date
kato b3ed538820 ci(release): add patchelf to CI workflows and update release documentation
Tests / Go and GTK tests (push) Canceled after 40s
Release / Tests before release (push) Successful in 1m44s
Release / Build and publish release (push) Successful in 3m2s
- Install patchelf in both build and release jobs to support runtime library relocation.
- Update release notes to describe the private desktop runtime archive.
- Clarify in README that development builds require GTK4 dev libraries, while release archives are self-contained with lib/ directory.
- Adjust troubleshooting and release procedure to reflect new offline runtime packaging.
2026-07-16 22:09:38 +03:00
kato 76bc3d8185 ci: add nodejs24 and nodejs24-bin to CI dependencies
Tests / Go and GTK tests (push) Successful in 1m38s
Release / Tests before release (push) Successful in 2m5s
Release / Build and publish release (push) Successful in 3m23s
2026-07-16 20:48:42 +03:00
kato 449626bffa docs: add CI/release documentation and ignore dist directory
Tests / Go and GTK tests (push) Failing after 1m8s
Add `/dist/` to `.gitignore` to exclude locally built distribution
artifacts. Document the Gitea Actions workflow, release tagging, and
branch protection setup in the README.
2026-07-16 19:40:21 +03:00
kato 13ddebbf98 feat(overlay): improve provisional text display and line splitting 2026-07-16 19:27:39 +03:00
kato 2f5ca41ac3 feat: add --overlay-font-family option for caption font customization
Allow users to specify a custom font family for the overlay captions via
a new --overlay-font-family flag, defaulting to "Sans". Validation
ensures the font family is not empty. Updated README, config, and tests.
2026-07-16 18:09:09 +03:00
kato 192666dd43 fix(overlay): enforce text wrap width to prevent overflow beyond screen
Add `gtk_label_set_width_chars` to set a concrete width for label allocation,
ensuring long sentences wrap within the configured max-chars bubble width
instead of extending off-screen.
2026-07-16 18:06:00 +03:00
12 changed files with 751 additions and 48 deletions
+115
View File
@@ -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 patchelf 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 patchelf 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 a private desktop runtime with GTK4, GTK4 Layer Shell, and Sherpa/ONNX 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"
+43
View File
@@ -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
+1
View File
@@ -5,3 +5,4 @@ models/silero_vad_v5.onnx
# Locally built executable. # Locally built executable.
/captioneer /captioneer
/build/ /build/
/dist/
+24 -6
View File
@@ -11,7 +11,7 @@ Captioneer provides two commands:
## Setup ## Setup
The terminal command requires Linux x86_64, Go 1.26.2 or newer, `pactl`, and `parec`. The desktop command additionally needs GTK4 and GTK4 Layer Shell development/runtime libraries. PipeWire works through its PulseAudio compatibility layer. The terminal command requires Linux x86_64, Go 1.26.2 or newer, `pactl`, and `parec`. Building the desktop command from source additionally needs GTK4 and GTK4 Layer Shell development libraries. Published release archives carry the desktop runtime libraries. PipeWire works through its PulseAudio compatibility layer.
Download the large recognition models once: Download the large recognition models once:
@@ -64,7 +64,9 @@ Use `--output=both` to keep terminal output as well:
./scripts/distrobox/run.sh --mode=vad --output=both ./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. 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: 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-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-opacity=0.90` | `0.90` | Dark bubble opacity from `0` to `1`. |
| `--overlay-font-size=28` | `28` | Font size in logical pixels. | | `--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-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-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-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: Examples:
```bash ```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 \ ./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 # Explicit XWayland/X11 route on the cursor's monitor
./scripts/distrobox/run.sh --mode=vad --output=both --overlay-backend=x11 ./scripts/distrobox/run.sh --mode=vad --output=both --overlay-backend=x11
@@ -162,7 +165,7 @@ Captioneer itself has no environment-based application configuration; use the co
- **Processing overload warning:** inference fell behind and the oldest 100 ms packet was dropped. Try more `--threads` or a longer `--chunk-duration`. - **Processing overload warning:** inference fell behind and the oldest 100 ms packet was dropped. Try more `--threads` or a longer `--chunk-duration`.
- **Overlay is not always above other windows:** read the backend warning. On Wayland this usually means the compositor lacks Layer Shell; add a compositor rule for the `Captioneer Overlay` window if supported. - **Overlay is not always above other windows:** read the backend warning. On Wayland this usually means the compositor lacks Layer Shell; add a compositor rule for the `Captioneer Overlay` window if supported.
- **Overlay initialization fails:** `--output=overlay` exits with the error. `--output=both` warns and continues terminal-only. - **Overlay initialization fails:** `--output=overlay` exits with the error. `--output=both` warns and continues terminal-only.
- **Desktop binary misses shared libraries on Bazzite:** use `scripts/distrobox/run.sh`; container-built GTK binaries are not self-contained. - **A locally built desktop binary misses shared libraries:** use `scripts/distrobox/run.sh` or install the matching GTK4 and GTK4 Layer Shell runtime packages. The desktop command in a published release carries these application libraries in its private `lib/` directory; keep the extracted directory together.
## Development ## Development
@@ -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. 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`, and a private runtime containing GTK4, GTK4 Layer Shell, Sherpa/ONNX, and their linked non-glibc dependencies. Extract the whole archive and run `./captioneer-desktop`; do not move the executable away from its adjacent `lib/` directory. Models, `pactl`, `parec`, host fonts, the display server, graphics drivers, and glibc remain host-provided.
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 ## 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. 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. Release archives dynamically link and redistribute the required shared libraries; `RUNTIME-LIBRARIES.tsv` records their source packages and `third-party-licenses/` contains the installed license notices supplied by those packages. Local source builds continue to link against the system libraries.
Version 1 intentionally has no animation, persisted 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.
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
cd -- "$repo_root"
for command in go ldd patchelf readelf rpm; do
command -v "$command" >/dev/null 2>&1 || {
printf 'captioneer: %s is required to build a release archive\n' "$command" >&2
exit 1
}
done
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" \
"$package_dir/third-party-licenses"
# The Sherpa Go module embeds an absolute build-machine RPATH. Add a portable
# build-time fallback; the final paths are normalized after dependency copying.
# shellcheck disable=SC2016 # $ORIGIN must remain literal for the ELF loader.
CGO_LDFLAGS='-Wl,-rpath,$ORIGIN/lib' \
go build -trimpath -o "$package_dir/captioneer" ./src/cmd/captioneer
# shellcheck disable=SC2016 # $ORIGIN must remain literal for the ELF loader.
CGO_LDFLAGS='-Wl,-rpath,$ORIGIN/lib' \
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"
declare -A bundled_libraries=()
declare -A copied_license_packages=()
manifest_entries="$(mktemp)"
trap 'rm -f -- "$manifest_entries"' EXIT
is_host_runtime_library() {
case "$1" in
ld-linux*.so* | libc.so.* | libm.so.* | libmvec.so.* | libdl.so.* | \
libpthread.so.* | librt.so.* | libresolv.so.* | libanl.so.* | \
libutil.so.* | libnss_*.so.* | libthread_db.so.* | libBrokenLocale.so.*)
return 0
;;
esac
return 1
}
copy_package_licenses() {
local package=$1
local license_path relative_path destination
[[ -z "${copied_license_packages[$package]:-}" ]] || return 0
copied_license_packages["$package"]=1
while IFS= read -r license_path; do
[[ -f "$license_path" && "$license_path" == /usr/share/licenses/* ]] || continue
relative_path="${license_path#/usr/share/licenses/}"
destination="$package_dir/third-party-licenses/$relative_path"
mkdir -p -- "$(dirname -- "$destination")"
cp -L -- "$license_path" "$destination"
done < <(rpm -ql "$package" 2>/dev/null || true)
}
bundle_library() {
local source=$1
local provider=${2:-}
local library_name resolved_source destination package
library_name="$(basename -- "$source")"
is_host_runtime_library "$library_name" && return
resolved_source="$(readlink -f -- "$source")"
[[ -f "$resolved_source" ]] || {
printf 'captioneer: runtime library not found: %s\n' "$source" >&2
exit 1
}
if [[ -n "${bundled_libraries[$library_name]:-}" ]]; then
if ! cmp -s -- "$resolved_source" "${bundled_libraries[$library_name]}"; then
printf 'captioneer: conflicting runtime libraries named %s\n' "$library_name" >&2
exit 1
fi
return
fi
destination="$package_dir/lib/$library_name"
cp -L -- "$resolved_source" "$destination"
bundled_libraries["$library_name"]="$resolved_source"
if [[ -z "$provider" ]] && command -v rpm >/dev/null 2>&1; then
package="$(rpm -qf --queryformat '%{NAME}\n' "$resolved_source" 2>/dev/null | head -n 1 || true)"
if [[ -n "$package" ]]; then
provider="$(rpm -q --queryformat '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' "$package")"
copy_package_licenses "$package"
fi
fi
provider=${provider:-unidentified-system-package}
printf '%s\t%s\n' "$library_name" "$provider" >> "$manifest_entries"
}
bundle_library "$sherpa_lib_dir/libsherpa-onnx-c-api.so" \
'github.com/k2-fsa/sherpa-onnx-go-linux'
bundle_library "$sherpa_lib_dir/libonnxruntime.so" \
'github.com/k2-fsa/sherpa-onnx-go-linux'
# ldd reports the complete linked dependency closure. Copy everything needed by
# either command except glibc and its loader, which must match the host system.
for executable in \
"$package_dir/captioneer" \
"$package_dir/captioneer-desktop"; do
ldd_output="$(ldd "$executable")"
if grep -Fq 'not found' <<< "$ldd_output"; then
printf 'captioneer: unresolved release dependencies for %s:\n%s\n' \
"$executable" "$ldd_output" >&2
exit 1
fi
while IFS= read -r library_path; do
[[ -n "$library_path" ]] || continue
if [[ "$library_path" == "$repo_root/$package_dir/lib/"* ]]; then
continue
fi
bundle_library "$library_path"
done < <(
awk '
$2 == "=>" && $3 ~ /^\// { print $3 }
$1 ~ /^\// { print $1 }
' <<< "$ldd_output"
)
done
{
printf 'Library\tProvider\n'
sort -u -- "$manifest_entries"
} > "$package_dir/RUNTIME-LIBRARIES.tsv"
# Give every object a location-relative path. Patching each library is required
# because an upstream RUNPATH (for example, Tinysparql's Fedora path) otherwise
# prevents the executable's path from resolving that library's own children.
# This avoids LD_LIBRARY_PATH, which would leak into spawned pactl/parec tools.
for library in "$package_dir"/lib/*; do
chmod u+w -- "$library"
# shellcheck disable=SC2016 # $ORIGIN must remain literal for the ELF loader.
patchelf --set-rpath '$ORIGIN' "$library"
chmod 0755 -- "$library"
done
for executable in captioneer captioneer-desktop; do
# shellcheck disable=SC2016 # $ORIGIN must remain literal for the ELF loader.
patchelf --set-rpath '$ORIGIN/lib' "$package_dir/$executable"
done
cp -- README.md LICENSE "$package_dir/"
cp -- scripts/download-models.sh "$package_dir/scripts/"
for executable in captioneer captioneer-desktop; do
dynamic_section="$(readelf -d "$package_dir/$executable")"
# shellcheck disable=SC2016 # Verify the literal ELF-loader token.
if [[ "$dynamic_section" != *'$ORIGIN/lib'* ]]; then
printf 'captioneer: %s is missing its private-runtime RUNPATH\n' \
"$executable" >&2
exit 1
fi
done
[[ -f "$package_dir/lib/libgtk4-layer-shell.so.0" ]] || {
printf 'captioneer: GTK4 Layer Shell was not included in the release runtime\n' >&2
exit 1
}
for library in "$package_dir"/lib/*; do
# shellcheck disable=SC2016 # Verify the literal ELF-loader token.
[[ "$(patchelf --print-rpath "$library")" == '$ORIGIN' ]] || {
printf 'captioneer: %s is missing its private dependency RUNPATH\n' \
"$(basename -- "$library")" >&2
exit 1
}
done
# Verify the archive itself contains every linked non-glibc dependency instead
# of allowing the build container's installed libraries to mask an omission.
for object in \
"$package_dir/captioneer" \
"$package_dir/captioneer-desktop" \
"$package_dir"/lib/*; do
while IFS= read -r needed_library; do
is_host_runtime_library "$needed_library" && continue
[[ -f "$package_dir/lib/$needed_library" ]] || {
printf 'captioneer: %s requires unbundled library %s\n' \
"$(basename -- "$object")" "$needed_library" >&2
exit 1
}
done < <(patchelf --print-needed "$object")
done
# --help exits before display/model initialization while still exercising the
# dynamic loader through the same executable release users invoke.
"$package_dir/captioneer-desktop" --help >/dev/null 2>&1
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"
+80
View File
@@ -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
+19
View File
@@ -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
+7
View File
@@ -4,11 +4,13 @@ package config
import ( import (
"errors" "errors"
"flag" "flag"
"strings"
"time" "time"
) )
const ( const (
DefaultModelsDir = "models" DefaultModelsDir = "models"
DefaultOverlayFontFamily = "Sans"
MinimumOverlayFinalLifetime = 3 * time.Second MinimumOverlayFinalLifetime = 3 * time.Second
) )
@@ -60,6 +62,7 @@ type OverlaySettings struct {
Backend OverlayBackend Backend OverlayBackend
Opacity float64 Opacity float64
FontSize int FontSize int
FontFamily string
BottomMargin int BottomMargin int
MaxWidth int MaxWidth int
Monitor string Monitor string
@@ -88,6 +91,7 @@ func ParseDesktop() DesktopSettings {
flags.StringVar(&backend, "overlay-backend", "auto", "display backend: auto, wayland, or x11") 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.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.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.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.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.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 { if s.Overlay.FontSize <= 0 {
return errors.New("--overlay-font-size must be positive") 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 { if s.Overlay.BottomMargin < 0 {
return errors.New("--overlay-bottom-margin cannot be negative") return errors.New("--overlay-bottom-margin cannot be negative")
} }
+2 -1
View File
@@ -20,7 +20,7 @@ func validDesktopSettings() DesktopSettings {
Settings: Settings{Mode: ModeVAD, ChunkDuration: time.Second, Threads: 2}, Settings: Settings{Mode: ModeVAD, ChunkDuration: time.Second, Threads: 2},
Output: OutputOverlay, Output: OutputOverlay,
Overlay: OverlaySettings{ 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, 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 }}, {"negative opacity", func(s *DesktopSettings) { s.Overlay.Opacity = -0.1 }},
{"large opacity", func(s *DesktopSettings) { s.Overlay.Opacity = 1.1 }}, {"large opacity", func(s *DesktopSettings) { s.Overlay.Opacity = 1.1 }},
{"zero font", func(s *DesktopSettings) { s.Overlay.FontSize = 0 }}, {"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 }}, {"negative margin", func(s *DesktopSettings) { s.Overlay.BottomMargin = -1 }},
{"zero width", func(s *DesktopSettings) { s.Overlay.MaxWidth = 0 }}, {"zero width", func(s *DesktopSettings) { s.Overlay.MaxWidth = 0 }},
{"empty monitor", func(s *DesktopSettings) { s.Overlay.Monitor = "" }}, {"empty monitor", func(s *DesktopSettings) { s.Overlay.Monitor = "" }},
+57 -11
View File
@@ -246,10 +246,27 @@ static gboolean captioneer_window_close(GtkWindow *window, gpointer data) {
return TRUE; 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( static CaptioneerOverlay *captioneer_overlay_new(
const char *backend, const char *backend,
double opacity, double opacity,
int font_size, int font_size,
const char *font_family,
int bottom_margin, int bottom_margin,
int max_width, int max_width,
const char *monitor_selection, const char *monitor_selection,
@@ -354,6 +371,9 @@ static CaptioneerOverlay *captioneer_overlay_new(
gtk_label_set_xalign(GTK_LABEL(labels[i]), 0.0f); gtk_label_set_xalign(GTK_LABEL(labels[i]), 0.0f);
gtk_label_set_wrap(GTK_LABEL(labels[i]), TRUE); 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_wrap_mode(GTK_LABEL(labels[i]), PANGO_WRAP_WORD_CHAR);
// 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_label_set_max_width_chars(GTK_LABEL(labels[i]), max_chars);
gtk_widget_set_halign(labels[i], GTK_ALIGN_FILL); gtk_widget_set_halign(labels[i], GTK_ALIGN_FILL);
gtk_box_append(GTK_BOX(box), labels[i]); gtk_box_append(GTK_BOX(box), labels[i]);
@@ -363,23 +383,27 @@ static CaptioneerOverlay *captioneer_overlay_new(
// view state, rather than GTK ellipsizing, enforces its six-entry limit. // view state, rather than GTK ellipsizing, enforces its six-entry limit.
gtk_label_set_lines(GTK_LABEL(overlay->final_label), -1); 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_ellipsize(GTK_LABEL(overlay->final_label), PANGO_ELLIPSIZE_NONE);
gtk_label_set_lines(GTK_LABEL(overlay->preview_label), 2); // Provisional text is already split and bounded by the Go view state. Let
gtk_label_set_ellipsize(GTK_LABEL(overlay->preview_label), PANGO_ELLIPSIZE_END); // 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->final_label, "captioneer-final");
gtk_widget_add_css_class(overlay->preview_label, "captioneer-preview"); gtk_widget_add_css_class(overlay->preview_label, "captioneer-preview");
gtk_window_set_child(overlay->window, box); gtk_window_set_child(overlay->window, box);
char *font_family_rule = captioneer_font_family_rule(font_family);
char *css = g_strdup_printf( char *css = g_strdup_printf(
"window.captioneer-overlay { background-color: transparent; box-shadow: none; }" "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-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-final { color: rgba(255, 255, 255, 1.0); font-size: %dpx; %s }"
".captioneer-preview { color: rgba(255, 255, 255, 0.78); font-size: %dpx; }", ".captioneer-preview { color: rgba(255, 255, 255, 0.78); font-size: %dpx; %s }",
opacity, font_size, font_size); opacity, font_size, font_family_rule, font_size, font_family_rule);
GtkCssProvider *provider = gtk_css_provider_new(); GtkCssProvider *provider = gtk_css_provider_new();
gtk_css_provider_load_from_string(provider, css); 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); gtk_style_context_add_provider_for_display(display, GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(provider); g_object_unref(provider);
g_free(css); g_free(css);
g_free(font_family_rule);
gtk_widget_realize(GTK_WIDGET(overlay->window)); gtk_widget_realize(GTK_WIDGET(overlay->window));
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window)); GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
@@ -449,12 +473,18 @@ import (
"tea.chunkbyte.com/kato/captioneer/src/config" "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 { type Sink struct {
native *C.CaptioneerOverlay native *C.CaptioneerOverlay
finalTimeout time.Duration finalTimeout time.Duration
finalTimer *time.Timer finalTimer *time.Timer
lineCharacters int
state viewState state viewState
mu sync.RWMutex mu sync.RWMutex
closed bool closed bool
@@ -462,8 +492,10 @@ type Sink struct {
func New(settings config.OverlaySettings) (*Sink, string, error) { func New(settings config.OverlaySettings) (*Sink, string, error) {
backend := C.CString(string(settings.Backend)) backend := C.CString(string(settings.Backend))
fontFamily := C.CString(settings.FontFamily)
monitor := C.CString(settings.Monitor) monitor := C.CString(settings.Monitor)
defer C.free(unsafe.Pointer(backend)) defer C.free(unsafe.Pointer(backend))
defer C.free(unsafe.Pointer(fontFamily))
defer C.free(unsafe.Pointer(monitor)) defer C.free(unsafe.Pointer(monitor))
var warningMessage *C.char var warningMessage *C.char
@@ -472,6 +504,7 @@ func New(settings config.OverlaySettings) (*Sink, string, error) {
backend, backend,
C.double(settings.Opacity), C.double(settings.Opacity),
C.int(settings.FontSize), C.int(settings.FontSize),
fontFamily,
C.int(settings.BottomMargin), C.int(settings.BottomMargin),
C.int(settings.MaxWidth), C.int(settings.MaxWidth),
monitor, monitor,
@@ -487,7 +520,11 @@ func New(settings config.OverlaySettings) (*Sink, string, error) {
} }
return nil, warning, errors.New(message) 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 { func (s *Sink) Publish(event captions.Event) error {
@@ -497,17 +534,26 @@ func (s *Sink) Publish(event captions.Event) error {
return errors.New("overlay is closed") return errors.New("overlay is closed")
} }
now := time.Now() now := time.Now()
s.state.Apply(event, now, s.finalTimeout) s.state.Apply(event, now, s.finalTimeout, s.lineCharacters)
if event.Kind == captions.Final || event.Kind == captions.Hide {
s.stopFinalTimer()
}
if event.Kind == captions.Final { if event.Kind == captions.Final {
s.stopFinalTimer()
s.scheduleFinalRefresh(now) s.scheduleFinalRefresh(now)
} }
s.postState() s.postState()
return nil 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() { func (s *Sink) refreshFinals() {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
+110 -8
View File
@@ -3,16 +3,20 @@ package overlay
import ( import (
"fmt" "fmt"
"html" "html"
"math"
"strings" "strings"
"time" "time"
"unicode/utf8"
"tea.chunkbyte.com/kato/captioneer/src/captions" "tea.chunkbyte.com/kato/captioneer/src/captions"
) )
const ( const (
maxVisibleFinalLines = 6 maxVisibleFinalLines = 6
maxVisiblePreviewLines = 3
oldestLineBrightness = 0.65 oldestLineBrightness = 0.65
defaultLineFadeDuration = 3 * time.Second defaultLineFadeDuration = 3 * time.Second
readingWordsPerSecond = 3.0
) )
type finalLine struct { type finalLine struct {
@@ -26,26 +30,39 @@ type viewState struct {
Provisional string 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 { switch event.Kind {
case captions.Provisional: 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: case captions.Final:
text := strings.TrimSpace(event.Text) chunks := splitCaptionText(event.Text, maxLineCharacters)
if text == "" { if len(chunks) == 0 {
return return
} }
if len(chunks) > maxVisibleFinalLines {
chunks = chunks[len(chunks)-maxVisibleFinalLines:]
}
expiresAt := now
for _, text := range chunks {
line := finalLine{CreatedAt: now, Text: text} line := finalLine{CreatedAt: now, Text: text}
if finalTimeout > 0 { if finalTimeout > 0 {
line.ExpiresAt = now.Add(finalTimeout) 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 { if len(s.Finals) > maxVisibleFinalLines {
s.Finals = s.Finals[len(s.Finals)-maxVisibleFinalLines:] s.Finals = s.Finals[len(s.Finals)-maxVisibleFinalLines:]
} }
s.Provisional = "" s.Provisional = ""
case captions.Hide: 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 { if i > 0 {
markup.WriteByte('\n') 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) channel := int(brightness*255 + 0.5)
fmt.Fprintf(&markup, `<span foreground="#%02X%02X%02X">`, channel, channel, channel) fmt.Fprintf(&markup, `<span foreground="#%02X%02X%02X">`, channel, channel, channel)
markup.WriteString(html.EscapeString(line.Text)) markup.WriteString(html.EscapeString(line.Text))
@@ -99,13 +120,94 @@ func (s viewState) NeedsFadeRefresh(now time.Time, lineLifetime time.Duration) b
fadeDuration = defaultLineFadeDuration fadeDuration = defaultLineFadeDuration
} }
for _, line := range s.Finals { 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 true
} }
} }
return false 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 { func (s viewState) NextFinalExpiry() time.Time {
if len(s.Finals) == 0 { if len(s.Finals) == 0 {
return time.Time{} return time.Time{}
+66 -13
View File
@@ -7,29 +7,31 @@ import (
"tea.chunkbyte.com/kato/captioneer/src/captions" "tea.chunkbyte.com/kato/captioneer/src/captions"
) )
const testLineCharacters = 80
func TestViewStateKeepsCompletedLinesAboveNewProvisional(t *testing.T) { func TestViewStateKeepsCompletedLinesAboveNewProvisional(t *testing.T) {
now := time.Unix(100, 0) now := time.Unix(100, 0)
var state viewState var state viewState
state.Apply(captions.Event{Kind: captions.Provisional, Text: " first 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) 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) 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) 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" { if state.FinalText() != "first final\nsecond final" || state.Provisional != "next draft" {
t.Fatalf("unexpected rows: %#v", state) t.Fatalf("unexpected rows: %#v", state)
} }
state.Apply(captions.Event{Kind: captions.Hide}, now, 4*time.Second) state.Apply(captions.Event{Kind: captions.Hide}, now, 4*time.Second, testLineCharacters)
if state.FinalText() != "" || state.Provisional != "" { if state.FinalText() != "first final\nsecond final" || state.Provisional != "" {
t.Fatalf("hide did not clear rows: %#v", state) t.Fatalf("blank result erased completed captions: %#v", state)
} }
} }
func TestViewStateExpiresEachCompletedLineAfterItsOwnLifetime(t *testing.T) { func TestViewStateExpiresEachCompletedLineAfterItsOwnLifetime(t *testing.T) {
now := time.Unix(100, 0) now := time.Unix(100, 0)
var state viewState var state viewState
state.Apply(captions.Event{Kind: captions.Final, Text: "first"}, 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) 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) state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 3*time.Second, testLineCharacters)
if state.ExpireFinal(now.Add(2999 * time.Millisecond)) { if state.ExpireFinal(now.Add(2999 * time.Millisecond)) {
t.Fatal("completed line expired early") t.Fatal("completed line expired early")
@@ -46,7 +48,7 @@ func TestViewStateLimitsVisibleCompletedLines(t *testing.T) {
now := time.Unix(100, 0) now := time.Unix(100, 0)
var state viewState var state viewState
for i := 0; i <= maxVisibleFinalLines; i++ { 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" { if len(state.Finals) != maxVisibleFinalLines || state.FinalText() != "b\nc\nd\ne\nf\ng" {
t.Fatalf("unexpected completed-line limit: %#v", state) t.Fatalf("unexpected completed-line limit: %#v", state)
@@ -56,7 +58,7 @@ func TestViewStateLimitsVisibleCompletedLines(t *testing.T) {
func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) { func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
now := time.Unix(100, 0) now := time.Unix(100, 0)
var state viewState 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)) { if state.ExpireFinal(now.Add(24 * time.Hour)) {
t.Fatal("zero-timeout caption expired") t.Fatal("zero-timeout caption expired")
} }
@@ -65,7 +67,7 @@ func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
func TestViewStateFinalMarkupFadesAndEscapesText(t *testing.T) { func TestViewStateFinalMarkupFadesAndEscapesText(t *testing.T) {
now := time.Unix(100, 0) now := time.Unix(100, 0)
var state viewState 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">&lt;hello &amp; goodbye&gt;</span>` { 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) t.Fatalf("new line markup = %q", got)
@@ -74,3 +76,54 @@ func TestViewStateFinalMarkupFadesAndEscapesText(t *testing.T) {
t.Fatalf("old line markup = %q", got) 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)
}
}