Compare commits
10
Commits
fea9161b3c
..
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55fbd357b0 | ||
|
|
b0487be39e | ||
|
|
b3ed538820 | ||
|
|
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 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"
|
||||||
@@ -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.
|
# Locally built executable.
|
||||||
/captioneer
|
/captioneer
|
||||||
/build/
|
/build/
|
||||||
|
/dist/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ This creates `models/parakeet-tdt-v2/` and `models/silero_vad_v5.onnx`. They are
|
|||||||
|
|
||||||
## Terminal captions
|
## Terminal captions
|
||||||
|
|
||||||
VAD mode is recommended for normal use. It redraws a provisional caption while speech is active and commits a timestamped caption after the utterance ends:
|
VAD mode is recommended for normal use. It redraws a provisional caption only after Silero detects speech and commits a timestamped caption after the utterance ends:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./src/cmd/captioneer --mode=vad
|
go run ./src/cmd/captioneer --mode=vad
|
||||||
@@ -50,6 +50,14 @@ Final captions use stdout; diagnostics and overload warnings use stderr. This ma
|
|||||||
|
|
||||||
Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop. Captioneer stops `parec` and flushes pending audio before exiting.
|
Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop. Captioneer stops `parec` and flushes pending audio before exiting.
|
||||||
|
|
||||||
|
Sherpa recognition, Silero VAD, and audio capture run in an isolated worker process. The terminal or GTK process stays responsive if native inference crashes or hangs: Captioneer restarts the worker automatically after a failure, and Ctrl+C force-kills only that worker if it cannot flush within the configured shutdown timeout. Send `SIGHUP` to the parent Captioneer PID to hot-reload the models without closing the terminal or overlay:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kill -HUP <captioneer-parent-pid>
|
||||||
|
```
|
||||||
|
|
||||||
|
An automatic or manual restart begins a fresh capture/transcription session; audio being processed by the failed worker cannot be recovered.
|
||||||
|
|
||||||
## Desktop overlay
|
## Desktop overlay
|
||||||
|
|
||||||
The simplest Bazzite development command is:
|
The simplest Bazzite development command is:
|
||||||
@@ -64,7 +72,11 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
The expensive Parakeet recognizer stays idle during silence and audio that Silero does not classify as speech. A small rolling preview window also prevents long continuous audio from making each refresh progressively slower. Silero itself continues receiving the live stream because it needs the quiet frames to detect when speech ends and when it starts again.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
@@ -95,7 +107,11 @@ Layer Shell and X11 hints are requests, not universal guarantees. Same-layer win
|
|||||||
| `--chunk-duration=1s` | `1s` | Fixed chunk length or VAD preview refresh interval. Accepts Go durations such as `500ms` or `2s`. |
|
| `--chunk-duration=1s` | `1s` | Fixed chunk length or VAD preview refresh interval. Accepts Go durations such as `500ms` or `2s`. |
|
||||||
| `--models-dir=models` | `models` | Directory containing `parakeet-tdt-v2/` and `silero_vad_v5.onnx`. |
|
| `--models-dir=models` | `models` | Directory containing `parakeet-tdt-v2/` and `silero_vad_v5.onnx`. |
|
||||||
| `--threads=2` | `2` | CPU threads used by recognition and VAD. |
|
| `--threads=2` | `2` | CPU threads used by recognition and VAD. |
|
||||||
| `--preview-threshold-dbfs=-45` | `-45` | RMS threshold that starts a provisional VAD caption. Raise it to reject background audio; lower it for quiet speech. |
|
| `--preview-threshold-dbfs=-45` | `-45` | RMS gate below which recognition stays idle. Raise it to reject quiet background noise; lower it for quiet speech. In fixed mode, gated chunks are not sent to Parakeet. |
|
||||||
|
| `--vad-threshold=0.5` | `0.5` | Silero speech confidence from `0` to `1`. Raise it to reject more music/noise; lower it if real speech is missed. Applies to VAD mode. |
|
||||||
|
| `--model-auto-restart=true` | `true` | Restart the isolated model worker after a crash, capture failure, or timeout. Set `false` to report the failure and exit instead. |
|
||||||
|
| `--model-timeout=30s` | `30s` | Maximum model startup, continuously busy processing time, or missing-heartbeat period before the worker is considered hung. `0s` disables hang detection. |
|
||||||
|
| `--model-shutdown-timeout=2s` | `2s` | Time allowed for pending-audio flush and model cleanup after Ctrl+C or reload before the worker is force-killed. |
|
||||||
|
|
||||||
### Desktop-only options
|
### Desktop-only options
|
||||||
|
|
||||||
@@ -105,18 +121,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-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`. |
|
||||||
| `--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. |
|
| `--overlay-click-through=true` | `true` | Use an empty input region where the display backend supports it. |
|
||||||
|
|
||||||
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
|
||||||
@@ -124,6 +141,10 @@ Examples:
|
|||||||
# Fixed two-second captions on a named monitor
|
# Fixed two-second captions on a named monitor
|
||||||
./scripts/distrobox/run.sh --mode=fixed --chunk-duration=2s \
|
./scripts/distrobox/run.sh --mode=fixed --chunk-duration=2s \
|
||||||
--output=overlay --overlay-monitor=DP-1
|
--output=overlay --overlay-monitor=DP-1
|
||||||
|
|
||||||
|
# Be more conservative when instrumental music causes false speech detections
|
||||||
|
./scripts/distrobox/run.sh --mode=vad --output=overlay \
|
||||||
|
--vad-threshold=0.65 --preview-threshold-dbfs=-40
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bazzite development with Distrobox
|
## Bazzite development with Distrobox
|
||||||
@@ -159,14 +180,16 @@ Captioneer itself has no environment-based application configuration; use the co
|
|||||||
- **Missing model files:** run `./scripts/download-models.sh` or correct `--models-dir`.
|
- **Missing model files:** run `./scripts/download-models.sh` or correct `--models-dir`.
|
||||||
- **`pactl` or `parec` missing:** install PulseAudio utilities, or use the Distrobox scripts.
|
- **`pactl` or `parec` missing:** install PulseAudio utilities, or use the Distrobox scripts.
|
||||||
- **No captions:** confirm the current sink with `pactl get-default-sink`; Captioneer listens to `<sink>.monitor`.
|
- **No captions:** confirm the current sink with `pactl get-default-sink`; Captioneer listens to `<sink>.monitor`.
|
||||||
|
- **Music/noise produces captions:** use VAD mode and try `--vad-threshold=0.65`. Increase it in small steps if false detections continue. `--preview-threshold-dbfs=-40` can also reject quiet background audio, but an RMS gate cannot distinguish loud music from speech. Singing may still be treated as speech.
|
||||||
- **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.
|
||||||
|
- **The model worker repeatedly restarts:** inspect the warning on stderr. Slow machines may need a larger `--model-timeout`; use `--model-auto-restart=false` when diagnosing a persistent crash so Captioneer exits on the first failure.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The transcription pipeline emits presentation-neutral events. `output/terminal` and `output/overlay` consume those events independently; GTK stays on its main OS thread while capture and recognition run on a worker.
|
The transcription pipeline emits presentation-neutral events. `output/terminal` and `output/overlay` consume those events independently; GTK stays on its main OS thread while capture, VAD, and recognition run in a supervised child process. Caption events and health messages cross a private pipe, and the child is placed in its own process group so its `parec` process can be stopped or replaced with it.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/app/ capture/model/process lifecycle
|
src/app/ capture/model/process lifecycle
|
||||||
@@ -198,8 +221,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, 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
+218
@@ -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"
|
||||||
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
|
||||||
+35
-5
@@ -14,15 +14,28 @@ import (
|
|||||||
"tea.chunkbyte.com/kato/captioneer/src/output"
|
"tea.chunkbyte.com/kato/captioneer/src/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer) error {
|
type directHooks struct {
|
||||||
if err := capture.ValidatePrograms(); err != nil {
|
ready func() error
|
||||||
|
busy func() error
|
||||||
|
idle func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDirect(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, hooks directHooks) error {
|
||||||
|
if err := hooks.busy(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resources, err := models.New(settings)
|
resources, err := models.New(settings)
|
||||||
|
if idleErr := hooks.idle(); err == nil {
|
||||||
|
err = idleErr
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resources.Close()
|
defer func() {
|
||||||
|
_ = hooks.busy()
|
||||||
|
resources.Close()
|
||||||
|
_ = hooks.idle()
|
||||||
|
}()
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -32,24 +45,41 @@ func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagno
|
|||||||
return fmt.Errorf("find default output monitor: %w", err)
|
return fmt.Errorf("find default output monitor: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(diagnostics, "Capturing from: %s\n", monitorSource)
|
fmt.Fprintf(diagnostics, "Capturing from: %s\n", monitorSource)
|
||||||
fmt.Fprintln(diagnostics, "Press Ctrl+C to stop.")
|
|
||||||
|
|
||||||
packets, waitCapture, err := capture.Packets(ctx, monitorSource)
|
packets, waitCapture, err := capture.Packets(ctx, monitorSource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("start system-audio capture: %w", err)
|
return fmt.Errorf("start system-audio capture: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := hooks.ready(); err != nil {
|
||||||
|
cancel()
|
||||||
|
_ = waitCapture()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
processor := captions.New(settings, resources, sink.Publish)
|
processor := captions.New(settings, resources, sink.Publish)
|
||||||
var processErr error
|
var processErr error
|
||||||
for packet := range packets {
|
for packet := range packets {
|
||||||
if err := processor.Accept(packet); err != nil {
|
if err := hooks.busy(); err != nil {
|
||||||
|
processErr = err
|
||||||
|
cancel()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
acceptErr := processor.Accept(packet)
|
||||||
|
idleErr := hooks.idle()
|
||||||
|
if acceptErr != nil || idleErr != nil {
|
||||||
|
err := errors.Join(acceptErr, idleErr)
|
||||||
processErr = fmt.Errorf("publish caption: %w", err)
|
processErr = fmt.Errorf("publish caption: %w", err)
|
||||||
cancel()
|
cancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if processErr == nil {
|
if processErr == nil {
|
||||||
|
if err := hooks.busy(); err != nil {
|
||||||
|
processErr = err
|
||||||
|
} else {
|
||||||
processErr = processor.Flush()
|
processErr = processor.Flush()
|
||||||
|
processErr = errors.Join(processErr, hooks.idle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
captureErr := waitCapture()
|
captureErr := waitCapture()
|
||||||
if captureErr != nil && ctx.Err() == nil {
|
if captureErr != nil && ctx.Err() == nil {
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tea.chunkbyte.com/kato/captioneer/src/capture"
|
||||||
|
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||||
|
"tea.chunkbyte.com/kato/captioneer/src/models"
|
||||||
|
"tea.chunkbyte.com/kato/captioneer/src/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
const modelRestartDelay = time.Second
|
||||||
|
|
||||||
|
type attemptResult uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
attemptStopped attemptResult = iota + 1
|
||||||
|
attemptReload
|
||||||
|
attemptFailed
|
||||||
|
attemptFatal
|
||||||
|
)
|
||||||
|
|
||||||
|
type workerHealth struct {
|
||||||
|
startedAt time.Time
|
||||||
|
lastSeen time.Time
|
||||||
|
ready bool
|
||||||
|
busySince time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *workerHealth) observe(messageType string, now time.Time) {
|
||||||
|
h.lastSeen = now
|
||||||
|
switch messageType {
|
||||||
|
case messageReady:
|
||||||
|
h.ready = true
|
||||||
|
h.busySince = time.Time{}
|
||||||
|
case messageBusy:
|
||||||
|
if h.busySince.IsZero() {
|
||||||
|
h.busySince = now
|
||||||
|
}
|
||||||
|
case messageIdle:
|
||||||
|
h.busySince = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h workerHealth) timeoutError(now time.Time, timeout time.Duration) error {
|
||||||
|
if timeout == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !h.ready && now.Sub(h.startedAt) >= timeout {
|
||||||
|
return fmt.Errorf("model worker did not become ready within %s", timeout)
|
||||||
|
}
|
||||||
|
if !h.busySince.IsZero() && now.Sub(h.busySince) >= timeout {
|
||||||
|
return fmt.Errorf("model operation exceeded %s", timeout)
|
||||||
|
}
|
||||||
|
if h.ready && now.Sub(h.lastSeen) >= timeout {
|
||||||
|
return fmt.Errorf("model worker heartbeat was silent for %s", timeout)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run supervises an isolated process containing capture, VAD, and recognition.
|
||||||
|
// Presentation stays in this process, so the native model can be replaced
|
||||||
|
// without restarting the terminal or GTK main loop.
|
||||||
|
func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer) error {
|
||||||
|
if err := capture.ValidatePrograms(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := models.Validate(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(diagnostics, "Press Ctrl+C to stop. Send SIGHUP to hot-reload the model worker.")
|
||||||
|
reload := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(reload, syscall.SIGHUP)
|
||||||
|
defer signal.Stop(reload)
|
||||||
|
|
||||||
|
for {
|
||||||
|
result, err := runWorkerAttempt(ctx, settings, sink, diagnostics, reload)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case attemptStopped:
|
||||||
|
return nil
|
||||||
|
case attemptFatal:
|
||||||
|
return err
|
||||||
|
case attemptReload:
|
||||||
|
fmt.Fprintln(diagnostics, "Reloading model worker.")
|
||||||
|
continue
|
||||||
|
case attemptFailed:
|
||||||
|
if !settings.ModelAutoRestart {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(diagnostics, "warning: %v; restarting model worker in %s\n", err, modelRestartDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(modelRestartDelay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-reload:
|
||||||
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
fmt.Fprintln(diagnostics, "Reloading model worker.")
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWorkerAttempt(
|
||||||
|
ctx context.Context,
|
||||||
|
settings config.Settings,
|
||||||
|
sink output.Sink,
|
||||||
|
diagnostics io.Writer,
|
||||||
|
reload <-chan os.Signal,
|
||||||
|
) (attemptResult, error) {
|
||||||
|
cmd, protocol, err := startModelWorker(settings, diagnostics)
|
||||||
|
if err != nil {
|
||||||
|
return attemptFailed, fmt.Errorf("start model worker: %w", err)
|
||||||
|
}
|
||||||
|
defer protocol.Close()
|
||||||
|
|
||||||
|
messages := make(chan workerMessage)
|
||||||
|
protocolDone := make(chan error, 1)
|
||||||
|
go readWorkerMessages(protocol, messages, protocolDone)
|
||||||
|
|
||||||
|
wait := make(chan error, 1)
|
||||||
|
go func() { wait <- cmd.Wait() }()
|
||||||
|
|
||||||
|
checkInterval := 250 * time.Millisecond
|
||||||
|
if settings.ModelTimeout > 0 && settings.ModelTimeout < checkInterval {
|
||||||
|
checkInterval = settings.ModelTimeout / 2
|
||||||
|
if checkInterval <= 0 {
|
||||||
|
checkInterval = time.Millisecond
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
startedAt := time.Now()
|
||||||
|
health := workerHealth{startedAt: startedAt, lastSeen: startedAt}
|
||||||
|
messageChannel := (<-chan workerMessage)(messages)
|
||||||
|
waitChannel := (<-chan error)(wait)
|
||||||
|
contextDone := ctx.Done()
|
||||||
|
var stopTimer *time.Timer
|
||||||
|
var stopDeadline <-chan time.Time
|
||||||
|
var stopping bool
|
||||||
|
var desiredResult attemptResult
|
||||||
|
var desiredErr error
|
||||||
|
var processDone bool
|
||||||
|
var processErr error
|
||||||
|
var protocolClosed bool
|
||||||
|
var protocolErr error
|
||||||
|
var workerFailure string
|
||||||
|
|
||||||
|
beginStop := func(result attemptResult, stopErr error) {
|
||||||
|
if stopping {
|
||||||
|
if result == attemptFatal {
|
||||||
|
desiredResult = result
|
||||||
|
desiredErr = errors.Join(desiredErr, stopErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopping = true
|
||||||
|
desiredResult = result
|
||||||
|
desiredErr = stopErr
|
||||||
|
if err := signalWorkerGroup(cmd, syscall.SIGTERM); err != nil {
|
||||||
|
desiredErr = errors.Join(desiredErr, err)
|
||||||
|
}
|
||||||
|
stopTimer = time.NewTimer(settings.ModelShutdownTimeout)
|
||||||
|
stopDeadline = stopTimer.C
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
if processDone && protocolClosed {
|
||||||
|
if stopTimer != nil && !stopTimer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-stopTimer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stopping {
|
||||||
|
return desiredResult, desiredErr
|
||||||
|
}
|
||||||
|
// The worker may have crashed before its parec child. A process group
|
||||||
|
// remains signalable after its leader exits, so clean up any survivors
|
||||||
|
// before starting a replacement worker.
|
||||||
|
cleanupErr := signalWorkerGroup(cmd, syscall.SIGKILL)
|
||||||
|
if protocolErr != nil {
|
||||||
|
return attemptFailed, errors.Join(fmt.Errorf("model worker protocol: %w", protocolErr), cleanupErr)
|
||||||
|
}
|
||||||
|
if workerFailure != "" {
|
||||||
|
return attemptFailed, errors.Join(errors.New(workerFailure), cleanupErr)
|
||||||
|
}
|
||||||
|
if processErr != nil {
|
||||||
|
return attemptFailed, errors.Join(fmt.Errorf("model worker exited: %w", processErr), cleanupErr)
|
||||||
|
}
|
||||||
|
return attemptFailed, errors.Join(errors.New("model worker exited unexpectedly"), cleanupErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-contextDone:
|
||||||
|
contextDone = nil
|
||||||
|
beginStop(attemptStopped, nil)
|
||||||
|
|
||||||
|
case <-reload:
|
||||||
|
beginStop(attemptReload, nil)
|
||||||
|
|
||||||
|
case message, ok := <-messageChannel:
|
||||||
|
if !ok {
|
||||||
|
messageChannel = nil
|
||||||
|
protocolClosed = true
|
||||||
|
protocolErr = <-protocolDone
|
||||||
|
if !processDone && !stopping {
|
||||||
|
beginStop(attemptFailed, errors.New("model worker protocol closed unexpectedly"))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
health.observe(message.Type, now)
|
||||||
|
switch message.Type {
|
||||||
|
case messageReady:
|
||||||
|
fmt.Fprintln(diagnostics, "Model worker ready.")
|
||||||
|
case messageBusy, messageIdle:
|
||||||
|
case messageHeartbeat:
|
||||||
|
case messageCaption:
|
||||||
|
if message.Event == nil {
|
||||||
|
beginStop(attemptFailed, errors.New("model worker sent an empty caption event"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sink.Publish(*message.Event); err != nil {
|
||||||
|
beginStop(attemptFatal, fmt.Errorf("publish caption: %w", err))
|
||||||
|
}
|
||||||
|
case messageFailure:
|
||||||
|
workerFailure = message.Error
|
||||||
|
default:
|
||||||
|
beginStop(attemptFailed, fmt.Errorf("unknown model worker message %q", message.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
case err := <-waitChannel:
|
||||||
|
waitChannel = nil
|
||||||
|
processDone = true
|
||||||
|
processErr = err
|
||||||
|
|
||||||
|
case now := <-ticker.C:
|
||||||
|
if !stopping {
|
||||||
|
if err := health.timeoutError(now, settings.ModelTimeout); err != nil {
|
||||||
|
beginStop(attemptFailed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-stopDeadline:
|
||||||
|
stopDeadline = nil
|
||||||
|
if !processDone {
|
||||||
|
fmt.Fprintf(diagnostics, "warning: model worker did not stop within %s; force-killing it\n", settings.ModelShutdownTimeout)
|
||||||
|
if err := signalWorkerGroup(cmd, syscall.SIGKILL); err != nil {
|
||||||
|
desiredErr = errors.Join(desiredErr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startModelWorker(settings config.Settings, diagnostics io.Writer) (*exec.Cmd, *os.File, error) {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
encodedSettings, err := encodeWorkerSettings(settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
protocolRead, protocolWrite, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(executable)
|
||||||
|
cmd.Env = workerEnvironment(encodedSettings)
|
||||||
|
cmd.ExtraFiles = []*os.File{protocolWrite}
|
||||||
|
cmd.Stdout = diagnostics
|
||||||
|
cmd.Stderr = diagnostics
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
Setpgid: true,
|
||||||
|
Pdeathsig: syscall.SIGKILL,
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
protocolRead.Close()
|
||||||
|
protocolWrite.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := protocolWrite.Close(); err != nil {
|
||||||
|
_ = signalWorkerGroup(cmd, syscall.SIGKILL)
|
||||||
|
_ = cmd.Wait()
|
||||||
|
protocolRead.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return cmd, protocolRead, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func workerEnvironment(encodedSettings string) []string {
|
||||||
|
environment := make([]string, 0, len(os.Environ())+2)
|
||||||
|
for _, item := range os.Environ() {
|
||||||
|
if strings.HasPrefix(item, modelWorkerEnvironment+"=") ||
|
||||||
|
strings.HasPrefix(item, modelSettingsEnvironment+"=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
environment = append(environment, item)
|
||||||
|
}
|
||||||
|
return append(environment,
|
||||||
|
modelWorkerEnvironment+"=1",
|
||||||
|
modelSettingsEnvironment+"="+encodedSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWorkerMessages(protocol io.Reader, messages chan<- workerMessage, done chan<- error) {
|
||||||
|
defer close(messages)
|
||||||
|
decoder := json.NewDecoder(protocol)
|
||||||
|
for {
|
||||||
|
var message workerMessage
|
||||||
|
if err := decoder.Decode(&message); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
done <- nil
|
||||||
|
} else {
|
||||||
|
done <- err
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messages <- message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signalWorkerGroup(cmd *exec.Cmd, signal syscall.Signal) error {
|
||||||
|
if cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := syscall.Kill(-cmd.Process.Pid, signal)
|
||||||
|
if errors.Is(err, syscall.ESRCH) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("signal model worker: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkerHealthDetectsStartupAndOperationTimeouts(t *testing.T) {
|
||||||
|
started := time.Unix(100, 0)
|
||||||
|
health := workerHealth{startedAt: started, lastSeen: started}
|
||||||
|
|
||||||
|
if err := health.timeoutError(started.Add(time.Second), 2*time.Second); err != nil {
|
||||||
|
t.Fatalf("healthy startup reported a timeout: %v", err)
|
||||||
|
}
|
||||||
|
if err := health.timeoutError(started.Add(2*time.Second), 2*time.Second); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "did not become ready") {
|
||||||
|
t.Fatalf("startup timeout = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
health.observe(messageReady, started.Add(2*time.Second))
|
||||||
|
health.observe(messageBusy, started.Add(3*time.Second))
|
||||||
|
if err := health.timeoutError(started.Add(4*time.Second), 2*time.Second); err != nil {
|
||||||
|
t.Fatalf("healthy operation reported a timeout: %v", err)
|
||||||
|
}
|
||||||
|
if err := health.timeoutError(started.Add(5*time.Second), 2*time.Second); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "operation exceeded") {
|
||||||
|
t.Fatalf("operation timeout = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
health.observe(messageIdle, started.Add(5*time.Second))
|
||||||
|
health.observe(messageHeartbeat, started.Add(6*time.Second))
|
||||||
|
if err := health.timeoutError(started.Add(7*time.Second), 2*time.Second); err != nil {
|
||||||
|
t.Fatalf("idle worker reported a timeout: %v", err)
|
||||||
|
}
|
||||||
|
if err := health.timeoutError(started.Add(8*time.Second), 2*time.Second); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "heartbeat was silent") {
|
||||||
|
t.Fatalf("heartbeat timeout = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerSettingsRoundTrip(t *testing.T) {
|
||||||
|
want := config.Settings{
|
||||||
|
Mode: config.ModeVAD,
|
||||||
|
ChunkDuration: time.Second,
|
||||||
|
ModelsDir: "models with spaces",
|
||||||
|
Threads: 3,
|
||||||
|
PreviewThresholdDB: -42,
|
||||||
|
VADThreshold: 0.65,
|
||||||
|
ModelAutoRestart: true,
|
||||||
|
ModelTimeout: 12 * time.Second,
|
||||||
|
ModelShutdownTimeout: 2 * time.Second,
|
||||||
|
}
|
||||||
|
encoded, err := encodeWorkerSettings(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := decodeWorkerSettings(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("settings round trip = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||||
|
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
modelWorkerEnvironment = "CAPTIONEER_INTERNAL_MODEL_WORKER"
|
||||||
|
modelSettingsEnvironment = "CAPTIONEER_INTERNAL_MODEL_SETTINGS"
|
||||||
|
modelProtocolFD = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
messageReady = "ready"
|
||||||
|
messageBusy = "busy"
|
||||||
|
messageIdle = "idle"
|
||||||
|
messageCaption = "caption"
|
||||||
|
messageFailure = "failure"
|
||||||
|
messageHeartbeat = "heartbeat"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workerMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Event *captions.Event `json:"event,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type workerReporter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
encoder *json.Encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *workerReporter) send(message workerMessage) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.encoder.Encode(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
type protocolSink struct {
|
||||||
|
reporter *workerReporter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s protocolSink) Publish(event captions.Event) error {
|
||||||
|
return s.reporter.send(workerMessage{Type: messageCaption, Event: &event})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (protocolSink) Close() error { return nil }
|
||||||
|
|
||||||
|
// IsModelWorker reports whether this process was created as Captioneer's
|
||||||
|
// private inference worker. The environment marker is intentionally internal.
|
||||||
|
func IsModelWorker() bool {
|
||||||
|
return os.Getenv(modelWorkerEnvironment) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeWorkerSettings(settings config.Settings) (string, error) {
|
||||||
|
data, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawStdEncoding.EncodeToString(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeWorkerSettings(value string) (config.Settings, error) {
|
||||||
|
data, err := base64.RawStdEncoding.DecodeString(value)
|
||||||
|
if err != nil {
|
||||||
|
return config.Settings{}, fmt.Errorf("decode model worker settings: %w", err)
|
||||||
|
}
|
||||||
|
var settings config.Settings
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return config.Settings{}, fmt.Errorf("parse model worker settings: %w", err)
|
||||||
|
}
|
||||||
|
if err := settings.Validate(); err != nil {
|
||||||
|
return config.Settings{}, fmt.Errorf("validate model worker settings: %w", err)
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunModelWorker owns all Sherpa/VAD resources in the supervised child. It
|
||||||
|
// returns a process exit code so command entrypoints can call os.Exit.
|
||||||
|
func RunModelWorker(diagnostics io.Writer) int {
|
||||||
|
protocol := os.NewFile(uintptr(modelProtocolFD), "captioneer-model-protocol")
|
||||||
|
if protocol == nil {
|
||||||
|
fmt.Fprintln(diagnostics, "captioneer: model worker protocol is unavailable")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
defer protocol.Close()
|
||||||
|
syscall.CloseOnExec(modelProtocolFD)
|
||||||
|
|
||||||
|
settings, err := decodeWorkerSettings(os.Getenv(modelSettingsEnvironment))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(diagnostics, "captioneer: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
reporter := &workerReporter{encoder: json.NewEncoder(protocol)}
|
||||||
|
hooks := directHooks{
|
||||||
|
ready: func() error { return reporter.send(workerMessage{Type: messageReady}) },
|
||||||
|
busy: func() error { return reporter.send(workerMessage{Type: messageBusy}) },
|
||||||
|
idle: func() error { return reporter.send(workerMessage{Type: messageIdle}) },
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeatInterval := time.Second
|
||||||
|
if settings.ModelTimeout > 0 && settings.ModelTimeout < 4*heartbeatInterval {
|
||||||
|
heartbeatInterval = settings.ModelTimeout / 4
|
||||||
|
if heartbeatInterval <= 0 {
|
||||||
|
heartbeatInterval = time.Millisecond
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heartbeatContext, stopHeartbeat := context.WithCancel(ctx)
|
||||||
|
heartbeatDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(heartbeatDone)
|
||||||
|
ticker := time.NewTicker(heartbeatInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-heartbeatContext.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if reporter.send(workerMessage{Type: messageHeartbeat}) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = runDirect(ctx, settings, protocolSink{reporter: reporter}, diagnostics, hooks)
|
||||||
|
stopHeartbeat()
|
||||||
|
<-heartbeatDone
|
||||||
|
if err == nil || errors.Is(err, context.Canceled) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
_ = reporter.send(workerMessage{Type: messageFailure, Error: err.Error()})
|
||||||
|
fmt.Fprintf(diagnostics, "captioneer: model worker: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ type Event struct {
|
|||||||
type Transcriber interface {
|
type Transcriber interface {
|
||||||
Decode(samples []float32) string
|
Decode(samples []float32) string
|
||||||
AcceptVAD(samples []float32)
|
AcceptVAD(samples []float32)
|
||||||
|
SpeechActive() bool
|
||||||
FlushVAD()
|
FlushVAD()
|
||||||
NextSpeechSegment() (start int, samples []float32, ok bool)
|
NextSpeechSegment() (start int, samples []float32, ok bool)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ package captions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"tea.chunkbyte.com/kato/captioneer/src/audio"
|
"tea.chunkbyte.com/kato/captioneer/src/audio"
|
||||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Provisional recognition uses only a recent window. Re-decoding an entire
|
||||||
|
// long utterance every refresh grows increasingly expensive, while the UI only
|
||||||
|
// presents the latest provisional lines. The final event still decodes the
|
||||||
|
// complete VAD segment.
|
||||||
|
const maxPreviewDuration = 10 * time.Second
|
||||||
|
|
||||||
type Processor struct {
|
type Processor struct {
|
||||||
settings config.Settings
|
settings config.Settings
|
||||||
transcriber Transcriber
|
transcriber Transcriber
|
||||||
@@ -18,6 +25,7 @@ type Processor struct {
|
|||||||
previewActive bool
|
previewActive bool
|
||||||
previewStart int
|
previewStart int
|
||||||
nextPreviewAt int
|
nextPreviewAt int
|
||||||
|
previewReceived int
|
||||||
totalSamples int
|
totalSamples int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,19 +51,26 @@ func (p *Processor) Accept(samples []float32) error {
|
|||||||
func (p *Processor) Flush() error {
|
func (p *Processor) Flush() error {
|
||||||
if p.settings.Mode == config.ModeFixed {
|
if p.settings.Mode == config.ModeFixed {
|
||||||
if len(p.fixedSamples) > 0 {
|
if len(p.fixedSamples) > 0 {
|
||||||
return p.emitFinal(p.totalSamples, p.totalSamples+len(p.fixedSamples), p.fixedSamples)
|
return p.emitFixedFinal(p.totalSamples, p.totalSamples+len(p.fixedSamples), p.fixedSamples)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
p.transcriber.FlushVAD()
|
p.transcriber.FlushVAD()
|
||||||
return p.drainVAD()
|
if err := p.drainVAD(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.previewActive {
|
||||||
|
p.resetPreview()
|
||||||
|
return p.emit(Event{Kind: Hide})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Processor) acceptFixed(samples []float32) error {
|
func (p *Processor) acceptFixed(samples []float32) error {
|
||||||
p.fixedSamples = append(p.fixedSamples, samples...)
|
p.fixedSamples = append(p.fixedSamples, samples...)
|
||||||
chunkSize := audio.DurationSamples(p.settings.ChunkDuration)
|
chunkSize := audio.DurationSamples(p.settings.ChunkDuration)
|
||||||
for len(p.fixedSamples) >= chunkSize {
|
for len(p.fixedSamples) >= chunkSize {
|
||||||
if err := p.emitFinal(p.totalSamples, p.totalSamples+chunkSize, p.fixedSamples[:chunkSize]); err != nil {
|
if err := p.emitFixedFinal(p.totalSamples, p.totalSamples+chunkSize, p.fixedSamples[:chunkSize]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
p.fixedSamples = p.fixedSamples[chunkSize:]
|
p.fixedSamples = p.fixedSamples[chunkSize:]
|
||||||
@@ -68,20 +83,33 @@ func (p *Processor) acceptVAD(samples []float32) error {
|
|||||||
p.totalSamples += len(samples)
|
p.totalSamples += len(samples)
|
||||||
p.transcriber.AcceptVAD(samples)
|
p.transcriber.AcceptVAD(samples)
|
||||||
|
|
||||||
if p.previewActive || audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB {
|
speechActive := p.transcriber.SpeechActive()
|
||||||
|
if speechActive && (p.previewActive || audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB) {
|
||||||
if !p.previewActive {
|
if !p.previewActive {
|
||||||
p.previewActive = true
|
p.previewActive = true
|
||||||
p.previewStart = p.totalSamples - len(samples)
|
p.previewStart = p.totalSamples - len(samples)
|
||||||
}
|
}
|
||||||
p.previewSamples = append(p.previewSamples, samples...)
|
p.previewSamples = append(p.previewSamples, samples...)
|
||||||
if len(p.previewSamples) >= p.nextPreviewAt {
|
p.previewReceived += len(samples)
|
||||||
|
p.trimPreviewWindow()
|
||||||
|
if p.previewReceived >= p.nextPreviewAt {
|
||||||
if err := p.emitProvisional(); err != nil {
|
if err := p.emitProvisional(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
p.nextPreviewAt += audio.DurationSamples(p.settings.ChunkDuration)
|
p.nextPreviewAt += audio.DurationSamples(p.settings.ChunkDuration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return p.drainVAD()
|
if err := p.drainVAD(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A short VAD false-positive can become active without meeting the minimum
|
||||||
|
// speech duration, so no final segment is produced. Clear its provisional
|
||||||
|
// text instead of leaving a stale caption on screen.
|
||||||
|
if !speechActive && p.previewActive {
|
||||||
|
p.resetPreview()
|
||||||
|
return p.emit(Event{Kind: Hide})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Processor) drainVAD() error {
|
func (p *Processor) drainVAD() error {
|
||||||
@@ -93,11 +121,26 @@ func (p *Processor) drainVAD() error {
|
|||||||
if err := p.emitFinal(start, start+len(samples), samples); err != nil {
|
if err := p.emitFinal(start, start+len(samples), samples); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
p.resetPreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) trimPreviewWindow() {
|
||||||
|
maxSamples := audio.DurationSamples(maxPreviewDuration)
|
||||||
|
if len(p.previewSamples) <= maxSamples {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dropped := len(p.previewSamples) - maxSamples
|
||||||
|
p.previewSamples = p.previewSamples[dropped:]
|
||||||
|
p.previewStart += dropped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) resetPreview() {
|
||||||
p.previewSamples = nil
|
p.previewSamples = nil
|
||||||
p.previewActive = false
|
p.previewActive = false
|
||||||
p.previewStart = 0
|
p.previewStart = 0
|
||||||
|
p.previewReceived = 0
|
||||||
p.nextPreviewAt = audio.DurationSamples(p.settings.ChunkDuration)
|
p.nextPreviewAt = audio.DurationSamples(p.settings.ChunkDuration)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Processor) emitProvisional() error {
|
func (p *Processor) emitProvisional() error {
|
||||||
@@ -125,3 +168,10 @@ func (p *Processor) emitFinal(start, end int, samples []float32) error {
|
|||||||
EndedAt: audio.SamplesDuration(end),
|
EndedAt: audio.SamplesDuration(end),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Processor) emitFixedFinal(start, end int, samples []float32) error {
|
||||||
|
if audio.RMSDBFS(samples) < p.settings.PreviewThresholdDB {
|
||||||
|
return p.emit(Event{Kind: Hide})
|
||||||
|
}
|
||||||
|
return p.emitFinal(start, end, samples)
|
||||||
|
}
|
||||||
|
|||||||
+125
-13
@@ -18,9 +18,13 @@ type fakeTranscriber struct {
|
|||||||
decodeTexts []string
|
decodeTexts []string
|
||||||
segments []fakeSegment
|
segments []fakeSegment
|
||||||
flushed bool
|
flushed bool
|
||||||
|
speechActive bool
|
||||||
|
decodeSizes []int
|
||||||
|
acceptedVAD int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeTranscriber) Decode([]float32) string {
|
func (f *fakeTranscriber) Decode(samples []float32) string {
|
||||||
|
f.decodeSizes = append(f.decodeSizes, len(samples))
|
||||||
if len(f.decodeTexts) == 0 {
|
if len(f.decodeTexts) == 0 {
|
||||||
return f.text
|
return f.text
|
||||||
}
|
}
|
||||||
@@ -28,7 +32,8 @@ func (f *fakeTranscriber) Decode([]float32) string {
|
|||||||
f.decodeTexts = f.decodeTexts[1:]
|
f.decodeTexts = f.decodeTexts[1:]
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
func (f *fakeTranscriber) AcceptVAD([]float32) {}
|
func (f *fakeTranscriber) AcceptVAD(samples []float32) { f.acceptedVAD += len(samples) }
|
||||||
|
func (f *fakeTranscriber) SpeechActive() bool { return f.speechActive }
|
||||||
func (f *fakeTranscriber) FlushVAD() { f.flushed = true }
|
func (f *fakeTranscriber) FlushVAD() { f.flushed = true }
|
||||||
func (f *fakeTranscriber) NextSpeechSegment() (int, []float32, bool) {
|
func (f *fakeTranscriber) NextSpeechSegment() (int, []float32, bool) {
|
||||||
if len(f.segments) == 0 {
|
if len(f.segments) == 0 {
|
||||||
@@ -42,12 +47,14 @@ func (f *fakeTranscriber) NextSpeechSegment() (int, []float32, bool) {
|
|||||||
func TestFixedModeEmitsFinalCaption(t *testing.T) {
|
func TestFixedModeEmitsFinalCaption(t *testing.T) {
|
||||||
transcriber := &fakeTranscriber{text: "hello"}
|
transcriber := &fakeTranscriber{text: "hello"}
|
||||||
var events []Event
|
var events []Event
|
||||||
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(event Event) error {
|
processor := New(config.Settings{
|
||||||
|
Mode: config.ModeFixed, ChunkDuration: time.Second, PreviewThresholdDB: -45,
|
||||||
|
}, transcriber, func(event Event) error {
|
||||||
events = append(events, event)
|
events = append(events, event)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
|
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(events) != 1 || events[0].Kind != Final || events[0].Text != "hello" {
|
if len(events) != 1 || events[0].Kind != Final || events[0].Text != "hello" {
|
||||||
@@ -61,12 +68,14 @@ func TestFixedModeEmitsFinalCaption(t *testing.T) {
|
|||||||
func TestFixedModeFlushesFinalPartialChunkWithExactTimestamps(t *testing.T) {
|
func TestFixedModeFlushesFinalPartialChunkWithExactTimestamps(t *testing.T) {
|
||||||
transcriber := &fakeTranscriber{decodeTexts: []string{"first", "partial"}}
|
transcriber := &fakeTranscriber{decodeTexts: []string{"first", "partial"}}
|
||||||
var events []Event
|
var events []Event
|
||||||
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(event Event) error {
|
processor := New(config.Settings{
|
||||||
|
Mode: config.ModeFixed, ChunkDuration: time.Second, PreviewThresholdDB: -45,
|
||||||
|
}, transcriber, func(event Event) error {
|
||||||
events = append(events, event)
|
events = append(events, event)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := processor.Accept(make([]float32, audio.SampleRate+audio.SampleRate/2)); err != nil {
|
if err := processor.Accept(constantSamples(audio.SampleRate+audio.SampleRate/2, 0.5)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := processor.Flush(); err != nil {
|
if err := processor.Flush(); err != nil {
|
||||||
@@ -81,7 +90,7 @@ func TestFixedModeFlushesFinalPartialChunkWithExactTimestamps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
|
func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
|
||||||
transcriber := &fakeTranscriber{text: "speech"}
|
transcriber := &fakeTranscriber{text: "speech", speechActive: true}
|
||||||
var events []Event
|
var events []Event
|
||||||
processor := New(config.Settings{
|
processor := New(config.Settings{
|
||||||
Mode: config.ModeVAD,
|
Mode: config.ModeVAD,
|
||||||
@@ -92,14 +101,12 @@ func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
loud := make([]float32, audio.SampleRate)
|
loud := constantSamples(audio.SampleRate, 0.5)
|
||||||
for i := range loud {
|
|
||||||
loud[i] = 0.5
|
|
||||||
}
|
|
||||||
if err := processor.Accept(loud); err != nil {
|
if err := processor.Accept(loud); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
transcriber.segments = []fakeSegment{{start: 0, samples: loud}}
|
transcriber.segments = []fakeSegment{{start: 0, samples: loud}}
|
||||||
|
transcriber.speechActive = false
|
||||||
if err := processor.Accept(make([]float32, audio.SamplesPerPacket())); err != nil {
|
if err := processor.Accept(make([]float32, audio.SamplesPerPacket())); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -112,11 +119,13 @@ func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
|
|||||||
func TestEmptyFinalEmitsHide(t *testing.T) {
|
func TestEmptyFinalEmitsHide(t *testing.T) {
|
||||||
transcriber := &fakeTranscriber{text: " \n\t "}
|
transcriber := &fakeTranscriber{text: " \n\t "}
|
||||||
var event Event
|
var event Event
|
||||||
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(got Event) error {
|
processor := New(config.Settings{
|
||||||
|
Mode: config.ModeFixed, ChunkDuration: time.Second, PreviewThresholdDB: -45,
|
||||||
|
}, transcriber, func(got Event) error {
|
||||||
event = got
|
event = got
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
|
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if event.Kind != Hide {
|
if event.Kind != Hide {
|
||||||
@@ -124,6 +133,101 @@ func TestEmptyFinalEmitsHide(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFixedModeSilenceStaysOutOfRecognizer(t *testing.T) {
|
||||||
|
transcriber := &fakeTranscriber{text: "hallucinated caption"}
|
||||||
|
var events []Event
|
||||||
|
processor := New(config.Settings{
|
||||||
|
Mode: config.ModeFixed,
|
||||||
|
ChunkDuration: time.Second,
|
||||||
|
PreviewThresholdDB: -45,
|
||||||
|
}, transcriber, func(event Event) error {
|
||||||
|
events = append(events, event)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(transcriber.decodeSizes) != 0 {
|
||||||
|
t.Fatalf("silent audio reached recognizer: decode sizes = %v", transcriber.decodeSizes)
|
||||||
|
}
|
||||||
|
if len(events) != 1 || events[0].Kind != Hide {
|
||||||
|
t.Fatalf("silent chunk events = %#v, want one Hide", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVADModeDoesNotPreviewLoudNonSpeech(t *testing.T) {
|
||||||
|
transcriber := &fakeTranscriber{text: "music hallucination"}
|
||||||
|
var events []Event
|
||||||
|
processor := New(config.Settings{
|
||||||
|
Mode: config.ModeVAD,
|
||||||
|
ChunkDuration: time.Second,
|
||||||
|
PreviewThresholdDB: -45,
|
||||||
|
}, transcriber, func(event Event) error {
|
||||||
|
events = append(events, event)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
loudMusic := constantSamples(audio.SampleRate, 0.5)
|
||||||
|
if err := processor.Accept(loudMusic); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if transcriber.acceptedVAD != len(loudMusic) {
|
||||||
|
t.Fatalf("VAD received %d samples, want %d", transcriber.acceptedVAD, len(loudMusic))
|
||||||
|
}
|
||||||
|
if len(transcriber.decodeSizes) != 0 || len(events) != 0 {
|
||||||
|
t.Fatalf("non-speech reached recognizer: decodes=%v events=%#v", transcriber.decodeSizes, events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVADModeClearsRejectedShortSpeechPreview(t *testing.T) {
|
||||||
|
transcriber := &fakeTranscriber{text: "draft", speechActive: true}
|
||||||
|
var events []Event
|
||||||
|
processor := New(config.Settings{
|
||||||
|
Mode: config.ModeVAD,
|
||||||
|
ChunkDuration: time.Second,
|
||||||
|
PreviewThresholdDB: -45,
|
||||||
|
}, transcriber, func(event Event) error {
|
||||||
|
events = append(events, event)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
transcriber.speechActive = false
|
||||||
|
if err := processor.Accept(make([]float32, audio.SamplesPerPacket())); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(events) != 2 || events[0].Kind != Provisional || events[1].Kind != Hide {
|
||||||
|
t.Fatalf("events = %#v, want Provisional then Hide", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVADPreviewRecognitionWindowIsBounded(t *testing.T) {
|
||||||
|
transcriber := &fakeTranscriber{text: "draft", speechActive: true}
|
||||||
|
processor := New(config.Settings{
|
||||||
|
Mode: config.ModeVAD,
|
||||||
|
ChunkDuration: time.Second,
|
||||||
|
PreviewThresholdDB: -45,
|
||||||
|
}, transcriber, func(Event) error { return nil })
|
||||||
|
|
||||||
|
for range 15 {
|
||||||
|
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxSamples := audio.DurationSamples(maxPreviewDuration)
|
||||||
|
for _, size := range transcriber.decodeSizes {
|
||||||
|
if size > maxSamples {
|
||||||
|
t.Fatalf("preview decode used %d samples, limit is %d", size, maxSamples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(transcriber.decodeSizes) != 15 {
|
||||||
|
t.Fatalf("preview decode count = %d, want 15", len(transcriber.decodeSizes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFlushFlushesVAD(t *testing.T) {
|
func TestFlushFlushesVAD(t *testing.T) {
|
||||||
transcriber := &fakeTranscriber{}
|
transcriber := &fakeTranscriber{}
|
||||||
processor := New(config.Settings{Mode: config.ModeVAD, ChunkDuration: time.Second}, transcriber, func(Event) error { return nil })
|
processor := New(config.Settings{Mode: config.ModeVAD, ChunkDuration: time.Second}, transcriber, func(Event) error { return nil })
|
||||||
@@ -134,3 +238,11 @@ func TestFlushFlushesVAD(t *testing.T) {
|
|||||||
t.Fatal("VAD was not flushed")
|
t.Fatal("VAD was not flushed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func constantSamples(count int, value float32) []float32 {
|
||||||
|
samples := make([]float32, count)
|
||||||
|
for i := range samples {
|
||||||
|
samples[i] = value
|
||||||
|
}
|
||||||
|
return samples
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"tea.chunkbyte.com/kato/captioneer/src/audio"
|
"tea.chunkbyte.com/kato/captioneer/src/audio"
|
||||||
)
|
)
|
||||||
@@ -41,6 +42,7 @@ func Packets(ctx context.Context, monitorSource string) (<-chan []float32, func(
|
|||||||
"--latency-msec=50",
|
"--latency-msec=50",
|
||||||
)
|
)
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if app.IsModelWorker() {
|
||||||
|
os.Exit(app.RunModelWorker(os.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
runtime.LockOSThread()
|
runtime.LockOSThread()
|
||||||
defer runtime.UnlockOSThread()
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if app.IsModelWorker() {
|
||||||
|
os.Exit(app.RunModelWorker(os.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
settings := config.Parse()
|
settings := config.Parse()
|
||||||
if err := settings.Validate(); err != nil {
|
if err := settings.Validate(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|||||||
+41
-4
@@ -4,10 +4,18 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultModelsDir = "models"
|
const (
|
||||||
|
DefaultModelsDir = "models"
|
||||||
|
DefaultVADThreshold = 0.5
|
||||||
|
DefaultOverlayFontFamily = "Sans"
|
||||||
|
MinimumOverlayFinalLifetime = 3 * time.Second
|
||||||
|
DefaultModelTimeout = 30 * time.Second
|
||||||
|
DefaultModelShutdownTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type Mode string
|
type Mode string
|
||||||
|
|
||||||
@@ -22,6 +30,10 @@ type Settings struct {
|
|||||||
ModelsDir string
|
ModelsDir string
|
||||||
Threads int
|
Threads int
|
||||||
PreviewThresholdDB float64
|
PreviewThresholdDB float64
|
||||||
|
VADThreshold float64
|
||||||
|
ModelAutoRestart bool
|
||||||
|
ModelTimeout time.Duration
|
||||||
|
ModelShutdownTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Parse() Settings {
|
func Parse() Settings {
|
||||||
@@ -31,7 +43,11 @@ func Parse() Settings {
|
|||||||
flag.DurationVar(&settings.ChunkDuration, "chunk-duration", time.Second, "fixed chunk size or VAD preview refresh interval")
|
flag.DurationVar(&settings.ChunkDuration, "chunk-duration", time.Second, "fixed chunk size or VAD preview refresh interval")
|
||||||
flag.StringVar(&settings.ModelsDir, "models-dir", DefaultModelsDir, "directory containing downloaded Sherpa models")
|
flag.StringVar(&settings.ModelsDir, "models-dir", DefaultModelsDir, "directory containing downloaded Sherpa models")
|
||||||
flag.IntVar(&settings.Threads, "threads", 2, "CPU threads used by recognition and VAD")
|
flag.IntVar(&settings.Threads, "threads", 2, "CPU threads used by recognition and VAD")
|
||||||
flag.Float64Var(&settings.PreviewThresholdDB, "preview-threshold-dbfs", -45, "RMS dBFS threshold used to begin VAD previews")
|
flag.Float64Var(&settings.PreviewThresholdDB, "preview-threshold-dbfs", -45, "RMS dBFS gate below which recognition stays idle")
|
||||||
|
flag.Float64Var(&settings.VADThreshold, "vad-threshold", DefaultVADThreshold, "speech detection sensitivity from 0 to 1; higher rejects more non-speech")
|
||||||
|
flag.BoolVar(&settings.ModelAutoRestart, "model-auto-restart", true, "restart the isolated model worker after a crash or timeout")
|
||||||
|
flag.DurationVar(&settings.ModelTimeout, "model-timeout", DefaultModelTimeout, "maximum model startup or processing time; zero disables hang detection")
|
||||||
|
flag.DurationVar(&settings.ModelShutdownTimeout, "model-shutdown-timeout", DefaultModelShutdownTimeout, "time allowed for model flush and cleanup before force-killing the worker")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
settings.Mode = Mode(mode)
|
settings.Mode = Mode(mode)
|
||||||
return settings
|
return settings
|
||||||
@@ -57,6 +73,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
|
||||||
@@ -80,15 +97,20 @@ func ParseDesktop() DesktopSettings {
|
|||||||
flags.DurationVar(&desktop.ChunkDuration, "chunk-duration", time.Second, "fixed chunk size or VAD preview refresh interval")
|
flags.DurationVar(&desktop.ChunkDuration, "chunk-duration", time.Second, "fixed chunk size or VAD preview refresh interval")
|
||||||
flags.StringVar(&desktop.ModelsDir, "models-dir", DefaultModelsDir, "directory containing downloaded Sherpa models")
|
flags.StringVar(&desktop.ModelsDir, "models-dir", DefaultModelsDir, "directory containing downloaded Sherpa models")
|
||||||
flags.IntVar(&desktop.Threads, "threads", 2, "CPU threads used by recognition and VAD")
|
flags.IntVar(&desktop.Threads, "threads", 2, "CPU threads used by recognition and VAD")
|
||||||
flags.Float64Var(&desktop.PreviewThresholdDB, "preview-threshold-dbfs", -45, "RMS dBFS threshold used to begin VAD previews")
|
flags.Float64Var(&desktop.PreviewThresholdDB, "preview-threshold-dbfs", -45, "RMS dBFS gate below which recognition stays idle")
|
||||||
|
flags.Float64Var(&desktop.VADThreshold, "vad-threshold", DefaultVADThreshold, "speech detection sensitivity from 0 to 1; higher rejects more non-speech")
|
||||||
|
flags.BoolVar(&desktop.ModelAutoRestart, "model-auto-restart", true, "restart the isolated model worker after a crash or timeout")
|
||||||
|
flags.DurationVar(&desktop.ModelTimeout, "model-timeout", DefaultModelTimeout, "maximum model startup or processing time; zero disables hang detection")
|
||||||
|
flags.DurationVar(&desktop.ModelShutdownTimeout, "model-shutdown-timeout", DefaultModelShutdownTimeout, "time allowed for model flush and cleanup before force-killing the worker")
|
||||||
flags.StringVar(&output, "output", "", "caption output: terminal, overlay, or both (required)")
|
flags.StringVar(&output, "output", "", "caption output: terminal, overlay, or both (required)")
|
||||||
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")
|
||||||
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")
|
flags.BoolVar(&desktop.Overlay.ClickThrough, "overlay-click-through", true, "pass pointer input through the caption window")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
desktop.Mode = Mode(mode)
|
desktop.Mode = Mode(mode)
|
||||||
@@ -107,6 +129,15 @@ func (s Settings) Validate() error {
|
|||||||
if s.Threads <= 0 {
|
if s.Threads <= 0 {
|
||||||
return errors.New("--threads must be positive")
|
return errors.New("--threads must be positive")
|
||||||
}
|
}
|
||||||
|
if s.VADThreshold < 0 || s.VADThreshold > 1 {
|
||||||
|
return errors.New("--vad-threshold must be between 0 and 1")
|
||||||
|
}
|
||||||
|
if s.ModelTimeout < 0 {
|
||||||
|
return errors.New("--model-timeout cannot be negative")
|
||||||
|
}
|
||||||
|
if s.ModelShutdownTimeout <= 0 {
|
||||||
|
return errors.New("--model-shutdown-timeout must be positive")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +157,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")
|
||||||
}
|
}
|
||||||
@@ -135,6 +169,9 @@ func (s DesktopSettings) Validate() error {
|
|||||||
if s.Overlay.FinalTimeout < 0 {
|
if s.Overlay.FinalTimeout < 0 {
|
||||||
return errors.New("--overlay-final-timeout cannot be negative")
|
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 == "" {
|
if s.Overlay.Monitor == "" {
|
||||||
return errors.New("--overlay-monitor cannot be empty")
|
return errors.New("--overlay-monitor cannot be empty")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSettingsValidate(t *testing.T) {
|
func TestSettingsValidate(t *testing.T) {
|
||||||
valid := Settings{Mode: ModeFixed, ChunkDuration: time.Second, Threads: 1}
|
valid := Settings{
|
||||||
|
Mode: ModeFixed,
|
||||||
|
ChunkDuration: time.Second,
|
||||||
|
Threads: 1,
|
||||||
|
ModelShutdownTimeout: DefaultModelShutdownTimeout,
|
||||||
|
}
|
||||||
if err := valid.Validate(); err != nil {
|
if err := valid.Validate(); err != nil {
|
||||||
t.Fatalf("valid settings failed: %v", err)
|
t.Fatalf("valid settings failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -17,10 +22,16 @@ func TestSettingsValidate(t *testing.T) {
|
|||||||
|
|
||||||
func validDesktopSettings() DesktopSettings {
|
func validDesktopSettings() DesktopSettings {
|
||||||
return DesktopSettings{
|
return DesktopSettings{
|
||||||
Settings: Settings{Mode: ModeVAD, ChunkDuration: time.Second, Threads: 2},
|
Settings: Settings{
|
||||||
|
Mode: ModeVAD,
|
||||||
|
ChunkDuration: time.Second,
|
||||||
|
Threads: 2,
|
||||||
|
ModelTimeout: DefaultModelTimeout,
|
||||||
|
ModelShutdownTimeout: DefaultModelShutdownTimeout,
|
||||||
|
},
|
||||||
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,10 +51,16 @@ 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 = "" }},
|
||||||
{"negative timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = -time.Second }},
|
{"negative timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = -time.Second }},
|
||||||
|
{"short timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = time.Second }},
|
||||||
|
{"negative model timeout", func(s *DesktopSettings) { s.ModelTimeout = -time.Second }},
|
||||||
|
{"zero model shutdown timeout", func(s *DesktopSettings) { s.ModelShutdownTimeout = 0 }},
|
||||||
|
{"negative VAD threshold", func(s *DesktopSettings) { s.VADThreshold = -0.1 }},
|
||||||
|
{"large VAD threshold", func(s *DesktopSettings) { s.VADThreshold = 1.1 }},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
+13
-3
@@ -25,6 +25,11 @@ type Resources struct {
|
|||||||
vad *sherpa.VoiceActivityDetector
|
vad *sherpa.VoiceActivityDetector
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Validate(settings config.Settings) error {
|
||||||
|
_, err := validatePaths(settings.ModelsDir, settings.Mode)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func New(settings config.Settings) (*Resources, error) {
|
func New(settings config.Settings) (*Resources, error) {
|
||||||
paths, err := validatePaths(settings.ModelsDir, settings.Mode)
|
paths, err := validatePaths(settings.ModelsDir, settings.Mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,7 +57,7 @@ func New(settings config.Settings) (*Resources, error) {
|
|||||||
|
|
||||||
resources := &Resources{recognizer: recognizer}
|
resources := &Resources{recognizer: recognizer}
|
||||||
if settings.Mode == config.ModeVAD {
|
if settings.Mode == config.ModeVAD {
|
||||||
resources.vad = newVAD(paths.vad, settings.Threads)
|
resources.vad = newVAD(paths.vad, settings.Threads, settings.VADThreshold)
|
||||||
if resources.vad == nil {
|
if resources.vad == nil {
|
||||||
resources.Close()
|
resources.Close()
|
||||||
return nil, fmt.Errorf("create Silero VAD: Sherpa returned nil")
|
return nil, fmt.Errorf("create Silero VAD: Sherpa returned nil")
|
||||||
@@ -87,6 +92,10 @@ func (r *Resources) AcceptVAD(samples []float32) {
|
|||||||
r.vad.AcceptWaveform(samples)
|
r.vad.AcceptWaveform(samples)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Resources) SpeechActive() bool {
|
||||||
|
return r.vad.IsSpeech()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Resources) FlushVAD() {
|
func (r *Resources) FlushVAD() {
|
||||||
r.vad.Flush()
|
r.vad.Flush()
|
||||||
}
|
}
|
||||||
@@ -127,11 +136,12 @@ func validatePaths(modelsDir string, mode config.Mode) (modelPaths, error) {
|
|||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVAD(model string, threads int) *sherpa.VoiceActivityDetector {
|
func newVAD(model string, threads int, threshold float64) *sherpa.VoiceActivityDetector {
|
||||||
return sherpa.NewVoiceActivityDetector(&sherpa.VadModelConfig{
|
return sherpa.NewVoiceActivityDetector(&sherpa.VadModelConfig{
|
||||||
SileroVad: sherpa.SileroVadModelConfig{
|
SileroVad: sherpa.SileroVadModelConfig{
|
||||||
Model: model,
|
Model: model,
|
||||||
Threshold: 0.5,
|
Threshold: float32(threshold),
|
||||||
|
// A 500 ms pause ends the current caption line and starts the next one.
|
||||||
MinSilenceDuration: 0.5,
|
MinSilenceDuration: 0.5,
|
||||||
MinSpeechDuration: 0.25,
|
MinSpeechDuration: 0.25,
|
||||||
WindowSize: 512,
|
WindowSize: 512,
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ static gboolean captioneer_apply_update(gpointer data) {
|
|||||||
CaptioneerOverlay *overlay = update->overlay;
|
CaptioneerOverlay *overlay = update->overlay;
|
||||||
|
|
||||||
overlay->final_visible = update->final_text[0] != '\0';
|
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);
|
gtk_widget_set_visible(overlay->final_label, overlay->final_visible);
|
||||||
|
|
||||||
overlay->preview_visible = update->preview_text[0] != '\0';
|
overlay->preview_visible = update->preview_text[0] != '\0';
|
||||||
@@ -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,28 +371,39 @@ 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);
|
||||||
gtk_label_set_lines(GTK_LABEL(labels[i]), 2);
|
// width-chars gives GTK a concrete allocation width, so wrapping happens
|
||||||
gtk_label_set_ellipsize(GTK_LABEL(labels[i]), PANGO_ELLIPSIZE_END);
|
// 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]);
|
||||||
gtk_widget_set_visible(labels[i], FALSE);
|
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->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));
|
||||||
@@ -445,10 +473,18 @@ import (
|
|||||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -456,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
|
||||||
@@ -466,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,
|
||||||
@@ -481,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 {
|
||||||
@@ -490,29 +533,42 @@ func (s *Sink) Publish(event captions.Event) error {
|
|||||||
if s.closed {
|
if s.closed {
|
||||||
return errors.New("overlay is closed")
|
return errors.New("overlay is closed")
|
||||||
}
|
}
|
||||||
s.state.Apply(event, time.Now(), s.finalTimeout)
|
now := time.Now()
|
||||||
if event.Kind == captions.Final || event.Kind == captions.Hide {
|
s.state.Apply(event, now, s.finalTimeout, s.lineCharacters)
|
||||||
|
if event.Kind == captions.Final {
|
||||||
s.stopFinalTimer()
|
s.stopFinalTimer()
|
||||||
}
|
s.scheduleFinalRefresh(now)
|
||||||
if event.Kind == captions.Final && s.finalTimeout > 0 {
|
|
||||||
s.finalTimer = time.AfterFunc(s.finalTimeout, s.expireFinal)
|
|
||||||
}
|
}
|
||||||
s.postState()
|
s.postState()
|
||||||
return nil
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if s.closed || !s.state.ExpireFinal(time.Now()) {
|
if s.closed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
|
s.state.ExpireFinal(now)
|
||||||
s.finalTimer = nil
|
s.finalTimer = nil
|
||||||
|
s.scheduleFinalRefresh(now)
|
||||||
s.postState()
|
s.postState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sink) 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)
|
previewText := C.CString(s.state.Provisional)
|
||||||
defer C.free(unsafe.Pointer(finalText))
|
defer C.free(unsafe.Pointer(finalText))
|
||||||
defer C.free(unsafe.Pointer(previewText))
|
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 {
|
func (s *Sink) Run() error {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
if s.closed {
|
if s.closed {
|
||||||
|
|||||||
+204
-16
@@ -1,39 +1,227 @@
|
|||||||
package overlay
|
package overlay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||||
)
|
)
|
||||||
|
|
||||||
type viewState struct {
|
const (
|
||||||
Final string
|
maxVisibleFinalLines = 6
|
||||||
Provisional string
|
maxVisiblePreviewLines = 3
|
||||||
FinalExpiresAt time.Time
|
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 {
|
switch event.Kind {
|
||||||
case captions.Provisional:
|
case captions.Provisional:
|
||||||
s.Provisional = strings.TrimSpace(event.Text)
|
chunks := splitCaptionText(event.Text, maxLineCharacters)
|
||||||
case captions.Final:
|
if len(chunks) > maxVisiblePreviewLines {
|
||||||
s.Final = strings.TrimSpace(event.Text)
|
chunks = chunks[len(chunks)-maxVisiblePreviewLines:]
|
||||||
s.Provisional = ""
|
|
||||||
s.FinalExpiresAt = time.Time{}
|
|
||||||
if finalTimeout > 0 {
|
|
||||||
s.FinalExpiresAt = now.Add(finalTimeout)
|
|
||||||
}
|
}
|
||||||
|
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:
|
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 {
|
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
|
return false
|
||||||
}
|
}
|
||||||
s.Final = ""
|
s.Finals = s.Finals[firstVisible:]
|
||||||
s.FinalExpiresAt = time.Time{}
|
|
||||||
return true
|
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"
|
"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)
|
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: " 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.Provisional, Text: " next draft "}, 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, 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)
|
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.Final != "" || 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 TestViewStateFinalTimeout(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: "final"}, now, 4*time.Second)
|
state.Apply(captions.Event{Kind: captions.Final, Text: "first"}, now, 3*time.Second, testLineCharacters)
|
||||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 4*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, testLineCharacters)
|
||||||
|
|
||||||
if state.ExpireFinal(now.Add(3999 * time.Millisecond)) {
|
if state.ExpireFinal(now.Add(2999 * time.Millisecond)) {
|
||||||
t.Fatal("final caption expired early")
|
t.Fatal("completed line expired early")
|
||||||
}
|
}
|
||||||
if !state.ExpireFinal(now.Add(4 * time.Second)) {
|
if !state.ExpireFinal(now.Add(3 * time.Second)) {
|
||||||
t.Fatal("final caption did not expire")
|
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)
|
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) {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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