chore: add Apache License 2.0 and ignore build directory

This commit is contained in:
2026-07-16 17:24:50 +03:00
parent 45e4bce2a0
commit 7affa0dbc8
23 changed files with 1814 additions and 157 deletions
+1
View File
@@ -4,3 +4,4 @@ models/silero_vad_v5.onnx
# Locally built executable.
/captioneer
/build/
+201
View File
@@ -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.
+152 -66
View File
@@ -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 <your-repository-url>
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 <kbd>Ctrl</kbd>+<kbd>C</kbd> 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 <kbd>Ctrl</kbd>+<kbd>C</kbd> 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 `<sink>.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.
+30
View File
@@ -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'
+70
View File
@@ -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
+21
View File
@@ -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" "$@"
+61
View File
@@ -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)
}
+4
View File
@@ -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)
+25
View File
@@ -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)
}
+56 -49
View File
@@ -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
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 {
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),
})
}
+136
View File
@@ -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")
}
}
+77
View File
@@ -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)
}
}
+6 -32
View File
@@ -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)
}
}
+91
View File
@@ -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
}
+41
View File
@@ -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")
}
})
}
}
+3 -8
View File
@@ -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) {
+575
View File
@@ -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 <gtk/gtk.h>
#include <gtk4-layer-shell.h>
#include <gdk/x11/gdkx.h>
#include <gdk/wayland/gdkwayland.h>
#include <X11/Xlib.h>
#include <X11/Xatom.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
// 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 *)&notification, 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
}
+39
View File
@@ -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
}
+50
View File
@@ -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")
}
}
+33
View File
@@ -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
}
+49
View File
@@ -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))
}
}
+54
View File
@@ -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
}
+37
View File
@@ -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)
}
}