diff --git a/.gitignore b/.gitignore index f50ac56..59fd219 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ models/silero_vad_v5.onnx # Locally built executable. /captioneer +/build/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index cc0138b..c5d57ea 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,205 @@ # Captioneer -Local, live English captions for the audio playing through your default PulseAudio/PipeWire output device. It captures that output's monitor source, runs the Parakeet TDT v2 model locally, and writes captions to the terminal. +Captioneer creates local, live English captions for audio playing through the current default PulseAudio or PipeWire output device. It captures the sink's monitor source and transcribes it with Parakeet TDT v2; VAD mode uses Silero to find speech boundaries. -Everything runs on your machine after the one-time model download. No audio is uploaded anywhere. +Audio and captions stay on your machine. Nothing is uploaded after the one-time model download. -## Requirements +Captioneer provides two commands: -- Linux on `amd64` / x86_64 -- Go 1.26.2 or newer -- `pactl` and `parec` available on `PATH` (on Debian/Ubuntu: `sudo apt install pulseaudio-utils`) -- `curl` and `tar` to download the models -- A working PulseAudio-compatible server, including PipeWire's PulseAudio compatibility layer - -The application listens to the monitor of the current default audio sink. Check it with: - -```bash -pactl get-default-sink -``` +- `captioneer` is the lightweight, terminal-only command and has no GTK dependency. +- `captioneer-desktop` adds a GTK4 subtitle overlay and can output to the overlay, terminal, or both. It is built with the `gtk` build tag. ## Setup -Clone the project, then download the required models once: +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. + +Download the large recognition models once: ```bash -git clone -cd captioneer ./scripts/download-models.sh ``` -The download installs the Parakeet TDT v2 int8 model and Silero VAD into `models/`. They are intentionally not committed to Git because they are large. +This creates `models/parakeet-tdt-v2/` and `models/silero_vad_v5.onnx`. They are intentionally excluded from Git. -## Run +## Terminal captions -Use VAD mode for normal live captions. It shows a temporary `…` caption while speech is active, then prints a timestamped final caption after 0.5 seconds of silence: +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: ```bash go run ./src/cmd/captioneer --mode=vad ``` -Use fixed mode to decode exact time slices, even through silence: +Fixed mode transcribes independent, exact-duration chunks: ```bash go run ./src/cmd/captioneer --mode=fixed --chunk-duration=1s ``` -Build a reusable executable instead of using `go run`: +Build and run the terminal binary: ```bash -go build -o captioneer ./src/cmd/captioneer -./captioneer --mode=vad +go build -o build/captioneer ./src/cmd/captioneer +./build/captioneer --mode=vad ``` -Press Ctrl+C to stop. Pending audio is flushed before exit. +Final captions use stdout; diagnostics and overload warnings use stderr. This makes redirection useful: -## Options +```bash +./build/captioneer --mode=vad > captions.txt +``` -`--mode` is required. All other options have defaults. +Press Ctrl+C to stop. Captioneer stops `parec` and flushes pending audio before exiting. -| Option | Default | Description | +## Desktop overlay + +The simplest Bazzite development command is: + +```bash +./scripts/distrobox/run.sh --mode=vad --output=overlay +``` + +Use `--output=both` to keep terminal output as well: + +```bash +./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. + +To build outside the helper script, install GTK4 and GTK4 Layer Shell development packages, then run: + +```bash +go build -tags gtk -o build/captioneer-desktop ./src/cmd/captioneer-desktop +./build/captioneer-desktop --mode=vad --output=overlay +``` + +### Wayland and X11 behavior + +On KDE Plasma Wayland, GTK4 Layer Shell places Captioneer in the overlay layer near the bottom center without taking keyboard focus. With automatic monitor selection, the compositor chooses the recently active output. A numeric monitor index or connector name can be selected explicitly. + +A normal GTK window does work on Wayland. What generic Wayland does **not** provide is a portable way for that window to force itself above all other windows or choose an exact screen position. If Layer Shell is unavailable, Captioneer uses a normal undecorated window and prints an explicit warning; compositor window rules may improve its stacking behavior. It never silently switches to XWayland—use `--overlay-backend=x11` when that route is wanted. + +On X11/XWayland, Captioneer selects the monitor containing the mouse cursor in automatic mode and requests notification, keep-above, skip-taskbar, and skip-pager window-manager hints. + +Layer Shell and X11 hints are requests, not universal guarantees. Same-layer windows, exclusive-fullscreen games, compositor policy, screenshots, screen sharing, and recording software can all behave differently. Captioneer does not attempt capture exclusion in this version. + +## Command-line options + +`--mode` is required for both commands. `captioneer-desktop` also requires `--output`. + +### Transcription options + +| Option | Default | Meaning | | --- | --- | --- | -| `--mode=vad` | — | Speech-aware captions. This is the recommended everyday mode. | -| `--mode=fixed` | — | Decode every fixed-duration audio chunk. | -| `--chunk-duration=1s` | `1s` | Fixed chunk length, or VAD preview refresh interval. Go duration syntax is used, e.g. `500ms`, `1s`, `2s`. | +| `--mode=vad|fixed` | required | Speech-aware utterances or independent fixed chunks. | +| `--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`. | -| `--threads=2` | `2` | CPU threads used by Parakeet and VAD. Increase only if your CPU has spare capacity. | -| `--preview-threshold-dbfs=-45` | `-45` | RMS level that begins a provisional VAD caption. Raise it (for example, `-35`) if background audio starts previews; lower it (for example, `-55`) if quiet speech does not. | +| `--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. | + +### Desktop-only options + +| Option | Default | Meaning | +| --- | --- | --- | +| `--output=terminal|overlay|both` | required | Select desktop-command renderers. `both` falls back to terminal if GTK initialization fails. | +| `--overlay-backend=auto|wayland|x11` | `auto` | Let GTK detect the backend, or require one explicitly. | +| `--overlay-opacity=0.90` | `0.90` | Dark bubble opacity from `0` to `1`. | +| `--overlay-font-size=28` | `28` | Font size in logical pixels. | +| `--overlay-bottom-margin=100` | `100` | Bottom margin in logical pixels. | +| `--overlay-max-width=1200` | `1200` | Width cap; the overlay also limits itself to 80% of the selected monitor. | +| `--overlay-monitor=auto` | `auto` | `auto`, a zero-based monitor index, or a connector name such as `DP-1`. | +| `--overlay-final-timeout=4s` | `4s` | Time before the final row disappears. `0s` keeps it until replaced or hidden. | +| `--overlay-click-through=true` | `true` | Use an empty input region where the display backend supports it. | Examples: ```bash -# Faster fixed chunks -go run ./src/cmd/captioneer --mode=fixed --chunk-duration=500ms +# Larger text, longer final-caption visibility +./scripts/distrobox/run.sh --mode=vad --output=overlay \ + --overlay-font-size=34 --overlay-final-timeout=6s -# Fewer VAD preview redraws and four CPU threads -go run ./src/cmd/captioneer --mode=vad --chunk-duration=2s --threads=4 +# Explicit XWayland/X11 route on the cursor's monitor +./scripts/distrobox/run.sh --mode=vad --output=both --overlay-backend=x11 -# Store or reuse models outside this repository -go run ./src/cmd/captioneer --mode=vad --models-dir=/path/to/models +# Fixed two-second captions on a named monitor +./scripts/distrobox/run.sh --mode=fixed --chunk-duration=2s \ + --output=overlay --overlay-monitor=DP-1 ``` -There are no environment variables to configure. All supported configuration is supplied with command-line options. +## Bazzite development with Distrobox -## Source layout +The helper scripts keep GTK development packages off the immutable host. They create and reuse `captioneer-dev` from the pinned `registry.fedoraproject.org/fedora-toolbox:44` image, install only missing packages, enter this mounted repository, and reuse the host's `models/` directory. -All Go code lives in `src/` as focused packages: - -- `cmd/captioneer` is the executable entrypoint. -- `config` defines command-line options. -- `audio` contains PCM conversion and audio calculations. -- `capture` reads the default system-audio monitor. -- `models` owns Parakeet and Silero model resources. -- `captions` handles fixed chunks, VAD segments, and terminal output. - -## Output and troubleshooting - -Final captions go to standard output in this form: - -```text -[12.34s-15.67s] This is a completed caption. -``` - -Startup messages and overload warnings go to standard error, so you can save final captions separately: +Run the desktop command from source: ```bash -go run ./src/cmd/captioneer --mode=vad > captions.txt +./scripts/distrobox/run.sh --mode=vad --output=overlay ``` -- **Missing model files:** run `./scripts/download-models.sh` again. -- **`pactl` or `parec` not found:** install `pulseaudio-utils` and start a PulseAudio-compatible audio server. -- **No captions while audio plays:** verify the default sink with `pactl get-default-sink`; the app records that sink's `.monitor` source. -- **Overload warning:** recognition is behind real time. Try `--threads=4` (or another suitable value) or `--chunk-duration=2s`. +Build both binaries into the host-visible `build/` directory: -## Verify changes +```bash +./scripts/distrobox/build.sh +``` + +The results are `build/captioneer` and `build/captioneer-desktop`. The terminal binary can normally run directly on Bazzite. The desktop binary is dynamically linked to GTK4 Layer Shell; run it through `run.sh` unless matching runtime libraries are available on the host. + +The scripts recognize these environment variables: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `CAPTIONEER_DISTROBOX_NAME` | `captioneer-dev` | Dedicated Distrobox container name. | +| `CAPTIONEER_DISTROBOX_IMAGE` | `registry.fedoraproject.org/fedora-toolbox:44` | Container image. Override it to test a newer Fedora release deliberately. | +| `GSK_RENDERER` | `cairo` in `run.sh` only | GTK renderer. The safe software default avoids host/container GPU DMABUF mismatches; an explicit value is preserved. | + +Captioneer itself has no environment-based application configuration; use the command-line flags above. The Fedora image is pinned for reproducibility and will not advance automatically. The name and image overrides apply when creating or selecting the container; an existing named container is reused. Update the image intentionally when Fedora 44 is no longer supported. Distrobox provides the normal home, display, session bus, PipeWire/PulseAudio, and runtime-directory integration; the scripts add no broad socket or device mounts. + +## Troubleshooting + +- **Missing model files:** run `./scripts/download-models.sh` or correct `--models-dir`. +- **`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 `.monitor`. +- **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 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. + +## 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. + +```text +src/app/ capture/model/process lifecycle +src/captions/ caption events and fixed/VAD coordination +src/output/terminal/ terminal renderer +src/output/overlay/ private GTK4/Layer Shell CGO adapter +src/cmd/captioneer/ terminal command +src/cmd/captioneer-desktop/ GTK-tagged desktop command +``` + +Run non-GTK verification on the host: ```bash go test ./... -go build -o captioneer ./src/cmd/captioneer +go build -o build/captioneer ./src/cmd/captioneer ``` + +Run full verification in the prepared Distrobox: + +```bash +repo_root="$(pwd -P)" +distrobox enter --no-workdir captioneer-dev -- bash -lc ' + cd -- "$1" + go test -tags gtk ./... + shellcheck scripts/distrobox/common.sh scripts/distrobox/build.sh scripts/distrobox/run.sh +' captioneer-verify "$repo_root" +./scripts/distrobox/build.sh +``` + +Run this from the repository root. The build and run helpers resolve that path automatically. + +## License + +Captioneer is licensed under the [Apache License 2.0](LICENSE). Sherpa-Onnx is Apache-2.0 licensed, GTK4 is LGPL-2.1-or-later, and GTK4 Layer Shell is MIT licensed. Captioneer links to the system GTK libraries rather than copying their source into the project. + +Version 1 intentionally has no animation, caption history, runtime dragging, background-free mode, capture exclusion, Flatpak, desktop entry, or autostart integration. diff --git a/scripts/distrobox/build.sh b/scripts/distrobox/build.sh new file mode 100755 index 0000000..f5efcb0 --- /dev/null +++ b/scripts/distrobox/build.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/distrobox/common.sh +source "$script_dir/common.sh" + +for argument in "$@"; do + if [[ "$argument" == "-o" || "$argument" == -o=* ]]; then + die "build output is managed by this script; do not pass -o" + fi +done + +prepare_distrobox +mkdir -p -- "$repo_root/build" + +# shellcheck disable=SC2016 # This script is evaluated inside the container. +build_script='set -Eeuo pipefail +repo_root=$1 +shift +cd -- "$repo_root" +go build "$@" -o build/captioneer ./src/cmd/captioneer +go build "$@" -tags gtk -o build/captioneer-desktop ./src/cmd/captioneer-desktop' + +distrobox enter --no-workdir --name "$container_name" -- \ + bash -lc "$build_script" captioneer-build "$repo_root" "$@" + +printf 'Built terminal binary: %s\n' "$repo_root/build/captioneer" +printf 'Built desktop binary: %s\n' "$repo_root/build/captioneer-desktop" +printf 'The desktop binary requires compatible GTK4 and gtk4-layer-shell runtime libraries.\n' diff --git a/scripts/distrobox/common.sh b/scripts/distrobox/common.sh new file mode 100755 index 0000000..5eae12b --- /dev/null +++ b/scripts/distrobox/common.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck disable=SC2034 # Exported to scripts that source this helper. +repo_root="$(cd -- "$script_dir/../.." && pwd -P)" + +container_name="${CAPTIONEER_DISTROBOX_NAME:-captioneer-dev}" +container_image="${CAPTIONEER_DISTROBOX_IMAGE:-registry.fedoraproject.org/fedora-toolbox:44}" + +container_packages=( + ShellCheck + bzip2 + curl + gcc + golang + gtk4-devel + gtk4-layer-shell-devel + libX11-devel + libXfixes-devel + pkgconf-pkg-config + pulseaudio-utils + tar +) + +die() { + printf 'captioneer: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "$1 was not found in PATH" +} + +ensure_container() { + require_command distrobox + distrobox create \ + --yes \ + --no-entry \ + --name "$container_name" \ + --image "$container_image" >/dev/null +} + +ensure_dependencies() { + local dependency_script + # shellcheck disable=SC2016 # This script is evaluated inside the container. + dependency_script='set -Eeuo pipefail +missing=() +for package in "$@"; do + if ! rpm -q "$package" >/dev/null 2>&1; then + missing+=("$package") + fi +done +if (( ${#missing[@]} > 0 )); then + printf "Installing missing Captioneer dependencies: %s\\n" "${missing[*]}" + sudo dnf install -y "${missing[@]}" +fi' + + distrobox enter --no-workdir --name "$container_name" -- \ + bash -lc "$dependency_script" captioneer-dependencies "${container_packages[@]}" +} + +prepare_distrobox() { + ensure_container + ensure_dependencies +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + die "common.sh is a shared helper; run build.sh or run.sh instead" +fi diff --git a/scripts/distrobox/run.sh b/scripts/distrobox/run.sh new file mode 100755 index 0000000..9652e7a --- /dev/null +++ b/scripts/distrobox/run.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/distrobox/common.sh +source "$script_dir/common.sh" + +prepare_distrobox + +# shellcheck disable=SC2016 # This script is evaluated inside the container. +run_script='set -Eeuo pipefail +repo_root=$1 +shift +cd -- "$repo_root" +# Avoid host/container DMABUF incompatibilities on mismatched GPU stacks. An +# explicit GTK renderer choice still takes precedence. +export GSK_RENDERER="${GSK_RENDERER:-cairo}" +exec go run -tags gtk ./src/cmd/captioneer-desktop "$@"' + +exec distrobox enter --no-workdir --name "$container_name" -- \ + bash -lc "$run_script" captioneer-run "$repo_root" "$@" diff --git a/src/app/run.go b/src/app/run.go new file mode 100644 index 0000000..55e71ea --- /dev/null +++ b/src/app/run.go @@ -0,0 +1,61 @@ +// Package app coordinates capture, transcription, and caption presentation. +package app + +import ( + "context" + "errors" + "fmt" + "io" + + "tea.chunkbyte.com/kato/captioneer/src/captions" + "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" +) + +func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer) error { + if err := capture.ValidatePrograms(); err != nil { + return err + } + resources, err := models.New(settings) + if err != nil { + return err + } + defer resources.Close() + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + monitorSource, err := capture.DefaultMonitorSource(ctx) + if err != nil { + return fmt.Errorf("find default output monitor: %w", err) + } + fmt.Fprintf(diagnostics, "Capturing from: %s\n", monitorSource) + fmt.Fprintln(diagnostics, "Press Ctrl+C to stop.") + + packets, waitCapture, err := capture.Packets(ctx, monitorSource) + if err != nil { + return fmt.Errorf("start system-audio capture: %w", err) + } + + processor := captions.New(settings, resources, sink.Publish) + var processErr error + for packet := range packets { + if err := processor.Accept(packet); err != nil { + processErr = fmt.Errorf("publish caption: %w", err) + cancel() + break + } + } + if processErr == nil { + processErr = processor.Flush() + } + captureErr := waitCapture() + if captureErr != nil && ctx.Err() == nil { + captureErr = fmt.Errorf("audio capture stopped unexpectedly: %w", captureErr) + } else { + captureErr = nil + } + return errors.Join(processErr, captureErr) +} diff --git a/src/audio/audio.go b/src/audio/audio.go index 3294714..8fd039b 100644 --- a/src/audio/audio.go +++ b/src/audio/audio.go @@ -31,6 +31,10 @@ func DurationSamples(duration time.Duration) int { return int(duration * SampleRate / time.Second) } +func SamplesDuration(samples int) time.Duration { + return time.Duration(samples) * time.Second / SampleRate +} + func RMSDBFS(samples []float32) float64 { if len(samples) == 0 { return math.Inf(-1) diff --git a/src/captions/event.go b/src/captions/event.go new file mode 100644 index 0000000..40ad364 --- /dev/null +++ b/src/captions/event.go @@ -0,0 +1,25 @@ +package captions + +import "time" + +type EventKind uint8 + +const ( + Provisional EventKind = iota + 1 + Final + Hide +) + +type Event struct { + Kind EventKind + Text string + StartedAt time.Duration + EndedAt time.Duration +} + +type Transcriber interface { + Decode(samples []float32) string + AcceptVAD(samples []float32) + FlushVAD() + NextSpeechSegment() (start int, samples []float32, ok bool) +} diff --git a/src/captions/processor.go b/src/captions/processor.go index 86f503b..065c3ed 100644 --- a/src/captions/processor.go +++ b/src/captions/processor.go @@ -1,120 +1,127 @@ -// Package captions turns audio packets into fixed or speech-aware captions. +// Package captions turns audio packets into presentation-neutral caption events. package captions import ( - "fmt" - "io" + "strings" "tea.chunkbyte.com/kato/captioneer/src/audio" "tea.chunkbyte.com/kato/captioneer/src/config" - "tea.chunkbyte.com/kato/captioneer/src/models" ) type Processor struct { - settings config.Settings - resources *models.Resources - output io.Writer + settings config.Settings + transcriber Transcriber + emit func(Event) error fixedSamples []float32 previewSamples []float32 previewActive bool - previewVisible bool + previewStart int nextPreviewAt int totalSamples int } -func New(settings config.Settings, resources *models.Resources, output io.Writer) *Processor { +func New(settings config.Settings, transcriber Transcriber, emit func(Event) error) *Processor { return &Processor{ settings: settings, - resources: resources, - output: output, + transcriber: transcriber, + emit: emit, nextPreviewAt: audio.DurationSamples(settings.ChunkDuration), } } -func (p *Processor) Accept(samples []float32) { +func (p *Processor) Accept(samples []float32) error { if len(samples) == 0 { - return + return nil } if p.settings.Mode == config.ModeFixed { - p.acceptFixed(samples) - return + return p.acceptFixed(samples) } - p.acceptVAD(samples) + return p.acceptVAD(samples) } -func (p *Processor) Flush() { +func (p *Processor) Flush() error { if p.settings.Mode == config.ModeFixed { if len(p.fixedSamples) > 0 { - p.emitCommitted(p.totalSamples, p.totalSamples+len(p.fixedSamples), p.fixedSamples) + return p.emitFinal(p.totalSamples, p.totalSamples+len(p.fixedSamples), p.fixedSamples) } - return + return nil } - p.resources.FlushVAD() - p.drainVAD() - p.clearPreview() + p.transcriber.FlushVAD() + return p.drainVAD() } -func (p *Processor) acceptFixed(samples []float32) { +func (p *Processor) acceptFixed(samples []float32) error { p.fixedSamples = append(p.fixedSamples, samples...) chunkSize := audio.DurationSamples(p.settings.ChunkDuration) for len(p.fixedSamples) >= chunkSize { - p.emitCommitted(p.totalSamples, p.totalSamples+chunkSize, p.fixedSamples[:chunkSize]) + if err := p.emitFinal(p.totalSamples, p.totalSamples+chunkSize, p.fixedSamples[:chunkSize]); err != nil { + return err + } p.fixedSamples = p.fixedSamples[chunkSize:] p.totalSamples += chunkSize } + return nil } -func (p *Processor) acceptVAD(samples []float32) { +func (p *Processor) acceptVAD(samples []float32) error { p.totalSamples += len(samples) - p.resources.AcceptVAD(samples) + p.transcriber.AcceptVAD(samples) if p.previewActive || audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB { - p.previewActive = true + if !p.previewActive { + p.previewActive = true + p.previewStart = p.totalSamples - len(samples) + } p.previewSamples = append(p.previewSamples, samples...) if len(p.previewSamples) >= p.nextPreviewAt { - p.emitPreview(p.previewSamples) + if err := p.emitProvisional(); err != nil { + return err + } p.nextPreviewAt += audio.DurationSamples(p.settings.ChunkDuration) } } - p.drainVAD() + return p.drainVAD() } -func (p *Processor) drainVAD() { +func (p *Processor) drainVAD() error { for { - segment, ok := p.resources.NextSpeechSegment() + start, samples, ok := p.transcriber.NextSpeechSegment() if !ok { - return + return nil + } + if err := p.emitFinal(start, start+len(samples), samples); err != nil { + return err } - p.clearPreview() - p.emitCommitted(segment.Start, segment.Start+len(segment.Samples), segment.Samples) p.previewSamples = nil p.previewActive = false + p.previewStart = 0 p.nextPreviewAt = audio.DurationSamples(p.settings.ChunkDuration) } } -func (p *Processor) emitPreview(samples []float32) { - text := p.resources.Decode(samples) +func (p *Processor) emitProvisional() error { + text := strings.TrimSpace(p.transcriber.Decode(p.previewSamples)) if text == "" { - return + return nil } - fmt.Fprintf(p.output, "\r\033[2K… %s", text) - p.previewVisible = true + return p.emit(Event{ + Kind: Provisional, + Text: text, + StartedAt: audio.SamplesDuration(p.previewStart), + EndedAt: audio.SamplesDuration(p.previewStart + len(p.previewSamples)), + }) } -func (p *Processor) emitCommitted(start, end int, samples []float32) { - text := p.resources.Decode(samples) +func (p *Processor) emitFinal(start, end int, samples []float32) error { + text := strings.TrimSpace(p.transcriber.Decode(samples)) if text == "" { - return - } - p.clearPreview() - fmt.Fprintf(p.output, "[%s-%s] %s\n", audio.FormatTime(start), audio.FormatTime(end), text) -} - -func (p *Processor) clearPreview() { - if p.previewVisible { - fmt.Fprint(p.output, "\r\033[2K") - p.previewVisible = false + return p.emit(Event{Kind: Hide}) } + return p.emit(Event{ + Kind: Final, + Text: text, + StartedAt: audio.SamplesDuration(start), + EndedAt: audio.SamplesDuration(end), + }) } diff --git a/src/captions/processor_test.go b/src/captions/processor_test.go new file mode 100644 index 0000000..4703b84 --- /dev/null +++ b/src/captions/processor_test.go @@ -0,0 +1,136 @@ +package captions + +import ( + "testing" + "time" + + "tea.chunkbyte.com/kato/captioneer/src/audio" + "tea.chunkbyte.com/kato/captioneer/src/config" +) + +type fakeSegment struct { + start int + samples []float32 +} + +type fakeTranscriber struct { + text string + decodeTexts []string + segments []fakeSegment + flushed bool +} + +func (f *fakeTranscriber) Decode([]float32) string { + if len(f.decodeTexts) == 0 { + return f.text + } + text := f.decodeTexts[0] + f.decodeTexts = f.decodeTexts[1:] + return text +} +func (f *fakeTranscriber) AcceptVAD([]float32) {} +func (f *fakeTranscriber) FlushVAD() { f.flushed = true } +func (f *fakeTranscriber) NextSpeechSegment() (int, []float32, bool) { + if len(f.segments) == 0 { + return 0, nil, false + } + segment := f.segments[0] + f.segments = f.segments[1:] + return segment.start, segment.samples, true +} + +func TestFixedModeEmitsFinalCaption(t *testing.T) { + transcriber := &fakeTranscriber{text: "hello"} + var events []Event + processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, 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(events) != 1 || events[0].Kind != Final || events[0].Text != "hello" { + t.Fatalf("unexpected events: %#v", events) + } + if events[0].StartedAt != 0 || events[0].EndedAt != time.Second { + t.Fatalf("unexpected timestamps: %#v", events[0]) + } +} + +func TestFixedModeFlushesFinalPartialChunkWithExactTimestamps(t *testing.T) { + transcriber := &fakeTranscriber{decodeTexts: []string{"first", "partial"}} + var events []Event + processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(event Event) error { + events = append(events, event) + return nil + }) + + if err := processor.Accept(make([]float32, audio.SampleRate+audio.SampleRate/2)); err != nil { + t.Fatal(err) + } + if err := processor.Flush(); err != nil { + t.Fatal(err) + } + if len(events) != 2 { + t.Fatalf("event count = %d, want 2", len(events)) + } + if events[1].Text != "partial" || events[1].StartedAt != time.Second || events[1].EndedAt != 1500*time.Millisecond { + t.Fatalf("unexpected partial event: %#v", events[1]) + } +} + +func TestVADModeEmitsProvisionalThenFinal(t *testing.T) { + transcriber := &fakeTranscriber{text: "speech"} + 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 + }) + + loud := make([]float32, audio.SampleRate) + for i := range loud { + loud[i] = 0.5 + } + if err := processor.Accept(loud); err != nil { + t.Fatal(err) + } + transcriber.segments = []fakeSegment{{start: 0, samples: loud}} + if err := processor.Accept(make([]float32, audio.SamplesPerPacket())); err != nil { + t.Fatal(err) + } + + if len(events) != 2 || events[0].Kind != Provisional || events[1].Kind != Final { + t.Fatalf("unexpected events: %#v", events) + } +} + +func TestEmptyFinalEmitsHide(t *testing.T) { + transcriber := &fakeTranscriber{text: " \n\t "} + var event Event + processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(got Event) error { + event = got + return nil + }) + if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil { + t.Fatal(err) + } + if event.Kind != Hide { + t.Fatalf("event kind = %d, want Hide", event.Kind) + } +} + +func TestFlushFlushesVAD(t *testing.T) { + transcriber := &fakeTranscriber{} + processor := New(config.Settings{Mode: config.ModeVAD, ChunkDuration: time.Second}, transcriber, func(Event) error { return nil }) + if err := processor.Flush(); err != nil { + t.Fatal(err) + } + if !transcriber.flushed { + t.Fatal("VAD was not flushed") + } +} diff --git a/src/cmd/captioneer-desktop/main.go b/src/cmd/captioneer-desktop/main.go new file mode 100644 index 0000000..013c23c --- /dev/null +++ b/src/cmd/captioneer-desktop/main.go @@ -0,0 +1,77 @@ +//go:build gtk + +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "os/signal" + "runtime" + "syscall" + + "tea.chunkbyte.com/kato/captioneer/src/app" + "tea.chunkbyte.com/kato/captioneer/src/config" + "tea.chunkbyte.com/kato/captioneer/src/output" + "tea.chunkbyte.com/kato/captioneer/src/output/overlay" + "tea.chunkbyte.com/kato/captioneer/src/output/terminal" +) + +func main() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + settings := config.ParseDesktop() + if err := settings.Validate(); err != nil { + log.Fatal(err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + terminalSink := terminal.New(os.Stdout) + if settings.Output == config.OutputTerminal { + defer terminalSink.Close() + if err := app.Run(ctx, settings.Settings, terminalSink, os.Stderr); err != nil { + log.Fatal(err) + } + return + } + + overlaySink, warning, overlayErr := overlay.New(settings.Overlay) + if warning != "" { + fmt.Fprintf(os.Stderr, "warning: %s\n", warning) + } + if overlayErr != nil { + if settings.Output == config.OutputBoth { + fmt.Fprintf(os.Stderr, "warning: overlay unavailable: %v; continuing with terminal output\n", overlayErr) + defer terminalSink.Close() + if err := app.Run(ctx, settings.Settings, terminalSink, os.Stderr); err != nil { + log.Fatal(err) + } + return + } + log.Fatalf("initialize overlay: %v", overlayErr) + } + + var sink output.Sink = overlaySink + if settings.Output == config.OutputBoth { + sink = output.MultiSink{terminalSink, overlaySink} + } + + workerDone := make(chan error, 1) + go func() { + workerDone <- app.Run(ctx, settings.Settings, sink, os.Stderr) + overlaySink.Quit() + }() + + runErr := overlaySink.Run() + stop() + workerErr := <-workerDone + closeErr := sink.Close() + if err := errors.Join(runErr, workerErr, closeErr); err != nil { + log.Fatal(err) + } +} diff --git a/src/cmd/captioneer/main.go b/src/cmd/captioneer/main.go index bc163ff..5df79e2 100644 --- a/src/cmd/captioneer/main.go +++ b/src/cmd/captioneer/main.go @@ -2,16 +2,14 @@ package main import ( "context" - "fmt" "log" "os" "os/signal" "syscall" - "tea.chunkbyte.com/kato/captioneer/src/captions" - "tea.chunkbyte.com/kato/captioneer/src/capture" + "tea.chunkbyte.com/kato/captioneer/src/app" "tea.chunkbyte.com/kato/captioneer/src/config" - "tea.chunkbyte.com/kato/captioneer/src/models" + "tea.chunkbyte.com/kato/captioneer/src/output/terminal" ) func main() { @@ -19,37 +17,13 @@ func main() { if err := settings.Validate(); err != nil { log.Fatal(err) } - if err := capture.ValidatePrograms(); err != nil { - log.Fatal(err) - } - - resources, err := models.New(settings) - if err != nil { - log.Fatal(err) - } - defer resources.Close() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - monitorSource, err := capture.DefaultMonitorSource(ctx) - if err != nil { - log.Fatalf("find default output monitor: %v", err) - } - fmt.Fprintf(os.Stderr, "Capturing from: %s\n", monitorSource) - fmt.Fprintln(os.Stderr, "Press Ctrl+C to stop.") - - packets, waitCapture, err := capture.Packets(ctx, monitorSource) - if err != nil { - log.Fatalf("start system-audio capture: %v", err) - } - - processor := captions.New(settings, resources, os.Stdout) - for packet := range packets { - processor.Accept(packet) - } - processor.Flush() - if err := waitCapture(); err != nil && ctx.Err() == nil { - log.Fatalf("audio capture stopped unexpectedly: %v", err) + sink := terminal.New(os.Stdout) + defer sink.Close() + if err := app.Run(ctx, settings, sink, os.Stderr); err != nil { + log.Fatal(err) } } diff --git a/src/config/config.go b/src/config/config.go index f986aab..143fef8 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -37,6 +37,66 @@ func Parse() Settings { return settings } +type OutputMode string + +const ( + OutputTerminal OutputMode = "terminal" + OutputOverlay OutputMode = "overlay" + OutputBoth OutputMode = "both" +) + +type OverlayBackend string + +const ( + BackendAuto OverlayBackend = "auto" + BackendWayland OverlayBackend = "wayland" + BackendX11 OverlayBackend = "x11" +) + +type OverlaySettings struct { + Backend OverlayBackend + Opacity float64 + FontSize int + BottomMargin int + MaxWidth int + Monitor string + FinalTimeout time.Duration + ClickThrough bool +} + +type DesktopSettings struct { + Settings + Output OutputMode + Overlay OverlaySettings +} + +func ParseDesktop() DesktopSettings { + flags := flag.CommandLine + var desktop DesktopSettings + var mode string + var output string + var backend string + flags.StringVar(&mode, "mode", "", "caption mode: vad or fixed (required)") + 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.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.StringVar(&output, "output", "", "caption output: terminal, overlay, or both (required)") + flags.StringVar(&backend, "overlay-backend", "auto", "display backend: auto, wayland, or x11") + flags.Float64Var(&desktop.Overlay.Opacity, "overlay-opacity", 0.90, "caption background opacity from 0 to 1") + flags.IntVar(&desktop.Overlay.FontSize, "overlay-font-size", 28, "caption font size in logical pixels") + flags.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, "maximum caption width in logical pixels") + 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.BoolVar(&desktop.Overlay.ClickThrough, "overlay-click-through", true, "pass pointer input through the caption window") + flag.Parse() + desktop.Mode = Mode(mode) + desktop.Output = OutputMode(output) + desktop.Overlay.Backend = OverlayBackend(backend) + return desktop +} + func (s Settings) Validate() error { if s.Mode != ModeFixed && s.Mode != ModeVAD { return errors.New("--mode is required and must be either fixed or vad") @@ -49,3 +109,34 @@ func (s Settings) Validate() error { } return nil } + +func (s DesktopSettings) Validate() error { + if err := s.Settings.Validate(); err != nil { + return err + } + if s.Output != OutputTerminal && s.Output != OutputOverlay && s.Output != OutputBoth { + return errors.New("--output is required and must be terminal, overlay, or both") + } + if s.Overlay.Backend != BackendAuto && s.Overlay.Backend != BackendWayland && s.Overlay.Backend != BackendX11 { + return errors.New("--overlay-backend must be auto, wayland, or x11") + } + if s.Overlay.Opacity < 0 || s.Overlay.Opacity > 1 { + return errors.New("--overlay-opacity must be between 0 and 1") + } + if s.Overlay.FontSize <= 0 { + return errors.New("--overlay-font-size must be positive") + } + if s.Overlay.BottomMargin < 0 { + return errors.New("--overlay-bottom-margin cannot be negative") + } + if s.Overlay.MaxWidth <= 0 { + return errors.New("--overlay-max-width must be positive") + } + if s.Overlay.FinalTimeout < 0 { + return errors.New("--overlay-final-timeout cannot be negative") + } + if s.Overlay.Monitor == "" { + return errors.New("--overlay-monitor cannot be empty") + } + return nil +} diff --git a/src/config/config_test.go b/src/config/config_test.go index b1ead89..dad619e 100644 --- a/src/config/config_test.go +++ b/src/config/config_test.go @@ -14,3 +14,44 @@ func TestSettingsValidate(t *testing.T) { t.Fatal("missing mode unexpectedly passed") } } + +func validDesktopSettings() DesktopSettings { + return DesktopSettings{ + Settings: Settings{Mode: ModeVAD, ChunkDuration: time.Second, Threads: 2}, + Output: OutputOverlay, + Overlay: OverlaySettings{ + Backend: BackendAuto, Opacity: 0.9, FontSize: 28, BottomMargin: 100, + MaxWidth: 1200, Monitor: "auto", FinalTimeout: 4 * time.Second, ClickThrough: true, + }, + } +} + +func TestDesktopSettingsValidate(t *testing.T) { + if err := validDesktopSettings().Validate(); err != nil { + t.Fatalf("valid desktop settings failed: %v", err) + } + + tests := []struct { + name string + change func(*DesktopSettings) + }{ + {"missing output", func(s *DesktopSettings) { s.Output = "" }}, + {"invalid backend", func(s *DesktopSettings) { s.Overlay.Backend = "other" }}, + {"negative opacity", func(s *DesktopSettings) { s.Overlay.Opacity = -0.1 }}, + {"large opacity", func(s *DesktopSettings) { s.Overlay.Opacity = 1.1 }}, + {"zero font", func(s *DesktopSettings) { s.Overlay.FontSize = 0 }}, + {"negative margin", func(s *DesktopSettings) { s.Overlay.BottomMargin = -1 }}, + {"zero width", func(s *DesktopSettings) { s.Overlay.MaxWidth = 0 }}, + {"empty monitor", func(s *DesktopSettings) { s.Overlay.Monitor = "" }}, + {"negative timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = -time.Second }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + settings := validDesktopSettings() + test.change(&settings) + if err := settings.Validate(); err == nil { + t.Fatal("invalid settings unexpectedly passed") + } + }) + } +} diff --git a/src/models/sherpa.go b/src/models/sherpa.go index 90732ba..d81a29b 100644 --- a/src/models/sherpa.go +++ b/src/models/sherpa.go @@ -12,11 +12,6 @@ import ( "tea.chunkbyte.com/kato/captioneer/src/config" ) -type SpeechSegment struct { - Start int - Samples []float32 -} - type modelPaths struct { encoder string decoder string @@ -96,13 +91,13 @@ func (r *Resources) FlushVAD() { r.vad.Flush() } -func (r *Resources) NextSpeechSegment() (SpeechSegment, bool) { +func (r *Resources) NextSpeechSegment() (int, []float32, bool) { if r.vad.IsEmpty() { - return SpeechSegment{}, false + return 0, nil, false } segment := r.vad.Front() r.vad.Pop() - return SpeechSegment{Start: segment.Start, Samples: segment.Samples}, true + return segment.Start, segment.Samples, true } func validatePaths(modelsDir string, mode config.Mode) (modelPaths, error) { diff --git a/src/output/overlay/overlay_gtk.go b/src/output/overlay/overlay_gtk.go new file mode 100644 index 0000000..ee4d6c4 --- /dev/null +++ b/src/output/overlay/overlay_gtk.go @@ -0,0 +1,575 @@ +//go:build gtk + +// Package overlay renders captions in a GTK4 desktop overlay. +package overlay + +/* +#cgo pkg-config: gtk4 gtk4-x11 gtk4-wayland gtk4-layer-shell-0 +#cgo LDFLAGS: -lX11 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// GTK4 deprecates direct Xlib access, but it remains the supported escape hatch +// for the EWMH hints needed by this deliberately isolated X11 fallback. +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + +typedef struct { + GMainLoop *loop; + GtkWindow *window; + GtkWidget *bubble; + GtkWidget *final_label; + GtkWidget *preview_label; + GdkMonitor *monitor; + gboolean final_visible; + gboolean preview_visible; + gboolean x11; +} CaptioneerOverlay; + +typedef struct { + CaptioneerOverlay *overlay; + char *final_text; + char *preview_text; +} CaptioneerUpdate; + +static void captioneer_append_warning(GString *warnings, const char *message) { + if (warnings->len > 0) { + g_string_append(warnings, " "); + } + g_string_append(warnings, message); +} + +static GdkMonitor *captioneer_monitor_by_index(GdkDisplay *display, guint index) { + GListModel *monitors = gdk_display_get_monitors(display); + if (index >= g_list_model_get_n_items(monitors)) { + return NULL; + } + return GDK_MONITOR(g_list_model_get_item(monitors, index)); +} + +static GdkMonitor *captioneer_monitor_by_connector(GdkDisplay *display, const char *connector) { + GListModel *monitors = gdk_display_get_monitors(display); + for (guint i = 0; i < g_list_model_get_n_items(monitors); i++) { + GdkMonitor *monitor = GDK_MONITOR(g_list_model_get_item(monitors, i)); + const char *candidate = gdk_monitor_get_connector(monitor); + if (candidate != NULL && g_strcmp0(candidate, connector) == 0) { + return monitor; + } + g_object_unref(monitor); + } + return NULL; +} + +static GdkMonitor *captioneer_x11_cursor_monitor(GdkDisplay *display) { + Display *xdisplay = GDK_DISPLAY_XDISPLAY(display); + Window root = DefaultRootWindow(xdisplay); + Window root_return, child_return; + int root_x = 0, root_y = 0, win_x = 0, win_y = 0; + unsigned int mask = 0; + if (!XQueryPointer(xdisplay, root, &root_return, &child_return, + &root_x, &root_y, &win_x, &win_y, &mask)) { + return NULL; + } + + GListModel *monitors = gdk_display_get_monitors(display); + for (guint i = 0; i < g_list_model_get_n_items(monitors); i++) { + GdkMonitor *monitor = GDK_MONITOR(g_list_model_get_item(monitors, i)); + GdkRectangle geometry; + gdk_monitor_get_geometry(monitor, &geometry); + if (root_x >= geometry.x && root_x < geometry.x + geometry.width && + root_y >= geometry.y && root_y < geometry.y + geometry.height) { + return monitor; + } + g_object_unref(monitor); + } + return NULL; +} + +static GdkMonitor *captioneer_select_monitor( + GdkDisplay *display, + const char *selection, + gboolean x11, + char **error_message +) { + if (g_strcmp0(selection, "auto") == 0) { + GdkMonitor *monitor = x11 ? captioneer_x11_cursor_monitor(display) : NULL; + if (monitor != NULL) { + return monitor; + } + return captioneer_monitor_by_index(display, 0); + } + + errno = 0; + char *end = NULL; + long index = strtol(selection, &end, 10); + if (errno == 0 && end != selection && *end == '\0' && index >= 0) { + GdkMonitor *monitor = captioneer_monitor_by_index(display, (guint)index); + if (monitor == NULL) { + *error_message = g_strdup_printf("monitor index %ld does not exist", index); + } + return monitor; + } + + GdkMonitor *monitor = captioneer_monitor_by_connector(display, selection); + if (monitor == NULL) { + *error_message = g_strdup_printf("monitor connector %s does not exist", selection); + } + return monitor; +} + +static void captioneer_set_input_passthrough(CaptioneerOverlay *overlay, GString *warnings) { + if (!gdk_display_supports_input_shapes(gtk_widget_get_display(GTK_WIDGET(overlay->window)))) { + captioneer_append_warning(warnings, "This display backend does not support pointer click-through."); + return; + } + GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window)); + cairo_region_t *empty = cairo_region_create(); + gdk_surface_set_input_region(surface, empty); + cairo_region_destroy(empty); +} + +static void captioneer_apply_x11_hints(CaptioneerOverlay *overlay) { + GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window)); + Display *display = GDK_SURFACE_XDISPLAY(surface); + Window window = GDK_SURFACE_XID(surface); + + Atom type_property = XInternAtom(display, "_NET_WM_WINDOW_TYPE", False); + Atom notification = XInternAtom(display, "_NET_WM_WINDOW_TYPE_NOTIFICATION", False); + XChangeProperty(display, window, type_property, XA_ATOM, 32, PropModeReplace, + (unsigned char *)¬ification, 1); + + Atom state_property = XInternAtom(display, "_NET_WM_STATE", False); + Atom states[] = { + XInternAtom(display, "_NET_WM_STATE_ABOVE", False), + XInternAtom(display, "_NET_WM_STATE_SKIP_TASKBAR", False), + XInternAtom(display, "_NET_WM_STATE_SKIP_PAGER", False), + XInternAtom(display, "_NET_WM_STATE_STICKY", False), + }; + XChangeProperty(display, window, state_property, XA_ATOM, 32, PropModeReplace, + (unsigned char *)states, G_N_ELEMENTS(states)); + + Atom desktop_property = XInternAtom(display, "_NET_WM_DESKTOP", False); + unsigned long all_desktops = 0xFFFFFFFFul; + XChangeProperty(display, window, desktop_property, XA_CARDINAL, 32, PropModeReplace, + (unsigned char *)&all_desktops, 1); + + Atom user_time_property = XInternAtom(display, "_NET_WM_USER_TIME", False); + unsigned long user_time = 0; + XChangeProperty(display, window, user_time_property, XA_CARDINAL, 32, PropModeReplace, + (unsigned char *)&user_time, 1); + + XWMHints *hints = XAllocWMHints(); + if (hints != NULL) { + hints->flags = InputHint; + hints->input = False; + XSetWMHints(display, window, hints); + XFree(hints); + } + XFlush(display); +} + +static gboolean captioneer_reposition_x11(gpointer data) { + CaptioneerOverlay *overlay = data; + if (!overlay->x11 || overlay->monitor == NULL) { + return G_SOURCE_REMOVE; + } + GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window)); + if (surface == NULL) { + return G_SOURCE_REMOVE; + } + GdkRectangle geometry; + gdk_monitor_get_geometry(overlay->monitor, &geometry); + int width = gdk_surface_get_width(surface); + int height = gdk_surface_get_height(surface); + int bottom_margin = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(overlay->window), "bottom-margin")); + int x = geometry.x + (geometry.width - width) / 2; + int y = geometry.y + geometry.height - height - bottom_margin; + XMoveWindow(GDK_SURFACE_XDISPLAY(surface), GDK_SURFACE_XID(surface), x, y); + return G_SOURCE_REMOVE; +} + +static void captioneer_surface_size_changed(GObject *object, GParamSpec *spec, gpointer data) { + (void)object; + (void)spec; + g_idle_add(captioneer_reposition_x11, data); +} + +static void captioneer_update_window_visibility(CaptioneerOverlay *overlay) { + gboolean visible = overlay->final_visible || overlay->preview_visible; + gtk_widget_set_visible(overlay->bubble, visible); + gtk_widget_set_visible(GTK_WIDGET(overlay->window), visible); + if (visible && overlay->x11) { + g_idle_add(captioneer_reposition_x11, overlay); + } +} + +static gboolean captioneer_apply_update(gpointer data) { + CaptioneerUpdate *update = data; + CaptioneerOverlay *overlay = update->overlay; + + overlay->final_visible = update->final_text[0] != '\0'; + gtk_label_set_text(GTK_LABEL(overlay->final_label), update->final_text); + gtk_widget_set_visible(overlay->final_label, overlay->final_visible); + + overlay->preview_visible = update->preview_text[0] != '\0'; + if (overlay->preview_visible) { + char *preview = g_strdup_printf("… %s", update->preview_text); + gtk_label_set_text(GTK_LABEL(overlay->preview_label), preview); + g_free(preview); + } else { + gtk_label_set_text(GTK_LABEL(overlay->preview_label), ""); + } + gtk_widget_set_visible(overlay->preview_label, overlay->preview_visible); + + captioneer_update_window_visibility(overlay); + g_free(update->final_text); + g_free(update->preview_text); + g_free(update); + return G_SOURCE_REMOVE; +} + +static gboolean captioneer_quit_on_main(gpointer data) { + g_main_loop_quit(((CaptioneerOverlay *)data)->loop); + return G_SOURCE_REMOVE; +} + +static gboolean captioneer_window_close(GtkWindow *window, gpointer data) { + (void)window; + g_main_loop_quit(((CaptioneerOverlay *)data)->loop); + return TRUE; +} + +static CaptioneerOverlay *captioneer_overlay_new( + const char *backend, + double opacity, + int font_size, + int bottom_margin, + int max_width, + const char *monitor_selection, + int click_through, + char **warning_message, + char **error_message +) { + *warning_message = NULL; + *error_message = NULL; + g_set_prgname("com.chunkbyte.captioneer.overlay"); + if (g_strcmp0(backend, "auto") != 0) { + g_setenv("GDK_BACKEND", backend, TRUE); + } + if (!gtk_init_check()) { + *error_message = g_strdup("GTK could not connect to the requested display backend"); + return NULL; + } + + GdkDisplay *display = gdk_display_get_default(); + gboolean wayland = GDK_IS_WAYLAND_DISPLAY(display); + gboolean x11 = GDK_IS_X11_DISPLAY(display); + if (!wayland && !x11) { + *error_message = g_strdup("GTK selected an unsupported display backend"); + return NULL; + } + + GString *warnings = g_string_new(NULL); + CaptioneerOverlay *overlay = g_new0(CaptioneerOverlay, 1); + overlay->loop = g_main_loop_new(NULL, FALSE); + overlay->x11 = x11; + + char *monitor_error = NULL; + if (wayland && g_strcmp0(monitor_selection, "auto") == 0) { + overlay->monitor = NULL; + } else { + overlay->monitor = captioneer_select_monitor(display, monitor_selection, x11, &monitor_error); + if (overlay->monitor == NULL) { + *error_message = monitor_error != NULL ? monitor_error : g_strdup("no monitor is available"); + g_main_loop_unref(overlay->loop); + g_free(overlay); + g_string_free(warnings, TRUE); + return NULL; + } + } + + overlay->window = GTK_WINDOW(gtk_window_new()); + gtk_window_set_title(overlay->window, "Captioneer Overlay"); + gtk_window_set_decorated(overlay->window, FALSE); + gtk_window_set_resizable(overlay->window, FALSE); + gtk_widget_set_focusable(GTK_WIDGET(overlay->window), FALSE); + gtk_widget_add_css_class(GTK_WIDGET(overlay->window), "captioneer-overlay"); + g_object_set_data(G_OBJECT(overlay->window), "bottom-margin", GINT_TO_POINTER(bottom_margin)); + g_signal_connect(overlay->window, "close-request", G_CALLBACK(captioneer_window_close), overlay); + + if (wayland && gtk_layer_is_supported()) { + gtk_layer_init_for_window(overlay->window); + gtk_layer_set_namespace(overlay->window, "captioneer"); + gtk_layer_set_layer(overlay->window, GTK_LAYER_SHELL_LAYER_OVERLAY); + gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, TRUE); + gtk_layer_set_margin(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, bottom_margin); + gtk_layer_set_exclusive_zone(overlay->window, 0); + gtk_layer_set_keyboard_mode(overlay->window, GTK_LAYER_SHELL_KEYBOARD_MODE_NONE); + if (overlay->monitor != NULL) { + gtk_layer_set_monitor(overlay->window, overlay->monitor); + } + } else if (wayland) { + captioneer_append_warning(warnings, + "The Wayland compositor does not support Layer Shell; using a normal window without guaranteed always-on-top, placement, taskbar, or fullscreen behavior."); + } + + GdkMonitor *size_monitor = overlay->monitor; + if (size_monitor == NULL) { + size_monitor = captioneer_monitor_by_index(display, 0); + } + int effective_width = max_width; + if (size_monitor != NULL) { + GdkRectangle geometry; + gdk_monitor_get_geometry(size_monitor, &geometry); + int relative_width = (int)(geometry.width * 0.8); + if (relative_width < effective_width) { + effective_width = relative_width; + } + if (size_monitor != overlay->monitor) { + g_object_unref(size_monitor); + } + } + int max_chars = (int)(effective_width / (font_size * 0.58)); + if (max_chars < 20) max_chars = 20; + if (max_chars > 120) max_chars = 120; + + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + overlay->bubble = box; + gtk_widget_add_css_class(box, "captioneer-bubble"); + gtk_widget_set_halign(box, GTK_ALIGN_CENTER); + + overlay->final_label = gtk_label_new(""); + overlay->preview_label = gtk_label_new(""); + GtkWidget *labels[] = {overlay->final_label, overlay->preview_label}; + for (guint i = 0; i < G_N_ELEMENTS(labels); i++) { + gtk_label_set_justify(GTK_LABEL(labels[i]), GTK_JUSTIFY_CENTER); + gtk_label_set_xalign(GTK_LABEL(labels[i]), 0.5f); + gtk_label_set_wrap(GTK_LABEL(labels[i]), TRUE); + gtk_label_set_wrap_mode(GTK_LABEL(labels[i]), PANGO_WRAP_WORD_CHAR); + gtk_label_set_lines(GTK_LABEL(labels[i]), 2); + gtk_label_set_ellipsize(GTK_LABEL(labels[i]), PANGO_ELLIPSIZE_END); + gtk_label_set_max_width_chars(GTK_LABEL(labels[i]), max_chars); + gtk_widget_set_halign(labels[i], GTK_ALIGN_CENTER); + gtk_box_append(GTK_BOX(box), labels[i]); + gtk_widget_set_visible(labels[i], FALSE); + } + gtk_widget_add_css_class(overlay->final_label, "captioneer-final"); + gtk_widget_add_css_class(overlay->preview_label, "captioneer-preview"); + gtk_window_set_child(overlay->window, box); + + char *css = g_strdup_printf( + "window.captioneer-overlay { background-color: transparent; box-shadow: none; }" + ".captioneer-bubble { background-color: rgba(0, 0, 0, %.3f); border-radius: 12px; padding: 14px 22px; }" + ".captioneer-final { color: rgba(255, 255, 255, 1.0); font-size: %dpx; }" + ".captioneer-preview { color: rgba(255, 255, 255, 0.78); font-size: %dpx; }", + opacity, font_size, font_size); + GtkCssProvider *provider = gtk_css_provider_new(); + gtk_css_provider_load_from_string(provider, css); + gtk_style_context_add_provider_for_display(display, GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref(provider); + g_free(css); + + gtk_widget_realize(GTK_WIDGET(overlay->window)); + GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window)); + if (click_through) { + captioneer_set_input_passthrough(overlay, warnings); + } + if (x11) { + captioneer_apply_x11_hints(overlay); + g_signal_connect(surface, "notify::width", G_CALLBACK(captioneer_surface_size_changed), overlay); + g_signal_connect(surface, "notify::height", G_CALLBACK(captioneer_surface_size_changed), overlay); + } + gtk_widget_set_visible(GTK_WIDGET(overlay->window), FALSE); + + if (warnings->len > 0) { + *warning_message = g_string_free(warnings, FALSE); + } else { + g_string_free(warnings, TRUE); + } + return overlay; +} + +static void captioneer_overlay_post( + CaptioneerOverlay *overlay, + const char *final_text, + const char *preview_text +) { + CaptioneerUpdate *update = g_new0(CaptioneerUpdate, 1); + update->overlay = overlay; + update->final_text = g_strdup(final_text == NULL ? "" : final_text); + update->preview_text = g_strdup(preview_text == NULL ? "" : preview_text); + g_idle_add(captioneer_apply_update, update); +} + +static void captioneer_overlay_run(CaptioneerOverlay *overlay) { + g_main_loop_run(overlay->loop); +} + +static void captioneer_overlay_quit(CaptioneerOverlay *overlay) { + g_idle_add(captioneer_quit_on_main, overlay); +} + +static void captioneer_overlay_free(CaptioneerOverlay *overlay) { + while (g_main_context_pending(NULL)) { + g_main_context_iteration(NULL, FALSE); + } + gtk_window_destroy(overlay->window); + if (overlay->monitor != NULL) { + g_object_unref(overlay->monitor); + } + g_main_loop_unref(overlay->loop); + g_free(overlay); +} + +static void captioneer_string_free(char *value) { + g_free(value); +} +*/ +import "C" + +import ( + "errors" + "sync" + "time" + "unsafe" + + "tea.chunkbyte.com/kato/captioneer/src/captions" + "tea.chunkbyte.com/kato/captioneer/src/config" +) + +type Sink struct { + native *C.CaptioneerOverlay + finalTimeout time.Duration + finalTimer *time.Timer + state viewState + mu sync.RWMutex + closed bool +} + +func New(settings config.OverlaySettings) (*Sink, string, error) { + backend := C.CString(string(settings.Backend)) + monitor := C.CString(settings.Monitor) + defer C.free(unsafe.Pointer(backend)) + defer C.free(unsafe.Pointer(monitor)) + + var warningMessage *C.char + var errorMessage *C.char + native := C.captioneer_overlay_new( + backend, + C.double(settings.Opacity), + C.int(settings.FontSize), + C.int(settings.BottomMargin), + C.int(settings.MaxWidth), + monitor, + boolInt(settings.ClickThrough), + &warningMessage, + &errorMessage, + ) + warning := takeString(warningMessage) + if native == nil { + message := takeString(errorMessage) + if message == "" { + message = "unknown GTK initialization failure" + } + return nil, warning, errors.New(message) + } + return &Sink{native: native, finalTimeout: settings.FinalTimeout}, warning, nil +} + +func (s *Sink) Publish(event captions.Event) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return errors.New("overlay is closed") + } + s.state.Apply(event, time.Now(), s.finalTimeout) + if event.Kind == captions.Final || event.Kind == captions.Hide { + s.stopFinalTimer() + } + if event.Kind == captions.Final && s.finalTimeout > 0 { + s.finalTimer = time.AfterFunc(s.finalTimeout, s.expireFinal) + } + s.postState() + return nil +} + +func (s *Sink) expireFinal() { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed || !s.state.ExpireFinal(time.Now()) { + return + } + s.finalTimer = nil + s.postState() +} + +func (s *Sink) postState() { + finalText := C.CString(s.state.Final) + previewText := C.CString(s.state.Provisional) + defer C.free(unsafe.Pointer(finalText)) + defer C.free(unsafe.Pointer(previewText)) + C.captioneer_overlay_post(s.native, finalText, previewText) +} + +func (s *Sink) stopFinalTimer() { + if s.finalTimer != nil { + s.finalTimer.Stop() + s.finalTimer = nil + } +} + +func (s *Sink) Run() error { + s.mu.RLock() + if s.closed { + s.mu.RUnlock() + return errors.New("overlay is closed") + } + native := s.native + s.mu.RUnlock() + C.captioneer_overlay_run(native) + return nil +} + +func (s *Sink) Quit() { + s.mu.RLock() + defer s.mu.RUnlock() + if !s.closed { + C.captioneer_overlay_quit(s.native) + } +} + +func (s *Sink) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.stopFinalTimer() + C.captioneer_overlay_free(s.native) + s.native = nil + s.closed = true + return nil +} + +func boolInt(value bool) C.int { + if value { + return 1 + } + return 0 +} + +func takeString(value *C.char) string { + if value == nil { + return "" + } + result := C.GoString(value) + C.captioneer_string_free(value) + return result +} diff --git a/src/output/overlay/state.go b/src/output/overlay/state.go new file mode 100644 index 0000000..326658e --- /dev/null +++ b/src/output/overlay/state.go @@ -0,0 +1,39 @@ +package overlay + +import ( + "strings" + "time" + + "tea.chunkbyte.com/kato/captioneer/src/captions" +) + +type viewState struct { + Final string + Provisional string + FinalExpiresAt time.Time +} + +func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration) { + switch event.Kind { + case captions.Provisional: + s.Provisional = strings.TrimSpace(event.Text) + case captions.Final: + s.Final = strings.TrimSpace(event.Text) + s.Provisional = "" + s.FinalExpiresAt = time.Time{} + if finalTimeout > 0 { + s.FinalExpiresAt = now.Add(finalTimeout) + } + case captions.Hide: + *s = viewState{} + } +} + +func (s *viewState) ExpireFinal(now time.Time) bool { + if s.FinalExpiresAt.IsZero() || now.Before(s.FinalExpiresAt) { + return false + } + s.Final = "" + s.FinalExpiresAt = time.Time{} + return true +} diff --git a/src/output/overlay/state_test.go b/src/output/overlay/state_test.go new file mode 100644 index 0000000..0039843 --- /dev/null +++ b/src/output/overlay/state_test.go @@ -0,0 +1,50 @@ +package overlay + +import ( + "testing" + "time" + + "tea.chunkbyte.com/kato/captioneer/src/captions" +) + +func TestViewStateKeepsFinalAboveNewProvisional(t *testing.T) { + now := time.Unix(100, 0) + var state viewState + state.Apply(captions.Event{Kind: captions.Provisional, Text: " first draft "}, now, 4*time.Second) + state.Apply(captions.Event{Kind: captions.Final, Text: " final "}, now, 4*time.Second) + state.Apply(captions.Event{Kind: captions.Provisional, Text: " next draft "}, now, 4*time.Second) + + if state.Final != "final" || state.Provisional != "next draft" { + t.Fatalf("unexpected rows: %#v", state) + } + state.Apply(captions.Event{Kind: captions.Hide}, now, 4*time.Second) + if state.Final != "" || state.Provisional != "" { + t.Fatalf("hide did not clear rows: %#v", state) + } +} + +func TestViewStateFinalTimeout(t *testing.T) { + now := time.Unix(100, 0) + var state viewState + state.Apply(captions.Event{Kind: captions.Final, Text: "final"}, now, 4*time.Second) + state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 4*time.Second) + + if state.ExpireFinal(now.Add(3999 * time.Millisecond)) { + t.Fatal("final caption expired early") + } + if !state.ExpireFinal(now.Add(4 * time.Second)) { + t.Fatal("final caption did not expire") + } + if state.Final != "" || state.Provisional != "draft" { + t.Fatalf("expiry changed the wrong row: %#v", state) + } +} + +func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) { + now := time.Unix(100, 0) + var state viewState + state.Apply(captions.Event{Kind: captions.Final, Text: "final"}, now, 0) + if state.ExpireFinal(now.Add(24 * time.Hour)) { + t.Fatal("zero-timeout caption expired") + } +} diff --git a/src/output/sink.go b/src/output/sink.go new file mode 100644 index 0000000..62f9fef --- /dev/null +++ b/src/output/sink.go @@ -0,0 +1,33 @@ +// Package output defines caption presentation sinks. +package output + +import ( + "errors" + + "tea.chunkbyte.com/kato/captioneer/src/captions" +) + +type Sink interface { + Publish(captions.Event) error + Close() error +} + +type MultiSink []Sink + +func (sinks MultiSink) Publish(event captions.Event) error { + var publishErr error + for _, sink := range sinks { + if err := sink.Publish(event); err != nil { + publishErr = errors.Join(publishErr, err) + } + } + return publishErr +} + +func (sinks MultiSink) Close() error { + var closeErr error + for i := len(sinks) - 1; i >= 0; i-- { + closeErr = errors.Join(closeErr, sinks[i].Close()) + } + return closeErr +} diff --git a/src/output/sink_test.go b/src/output/sink_test.go new file mode 100644 index 0000000..915cb7f --- /dev/null +++ b/src/output/sink_test.go @@ -0,0 +1,49 @@ +package output + +import ( + "errors" + "testing" + + "tea.chunkbyte.com/kato/captioneer/src/captions" +) + +type recordingSink struct { + events []captions.Event + closed bool + err error +} + +func (s *recordingSink) Publish(event captions.Event) error { + s.events = append(s.events, event) + return s.err +} +func (s *recordingSink) Close() error { s.closed = true; return s.err } + +func TestMultiSinkPublishesAndClosesAll(t *testing.T) { + first := &recordingSink{} + second := &recordingSink{} + sinks := MultiSink{first, second} + event := captions.Event{Kind: captions.Final, Text: "hello"} + if err := sinks.Publish(event); err != nil { + t.Fatal(err) + } + if len(first.events) != 1 || len(second.events) != 1 { + t.Fatalf("event was not dispatched: %d, %d", len(first.events), len(second.events)) + } + if err := sinks.Close(); err != nil { + t.Fatal(err) + } + if !first.closed || !second.closed { + t.Fatal("not all sinks were closed") + } +} + +func TestMultiSinkStillPublishesToOtherSinksAfterError(t *testing.T) { + want := errors.New("publish failed") + first := &recordingSink{err: want} + second := &recordingSink{} + err := (MultiSink{first, second}).Publish(captions.Event{Kind: captions.Hide}) + if !errors.Is(err, want) || len(second.events) != 1 { + t.Fatalf("unexpected result: err=%v second=%d", err, len(second.events)) + } +} diff --git a/src/output/terminal/terminal.go b/src/output/terminal/terminal.go new file mode 100644 index 0000000..6277ce9 --- /dev/null +++ b/src/output/terminal/terminal.go @@ -0,0 +1,54 @@ +// Package terminal renders captions using terminal control sequences. +package terminal + +import ( + "fmt" + "io" + + "tea.chunkbyte.com/kato/captioneer/src/captions" +) + +type Sink struct { + writer io.Writer + previewVisible bool +} + +func New(writer io.Writer) *Sink { + return &Sink{writer: writer} +} + +func (s *Sink) Publish(event captions.Event) error { + switch event.Kind { + case captions.Provisional: + _, err := fmt.Fprintf(s.writer, "\r\033[2K… %s", event.Text) + if err == nil { + s.previewVisible = true + } + return err + case captions.Final: + if err := s.clearPreview(); err != nil { + return err + } + _, err := fmt.Fprintf(s.writer, "[%.2fs-%.2fs] %s\n", event.StartedAt.Seconds(), event.EndedAt.Seconds(), event.Text) + return err + case captions.Hide: + return s.clearPreview() + default: + return fmt.Errorf("unsupported caption event kind %d", event.Kind) + } +} + +func (s *Sink) Close() error { + return s.clearPreview() +} + +func (s *Sink) clearPreview() error { + if !s.previewVisible { + return nil + } + _, err := fmt.Fprint(s.writer, "\r\033[2K") + if err == nil { + s.previewVisible = false + } + return err +} diff --git a/src/output/terminal/terminal_test.go b/src/output/terminal/terminal_test.go new file mode 100644 index 0000000..f8918dd --- /dev/null +++ b/src/output/terminal/terminal_test.go @@ -0,0 +1,37 @@ +package terminal + +import ( + "bytes" + "testing" + "time" + + "tea.chunkbyte.com/kato/captioneer/src/captions" +) + +func TestTerminalProvisionalAndFinalOutput(t *testing.T) { + var output bytes.Buffer + sink := New(&output) + if err := sink.Publish(captions.Event{Kind: captions.Provisional, Text: "draft"}); err != nil { + t.Fatal(err) + } + if err := sink.Publish(captions.Event{ + Kind: captions.Final, Text: "done", StartedAt: time.Second, EndedAt: 2500 * time.Millisecond, + }); err != nil { + t.Fatal(err) + } + want := "\r\033[2K… draft\r\033[2K[1.00s-2.50s] done\n" + if output.String() != want { + t.Fatalf("output = %q, want %q", output.String(), want) + } +} + +func TestTerminalHideClearsOnlyVisiblePreview(t *testing.T) { + var output bytes.Buffer + sink := New(&output) + _ = sink.Publish(captions.Event{Kind: captions.Hide}) + _ = sink.Publish(captions.Event{Kind: captions.Provisional, Text: "draft"}) + _ = sink.Publish(captions.Event{Kind: captions.Hide}) + if got := output.String(); got != "\r\033[2K… draft\r\033[2K" { + t.Fatalf("unexpected output %q", got) + } +}