Compare commits
6
Commits
v0.1.0
...
41867429a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41867429a5 | ||
|
|
04ad4eb3d9 | ||
|
|
54fb100d3c | ||
|
|
55fbd357b0 | ||
|
|
b0487be39e | ||
|
|
b3ed538820 |
@@ -27,7 +27,7 @@ jobs:
|
||||
run: >-
|
||||
dnf install -y
|
||||
bzip2 curl gcc gcc-c++ git gtk4-devel gtk4-layer-shell-devel
|
||||
jq libX11-devel libXfixes-devel nodejs24 nodejs24-bin pkgconf-pkg-config
|
||||
jq libX11-devel libXfixes-devel nodejs24 nodejs24-bin patchelf pkgconf-pkg-config
|
||||
pulseaudio-utils ShellCheck tar gzip binutils
|
||||
|
||||
- name: Check out repository
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
run: >-
|
||||
dnf install -y
|
||||
bzip2 curl gcc gcc-c++ git gtk4-devel gtk4-layer-shell-devel
|
||||
jq libX11-devel libXfixes-devel nodejs24 nodejs24-bin pkgconf-pkg-config
|
||||
jq libX11-devel libXfixes-devel nodejs24 nodejs24-bin patchelf pkgconf-pkg-config
|
||||
pulseaudio-utils ShellCheck tar gzip binutils
|
||||
|
||||
- name: Check out repository and tags
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" --match 'v[0-9]*' 2>/dev/null || true)"
|
||||
{
|
||||
printf 'Automated Captioneer release for `%s`.\n\n' "${GITHUB_REF_NAME}"
|
||||
printf 'The Linux x86_64 archive contains both Captioneer commands and their Sherpa/ONNX native libraries. Models are downloaded separately with `scripts/download-models.sh`.\n'
|
||||
printf 'The Linux x86_64 archive contains both Captioneer commands and a private desktop runtime with GTK4, GTK4 Layer Shell, and Sherpa/ONNX libraries. Models are downloaded separately with `scripts/download-models.sh`.\n'
|
||||
if [[ -n "$previous_tag" ]]; then
|
||||
printf '\nChanges since `%s`:\n\n' "$previous_tag"
|
||||
git log --no-merges --pretty='- %s (`%h`)' "${previous_tag}..${GITHUB_REF_NAME}"
|
||||
|
||||
@@ -7,11 +7,11 @@ Audio and captions stay on your machine. Nothing is uploaded after the one-time
|
||||
Captioneer provides two commands:
|
||||
|
||||
- `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.
|
||||
- `captioneer-desktop` is the GTK4 desktop utility. It manages caption processing, the overlay, caption history, the application Console, and saved settings. It is built with the `gtk` build tag.
|
||||
|
||||
## Setup
|
||||
|
||||
The terminal command requires Linux x86_64, Go 1.26.2 or newer, `pactl`, and `parec`. The desktop command additionally needs GTK4 and GTK4 Layer Shell development/runtime libraries. PipeWire works through its PulseAudio compatibility layer.
|
||||
The terminal command requires Linux x86_64, Go 1.26.2 or newer, `pactl`, and `parec`. Building the desktop command from source additionally needs GTK4, GTK4 Layer Shell, GObject Introspection, and X11 development libraries. Published release archives carry the desktop runtime libraries. PipeWire works through its PulseAudio compatibility layer.
|
||||
|
||||
Download the large recognition models once:
|
||||
|
||||
@@ -23,7 +23,7 @@ This creates `models/parakeet-tdt-v2/` and `models/silero_vad_v5.onnx`. They are
|
||||
|
||||
## Terminal captions
|
||||
|
||||
VAD mode is recommended for normal use. It redraws a provisional caption while speech is active and commits a timestamped caption after the utterance ends:
|
||||
VAD mode is recommended for normal use. It redraws a provisional caption only after Silero detects speech and commits a timestamped caption after the utterance ends:
|
||||
|
||||
```bash
|
||||
go run ./src/cmd/captioneer --mode=vad
|
||||
@@ -50,29 +50,47 @@ Final captions use stdout; diagnostics and overload warnings use stderr. This ma
|
||||
|
||||
Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop. Captioneer stops `parec` and flushes pending audio before exiting.
|
||||
|
||||
## Desktop overlay
|
||||
Sherpa recognition, Silero VAD, and audio capture run in an isolated worker process. The terminal or GTK process stays responsive if native inference crashes or hangs: Captioneer restarts the worker automatically after a failure, and Ctrl+C force-kills only that worker if it cannot flush within the configured shutdown timeout. Send `SIGHUP` to the parent Captioneer PID to hot-reload the models without closing the terminal or overlay:
|
||||
|
||||
```bash
|
||||
kill -HUP <captioneer-parent-pid>
|
||||
```
|
||||
|
||||
An automatic or manual restart begins a fresh capture/transcription session; audio being processed by the failed worker cannot be recovered.
|
||||
|
||||
## Desktop application
|
||||
|
||||
The simplest Bazzite development command is:
|
||||
|
||||
```bash
|
||||
./scripts/distrobox/run.sh --mode=vad --output=overlay
|
||||
./scripts/distrobox/run.sh
|
||||
```
|
||||
|
||||
Use `--output=both` to keep terminal output as well:
|
||||
The main window has three top-level tabs:
|
||||
|
||||
```bash
|
||||
./scripts/distrobox/run.sh --mode=vad --output=both
|
||||
```
|
||||
- **Captions** keeps up to 500 final captions and one mutable live/provisional row. Scrolling upward pauses automatic scrolling; **Jump to Latest** resumes it. Selected text or the full in-memory transcript can be copied.
|
||||
- **Console** keeps up to 2,000 timestamped diagnostic entries, including startup, capture, model, overload, warning, error, and shutdown messages. It has independent scrolling, copying, and clearing controls.
|
||||
- **Settings** exposes the meaningful CLI configuration with validation and folder pickers. Advanced recovery settings are collapsed by default. **Apply & Restart Captions** appears when a recognition change requires restarting a running pipeline.
|
||||
|
||||
The header contains the running state, current processing mode, a Start/Stop control, and native GTK window controls. Closing the main window exits Captioneer in this stage. Captions start automatically by default, but this can be changed under **Settings → General**. The overlay, GUI history, stdout captions, stderr diagnostics, and timestamped caption files can all be enabled independently.
|
||||
|
||||
Caption logs are final captions only. Each session creates a new file named like `captioneer-2026-07-17_13-45-20.000.log`; every line contains its creation timestamp, audio-relative start/end time, and caption. Lines are flushed immediately. The default directory is `$XDG_STATE_HOME/captioneer/transcripts`, or `~/.local/state/captioneer/transcripts` when `XDG_STATE_HOME` is unset.
|
||||
|
||||
The interface deliberately uses one compact olive/gray-green, early-2000s desktop theme. It uses GTK client-side decoration for native dragging, double-click behavior, keyboard access, and window controls rather than imitating those behaviors manually. Replace [`assets/icon/icon.png`](assets/icon/icon.png) to change the header/release icon while keeping the same path.
|
||||
|
||||
### Caption overlay behavior
|
||||
|
||||
In VAD mode, a 500 ms pause finalizes the current speech segment. Captioneer splits that transcript again at sentence punctuation and at word-safe boundaries of at most 64 characters, so continuous speech does not become one enormous paragraph. The live provisional view keeps the latest three digestible chunks and grows vertically without ellipsizing them. Completed chunks remain visible for at least three seconds, receive extra time based on their word count, and gradually fade from white to a restrained gray as they age. When several chunks finalize together, their removal is staggered in reading order instead of deleting the whole block at once. Up to six completed chunks remain above the provisional text; if a seventh arrives, the oldest is removed. Blank recognition results never erase completed text before its reading lifetime expires.
|
||||
|
||||
The expensive Parakeet recognizer stays idle during silence and audio that Silero does not classify as speech. A small rolling preview window also prevents long continuous audio from making each refresh progressively slower. Silero itself continues receiving the live stream because it needs the quiet frames to detect when speech ends and when it starts again.
|
||||
|
||||
Text is left-aligned inside the centered, fixed-width dark bubble and wraps at its configured width instead of extending off-screen. Pointer input passes through the window by default.
|
||||
|
||||
To build outside the helper script, install GTK4 and GTK4 Layer Shell development packages, then run:
|
||||
To build outside the helper script, install GTK4, GTK4 Layer Shell, GObject Introspection, and X11 development packages, then run:
|
||||
|
||||
```bash
|
||||
go build -tags gtk -o build/captioneer-desktop ./src/cmd/captioneer-desktop
|
||||
./build/captioneer-desktop --mode=vad --output=overlay
|
||||
./build/captioneer-desktop
|
||||
```
|
||||
|
||||
### Wayland and X11 behavior
|
||||
@@ -87,23 +105,40 @@ Layer Shell and X11 hints are requests, not universal guarantees. Same-layer win
|
||||
|
||||
## Command-line options
|
||||
|
||||
`--mode` is required for both commands. `captioneer-desktop` also requires `--output`.
|
||||
Both commands use built-in defaults, then the saved configuration, then explicit command-line flags. No mode or output flag is required. The compatibility flag `captioneer-desktop --output=terminal|overlay|both` remains available and changes caption destinations for that launch only.
|
||||
|
||||
### Configuration options
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--config=PATH` | XDG path | Read configuration from another JSON file. |
|
||||
| `--no-config` | `false` | Ignore saved settings and start from built-in defaults for this launch. |
|
||||
|
||||
### Transcription options
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--mode=vad|fixed` | required | Speech-aware utterances or independent fixed chunks. |
|
||||
| `--mode=vad|fixed` | `vad` | 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 recognition and VAD. |
|
||||
| `--preview-threshold-dbfs=-45` | `-45` | RMS threshold that starts a provisional VAD caption. Raise it to reject background audio; lower it for quiet speech. |
|
||||
| `--preview-threshold-dbfs=-45` | `-45` | RMS gate below which recognition stays idle. Raise it to reject quiet background noise; lower it for quiet speech. In fixed mode, gated chunks are not sent to Parakeet. |
|
||||
| `--vad-threshold=0.5` | `0.5` | Silero speech confidence from `0` to `1`. Raise it to reject more music/noise; lower it if real speech is missed. Applies to VAD mode. |
|
||||
| `--model-auto-restart=true` | `true` | Restart the isolated model worker after a crash, capture failure, or timeout. Set `false` to report the failure and exit instead. |
|
||||
| `--model-timeout=30s` | `30s` | Maximum model startup, continuously busy processing time, or missing-heartbeat period before the worker is considered hung. `0s` disables hang detection. |
|
||||
| `--model-shutdown-timeout=2s` | `2s` | Time allowed for pending-audio flush and model cleanup after Ctrl+C or reload before the worker is force-killed. |
|
||||
|
||||
### Desktop-only options
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--output=terminal|overlay|both` | required | Select desktop-command renderers. `both` falls back to terminal if GTK initialization fails. |
|
||||
| `--output=terminal|overlay|both` | saved outputs | Compatibility preset for overlay and stdout caption destinations. |
|
||||
| `--gui-history=true|false` | `true` | Retain captions in the bounded GUI history. |
|
||||
| `--stderr-diagnostics=true|false` | `true` | Mirror structured diagnostics to stderr as well as the Console. |
|
||||
| `--start-captions=true|false` | `true` | Start processing when the desktop window opens. |
|
||||
| `--main-window-always-on-top=true|false` | `false` | Best-effort keep-above request for the main window. Supported through X11 window-manager hints; Wayland may reject it. |
|
||||
| `--caption-log=true|false` | `false` | Save finalized captions to a timestamped session file. |
|
||||
| `--caption-log-dir=PATH` | XDG state path | Directory for caption session logs. |
|
||||
| `--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. |
|
||||
@@ -118,7 +153,7 @@ Examples:
|
||||
|
||||
```bash
|
||||
# Larger text, a custom font, and longer final-caption visibility
|
||||
./scripts/distrobox/run.sh --mode=vad --output=overlay \
|
||||
./scripts/distrobox/run.sh --mode=vad \
|
||||
--overlay-font-size=34 --overlay-font-family="Noto Sans" --overlay-final-timeout=6s
|
||||
|
||||
# Explicit XWayland/X11 route on the cursor's monitor
|
||||
@@ -127,8 +162,30 @@ Examples:
|
||||
# Fixed two-second captions on a named monitor
|
||||
./scripts/distrobox/run.sh --mode=fixed --chunk-duration=2s \
|
||||
--output=overlay --overlay-monitor=DP-1
|
||||
|
||||
# Be more conservative when instrumental music causes false speech detections
|
||||
./scripts/distrobox/run.sh --mode=vad \
|
||||
--vad-threshold=0.65 --preview-threshold-dbfs=-40
|
||||
```
|
||||
|
||||
## Saved configuration
|
||||
|
||||
The GUI saves a readable, versioned JSON file at:
|
||||
|
||||
```text
|
||||
$XDG_CONFIG_HOME/captioneer/config.json
|
||||
```
|
||||
|
||||
When `XDG_CONFIG_HOME` is unset, the normal Linux fallback is `~/.config/captioneer/config.json`. Saving is atomic and creates the directory/file with user-only permissions. Both `captioneer` and `captioneer-desktop` load this file, so ordinary terminal use benefits from the same recognition settings without repeating flags. Explicit flags always win and do not silently rewrite the saved file.
|
||||
|
||||
Precedence is unambiguous:
|
||||
|
||||
```text
|
||||
built-in defaults < saved JSON configuration < explicit command-line flags
|
||||
```
|
||||
|
||||
Use `--no-config` for a clean one-off launch or `--config=/path/to/config.json` for a separate profile. The GUI's **Help → Open Configuration Folder** opens the active file's parent directory. Caption history itself remains in memory and does not survive application restarts; timestamped caption logging is the opt-in persistent transcript path.
|
||||
|
||||
## Bazzite development with Distrobox
|
||||
|
||||
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.
|
||||
@@ -136,7 +193,7 @@ The helper scripts keep GTK development packages off the immutable host. They cr
|
||||
Run the desktop command from source:
|
||||
|
||||
```bash
|
||||
./scripts/distrobox/run.sh --mode=vad --output=overlay
|
||||
./scripts/distrobox/run.sh
|
||||
```
|
||||
|
||||
Build both binaries into the host-visible `build/` directory:
|
||||
@@ -155,27 +212,37 @@ The scripts recognize these environment variables:
|
||||
| `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.
|
||||
Captioneer uses XDG configuration/state locations rather than application-specific environment variables. Use the GUI, JSON file, or 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`.
|
||||
- **Music/noise produces captions:** use VAD mode and try `--vad-threshold=0.65`. Increase it in small steps if false detections continue. `--preview-threshold-dbfs=-40` can also reject quiet background audio, but an RMS gate cannot distinguish loud music from speech. Singing may still be treated as speech.
|
||||
- **Processing overload warning:** inference fell behind and the oldest 100 ms packet was dropped. Try more `--threads` or a longer `--chunk-duration`.
|
||||
- **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.
|
||||
- **Overlay initialization fails:** disable **Settings → Outputs → Caption overlay** and use GUI history or stdout; details remain available in the Console.
|
||||
- **Always on top does not work for the main window:** this is a best-effort X11 feature. Generic Wayland intentionally leaves stacking policy to the compositor, and Captioneer reports that limitation in the Console.
|
||||
- **Settings do not apply:** validation appears beside the Apply button. Recognition changes restart a running pipeline; an overlay backend change is saved but requires restarting the whole application.
|
||||
- **Caption log is missing:** enable **Save timestamped caption log**, verify its directory, then look for the dated per-session file shown in the Console.
|
||||
- **A locally built desktop binary misses shared libraries:** use `scripts/distrobox/run.sh` or install the matching GTK4 and GTK4 Layer Shell runtime packages. The desktop command in a published release carries these application libraries in its private `lib/` directory; keep the extracted directory together.
|
||||
- **The model worker repeatedly restarts:** inspect the warning on stderr. Slow machines may need a larger `--model-timeout`; use `--model-auto-restart=false` when diagnosing a persistent crash so Captioneer exits on the first failure.
|
||||
|
||||
## Development
|
||||
|
||||
The transcription pipeline emits presentation-neutral events. `output/terminal` and `output/overlay` consume those events independently; GTK stays on its main OS thread while capture and recognition run on a worker.
|
||||
The transcription pipeline emits presentation-neutral events. `output/terminal` and `output/overlay` consume those events independently; GTK stays on its main OS thread while capture, VAD, and recognition run in a supervised child process. Caption events and health messages cross a private pipe, and the child is placed in its own process group so its `parec` process can be stopped or replaced with it.
|
||||
|
||||
```text
|
||||
src/app/ capture/model/process lifecycle
|
||||
src/captions/ caption events and fixed/VAD coordination
|
||||
src/config/ defaults, CLI precedence, and XDG JSON persistence
|
||||
src/diagnostics/ bounded structured application log
|
||||
src/gui/ GTK4 main window, pages, settings, and styling
|
||||
src/output/history/ bounded GUI caption history
|
||||
src/output/terminal/ terminal renderer
|
||||
src/output/overlay/ private GTK4/Layer Shell CGO adapter
|
||||
src/output/transcript/ timestamped final-caption files
|
||||
src/cmd/captioneer/ terminal command
|
||||
src/cmd/captioneer-desktop/ GTK-tagged desktop command
|
||||
```
|
||||
@@ -212,12 +279,12 @@ git tag v1.3.519
|
||||
git push origin v1.3.519
|
||||
```
|
||||
|
||||
The release contains a checksummed Linux x86_64 archive with `captioneer`, `captioneer-desktop`, the required Sherpa/ONNX shared libraries, the model download script, the README, and the license. The desktop command still requires GTK4 and GTK4 Layer Shell runtime libraries on the destination system.
|
||||
The release contains a checksummed Linux x86_64 archive with `captioneer`, `captioneer-desktop`, and a private runtime containing GTK4, GTK4 Layer Shell, Sherpa/ONNX, and their linked non-glibc dependencies. Extract the whole archive and run `./captioneer-desktop`; do not move the executable away from its adjacent `lib/` directory. Models, `pactl`, `parec`, host fonts, the display server, graphics drivers, and glibc remain host-provided.
|
||||
|
||||
The release workflow uses Gitea's built-in `GITEA_TOKEN`; no personal token is required. In **Settings → Actions → General**, allow the workflow token `Read` access to code and `Write` access to releases. Gitea clamps workflow permissions to those repository settings, so a more restrictive maximum produces an actionable HTTP 403 during publishing.
|
||||
|
||||
## License
|
||||
|
||||
Captioneer is licensed under the [Apache License 2.0](LICENSE). Sherpa-Onnx is Apache-2.0 licensed, GTK4 is LGPL-2.1-or-later, and GTK4 Layer Shell is MIT licensed. Captioneer links to the system GTK libraries rather than copying their source into the project.
|
||||
Captioneer is licensed under the [Apache License 2.0](LICENSE). Sherpa-Onnx is Apache-2.0 licensed, GTK4 is LGPL-2.1-or-later, and GTK4 Layer Shell is MIT licensed. Release archives dynamically link and redistribute the required shared libraries; `RUNTIME-LIBRARIES.tsv` records their source packages and `third-party-licenses/` contains the installed license notices supplied by those packages. Local source builds continue to link against the system libraries.
|
||||
|
||||
Version 1 intentionally has no animation, persisted caption history, runtime dragging, background-free mode, capture exclusion, Flatpak, desktop entry, or autostart integration.
|
||||
This first GUI stage intentionally has no system tray, start-at-login behavior, global shortcuts, notifications, transcript database, cloud sync, plugin system, remote control, automatic updates, multiple themes, or full terminal emulator. Closing the main window exits the application; background availability belongs to the later tray phase.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -2,4 +2,13 @@ module tea.chunkbyte.com/kato/captioneer
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require github.com/k2-fsa/sherpa-onnx-go-linux v1.13.4
|
||||
require (
|
||||
github.com/diamondburned/gotk4/pkg v0.3.1
|
||||
github.com/k2-fsa/sherpa-onnx-go-linux v1.13.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KarpelesLab/weak v0.1.1 // indirect
|
||||
go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||
)
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
github.com/KarpelesLab/weak v0.1.1 h1:fNnlPo3aypS9tBzoEQluY13XyUfd/eWaSE/vMvo9s4g=
|
||||
github.com/KarpelesLab/weak v0.1.1/go.mod h1:pzXsWs5f2bf+fpgHayTlBE1qJpO3MpJKo5sRaLu1XNw=
|
||||
github.com/diamondburned/gotk4/pkg v0.3.1 h1:uhkXSUPUsCyz3yujdvl7DSN8jiLS2BgNTQE95hk6ygg=
|
||||
github.com/diamondburned/gotk4/pkg v0.3.1/go.mod h1:DqeOW+MxSZFg9OO+esk4JgQk0TiUJJUBfMltKhG+ub4=
|
||||
github.com/k2-fsa/sherpa-onnx-go-linux v1.13.4 h1:FpEUVeTtdkUlb+YToY8rCWFmvB+zT70YKIKdvnDWIzU=
|
||||
github.com/k2-fsa/sherpa-onnx-go-linux v1.13.4/go.mod h1:NXEH2rsBgTdqY59YpPq6CtSBlBAXy/8a9FmpLERU97I=
|
||||
go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 h1:lGdhQUN/cnWdSH3291CUuxSEqc+AsGTiDxPP3r2J0l4=
|
||||
go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
@@ -4,6 +4,13 @@ set -Eeuo pipefail
|
||||
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
cd -- "$repo_root"
|
||||
|
||||
for command in go ldd patchelf readelf rpm; do
|
||||
command -v "$command" >/dev/null 2>&1 || {
|
||||
printf 'captioneer: %s is required to build a release archive\n' "$command" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
tag=${1:-}
|
||||
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
printf 'captioneer: release tag must match vMAJOR.MINOR.BUG, got %q\n' "$tag" >&2
|
||||
@@ -17,29 +24,192 @@ package_dir="dist/$package_name"
|
||||
|
||||
rm -rf -- "$package_dir"
|
||||
rm -f -- "dist/$archive_name" "dist/$archive_name.sha256"
|
||||
mkdir -p -- "$package_dir/lib" "$package_dir/scripts"
|
||||
mkdir -p -- \
|
||||
"$package_dir/assets/icon" \
|
||||
"$package_dir/lib" \
|
||||
"$package_dir/scripts" \
|
||||
"$package_dir/third-party-licenses"
|
||||
|
||||
# The Sherpa Go module embeds an absolute build-machine RPATH. Add a portable
|
||||
# fallback so the bundled native libraries resolve beside the extracted tools.
|
||||
# build-time fallback; the final paths are normalized after dependency copying.
|
||||
# gtk4-layer-shell must precede libwayland-client so its Wayland shim installs.
|
||||
# shellcheck disable=SC2016 # $ORIGIN must remain literal for the ELF loader.
|
||||
export CGO_LDFLAGS='-Wl,-rpath,$ORIGIN/lib'
|
||||
CGO_LDFLAGS='-Wl,--no-as-needed -lgtk4-layer-shell -Wl,-rpath,$ORIGIN/lib' \
|
||||
go build -trimpath -o "$package_dir/captioneer" ./src/cmd/captioneer
|
||||
go build -trimpath -tags gtk -o "$package_dir/captioneer-desktop" ./src/cmd/captioneer-desktop
|
||||
# shellcheck disable=SC2016 # $ORIGIN must remain literal for the ELF loader.
|
||||
CGO_LDFLAGS='-Wl,--no-as-needed -lgtk4-layer-shell -Wl,-rpath,$ORIGIN/lib' \
|
||||
go build -trimpath -tags gtk \
|
||||
-o "$package_dir/captioneer-desktop" ./src/cmd/captioneer-desktop
|
||||
|
||||
sherpa_dir="$(go list -m -f '{{.Dir}}' github.com/k2-fsa/sherpa-onnx-go-linux)"
|
||||
sherpa_lib_dir="$sherpa_dir/lib/x86_64-unknown-linux-gnu"
|
||||
cp -- "$sherpa_lib_dir/libsherpa-onnx-c-api.so" "$package_dir/lib/"
|
||||
cp -- "$sherpa_lib_dir/libonnxruntime.so" "$package_dir/lib/"
|
||||
|
||||
declare -A bundled_libraries=()
|
||||
declare -A copied_license_packages=()
|
||||
manifest_entries="$(mktemp)"
|
||||
trap 'rm -f -- "$manifest_entries"' EXIT
|
||||
|
||||
is_host_runtime_library() {
|
||||
case "$1" in
|
||||
ld-linux*.so* | libc.so.* | libm.so.* | libmvec.so.* | libdl.so.* | \
|
||||
libpthread.so.* | librt.so.* | libresolv.so.* | libanl.so.* | \
|
||||
libutil.so.* | libnss_*.so.* | libthread_db.so.* | libBrokenLocale.so.*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
copy_package_licenses() {
|
||||
local package=$1
|
||||
local license_path relative_path destination
|
||||
|
||||
[[ -z "${copied_license_packages[$package]:-}" ]] || return 0
|
||||
copied_license_packages["$package"]=1
|
||||
|
||||
while IFS= read -r license_path; do
|
||||
[[ -f "$license_path" && "$license_path" == /usr/share/licenses/* ]] || continue
|
||||
relative_path="${license_path#/usr/share/licenses/}"
|
||||
destination="$package_dir/third-party-licenses/$relative_path"
|
||||
mkdir -p -- "$(dirname -- "$destination")"
|
||||
cp -L -- "$license_path" "$destination"
|
||||
done < <(rpm -ql "$package" 2>/dev/null || true)
|
||||
}
|
||||
|
||||
bundle_library() {
|
||||
local source=$1
|
||||
local provider=${2:-}
|
||||
local library_name resolved_source destination package
|
||||
|
||||
library_name="$(basename -- "$source")"
|
||||
is_host_runtime_library "$library_name" && return
|
||||
|
||||
resolved_source="$(readlink -f -- "$source")"
|
||||
[[ -f "$resolved_source" ]] || {
|
||||
printf 'captioneer: runtime library not found: %s\n' "$source" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -n "${bundled_libraries[$library_name]:-}" ]]; then
|
||||
if ! cmp -s -- "$resolved_source" "${bundled_libraries[$library_name]}"; then
|
||||
printf 'captioneer: conflicting runtime libraries named %s\n' "$library_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
destination="$package_dir/lib/$library_name"
|
||||
cp -L -- "$resolved_source" "$destination"
|
||||
bundled_libraries["$library_name"]="$resolved_source"
|
||||
|
||||
if [[ -z "$provider" ]] && command -v rpm >/dev/null 2>&1; then
|
||||
package="$(rpm -qf --queryformat '%{NAME}\n' "$resolved_source" 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -n "$package" ]]; then
|
||||
provider="$(rpm -q --queryformat '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' "$package")"
|
||||
copy_package_licenses "$package"
|
||||
fi
|
||||
fi
|
||||
provider=${provider:-unidentified-system-package}
|
||||
printf '%s\t%s\n' "$library_name" "$provider" >> "$manifest_entries"
|
||||
}
|
||||
|
||||
bundle_library "$sherpa_lib_dir/libsherpa-onnx-c-api.so" \
|
||||
'github.com/k2-fsa/sherpa-onnx-go-linux'
|
||||
bundle_library "$sherpa_lib_dir/libonnxruntime.so" \
|
||||
'github.com/k2-fsa/sherpa-onnx-go-linux'
|
||||
|
||||
# ldd reports the complete linked dependency closure. Copy everything needed by
|
||||
# either command except glibc and its loader, which must match the host system.
|
||||
for executable in \
|
||||
"$package_dir/captioneer" \
|
||||
"$package_dir/captioneer-desktop"; do
|
||||
ldd_output="$(ldd "$executable")"
|
||||
if grep -Fq 'not found' <<< "$ldd_output"; then
|
||||
printf 'captioneer: unresolved release dependencies for %s:\n%s\n' \
|
||||
"$executable" "$ldd_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r library_path; do
|
||||
[[ -n "$library_path" ]] || continue
|
||||
if [[ "$library_path" == "$repo_root/$package_dir/lib/"* ]]; then
|
||||
continue
|
||||
fi
|
||||
bundle_library "$library_path"
|
||||
done < <(
|
||||
awk '
|
||||
$2 == "=>" && $3 ~ /^\// { print $3 }
|
||||
$1 ~ /^\// { print $1 }
|
||||
' <<< "$ldd_output"
|
||||
)
|
||||
done
|
||||
|
||||
{
|
||||
printf 'Library\tProvider\n'
|
||||
sort -u -- "$manifest_entries"
|
||||
} > "$package_dir/RUNTIME-LIBRARIES.tsv"
|
||||
|
||||
# Give every object a location-relative path. Patching each library is required
|
||||
# because an upstream RUNPATH (for example, Tinysparql's Fedora path) otherwise
|
||||
# prevents the executable's path from resolving that library's own children.
|
||||
# This avoids LD_LIBRARY_PATH, which would leak into spawned pactl/parec tools.
|
||||
for library in "$package_dir"/lib/*; do
|
||||
chmod u+w -- "$library"
|
||||
# shellcheck disable=SC2016 # $ORIGIN must remain literal for the ELF loader.
|
||||
patchelf --set-rpath '$ORIGIN' "$library"
|
||||
chmod 0755 -- "$library"
|
||||
done
|
||||
for executable in captioneer captioneer-desktop; do
|
||||
# shellcheck disable=SC2016 # $ORIGIN must remain literal for the ELF loader.
|
||||
patchelf --set-rpath '$ORIGIN/lib' "$package_dir/$executable"
|
||||
done
|
||||
|
||||
cp -- README.md LICENSE "$package_dir/"
|
||||
cp -- assets/icon/icon.png "$package_dir/assets/icon/"
|
||||
cp -- scripts/download-models.sh "$package_dir/scripts/"
|
||||
|
||||
for executable in captioneer captioneer-desktop; do
|
||||
dynamic_section="$(readelf -d "$package_dir/$executable")"
|
||||
# shellcheck disable=SC2016 # Verify the literal ELF-loader token.
|
||||
if ! readelf -d "$package_dir/$executable" | grep -Fq '$ORIGIN/lib'; then
|
||||
printf 'captioneer: %s is missing the portable native-library RPATH\n' "$executable" >&2
|
||||
if [[ "$dynamic_section" != *'$ORIGIN/lib'* ]]; then
|
||||
printf 'captioneer: %s is missing its private-runtime RUNPATH\n' \
|
||||
"$executable" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
[[ -f "$package_dir/lib/libgtk4-layer-shell.so.0" ]] || {
|
||||
printf 'captioneer: GTK4 Layer Shell was not included in the release runtime\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for library in "$package_dir"/lib/*; do
|
||||
# shellcheck disable=SC2016 # Verify the literal ELF-loader token.
|
||||
[[ "$(patchelf --print-rpath "$library")" == '$ORIGIN' ]] || {
|
||||
printf 'captioneer: %s is missing its private dependency RUNPATH\n' \
|
||||
"$(basename -- "$library")" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
# Verify the archive itself contains every linked non-glibc dependency instead
|
||||
# of allowing the build container's installed libraries to mask an omission.
|
||||
for object in \
|
||||
"$package_dir/captioneer" \
|
||||
"$package_dir/captioneer-desktop" \
|
||||
"$package_dir"/lib/*; do
|
||||
while IFS= read -r needed_library; do
|
||||
is_host_runtime_library "$needed_library" && continue
|
||||
[[ -f "$package_dir/lib/$needed_library" ]] || {
|
||||
printf 'captioneer: %s requires unbundled library %s\n' \
|
||||
"$(basename -- "$object")" "$needed_library" >&2
|
||||
exit 1
|
||||
}
|
||||
done < <(patchelf --print-needed "$object")
|
||||
done
|
||||
|
||||
# --help exits before display/model initialization while still exercising the
|
||||
# dynamic loader through the same executable release users invoke.
|
||||
"$package_dir/captioneer-desktop" --help >/dev/null 2>&1
|
||||
|
||||
tar -C dist -czf "dist/$archive_name" "$package_name"
|
||||
(
|
||||
|
||||
@@ -19,6 +19,9 @@ build_script='set -Eeuo pipefail
|
||||
repo_root=$1
|
||||
shift
|
||||
cd -- "$repo_root"
|
||||
# gtk4-layer-shell must precede libwayland-client in the loader order or the
|
||||
# Wayland shim never installs and the overlay cannot be positioned.
|
||||
export CGO_LDFLAGS="-Wl,--no-as-needed -lgtk4-layer-shell ${CGO_LDFLAGS:-}"
|
||||
go build "$@" -o build/captioneer ./src/cmd/captioneer
|
||||
go build "$@" -tags gtk -o build/captioneer-desktop ./src/cmd/captioneer-desktop'
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ container_packages=(
|
||||
curl
|
||||
gcc
|
||||
golang
|
||||
gobject-introspection-devel
|
||||
gtk4-devel
|
||||
gtk4-layer-shell-devel
|
||||
libX11-devel
|
||||
|
||||
@@ -15,6 +15,13 @@ 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}"
|
||||
# gtk4-layer-shell must load before libwayland-client or its Wayland shim is
|
||||
# skipped and the overlay sits as an unpositioned normal window.
|
||||
layer_shell_lib="$(pkg-config --variable=libdir gtk4-layer-shell-0)/libgtk4-layer-shell.so.0"
|
||||
if [[ -f "$layer_shell_lib" ]]; then
|
||||
export LD_PRELOAD="${layer_shell_lib}${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||
fi
|
||||
export CGO_LDFLAGS="-Wl,--no-as-needed -lgtk4-layer-shell ${CGO_LDFLAGS:-}"
|
||||
exec go run -tags gtk ./src/cmd/captioneer-desktop "$@"'
|
||||
|
||||
exec distrobox enter --no-workdir --name "$container_name" -- \
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output"
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateStopped State = "stopped"
|
||||
StateStarting State = "starting"
|
||||
StateListening State = "listening"
|
||||
StateProcessing State = "processing"
|
||||
StateRestarting State = "restarting"
|
||||
StateStopping State = "stopping"
|
||||
StateError State = "error"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
State State
|
||||
Mode config.Mode
|
||||
Source string
|
||||
SpeechActive bool
|
||||
OverloadedAt time.Time
|
||||
LastError string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type sessionRunner func(context.Context, config.Settings, output.Sink, io.Writer, RuntimeObserver) error
|
||||
|
||||
type Controller struct {
|
||||
mu sync.Mutex
|
||||
runner sessionRunner
|
||||
sink output.Sink
|
||||
diagnostics io.Writer
|
||||
cancel context.CancelFunc
|
||||
done chan error
|
||||
status Status
|
||||
subscribers map[chan Status]struct{}
|
||||
}
|
||||
|
||||
func NewController(sink output.Sink, diagnostics io.Writer) *Controller {
|
||||
return newControllerWithRunner(sink, diagnostics, RunObserved)
|
||||
}
|
||||
|
||||
func newControllerWithRunner(sink output.Sink, diagnostics io.Writer, runner sessionRunner) *Controller {
|
||||
return &Controller{
|
||||
runner: runner,
|
||||
sink: sink,
|
||||
diagnostics: diagnostics,
|
||||
status: Status{State: StateStopped, UpdatedAt: time.Now()},
|
||||
subscribers: make(map[chan Status]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) Start(settings config.Settings) error {
|
||||
if err := settings.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.cancel != nil {
|
||||
c.mu.Unlock()
|
||||
return errors.New("caption processing is already running")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
c.cancel = cancel
|
||||
c.done = done
|
||||
c.status = Status{State: StateStarting, Mode: settings.Mode, UpdatedAt: time.Now()}
|
||||
c.publishLocked()
|
||||
c.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
err := c.runner(ctx, settings, c.sink, c.diagnostics, c.observe)
|
||||
c.mu.Lock()
|
||||
if c.done == done {
|
||||
c.cancel = nil
|
||||
c.done = nil
|
||||
if err != nil {
|
||||
c.status.State = StateError
|
||||
c.status.LastError = err.Error()
|
||||
} else {
|
||||
c.status.State = StateStopped
|
||||
c.status.SpeechActive = false
|
||||
}
|
||||
c.status.UpdatedAt = time.Now()
|
||||
c.publishLocked()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
done <- err
|
||||
close(done)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) Stop(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
if c.cancel == nil {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if c.status.State != StateRestarting {
|
||||
c.status.State = StateStopping
|
||||
}
|
||||
c.status.UpdatedAt = time.Now()
|
||||
c.publishLocked()
|
||||
cancel := c.cancel
|
||||
done := c.done
|
||||
c.mu.Unlock()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) Restart(ctx context.Context, settings config.Settings) error {
|
||||
c.mu.Lock()
|
||||
if c.cancel != nil {
|
||||
c.status.State = StateRestarting
|
||||
c.status.UpdatedAt = time.Now()
|
||||
c.publishLocked()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if err := c.Stop(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Start(settings)
|
||||
}
|
||||
|
||||
func (c *Controller) Status() Status {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.status
|
||||
}
|
||||
|
||||
func (c *Controller) Subscribe(buffer int) (<-chan Status, func()) {
|
||||
if buffer < 1 {
|
||||
buffer = 1
|
||||
}
|
||||
updates := make(chan Status, buffer)
|
||||
c.mu.Lock()
|
||||
c.subscribers[updates] = struct{}{}
|
||||
updates <- c.status
|
||||
c.mu.Unlock()
|
||||
return updates, func() {
|
||||
c.mu.Lock()
|
||||
if _, ok := c.subscribers[updates]; ok {
|
||||
delete(c.subscribers, updates)
|
||||
close(updates)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) observe(event RuntimeEvent) {
|
||||
c.mu.Lock()
|
||||
switch event.Kind {
|
||||
case RuntimeStarting:
|
||||
c.status.State = StateStarting
|
||||
c.status.LastError = ""
|
||||
case RuntimeListening:
|
||||
c.status.State = StateListening
|
||||
c.status.LastError = ""
|
||||
case RuntimeProcessing:
|
||||
c.status.State = StateProcessing
|
||||
case RuntimeRestarting:
|
||||
c.status.State = StateRestarting
|
||||
case RuntimeStopped:
|
||||
c.status.State = StateStopped
|
||||
c.status.SpeechActive = false
|
||||
case RuntimeError:
|
||||
if event.Err != nil {
|
||||
c.status.LastError = event.Err.Error()
|
||||
}
|
||||
case RuntimeSource:
|
||||
c.status.Source = event.Value
|
||||
case RuntimeSpeech:
|
||||
c.status.SpeechActive = event.Active
|
||||
case RuntimeOverloaded:
|
||||
c.status.OverloadedAt = event.At
|
||||
}
|
||||
c.status.UpdatedAt = event.At
|
||||
if c.status.UpdatedAt.IsZero() {
|
||||
c.status.UpdatedAt = time.Now()
|
||||
}
|
||||
c.publishLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Controller) publishLocked() {
|
||||
for subscriber := range c.subscribers {
|
||||
select {
|
||||
case subscriber <- c.status:
|
||||
default:
|
||||
select {
|
||||
case <-subscriber:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case subscriber <- c.status:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output"
|
||||
)
|
||||
|
||||
func TestControllerStartObserveAndStop(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
runner := func(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, observer RuntimeObserver) error {
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeSource, Value: "monitor-source"})
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeListening})
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeSpeech, Active: true})
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeStopped})
|
||||
return nil
|
||||
}
|
||||
controller := newControllerWithRunner(nil, io.Discard, runner)
|
||||
settings := config.DefaultSettings()
|
||||
if err := controller.Start(settings); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("runner did not start")
|
||||
}
|
||||
status := controller.Status()
|
||||
if status.State != StateListening || status.Source != "monitor-source" || !status.SpeechActive {
|
||||
t.Fatalf("Status() = %#v", status)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := controller.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop() error = %v", err)
|
||||
}
|
||||
status = controller.Status()
|
||||
if status.State != StateStopped || status.SpeechActive {
|
||||
t.Fatalf("Status() after stop = %#v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerRestartUsesNewSettings(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var modes []config.Mode
|
||||
started := make(chan struct{}, 2)
|
||||
runner := func(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, observer RuntimeObserver) error {
|
||||
mu.Lock()
|
||||
modes = append(modes, settings.Mode)
|
||||
mu.Unlock()
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeListening})
|
||||
started <- struct{}{}
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
controller := newControllerWithRunner(nil, io.Discard, runner)
|
||||
settings := config.DefaultSettings()
|
||||
if err := controller.Start(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
settings.Mode = config.ModeFixed
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := controller.Restart(ctx, settings); err != nil {
|
||||
t.Fatalf("Restart() error = %v", err)
|
||||
}
|
||||
<-started
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(modes) != 2 || modes[0] != config.ModeVAD || modes[1] != config.ModeFixed {
|
||||
t.Fatalf("runner modes = %v", modes)
|
||||
}
|
||||
}
|
||||
+61
-7
@@ -14,15 +14,32 @@ import (
|
||||
"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 {
|
||||
type directHooks struct {
|
||||
ready func() error
|
||||
busy func() error
|
||||
idle func() error
|
||||
source func(string) error
|
||||
speech func(bool) error
|
||||
recognition func(bool) error
|
||||
overloaded func() error
|
||||
}
|
||||
|
||||
func runDirect(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer, hooks directHooks) error {
|
||||
if err := hooks.busy(); err != nil {
|
||||
return err
|
||||
}
|
||||
resources, err := models.New(settings)
|
||||
if idleErr := hooks.idle(); err == nil {
|
||||
err = idleErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resources.Close()
|
||||
defer func() {
|
||||
_ = hooks.busy()
|
||||
resources.Close()
|
||||
_ = hooks.idle()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
@@ -32,24 +49,61 @@ func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagno
|
||||
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.")
|
||||
if hooks.source != nil {
|
||||
if err := hooks.source(monitorSource); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
packets, waitCapture, err := capture.Packets(ctx, monitorSource)
|
||||
packets, waitCapture, err := capture.PacketsObserved(ctx, monitorSource, diagnostics, func() {
|
||||
if hooks.overloaded != nil {
|
||||
_ = hooks.overloaded()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("start system-audio capture: %w", err)
|
||||
}
|
||||
if err := hooks.ready(); err != nil {
|
||||
cancel()
|
||||
_ = waitCapture()
|
||||
return err
|
||||
}
|
||||
|
||||
processor := captions.New(settings, resources, sink.Publish)
|
||||
processor := captions.NewWithActivity(settings, resources, sink.Publish, captions.Activity{
|
||||
SpeechChanged: func(active bool) {
|
||||
if hooks.speech != nil {
|
||||
_ = hooks.speech(active)
|
||||
}
|
||||
},
|
||||
RecognitionChanged: func(active bool) {
|
||||
if hooks.recognition != nil {
|
||||
_ = hooks.recognition(active)
|
||||
}
|
||||
},
|
||||
})
|
||||
var processErr error
|
||||
for packet := range packets {
|
||||
if err := processor.Accept(packet); err != nil {
|
||||
if err := hooks.busy(); err != nil {
|
||||
processErr = err
|
||||
cancel()
|
||||
break
|
||||
}
|
||||
acceptErr := processor.Accept(packet)
|
||||
idleErr := hooks.idle()
|
||||
if acceptErr != nil || idleErr != nil {
|
||||
err := errors.Join(acceptErr, idleErr)
|
||||
processErr = fmt.Errorf("publish caption: %w", err)
|
||||
cancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
if processErr == nil {
|
||||
if err := hooks.busy(); err != nil {
|
||||
processErr = err
|
||||
} else {
|
||||
processErr = processor.Flush()
|
||||
processErr = errors.Join(processErr, hooks.idle())
|
||||
}
|
||||
}
|
||||
captureErr := waitCapture()
|
||||
if captureErr != nil && ctx.Err() == nil {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package app
|
||||
|
||||
import "time"
|
||||
|
||||
type RuntimeEventKind string
|
||||
|
||||
const (
|
||||
RuntimeStarting RuntimeEventKind = "starting"
|
||||
RuntimeListening RuntimeEventKind = "listening"
|
||||
RuntimeProcessing RuntimeEventKind = "processing"
|
||||
RuntimeRestarting RuntimeEventKind = "restarting"
|
||||
RuntimeStopped RuntimeEventKind = "stopped"
|
||||
RuntimeError RuntimeEventKind = "error"
|
||||
RuntimeSource RuntimeEventKind = "source"
|
||||
RuntimeSpeech RuntimeEventKind = "speech"
|
||||
RuntimeOverloaded RuntimeEventKind = "overloaded"
|
||||
)
|
||||
|
||||
type RuntimeEvent struct {
|
||||
Kind RuntimeEventKind
|
||||
At time.Time
|
||||
Value string
|
||||
Active bool
|
||||
Err error
|
||||
}
|
||||
|
||||
type RuntimeObserver func(RuntimeEvent)
|
||||
|
||||
func observe(observer RuntimeObserver, event RuntimeEvent) {
|
||||
if observer == nil {
|
||||
return
|
||||
}
|
||||
if event.At.IsZero() {
|
||||
event.At = time.Now()
|
||||
}
|
||||
observer(event)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/capture"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/models"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output"
|
||||
)
|
||||
|
||||
const modelRestartDelay = time.Second
|
||||
|
||||
type attemptResult uint8
|
||||
|
||||
const (
|
||||
attemptStopped attemptResult = iota + 1
|
||||
attemptReload
|
||||
attemptFailed
|
||||
attemptFatal
|
||||
)
|
||||
|
||||
type workerHealth struct {
|
||||
startedAt time.Time
|
||||
lastSeen time.Time
|
||||
ready bool
|
||||
busySince time.Time
|
||||
}
|
||||
|
||||
func (h *workerHealth) observe(messageType string, now time.Time) {
|
||||
h.lastSeen = now
|
||||
switch messageType {
|
||||
case messageReady:
|
||||
h.ready = true
|
||||
h.busySince = time.Time{}
|
||||
case messageBusy:
|
||||
if h.busySince.IsZero() {
|
||||
h.busySince = now
|
||||
}
|
||||
case messageIdle:
|
||||
h.busySince = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
func (h workerHealth) timeoutError(now time.Time, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
return nil
|
||||
}
|
||||
if !h.ready && now.Sub(h.startedAt) >= timeout {
|
||||
return fmt.Errorf("model worker did not become ready within %s", timeout)
|
||||
}
|
||||
if !h.busySince.IsZero() && now.Sub(h.busySince) >= timeout {
|
||||
return fmt.Errorf("model operation exceeded %s", timeout)
|
||||
}
|
||||
if h.ready && now.Sub(h.lastSeen) >= timeout {
|
||||
return fmt.Errorf("model worker heartbeat was silent for %s", timeout)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run supervises an isolated process containing capture, VAD, and recognition.
|
||||
// Presentation stays in this process, so the native model can be replaced
|
||||
// without restarting the terminal or GTK main loop.
|
||||
func Run(ctx context.Context, settings config.Settings, sink output.Sink, diagnostics io.Writer) error {
|
||||
return RunObserved(ctx, settings, sink, diagnostics, nil)
|
||||
}
|
||||
|
||||
func RunObserved(
|
||||
ctx context.Context,
|
||||
settings config.Settings,
|
||||
sink output.Sink,
|
||||
diagnostics io.Writer,
|
||||
observer RuntimeObserver,
|
||||
) error {
|
||||
if err := capture.ValidatePrograms(); err != nil {
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
|
||||
return err
|
||||
}
|
||||
if err := models.Validate(settings); err != nil {
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(diagnostics, "Press Ctrl+C to stop. Send SIGHUP to hot-reload the model worker.")
|
||||
reload := make(chan os.Signal, 1)
|
||||
signal.Notify(reload, syscall.SIGHUP)
|
||||
defer signal.Stop(reload)
|
||||
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeStarting})
|
||||
for {
|
||||
result, err := runWorkerAttempt(ctx, settings, sink, diagnostics, reload, observer)
|
||||
if ctx.Err() != nil {
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeStopped})
|
||||
return nil
|
||||
}
|
||||
switch result {
|
||||
case attemptStopped:
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeStopped})
|
||||
return nil
|
||||
case attemptFatal:
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
|
||||
return err
|
||||
case attemptReload:
|
||||
fmt.Fprintln(diagnostics, "Reloading model worker.")
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeRestarting})
|
||||
continue
|
||||
case attemptFailed:
|
||||
if !settings.ModelAutoRestart {
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(diagnostics, "warning: %v; restarting model worker in %s\n", err, modelRestartDelay)
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeError, Err: err})
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeRestarting})
|
||||
}
|
||||
|
||||
timer := time.NewTimer(modelRestartDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return nil
|
||||
case <-reload:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
fmt.Fprintln(diagnostics, "Reloading model worker.")
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeRestarting})
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runWorkerAttempt(
|
||||
ctx context.Context,
|
||||
settings config.Settings,
|
||||
sink output.Sink,
|
||||
diagnostics io.Writer,
|
||||
reload <-chan os.Signal,
|
||||
observer RuntimeObserver,
|
||||
) (attemptResult, error) {
|
||||
cmd, protocol, err := startModelWorker(settings, diagnostics)
|
||||
if err != nil {
|
||||
return attemptFailed, fmt.Errorf("start model worker: %w", err)
|
||||
}
|
||||
defer protocol.Close()
|
||||
|
||||
messages := make(chan workerMessage)
|
||||
protocolDone := make(chan error, 1)
|
||||
go readWorkerMessages(protocol, messages, protocolDone)
|
||||
|
||||
wait := make(chan error, 1)
|
||||
go func() { wait <- cmd.Wait() }()
|
||||
|
||||
checkInterval := 250 * time.Millisecond
|
||||
if settings.ModelTimeout > 0 && settings.ModelTimeout < checkInterval {
|
||||
checkInterval = settings.ModelTimeout / 2
|
||||
if checkInterval <= 0 {
|
||||
checkInterval = time.Millisecond
|
||||
}
|
||||
}
|
||||
ticker := time.NewTicker(checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
startedAt := time.Now()
|
||||
health := workerHealth{startedAt: startedAt, lastSeen: startedAt}
|
||||
messageChannel := (<-chan workerMessage)(messages)
|
||||
waitChannel := (<-chan error)(wait)
|
||||
contextDone := ctx.Done()
|
||||
var stopTimer *time.Timer
|
||||
var stopDeadline <-chan time.Time
|
||||
var stopping bool
|
||||
var desiredResult attemptResult
|
||||
var desiredErr error
|
||||
var processDone bool
|
||||
var processErr error
|
||||
var protocolClosed bool
|
||||
var protocolErr error
|
||||
var workerFailure string
|
||||
|
||||
beginStop := func(result attemptResult, stopErr error) {
|
||||
if stopping {
|
||||
if result == attemptFatal {
|
||||
desiredResult = result
|
||||
desiredErr = errors.Join(desiredErr, stopErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
stopping = true
|
||||
desiredResult = result
|
||||
desiredErr = stopErr
|
||||
if err := signalWorkerGroup(cmd, syscall.SIGTERM); err != nil {
|
||||
desiredErr = errors.Join(desiredErr, err)
|
||||
}
|
||||
stopTimer = time.NewTimer(settings.ModelShutdownTimeout)
|
||||
stopDeadline = stopTimer.C
|
||||
}
|
||||
|
||||
for {
|
||||
if processDone && protocolClosed {
|
||||
if stopTimer != nil && !stopTimer.Stop() {
|
||||
select {
|
||||
case <-stopTimer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if stopping {
|
||||
return desiredResult, desiredErr
|
||||
}
|
||||
// The worker may have crashed before its parec child. A process group
|
||||
// remains signalable after its leader exits, so clean up any survivors
|
||||
// before starting a replacement worker.
|
||||
cleanupErr := signalWorkerGroup(cmd, syscall.SIGKILL)
|
||||
if protocolErr != nil {
|
||||
return attemptFailed, errors.Join(fmt.Errorf("model worker protocol: %w", protocolErr), cleanupErr)
|
||||
}
|
||||
if workerFailure != "" {
|
||||
return attemptFailed, errors.Join(errors.New(workerFailure), cleanupErr)
|
||||
}
|
||||
if processErr != nil {
|
||||
return attemptFailed, errors.Join(fmt.Errorf("model worker exited: %w", processErr), cleanupErr)
|
||||
}
|
||||
return attemptFailed, errors.Join(errors.New("model worker exited unexpectedly"), cleanupErr)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-contextDone:
|
||||
contextDone = nil
|
||||
beginStop(attemptStopped, nil)
|
||||
|
||||
case <-reload:
|
||||
beginStop(attemptReload, nil)
|
||||
|
||||
case message, ok := <-messageChannel:
|
||||
if !ok {
|
||||
messageChannel = nil
|
||||
protocolClosed = true
|
||||
protocolErr = <-protocolDone
|
||||
if !processDone && !stopping {
|
||||
beginStop(attemptFailed, errors.New("model worker protocol closed unexpectedly"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
health.observe(message.Type, now)
|
||||
switch message.Type {
|
||||
case messageReady:
|
||||
fmt.Fprintln(diagnostics, "Model worker ready.")
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeListening})
|
||||
case messageBusy, messageIdle:
|
||||
case messageHeartbeat:
|
||||
case messageSource:
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeSource, Value: message.Value})
|
||||
case messageSpeech:
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeSpeech, Active: message.Active})
|
||||
case messageRecognition:
|
||||
kind := RuntimeListening
|
||||
if message.Active {
|
||||
kind = RuntimeProcessing
|
||||
}
|
||||
observe(observer, RuntimeEvent{Kind: kind, Active: message.Active})
|
||||
case messageOverloaded:
|
||||
observe(observer, RuntimeEvent{Kind: RuntimeOverloaded})
|
||||
case messageCaption:
|
||||
if message.Event == nil {
|
||||
beginStop(attemptFailed, errors.New("model worker sent an empty caption event"))
|
||||
continue
|
||||
}
|
||||
if err := sink.Publish(*message.Event); err != nil {
|
||||
beginStop(attemptFatal, fmt.Errorf("publish caption: %w", err))
|
||||
}
|
||||
case messageFailure:
|
||||
workerFailure = message.Error
|
||||
default:
|
||||
beginStop(attemptFailed, fmt.Errorf("unknown model worker message %q", message.Type))
|
||||
}
|
||||
|
||||
case err := <-waitChannel:
|
||||
waitChannel = nil
|
||||
processDone = true
|
||||
processErr = err
|
||||
|
||||
case now := <-ticker.C:
|
||||
if !stopping {
|
||||
if err := health.timeoutError(now, settings.ModelTimeout); err != nil {
|
||||
beginStop(attemptFailed, err)
|
||||
}
|
||||
}
|
||||
|
||||
case <-stopDeadline:
|
||||
stopDeadline = nil
|
||||
if !processDone {
|
||||
fmt.Fprintf(diagnostics, "warning: model worker did not stop within %s; force-killing it\n", settings.ModelShutdownTimeout)
|
||||
if err := signalWorkerGroup(cmd, syscall.SIGKILL); err != nil {
|
||||
desiredErr = errors.Join(desiredErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startModelWorker(settings config.Settings, diagnostics io.Writer) (*exec.Cmd, *os.File, error) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
encodedSettings, err := encodeWorkerSettings(settings)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
protocolRead, protocolWrite, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cmd := exec.Command(executable)
|
||||
cmd.Env = workerEnvironment(encodedSettings)
|
||||
cmd.ExtraFiles = []*os.File{protocolWrite}
|
||||
cmd.Stdout = diagnostics
|
||||
cmd.Stderr = diagnostics
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
Pdeathsig: syscall.SIGKILL,
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
protocolRead.Close()
|
||||
protocolWrite.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := protocolWrite.Close(); err != nil {
|
||||
_ = signalWorkerGroup(cmd, syscall.SIGKILL)
|
||||
_ = cmd.Wait()
|
||||
protocolRead.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return cmd, protocolRead, nil
|
||||
}
|
||||
|
||||
func workerEnvironment(encodedSettings string) []string {
|
||||
environment := make([]string, 0, len(os.Environ())+2)
|
||||
for _, item := range os.Environ() {
|
||||
if strings.HasPrefix(item, modelWorkerEnvironment+"=") ||
|
||||
strings.HasPrefix(item, modelSettingsEnvironment+"=") {
|
||||
continue
|
||||
}
|
||||
environment = append(environment, item)
|
||||
}
|
||||
return append(environment,
|
||||
modelWorkerEnvironment+"=1",
|
||||
modelSettingsEnvironment+"="+encodedSettings,
|
||||
)
|
||||
}
|
||||
|
||||
func readWorkerMessages(protocol io.Reader, messages chan<- workerMessage, done chan<- error) {
|
||||
defer close(messages)
|
||||
decoder := json.NewDecoder(protocol)
|
||||
for {
|
||||
var message workerMessage
|
||||
if err := decoder.Decode(&message); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
done <- nil
|
||||
} else {
|
||||
done <- err
|
||||
}
|
||||
return
|
||||
}
|
||||
messages <- message
|
||||
}
|
||||
}
|
||||
|
||||
func signalWorkerGroup(cmd *exec.Cmd, signal syscall.Signal) error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
err := syscall.Kill(-cmd.Process.Pid, signal)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("signal model worker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
)
|
||||
|
||||
func TestWorkerHealthDetectsStartupAndOperationTimeouts(t *testing.T) {
|
||||
started := time.Unix(100, 0)
|
||||
health := workerHealth{startedAt: started, lastSeen: started}
|
||||
|
||||
if err := health.timeoutError(started.Add(time.Second), 2*time.Second); err != nil {
|
||||
t.Fatalf("healthy startup reported a timeout: %v", err)
|
||||
}
|
||||
if err := health.timeoutError(started.Add(2*time.Second), 2*time.Second); err == nil ||
|
||||
!strings.Contains(err.Error(), "did not become ready") {
|
||||
t.Fatalf("startup timeout = %v", err)
|
||||
}
|
||||
|
||||
health.observe(messageReady, started.Add(2*time.Second))
|
||||
health.observe(messageBusy, started.Add(3*time.Second))
|
||||
if err := health.timeoutError(started.Add(4*time.Second), 2*time.Second); err != nil {
|
||||
t.Fatalf("healthy operation reported a timeout: %v", err)
|
||||
}
|
||||
if err := health.timeoutError(started.Add(5*time.Second), 2*time.Second); err == nil ||
|
||||
!strings.Contains(err.Error(), "operation exceeded") {
|
||||
t.Fatalf("operation timeout = %v", err)
|
||||
}
|
||||
|
||||
health.observe(messageIdle, started.Add(5*time.Second))
|
||||
health.observe(messageHeartbeat, started.Add(6*time.Second))
|
||||
if err := health.timeoutError(started.Add(7*time.Second), 2*time.Second); err != nil {
|
||||
t.Fatalf("idle worker reported a timeout: %v", err)
|
||||
}
|
||||
if err := health.timeoutError(started.Add(8*time.Second), 2*time.Second); err == nil ||
|
||||
!strings.Contains(err.Error(), "heartbeat was silent") {
|
||||
t.Fatalf("heartbeat timeout = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerSettingsRoundTrip(t *testing.T) {
|
||||
want := config.Settings{
|
||||
Mode: config.ModeVAD,
|
||||
ChunkDuration: time.Second,
|
||||
ModelsDir: "models with spaces",
|
||||
Threads: 3,
|
||||
PreviewThresholdDB: -42,
|
||||
VADThreshold: 0.65,
|
||||
ModelAutoRestart: true,
|
||||
ModelTimeout: 12 * time.Second,
|
||||
ModelShutdownTimeout: 2 * time.Second,
|
||||
}
|
||||
encoded, err := encodeWorkerSettings(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := decodeWorkerSettings(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("settings round trip = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
)
|
||||
|
||||
const (
|
||||
modelWorkerEnvironment = "CAPTIONEER_INTERNAL_MODEL_WORKER"
|
||||
modelSettingsEnvironment = "CAPTIONEER_INTERNAL_MODEL_SETTINGS"
|
||||
modelProtocolFD = 3
|
||||
)
|
||||
|
||||
const (
|
||||
messageReady = "ready"
|
||||
messageBusy = "busy"
|
||||
messageIdle = "idle"
|
||||
messageCaption = "caption"
|
||||
messageFailure = "failure"
|
||||
messageHeartbeat = "heartbeat"
|
||||
messageSource = "source"
|
||||
messageSpeech = "speech"
|
||||
messageRecognition = "recognition"
|
||||
messageOverloaded = "overloaded"
|
||||
)
|
||||
|
||||
type workerMessage struct {
|
||||
Type string `json:"type"`
|
||||
Event *captions.Event `json:"event,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Active bool `json:"active,omitempty"`
|
||||
}
|
||||
|
||||
type workerReporter struct {
|
||||
mu sync.Mutex
|
||||
encoder *json.Encoder
|
||||
}
|
||||
|
||||
func (r *workerReporter) send(message workerMessage) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.encoder.Encode(message)
|
||||
}
|
||||
|
||||
type protocolSink struct {
|
||||
reporter *workerReporter
|
||||
}
|
||||
|
||||
func (s protocolSink) Publish(event captions.Event) error {
|
||||
return s.reporter.send(workerMessage{Type: messageCaption, Event: &event})
|
||||
}
|
||||
|
||||
func (protocolSink) Close() error { return nil }
|
||||
|
||||
// IsModelWorker reports whether this process was created as Captioneer's
|
||||
// private inference worker. The environment marker is intentionally internal.
|
||||
func IsModelWorker() bool {
|
||||
return os.Getenv(modelWorkerEnvironment) == "1"
|
||||
}
|
||||
|
||||
func encodeWorkerSettings(settings config.Settings) (string, error) {
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
func decodeWorkerSettings(value string) (config.Settings, error) {
|
||||
data, err := base64.RawStdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return config.Settings{}, fmt.Errorf("decode model worker settings: %w", err)
|
||||
}
|
||||
var settings config.Settings
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return config.Settings{}, fmt.Errorf("parse model worker settings: %w", err)
|
||||
}
|
||||
if err := settings.Validate(); err != nil {
|
||||
return config.Settings{}, fmt.Errorf("validate model worker settings: %w", err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// RunModelWorker owns all Sherpa/VAD resources in the supervised child. It
|
||||
// returns a process exit code so command entrypoints can call os.Exit.
|
||||
func RunModelWorker(diagnostics io.Writer) int {
|
||||
protocol := os.NewFile(uintptr(modelProtocolFD), "captioneer-model-protocol")
|
||||
if protocol == nil {
|
||||
fmt.Fprintln(diagnostics, "captioneer: model worker protocol is unavailable")
|
||||
return 1
|
||||
}
|
||||
defer protocol.Close()
|
||||
syscall.CloseOnExec(modelProtocolFD)
|
||||
|
||||
settings, err := decodeWorkerSettings(os.Getenv(modelSettingsEnvironment))
|
||||
if err != nil {
|
||||
fmt.Fprintf(diagnostics, "captioneer: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
reporter := &workerReporter{encoder: json.NewEncoder(protocol)}
|
||||
hooks := directHooks{
|
||||
ready: func() error { return reporter.send(workerMessage{Type: messageReady}) },
|
||||
busy: func() error { return reporter.send(workerMessage{Type: messageBusy}) },
|
||||
idle: func() error { return reporter.send(workerMessage{Type: messageIdle}) },
|
||||
source: func(value string) error { return reporter.send(workerMessage{Type: messageSource, Value: value}) },
|
||||
speech: func(active bool) error { return reporter.send(workerMessage{Type: messageSpeech, Active: active}) },
|
||||
recognition: func(active bool) error { return reporter.send(workerMessage{Type: messageRecognition, Active: active}) },
|
||||
overloaded: func() error { return reporter.send(workerMessage{Type: messageOverloaded}) },
|
||||
}
|
||||
|
||||
heartbeatInterval := time.Second
|
||||
if settings.ModelTimeout > 0 && settings.ModelTimeout < 4*heartbeatInterval {
|
||||
heartbeatInterval = settings.ModelTimeout / 4
|
||||
if heartbeatInterval <= 0 {
|
||||
heartbeatInterval = time.Millisecond
|
||||
}
|
||||
}
|
||||
heartbeatContext, stopHeartbeat := context.WithCancel(ctx)
|
||||
heartbeatDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(heartbeatDone)
|
||||
ticker := time.NewTicker(heartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-heartbeatContext.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if reporter.send(workerMessage{Type: messageHeartbeat}) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = runDirect(ctx, settings, protocolSink{reporter: reporter}, diagnostics, hooks)
|
||||
stopHeartbeat()
|
||||
<-heartbeatDone
|
||||
if err == nil || errors.Is(err, context.Canceled) {
|
||||
return 0
|
||||
}
|
||||
_ = reporter.send(workerMessage{Type: messageFailure, Error: err.Error()})
|
||||
fmt.Fprintf(diagnostics, "captioneer: model worker: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Event struct {
|
||||
type Transcriber interface {
|
||||
Decode(samples []float32) string
|
||||
AcceptVAD(samples []float32)
|
||||
SpeechActive() bool
|
||||
FlushVAD()
|
||||
NextSpeechSegment() (start int, samples []float32, ok bool)
|
||||
}
|
||||
|
||||
+96
-12
@@ -3,11 +3,18 @@ package captions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/audio"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
)
|
||||
|
||||
// Provisional recognition uses only a recent window. Re-decoding an entire
|
||||
// long utterance every refresh grows increasingly expensive, while the UI only
|
||||
// presents the latest provisional lines. The final event still decodes the
|
||||
// complete VAD segment.
|
||||
const maxPreviewDuration = 10 * time.Second
|
||||
|
||||
type Processor struct {
|
||||
settings config.Settings
|
||||
transcriber Transcriber
|
||||
@@ -18,15 +25,29 @@ type Processor struct {
|
||||
previewActive bool
|
||||
previewStart int
|
||||
nextPreviewAt int
|
||||
previewReceived int
|
||||
totalSamples int
|
||||
activity Activity
|
||||
speechKnown bool
|
||||
speechActive bool
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
SpeechChanged func(bool)
|
||||
RecognitionChanged func(bool)
|
||||
}
|
||||
|
||||
func New(settings config.Settings, transcriber Transcriber, emit func(Event) error) *Processor {
|
||||
return NewWithActivity(settings, transcriber, emit, Activity{})
|
||||
}
|
||||
|
||||
func NewWithActivity(settings config.Settings, transcriber Transcriber, emit func(Event) error, activity Activity) *Processor {
|
||||
return &Processor{
|
||||
settings: settings,
|
||||
transcriber: transcriber,
|
||||
emit: emit,
|
||||
nextPreviewAt: audio.DurationSamples(settings.ChunkDuration),
|
||||
activity: activity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +56,7 @@ func (p *Processor) Accept(samples []float32) error {
|
||||
return nil
|
||||
}
|
||||
if p.settings.Mode == config.ModeFixed {
|
||||
p.setSpeechActive(audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB)
|
||||
return p.acceptFixed(samples)
|
||||
}
|
||||
return p.acceptVAD(samples)
|
||||
@@ -43,19 +65,26 @@ func (p *Processor) Accept(samples []float32) error {
|
||||
func (p *Processor) Flush() error {
|
||||
if p.settings.Mode == config.ModeFixed {
|
||||
if len(p.fixedSamples) > 0 {
|
||||
return p.emitFinal(p.totalSamples, p.totalSamples+len(p.fixedSamples), p.fixedSamples)
|
||||
return p.emitFixedFinal(p.totalSamples, p.totalSamples+len(p.fixedSamples), p.fixedSamples)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
p.transcriber.FlushVAD()
|
||||
return p.drainVAD()
|
||||
if err := p.drainVAD(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.previewActive {
|
||||
p.resetPreview()
|
||||
return p.emit(Event{Kind: Hide})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Processor) acceptFixed(samples []float32) error {
|
||||
p.fixedSamples = append(p.fixedSamples, samples...)
|
||||
chunkSize := audio.DurationSamples(p.settings.ChunkDuration)
|
||||
for len(p.fixedSamples) >= chunkSize {
|
||||
if err := p.emitFinal(p.totalSamples, p.totalSamples+chunkSize, p.fixedSamples[:chunkSize]); err != nil {
|
||||
if err := p.emitFixedFinal(p.totalSamples, p.totalSamples+chunkSize, p.fixedSamples[:chunkSize]); err != nil {
|
||||
return err
|
||||
}
|
||||
p.fixedSamples = p.fixedSamples[chunkSize:]
|
||||
@@ -68,20 +97,34 @@ func (p *Processor) acceptVAD(samples []float32) error {
|
||||
p.totalSamples += len(samples)
|
||||
p.transcriber.AcceptVAD(samples)
|
||||
|
||||
if p.previewActive || audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB {
|
||||
speechActive := p.transcriber.SpeechActive()
|
||||
p.setSpeechActive(speechActive)
|
||||
if speechActive && (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.previewReceived += len(samples)
|
||||
p.trimPreviewWindow()
|
||||
if p.previewReceived >= p.nextPreviewAt {
|
||||
if err := p.emitProvisional(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.nextPreviewAt += audio.DurationSamples(p.settings.ChunkDuration)
|
||||
}
|
||||
}
|
||||
return p.drainVAD()
|
||||
if err := p.drainVAD(); err != nil {
|
||||
return err
|
||||
}
|
||||
// A short VAD false-positive can become active without meeting the minimum
|
||||
// speech duration, so no final segment is produced. Clear its provisional
|
||||
// text instead of leaving a stale caption on screen.
|
||||
if !speechActive && p.previewActive {
|
||||
p.resetPreview()
|
||||
return p.emit(Event{Kind: Hide})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Processor) drainVAD() error {
|
||||
@@ -93,15 +136,30 @@ func (p *Processor) drainVAD() error {
|
||||
if err := p.emitFinal(start, start+len(samples), samples); err != nil {
|
||||
return err
|
||||
}
|
||||
p.previewSamples = nil
|
||||
p.previewActive = false
|
||||
p.previewStart = 0
|
||||
p.nextPreviewAt = audio.DurationSamples(p.settings.ChunkDuration)
|
||||
p.resetPreview()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) trimPreviewWindow() {
|
||||
maxSamples := audio.DurationSamples(maxPreviewDuration)
|
||||
if len(p.previewSamples) <= maxSamples {
|
||||
return
|
||||
}
|
||||
dropped := len(p.previewSamples) - maxSamples
|
||||
p.previewSamples = p.previewSamples[dropped:]
|
||||
p.previewStart += dropped
|
||||
}
|
||||
|
||||
func (p *Processor) resetPreview() {
|
||||
p.previewSamples = nil
|
||||
p.previewActive = false
|
||||
p.previewStart = 0
|
||||
p.previewReceived = 0
|
||||
p.nextPreviewAt = audio.DurationSamples(p.settings.ChunkDuration)
|
||||
}
|
||||
|
||||
func (p *Processor) emitProvisional() error {
|
||||
text := strings.TrimSpace(p.transcriber.Decode(p.previewSamples))
|
||||
text := strings.TrimSpace(p.decode(p.previewSamples))
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -114,7 +172,7 @@ func (p *Processor) emitProvisional() error {
|
||||
}
|
||||
|
||||
func (p *Processor) emitFinal(start, end int, samples []float32) error {
|
||||
text := strings.TrimSpace(p.transcriber.Decode(samples))
|
||||
text := strings.TrimSpace(p.decode(samples))
|
||||
if text == "" {
|
||||
return p.emit(Event{Kind: Hide})
|
||||
}
|
||||
@@ -125,3 +183,29 @@ func (p *Processor) emitFinal(start, end int, samples []float32) error {
|
||||
EndedAt: audio.SamplesDuration(end),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Processor) decode(samples []float32) string {
|
||||
if p.activity.RecognitionChanged != nil {
|
||||
p.activity.RecognitionChanged(true)
|
||||
defer p.activity.RecognitionChanged(false)
|
||||
}
|
||||
return p.transcriber.Decode(samples)
|
||||
}
|
||||
|
||||
func (p *Processor) setSpeechActive(active bool) {
|
||||
if p.speechKnown && p.speechActive == active {
|
||||
return
|
||||
}
|
||||
p.speechKnown = true
|
||||
p.speechActive = active
|
||||
if p.activity.SpeechChanged != nil {
|
||||
p.activity.SpeechChanged(active)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) emitFixedFinal(start, end int, samples []float32) error {
|
||||
if audio.RMSDBFS(samples) < p.settings.PreviewThresholdDB {
|
||||
return p.emit(Event{Kind: Hide})
|
||||
}
|
||||
return p.emitFinal(start, end, samples)
|
||||
}
|
||||
|
||||
+125
-13
@@ -18,9 +18,13 @@ type fakeTranscriber struct {
|
||||
decodeTexts []string
|
||||
segments []fakeSegment
|
||||
flushed bool
|
||||
speechActive bool
|
||||
decodeSizes []int
|
||||
acceptedVAD int
|
||||
}
|
||||
|
||||
func (f *fakeTranscriber) Decode([]float32) string {
|
||||
func (f *fakeTranscriber) Decode(samples []float32) string {
|
||||
f.decodeSizes = append(f.decodeSizes, len(samples))
|
||||
if len(f.decodeTexts) == 0 {
|
||||
return f.text
|
||||
}
|
||||
@@ -28,7 +32,8 @@ func (f *fakeTranscriber) Decode([]float32) string {
|
||||
f.decodeTexts = f.decodeTexts[1:]
|
||||
return text
|
||||
}
|
||||
func (f *fakeTranscriber) AcceptVAD([]float32) {}
|
||||
func (f *fakeTranscriber) AcceptVAD(samples []float32) { f.acceptedVAD += len(samples) }
|
||||
func (f *fakeTranscriber) SpeechActive() bool { return f.speechActive }
|
||||
func (f *fakeTranscriber) FlushVAD() { f.flushed = true }
|
||||
func (f *fakeTranscriber) NextSpeechSegment() (int, []float32, bool) {
|
||||
if len(f.segments) == 0 {
|
||||
@@ -42,12 +47,14 @@ func (f *fakeTranscriber) NextSpeechSegment() (int, []float32, bool) {
|
||||
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 {
|
||||
processor := New(config.Settings{
|
||||
Mode: config.ModeFixed, ChunkDuration: time.Second, PreviewThresholdDB: -45,
|
||||
}, transcriber, func(event Event) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
|
||||
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Kind != Final || events[0].Text != "hello" {
|
||||
@@ -61,12 +68,14 @@ func TestFixedModeEmitsFinalCaption(t *testing.T) {
|
||||
func TestFixedModeFlushesFinalPartialChunkWithExactTimestamps(t *testing.T) {
|
||||
transcriber := &fakeTranscriber{decodeTexts: []string{"first", "partial"}}
|
||||
var events []Event
|
||||
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(event Event) error {
|
||||
processor := New(config.Settings{
|
||||
Mode: config.ModeFixed, ChunkDuration: time.Second, PreviewThresholdDB: -45,
|
||||
}, transcriber, func(event Event) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := processor.Accept(make([]float32, audio.SampleRate+audio.SampleRate/2)); err != nil {
|
||||
if err := processor.Accept(constantSamples(audio.SampleRate+audio.SampleRate/2, 0.5)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := processor.Flush(); err != nil {
|
||||
@@ -81,7 +90,7 @@ func TestFixedModeFlushesFinalPartialChunkWithExactTimestamps(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
|
||||
transcriber := &fakeTranscriber{text: "speech"}
|
||||
transcriber := &fakeTranscriber{text: "speech", speechActive: true}
|
||||
var events []Event
|
||||
processor := New(config.Settings{
|
||||
Mode: config.ModeVAD,
|
||||
@@ -92,14 +101,12 @@ func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
loud := make([]float32, audio.SampleRate)
|
||||
for i := range loud {
|
||||
loud[i] = 0.5
|
||||
}
|
||||
loud := constantSamples(audio.SampleRate, 0.5)
|
||||
if err := processor.Accept(loud); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcriber.segments = []fakeSegment{{start: 0, samples: loud}}
|
||||
transcriber.speechActive = false
|
||||
if err := processor.Accept(make([]float32, audio.SamplesPerPacket())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -112,11 +119,13 @@ func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
|
||||
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 {
|
||||
processor := New(config.Settings{
|
||||
Mode: config.ModeFixed, ChunkDuration: time.Second, PreviewThresholdDB: -45,
|
||||
}, transcriber, func(got Event) error {
|
||||
event = got
|
||||
return nil
|
||||
})
|
||||
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
|
||||
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Kind != Hide {
|
||||
@@ -124,6 +133,101 @@ func TestEmptyFinalEmitsHide(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedModeSilenceStaysOutOfRecognizer(t *testing.T) {
|
||||
transcriber := &fakeTranscriber{text: "hallucinated caption"}
|
||||
var events []Event
|
||||
processor := New(config.Settings{
|
||||
Mode: config.ModeFixed,
|
||||
ChunkDuration: time.Second,
|
||||
PreviewThresholdDB: -45,
|
||||
}, transcriber, func(event Event) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(transcriber.decodeSizes) != 0 {
|
||||
t.Fatalf("silent audio reached recognizer: decode sizes = %v", transcriber.decodeSizes)
|
||||
}
|
||||
if len(events) != 1 || events[0].Kind != Hide {
|
||||
t.Fatalf("silent chunk events = %#v, want one Hide", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVADModeDoesNotPreviewLoudNonSpeech(t *testing.T) {
|
||||
transcriber := &fakeTranscriber{text: "music hallucination"}
|
||||
var events []Event
|
||||
processor := New(config.Settings{
|
||||
Mode: config.ModeVAD,
|
||||
ChunkDuration: time.Second,
|
||||
PreviewThresholdDB: -45,
|
||||
}, transcriber, func(event Event) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
})
|
||||
|
||||
loudMusic := constantSamples(audio.SampleRate, 0.5)
|
||||
if err := processor.Accept(loudMusic); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if transcriber.acceptedVAD != len(loudMusic) {
|
||||
t.Fatalf("VAD received %d samples, want %d", transcriber.acceptedVAD, len(loudMusic))
|
||||
}
|
||||
if len(transcriber.decodeSizes) != 0 || len(events) != 0 {
|
||||
t.Fatalf("non-speech reached recognizer: decodes=%v events=%#v", transcriber.decodeSizes, events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVADModeClearsRejectedShortSpeechPreview(t *testing.T) {
|
||||
transcriber := &fakeTranscriber{text: "draft", speechActive: true}
|
||||
var events []Event
|
||||
processor := New(config.Settings{
|
||||
Mode: config.ModeVAD,
|
||||
ChunkDuration: time.Second,
|
||||
PreviewThresholdDB: -45,
|
||||
}, transcriber, func(event Event) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcriber.speechActive = false
|
||||
if err := processor.Accept(make([]float32, audio.SamplesPerPacket())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 2 || events[0].Kind != Provisional || events[1].Kind != Hide {
|
||||
t.Fatalf("events = %#v, want Provisional then Hide", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVADPreviewRecognitionWindowIsBounded(t *testing.T) {
|
||||
transcriber := &fakeTranscriber{text: "draft", speechActive: true}
|
||||
processor := New(config.Settings{
|
||||
Mode: config.ModeVAD,
|
||||
ChunkDuration: time.Second,
|
||||
PreviewThresholdDB: -45,
|
||||
}, transcriber, func(Event) error { return nil })
|
||||
|
||||
for range 15 {
|
||||
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
maxSamples := audio.DurationSamples(maxPreviewDuration)
|
||||
for _, size := range transcriber.decodeSizes {
|
||||
if size > maxSamples {
|
||||
t.Fatalf("preview decode used %d samples, limit is %d", size, maxSamples)
|
||||
}
|
||||
}
|
||||
if len(transcriber.decodeSizes) != 15 {
|
||||
t.Fatalf("preview decode count = %d, want 15", len(transcriber.decodeSizes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushFlushesVAD(t *testing.T) {
|
||||
transcriber := &fakeTranscriber{}
|
||||
processor := New(config.Settings{Mode: config.ModeVAD, ChunkDuration: time.Second}, transcriber, func(Event) error { return nil })
|
||||
@@ -134,3 +238,11 @@ func TestFlushFlushesVAD(t *testing.T) {
|
||||
t.Fatal("VAD was not flushed")
|
||||
}
|
||||
}
|
||||
|
||||
func constantSamples(count int, value float32) []float32 {
|
||||
samples := make([]float32, count)
|
||||
for i := range samples {
|
||||
samples[i] = value
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
+19
-5
@@ -10,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/audio"
|
||||
)
|
||||
@@ -32,6 +33,15 @@ func DefaultMonitorSource(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func Packets(ctx context.Context, monitorSource string) (<-chan []float32, func() error, error) {
|
||||
return PacketsObserved(ctx, monitorSource, os.Stderr, nil)
|
||||
}
|
||||
|
||||
func PacketsObserved(
|
||||
ctx context.Context,
|
||||
monitorSource string,
|
||||
diagnostics io.Writer,
|
||||
overloaded func(),
|
||||
) (<-chan []float32, func() error, error) {
|
||||
cmd := exec.CommandContext(ctx, "parec",
|
||||
"--device="+monitorSource,
|
||||
"--format=s16le",
|
||||
@@ -40,7 +50,8 @@ func Packets(ctx context.Context, monitorSource string) (<-chan []float32, func(
|
||||
"--raw",
|
||||
"--latency-msec=50",
|
||||
)
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stderr = diagnostics
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -63,11 +74,11 @@ func Packets(ctx context.Context, monitorSource string) (<-chan []float32, func(
|
||||
for {
|
||||
n, readErr := io.ReadFull(stdout, packetBytes)
|
||||
if n > 0 {
|
||||
enqueueLatest(packets, audio.PCM16LEToFloat32(packetBytes[:n]))
|
||||
enqueueLatest(packets, audio.PCM16LEToFloat32(packetBytes[:n]), diagnostics, overloaded)
|
||||
}
|
||||
if readErr != nil {
|
||||
if ctx.Err() == nil && !errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrUnexpectedEOF) {
|
||||
fmt.Fprintf(os.Stderr, "audio capture read error: %v\n", readErr)
|
||||
fmt.Fprintf(diagnostics, "audio capture read error: %v\n", readErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -76,7 +87,7 @@ func Packets(ctx context.Context, monitorSource string) (<-chan []float32, func(
|
||||
return packets, wait, nil
|
||||
}
|
||||
|
||||
func enqueueLatest(packets chan []float32, packet []float32) {
|
||||
func enqueueLatest(packets chan []float32, packet []float32, diagnostics io.Writer, overloaded func()) {
|
||||
select {
|
||||
case packets <- packet:
|
||||
return
|
||||
@@ -91,5 +102,8 @@ func enqueueLatest(packets chan []float32, packet []float32) {
|
||||
case packets <- packet:
|
||||
default:
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "warning: caption processing overloaded; dropped 100 ms of oldest audio")
|
||||
fmt.Fprintln(diagnostics, "warning: caption processing overloaded; dropped 100 ms of oldest audio")
|
||||
if overloaded != nil {
|
||||
overloaded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build gtk
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk4-layer-shell-0
|
||||
#cgo LDFLAGS: -Wl,--no-as-needed -lgtk4-layer-shell
|
||||
|
||||
// gtk4-layer-shell shims libwayland-client. It must appear before
|
||||
// libwayland-client in the dynamic loader order or gtk_layer_is_supported()
|
||||
// returns false and the overlay becomes an unpositioned normal window.
|
||||
// Referencing the symbol from the main package keeps the dependency alive
|
||||
// even when the external linker would otherwise drop or reorder it.
|
||||
#include <gtk4-layer-shell.h>
|
||||
|
||||
static void captioneer_keep_layershell_linked(void) __attribute__((used));
|
||||
static void captioneer_keep_layershell_linked(void) {
|
||||
(void)gtk_layer_get_major_version;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
@@ -3,75 +3,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"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"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/gui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if app.IsModelWorker() {
|
||||
os.Exit(app.RunModelWorker(os.Stderr))
|
||||
}
|
||||
|
||||
launch, err := config.ParseDesktop(os.Args[1:], os.Stderr)
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
os.Exit(gui.Run(launch, os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -13,10 +16,21 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
settings := config.Parse()
|
||||
if err := settings.Validate(); err != nil {
|
||||
if app.IsModelWorker() {
|
||||
os.Exit(app.RunModelWorker(os.Stderr))
|
||||
}
|
||||
|
||||
launch, err := config.Parse(os.Args[1:], os.Stderr)
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if launch.Warning != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %v; using built-in defaults\n", launch.Warning)
|
||||
}
|
||||
settings := launch.Config.Settings
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LaunchConfig struct {
|
||||
Config AppConfig
|
||||
Path string
|
||||
Warning error
|
||||
}
|
||||
|
||||
func Parse(args []string, help io.Writer) (LaunchConfig, error) {
|
||||
launch, err := loadLaunchConfig(args)
|
||||
if err != nil {
|
||||
return LaunchConfig{}, err
|
||||
}
|
||||
flags := flag.NewFlagSet("captioneer", flag.ContinueOnError)
|
||||
flags.SetOutput(help)
|
||||
registerConfigFlags(flags, &launch, args)
|
||||
registerRecognitionFlags(flags, &launch.Config.Settings)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return LaunchConfig{}, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return LaunchConfig{}, fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
|
||||
}
|
||||
if err := launch.Config.Settings.Validate(); err != nil {
|
||||
return LaunchConfig{}, err
|
||||
}
|
||||
return launch, nil
|
||||
}
|
||||
|
||||
func ParseDesktop(args []string, help io.Writer) (LaunchConfig, error) {
|
||||
launch, err := loadLaunchConfig(args)
|
||||
if err != nil {
|
||||
return LaunchConfig{}, err
|
||||
}
|
||||
flags := flag.NewFlagSet("captioneer-desktop", flag.ContinueOnError)
|
||||
flags.SetOutput(help)
|
||||
registerConfigFlags(flags, &launch, args)
|
||||
registerRecognitionFlags(flags, &launch.Config.Settings)
|
||||
|
||||
output := string(outputModeFor(launch.Config.Outputs))
|
||||
backend := string(launch.Config.Overlay.Backend)
|
||||
flags.StringVar(&output, "output", output, "caption output compatibility preset: terminal, overlay, or both")
|
||||
flags.StringVar(&backend, "overlay-backend", backend, "display backend: auto, wayland, or x11")
|
||||
flags.Float64Var(&launch.Config.Overlay.Opacity, "overlay-opacity", launch.Config.Overlay.Opacity, "caption background opacity from 0 to 1")
|
||||
flags.IntVar(&launch.Config.Overlay.FontSize, "overlay-font-size", launch.Config.Overlay.FontSize, "caption font size in logical pixels")
|
||||
flags.StringVar(&launch.Config.Overlay.FontFamily, "overlay-font-family", launch.Config.Overlay.FontFamily, "caption font family")
|
||||
flags.IntVar(&launch.Config.Overlay.BottomMargin, "overlay-bottom-margin", launch.Config.Overlay.BottomMargin, "distance from the monitor bottom in logical pixels")
|
||||
flags.IntVar(&launch.Config.Overlay.MaxWidth, "overlay-max-width", launch.Config.Overlay.MaxWidth, "fixed caption width in logical pixels before the monitor safety cap")
|
||||
flags.StringVar(&launch.Config.Overlay.Monitor, "overlay-monitor", launch.Config.Overlay.Monitor, "monitor selection: auto, numeric index, or connector name")
|
||||
flags.DurationVar(&launch.Config.Overlay.FinalTimeout, "overlay-final-timeout", launch.Config.Overlay.FinalTimeout, "completed-caption lifetime; zero keeps lines until the six-line cap removes them")
|
||||
flags.BoolVar(&launch.Config.Overlay.ClickThrough, "overlay-click-through", launch.Config.Overlay.ClickThrough, "pass pointer input through the caption window")
|
||||
flags.BoolVar(&launch.Config.Outputs.History, "gui-history", launch.Config.Outputs.History, "retain captions in the GUI history")
|
||||
flags.BoolVar(&launch.Config.Outputs.StderrDiagnostics, "stderr-diagnostics", launch.Config.Outputs.StderrDiagnostics, "mirror diagnostics to stderr")
|
||||
flags.BoolVar(&launch.Config.GUI.StartCaptions, "start-captions", launch.Config.GUI.StartCaptions, "start caption processing when the GUI opens")
|
||||
flags.BoolVar(&launch.Config.GUI.MainWindowAlwaysOnTop, "main-window-always-on-top", launch.Config.GUI.MainWindowAlwaysOnTop, "request that the main window remain above other windows")
|
||||
flags.BoolVar(&launch.Config.TranscriptLog.Enabled, "caption-log", launch.Config.TranscriptLog.Enabled, "save finalized captions to a timestamped transcript file")
|
||||
flags.StringVar(&launch.Config.TranscriptLog.Directory, "caption-log-dir", launch.Config.TranscriptLog.Directory, "directory for timestamped caption transcript files")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return LaunchConfig{}, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return LaunchConfig{}, fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
|
||||
}
|
||||
|
||||
launch.Config.Overlay.Backend = OverlayBackend(backend)
|
||||
if flagWasSet(flags, "output") {
|
||||
switch OutputMode(output) {
|
||||
case OutputTerminal:
|
||||
launch.Config.Outputs.Overlay = false
|
||||
launch.Config.Outputs.StdoutCaptions = true
|
||||
case OutputOverlay:
|
||||
launch.Config.Outputs.Overlay = true
|
||||
launch.Config.Outputs.StdoutCaptions = false
|
||||
case OutputBoth:
|
||||
launch.Config.Outputs.Overlay = true
|
||||
launch.Config.Outputs.StdoutCaptions = true
|
||||
default:
|
||||
return LaunchConfig{}, errors.New("--output must be terminal, overlay, or both")
|
||||
}
|
||||
}
|
||||
if err := launch.Config.Validate(); err != nil {
|
||||
return LaunchConfig{}, err
|
||||
}
|
||||
return launch, nil
|
||||
}
|
||||
|
||||
func registerRecognitionFlags(flags *flag.FlagSet, settings *Settings) {
|
||||
mode := string(settings.Mode)
|
||||
flags.Var(modeValue{target: &settings.Mode, value: &mode}, "mode", "caption mode: vad or fixed")
|
||||
flags.DurationVar(&settings.ChunkDuration, "chunk-duration", settings.ChunkDuration, "fixed chunk size or VAD preview refresh interval")
|
||||
flags.StringVar(&settings.ModelsDir, "models-dir", settings.ModelsDir, "directory containing downloaded Sherpa models")
|
||||
flags.IntVar(&settings.Threads, "threads", settings.Threads, "CPU threads used by recognition and VAD")
|
||||
flags.Float64Var(&settings.PreviewThresholdDB, "preview-threshold-dbfs", settings.PreviewThresholdDB, "RMS dBFS gate below which recognition stays idle")
|
||||
flags.Float64Var(&settings.VADThreshold, "vad-threshold", settings.VADThreshold, "speech detection sensitivity from 0 to 1; higher rejects more non-speech")
|
||||
flags.BoolVar(&settings.ModelAutoRestart, "model-auto-restart", settings.ModelAutoRestart, "restart the isolated model worker after a crash or timeout")
|
||||
flags.DurationVar(&settings.ModelTimeout, "model-timeout", settings.ModelTimeout, "maximum model startup or processing time; zero disables hang detection")
|
||||
flags.DurationVar(&settings.ModelShutdownTimeout, "model-shutdown-timeout", settings.ModelShutdownTimeout, "time allowed for model flush and cleanup before force-killing the worker")
|
||||
}
|
||||
|
||||
type modeValue struct {
|
||||
target *Mode
|
||||
value *string
|
||||
}
|
||||
|
||||
func (v modeValue) String() string {
|
||||
if v.value == nil {
|
||||
return ""
|
||||
}
|
||||
return *v.value
|
||||
}
|
||||
func (v modeValue) Set(value string) error {
|
||||
*v.value = value
|
||||
*v.target = Mode(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerConfigFlags(flags *flag.FlagSet, launch *LaunchConfig, args []string) {
|
||||
noConfig := hasNoConfig(args)
|
||||
flags.StringVar(&launch.Path, "config", launch.Path, "configuration file path")
|
||||
flags.BoolVar(&noConfig, "no-config", noConfig, "ignore the saved configuration for this launch")
|
||||
}
|
||||
|
||||
func loadLaunchConfig(args []string) (LaunchConfig, error) {
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return LaunchConfig{}, err
|
||||
}
|
||||
if explicit := explicitConfigPath(args); explicit != "" {
|
||||
path = explicit
|
||||
}
|
||||
launch := LaunchConfig{Config: DefaultAppConfig(), Path: path}
|
||||
if hasNoConfig(args) {
|
||||
return launch, nil
|
||||
}
|
||||
loaded, loadErr := Load(path)
|
||||
if loadErr != nil {
|
||||
launch.Warning = loadErr
|
||||
return launch, nil
|
||||
}
|
||||
launch.Config = loaded
|
||||
return launch, nil
|
||||
}
|
||||
|
||||
func explicitConfigPath(args []string) string {
|
||||
for index, argument := range args {
|
||||
if strings.HasPrefix(argument, "--config=") {
|
||||
return strings.TrimPrefix(argument, "--config=")
|
||||
}
|
||||
if argument == "--config" && index+1 < len(args) {
|
||||
return args[index+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasNoConfig(args []string) bool {
|
||||
for _, argument := range args {
|
||||
if argument == "--no-config" || argument == "--no-config=true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func flagWasSet(flags *flag.FlagSet, name string) bool {
|
||||
set := false
|
||||
flags.Visit(func(candidate *flag.Flag) {
|
||||
if candidate.Name == name {
|
||||
set = true
|
||||
}
|
||||
})
|
||||
return set
|
||||
}
|
||||
|
||||
func outputModeFor(outputs OutputSettings) OutputMode {
|
||||
switch {
|
||||
case outputs.Overlay && outputs.StdoutCaptions:
|
||||
return OutputBoth
|
||||
case outputs.StdoutCaptions:
|
||||
return OutputTerminal
|
||||
default:
|
||||
return OutputOverlay
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLIHelpDoesNotPanic(t *testing.T) {
|
||||
var help bytes.Buffer
|
||||
_, err := Parse([]string{"--help"}, &help)
|
||||
if !errors.Is(err, flag.ErrHelp) {
|
||||
t.Fatalf("Parse(--help) error = %v", err)
|
||||
}
|
||||
if !bytes.Contains(help.Bytes(), []byte("-mode")) {
|
||||
t.Fatalf("help output did not contain mode flag:\n%s", help.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIUsesSavedConfigThenExplicitFlags(t *testing.T) {
|
||||
configHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
path := filepath.Join(configHome, "captioneer", configFileName)
|
||||
saved := DefaultAppConfig()
|
||||
saved.Settings.Threads = 5
|
||||
saved.Settings.ModelsDir = "saved-models"
|
||||
if err := Save(path, saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
launch, err := Parse([]string{"--threads=7"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if launch.Config.Settings.Threads != 7 || launch.Config.Settings.ModelsDir != "saved-models" {
|
||||
t.Fatalf("precedence result = %#v", launch.Config.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLINoConfigUsesBuiltInDefaults(t *testing.T) {
|
||||
configHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
path := filepath.Join(configHome, "captioneer", configFileName)
|
||||
saved := DefaultAppConfig()
|
||||
saved.Settings.Threads = 9
|
||||
if err := Save(path, saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
launch, err := Parse([]string{"--no-config"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if launch.Config.Settings.Threads != DefaultSettings().Threads {
|
||||
t.Fatalf("--no-config threads = %d", launch.Config.Settings.Threads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopOutputFlagOverridesSavedCaptionDestinations(t *testing.T) {
|
||||
configHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
path := filepath.Join(configHome, "captioneer", configFileName)
|
||||
saved := DefaultAppConfig()
|
||||
saved.Outputs.Overlay = true
|
||||
saved.Outputs.StdoutCaptions = false
|
||||
if err := Save(path, saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
launch, err := ParseDesktop([]string{"--output=both"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !launch.Config.Outputs.Overlay || !launch.Config.Outputs.StdoutCaptions {
|
||||
t.Fatalf("output flags = %#v", launch.Config.Outputs)
|
||||
}
|
||||
}
|
||||
+116
-49
@@ -3,15 +3,18 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CurrentConfigVersion = 1
|
||||
DefaultModelsDir = "models"
|
||||
DefaultVADThreshold = 0.5
|
||||
DefaultOverlayFontFamily = "Sans"
|
||||
MinimumOverlayFinalLifetime = 3 * time.Second
|
||||
DefaultModelTimeout = 30 * time.Second
|
||||
DefaultModelShutdownTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type Mode string
|
||||
@@ -27,19 +30,24 @@ type Settings struct {
|
||||
ModelsDir string
|
||||
Threads int
|
||||
PreviewThresholdDB float64
|
||||
VADThreshold float64
|
||||
ModelAutoRestart bool
|
||||
ModelTimeout time.Duration
|
||||
ModelShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func Parse() Settings {
|
||||
var settings Settings
|
||||
mode := ""
|
||||
flag.StringVar(&mode, "mode", "", "caption mode: vad or fixed (required)")
|
||||
flag.DurationVar(&settings.ChunkDuration, "chunk-duration", time.Second, "fixed chunk size or VAD preview refresh interval")
|
||||
flag.StringVar(&settings.ModelsDir, "models-dir", DefaultModelsDir, "directory containing downloaded Sherpa models")
|
||||
flag.IntVar(&settings.Threads, "threads", 2, "CPU threads used by recognition and VAD")
|
||||
flag.Float64Var(&settings.PreviewThresholdDB, "preview-threshold-dbfs", -45, "RMS dBFS threshold used to begin VAD previews")
|
||||
flag.Parse()
|
||||
settings.Mode = Mode(mode)
|
||||
return settings
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{
|
||||
Mode: ModeVAD,
|
||||
ChunkDuration: time.Second,
|
||||
ModelsDir: DefaultModelsDir,
|
||||
Threads: 2,
|
||||
PreviewThresholdDB: -45,
|
||||
VADThreshold: DefaultVADThreshold,
|
||||
ModelAutoRestart: true,
|
||||
ModelTimeout: DefaultModelTimeout,
|
||||
ModelShutdownTimeout: DefaultModelShutdownTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
type OutputMode string
|
||||
@@ -70,40 +78,70 @@ type OverlaySettings struct {
|
||||
ClickThrough bool
|
||||
}
|
||||
|
||||
func DefaultOverlaySettings() OverlaySettings {
|
||||
return OverlaySettings{
|
||||
Backend: BackendAuto,
|
||||
Opacity: 0.90,
|
||||
FontSize: 28,
|
||||
FontFamily: DefaultOverlayFontFamily,
|
||||
BottomMargin: 100,
|
||||
MaxWidth: 1200,
|
||||
Monitor: "auto",
|
||||
FinalTimeout: MinimumOverlayFinalLifetime,
|
||||
ClickThrough: true,
|
||||
}
|
||||
}
|
||||
|
||||
type OutputSettings struct {
|
||||
Overlay bool
|
||||
History bool
|
||||
StdoutCaptions bool
|
||||
StderrDiagnostics bool
|
||||
}
|
||||
|
||||
type GUISettings struct {
|
||||
StartCaptions bool
|
||||
MainWindowAlwaysOnTop bool
|
||||
}
|
||||
|
||||
type TranscriptLogSettings struct {
|
||||
Enabled bool
|
||||
Directory string
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Version int
|
||||
Settings Settings
|
||||
Overlay OverlaySettings
|
||||
Outputs OutputSettings
|
||||
GUI GUISettings
|
||||
TranscriptLog TranscriptLogSettings
|
||||
}
|
||||
|
||||
func DefaultAppConfig() AppConfig {
|
||||
return AppConfig{
|
||||
Version: CurrentConfigVersion,
|
||||
Settings: DefaultSettings(),
|
||||
Overlay: DefaultOverlaySettings(),
|
||||
Outputs: OutputSettings{
|
||||
Overlay: true,
|
||||
History: true,
|
||||
StdoutCaptions: false,
|
||||
StderrDiagnostics: true,
|
||||
},
|
||||
GUI: GUISettings{StartCaptions: true},
|
||||
TranscriptLog: TranscriptLogSettings{
|
||||
Directory: DefaultTranscriptDirectory(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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.StringVar(&desktop.Overlay.FontFamily, "overlay-font-family", DefaultOverlayFontFamily, "caption font family")
|
||||
flags.IntVar(&desktop.Overlay.BottomMargin, "overlay-bottom-margin", 100, "distance from the monitor bottom in logical pixels")
|
||||
flags.IntVar(&desktop.Overlay.MaxWidth, "overlay-max-width", 1200, "fixed caption width in logical pixels before the monitor safety cap")
|
||||
flags.StringVar(&desktop.Overlay.Monitor, "overlay-monitor", "auto", "monitor selection: auto, numeric index, or connector name")
|
||||
flags.DurationVar(&desktop.Overlay.FinalTimeout, "overlay-final-timeout", MinimumOverlayFinalLifetime, "completed-caption lifetime; zero keeps lines until the six-line cap removes them")
|
||||
flags.BoolVar(&desktop.Overlay.ClickThrough, "overlay-click-through", true, "pass pointer input through the caption window")
|
||||
flag.Parse()
|
||||
desktop.Mode = Mode(mode)
|
||||
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")
|
||||
@@ -114,6 +152,15 @@ func (s Settings) Validate() error {
|
||||
if s.Threads <= 0 {
|
||||
return errors.New("--threads must be positive")
|
||||
}
|
||||
if s.VADThreshold < 0 || s.VADThreshold > 1 {
|
||||
return errors.New("--vad-threshold must be between 0 and 1")
|
||||
}
|
||||
if s.ModelTimeout < 0 {
|
||||
return errors.New("--model-timeout cannot be negative")
|
||||
}
|
||||
if s.ModelShutdownTimeout <= 0 {
|
||||
return errors.New("--model-shutdown-timeout must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -124,32 +171,52 @@ func (s DesktopSettings) Validate() error {
|
||||
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 s.Overlay.Validate()
|
||||
}
|
||||
|
||||
func (s OverlaySettings) Validate() error {
|
||||
if s.Backend != BackendAuto && s.Backend != BackendWayland && s.Backend != BackendX11 {
|
||||
return errors.New("--overlay-backend must be auto, wayland, or x11")
|
||||
}
|
||||
if s.Overlay.Opacity < 0 || s.Overlay.Opacity > 1 {
|
||||
if s.Opacity < 0 || s.Opacity > 1 {
|
||||
return errors.New("--overlay-opacity must be between 0 and 1")
|
||||
}
|
||||
if s.Overlay.FontSize <= 0 {
|
||||
if s.FontSize <= 0 {
|
||||
return errors.New("--overlay-font-size must be positive")
|
||||
}
|
||||
if strings.TrimSpace(s.Overlay.FontFamily) == "" {
|
||||
if strings.TrimSpace(s.FontFamily) == "" {
|
||||
return errors.New("--overlay-font-family cannot be empty")
|
||||
}
|
||||
if s.Overlay.BottomMargin < 0 {
|
||||
if s.BottomMargin < 0 {
|
||||
return errors.New("--overlay-bottom-margin cannot be negative")
|
||||
}
|
||||
if s.Overlay.MaxWidth <= 0 {
|
||||
if s.MaxWidth <= 0 {
|
||||
return errors.New("--overlay-max-width must be positive")
|
||||
}
|
||||
if s.Overlay.FinalTimeout < 0 {
|
||||
if s.FinalTimeout < 0 {
|
||||
return errors.New("--overlay-final-timeout cannot be negative")
|
||||
}
|
||||
if s.Overlay.FinalTimeout > 0 && s.Overlay.FinalTimeout < MinimumOverlayFinalLifetime {
|
||||
if s.FinalTimeout > 0 && s.FinalTimeout < MinimumOverlayFinalLifetime {
|
||||
return errors.New("--overlay-final-timeout must be zero or at least 3s")
|
||||
}
|
||||
if s.Overlay.Monitor == "" {
|
||||
if s.Monitor == "" {
|
||||
return errors.New("--overlay-monitor cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c AppConfig) Validate() error {
|
||||
if c.Version != CurrentConfigVersion {
|
||||
return errors.New("unsupported configuration version")
|
||||
}
|
||||
if err := c.Settings.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Overlay.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.TranscriptLog.Enabled && strings.TrimSpace(c.TranscriptLog.Directory) == "" {
|
||||
return errors.New("caption log directory cannot be empty when logging is enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import (
|
||||
)
|
||||
|
||||
func TestSettingsValidate(t *testing.T) {
|
||||
valid := Settings{Mode: ModeFixed, ChunkDuration: time.Second, Threads: 1}
|
||||
valid := Settings{
|
||||
Mode: ModeFixed,
|
||||
ChunkDuration: time.Second,
|
||||
Threads: 1,
|
||||
ModelShutdownTimeout: DefaultModelShutdownTimeout,
|
||||
}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("valid settings failed: %v", err)
|
||||
}
|
||||
@@ -17,7 +22,13 @@ func TestSettingsValidate(t *testing.T) {
|
||||
|
||||
func validDesktopSettings() DesktopSettings {
|
||||
return DesktopSettings{
|
||||
Settings: Settings{Mode: ModeVAD, ChunkDuration: time.Second, Threads: 2},
|
||||
Settings: Settings{
|
||||
Mode: ModeVAD,
|
||||
ChunkDuration: time.Second,
|
||||
Threads: 2,
|
||||
ModelTimeout: DefaultModelTimeout,
|
||||
ModelShutdownTimeout: DefaultModelShutdownTimeout,
|
||||
},
|
||||
Output: OutputOverlay,
|
||||
Overlay: OverlaySettings{
|
||||
Backend: BackendAuto, Opacity: 0.9, FontSize: 28, FontFamily: DefaultOverlayFontFamily, BottomMargin: 100,
|
||||
@@ -46,6 +57,10 @@ func TestDesktopSettingsValidate(t *testing.T) {
|
||||
{"empty monitor", func(s *DesktopSettings) { s.Overlay.Monitor = "" }},
|
||||
{"negative timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = -time.Second }},
|
||||
{"short timeout", func(s *DesktopSettings) { s.Overlay.FinalTimeout = time.Second }},
|
||||
{"negative model timeout", func(s *DesktopSettings) { s.ModelTimeout = -time.Second }},
|
||||
{"zero model shutdown timeout", func(s *DesktopSettings) { s.ModelShutdownTimeout = 0 }},
|
||||
{"negative VAD threshold", func(s *DesktopSettings) { s.VADThreshold = -0.1 }},
|
||||
{"large VAD threshold", func(s *DesktopSettings) { s.VADThreshold = 1.1 }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const configFileName = "config.json"
|
||||
|
||||
type diskConfig struct {
|
||||
Version int `json:"version"`
|
||||
General struct {
|
||||
StartCaptions bool `json:"start_captions"`
|
||||
MainWindowAlwaysOnTop bool `json:"main_window_always_on_top"`
|
||||
} `json:"general"`
|
||||
Recognition struct {
|
||||
Mode Mode `json:"mode"`
|
||||
ChunkDuration string `json:"chunk_duration"`
|
||||
ModelsDir string `json:"models_directory"`
|
||||
Threads int `json:"threads"`
|
||||
PreviewThresholdDB float64 `json:"preview_threshold_dbfs"`
|
||||
VADThreshold float64 `json:"vad_threshold"`
|
||||
ModelAutoRestart bool `json:"model_auto_restart"`
|
||||
ModelTimeout string `json:"model_timeout"`
|
||||
ModelShutdownTimeout string `json:"model_shutdown_timeout"`
|
||||
} `json:"recognition"`
|
||||
Outputs struct {
|
||||
Overlay bool `json:"overlay"`
|
||||
History bool `json:"history"`
|
||||
StdoutCaptions bool `json:"stdout_captions"`
|
||||
StderrDiagnostics bool `json:"stderr_diagnostics"`
|
||||
} `json:"outputs"`
|
||||
Overlay struct {
|
||||
Backend OverlayBackend `json:"backend"`
|
||||
Opacity float64 `json:"opacity"`
|
||||
FontSize int `json:"font_size"`
|
||||
FontFamily string `json:"font_family"`
|
||||
BottomMargin int `json:"bottom_margin"`
|
||||
MaxWidth int `json:"max_width"`
|
||||
Monitor string `json:"monitor"`
|
||||
FinalTimeout string `json:"final_timeout"`
|
||||
ClickThrough bool `json:"click_through"`
|
||||
} `json:"overlay"`
|
||||
TranscriptLog struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Directory string `json:"directory"`
|
||||
} `json:"transcript_log"`
|
||||
}
|
||||
|
||||
func ConfigPath() (string, error) {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find user configuration directory: %w", err)
|
||||
}
|
||||
return filepath.Join(base, "captioneer", configFileName), nil
|
||||
}
|
||||
|
||||
func DefaultTranscriptDirectory() string {
|
||||
if stateHome := os.Getenv("XDG_STATE_HOME"); filepath.IsAbs(stateHome) {
|
||||
return filepath.Join(stateHome, "captioneer", "transcripts")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return filepath.Join("captioneer", "transcripts")
|
||||
}
|
||||
return filepath.Join(home, ".local", "state", "captioneer", "transcripts")
|
||||
}
|
||||
|
||||
func Load(path string) (AppConfig, error) {
|
||||
defaults := DefaultAppConfig()
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return defaults, nil
|
||||
}
|
||||
if err != nil {
|
||||
return defaults, fmt.Errorf("read configuration: %w", err)
|
||||
}
|
||||
|
||||
disk := diskFromConfig(defaults)
|
||||
if err := json.Unmarshal(data, &disk); err != nil {
|
||||
return defaults, fmt.Errorf("parse configuration: %w", err)
|
||||
}
|
||||
loaded, err := disk.toConfig()
|
||||
if err != nil {
|
||||
return defaults, err
|
||||
}
|
||||
if err := loaded.Validate(); err != nil {
|
||||
return defaults, fmt.Errorf("validate configuration: %w", err)
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
func Save(path string, config AppConfig) error {
|
||||
config.Version = CurrentConfigVersion
|
||||
if err := config.Validate(); err != nil {
|
||||
return fmt.Errorf("validate configuration: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(diskFromConfig(config), "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode configuration: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
directory := filepath.Dir(path)
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return fmt.Errorf("create configuration directory: %w", err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, ".config-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary configuration: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("secure temporary configuration: %w", err)
|
||||
}
|
||||
if _, err := temporary.Write(data); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("write temporary configuration: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("sync temporary configuration: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary configuration: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, path); err != nil {
|
||||
return fmt.Errorf("replace configuration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func diskFromConfig(config AppConfig) diskConfig {
|
||||
var disk diskConfig
|
||||
disk.Version = config.Version
|
||||
disk.General.StartCaptions = config.GUI.StartCaptions
|
||||
disk.General.MainWindowAlwaysOnTop = config.GUI.MainWindowAlwaysOnTop
|
||||
disk.Recognition.Mode = config.Settings.Mode
|
||||
disk.Recognition.ChunkDuration = config.Settings.ChunkDuration.String()
|
||||
disk.Recognition.ModelsDir = config.Settings.ModelsDir
|
||||
disk.Recognition.Threads = config.Settings.Threads
|
||||
disk.Recognition.PreviewThresholdDB = config.Settings.PreviewThresholdDB
|
||||
disk.Recognition.VADThreshold = config.Settings.VADThreshold
|
||||
disk.Recognition.ModelAutoRestart = config.Settings.ModelAutoRestart
|
||||
disk.Recognition.ModelTimeout = config.Settings.ModelTimeout.String()
|
||||
disk.Recognition.ModelShutdownTimeout = config.Settings.ModelShutdownTimeout.String()
|
||||
disk.Outputs.Overlay = config.Outputs.Overlay
|
||||
disk.Outputs.History = config.Outputs.History
|
||||
disk.Outputs.StdoutCaptions = config.Outputs.StdoutCaptions
|
||||
disk.Outputs.StderrDiagnostics = config.Outputs.StderrDiagnostics
|
||||
disk.Overlay.Backend = config.Overlay.Backend
|
||||
disk.Overlay.Opacity = config.Overlay.Opacity
|
||||
disk.Overlay.FontSize = config.Overlay.FontSize
|
||||
disk.Overlay.FontFamily = config.Overlay.FontFamily
|
||||
disk.Overlay.BottomMargin = config.Overlay.BottomMargin
|
||||
disk.Overlay.MaxWidth = config.Overlay.MaxWidth
|
||||
disk.Overlay.Monitor = config.Overlay.Monitor
|
||||
disk.Overlay.FinalTimeout = config.Overlay.FinalTimeout.String()
|
||||
disk.Overlay.ClickThrough = config.Overlay.ClickThrough
|
||||
disk.TranscriptLog.Enabled = config.TranscriptLog.Enabled
|
||||
disk.TranscriptLog.Directory = config.TranscriptLog.Directory
|
||||
return disk
|
||||
}
|
||||
|
||||
func (disk diskConfig) toConfig() (AppConfig, error) {
|
||||
chunkDuration, err := parseStoredDuration("chunk duration", disk.Recognition.ChunkDuration)
|
||||
if err != nil {
|
||||
return AppConfig{}, err
|
||||
}
|
||||
modelTimeout, err := parseStoredDuration("model timeout", disk.Recognition.ModelTimeout)
|
||||
if err != nil {
|
||||
return AppConfig{}, err
|
||||
}
|
||||
shutdownTimeout, err := parseStoredDuration("model shutdown timeout", disk.Recognition.ModelShutdownTimeout)
|
||||
if err != nil {
|
||||
return AppConfig{}, err
|
||||
}
|
||||
finalTimeout, err := parseStoredDuration("overlay final timeout", disk.Overlay.FinalTimeout)
|
||||
if err != nil {
|
||||
return AppConfig{}, err
|
||||
}
|
||||
return AppConfig{
|
||||
Version: disk.Version,
|
||||
GUI: GUISettings{
|
||||
StartCaptions: disk.General.StartCaptions,
|
||||
MainWindowAlwaysOnTop: disk.General.MainWindowAlwaysOnTop,
|
||||
},
|
||||
Settings: Settings{
|
||||
Mode: disk.Recognition.Mode,
|
||||
ChunkDuration: chunkDuration,
|
||||
ModelsDir: disk.Recognition.ModelsDir,
|
||||
Threads: disk.Recognition.Threads,
|
||||
PreviewThresholdDB: disk.Recognition.PreviewThresholdDB,
|
||||
VADThreshold: disk.Recognition.VADThreshold,
|
||||
ModelAutoRestart: disk.Recognition.ModelAutoRestart,
|
||||
ModelTimeout: modelTimeout,
|
||||
ModelShutdownTimeout: shutdownTimeout,
|
||||
},
|
||||
Outputs: OutputSettings{
|
||||
Overlay: disk.Outputs.Overlay,
|
||||
History: disk.Outputs.History,
|
||||
StdoutCaptions: disk.Outputs.StdoutCaptions,
|
||||
StderrDiagnostics: disk.Outputs.StderrDiagnostics,
|
||||
},
|
||||
Overlay: OverlaySettings{
|
||||
Backend: disk.Overlay.Backend,
|
||||
Opacity: disk.Overlay.Opacity,
|
||||
FontSize: disk.Overlay.FontSize,
|
||||
FontFamily: disk.Overlay.FontFamily,
|
||||
BottomMargin: disk.Overlay.BottomMargin,
|
||||
MaxWidth: disk.Overlay.MaxWidth,
|
||||
Monitor: disk.Overlay.Monitor,
|
||||
FinalTimeout: finalTimeout,
|
||||
ClickThrough: disk.Overlay.ClickThrough,
|
||||
},
|
||||
TranscriptLog: TranscriptLogSettings{
|
||||
Enabled: disk.TranscriptLog.Enabled,
|
||||
Directory: disk.TranscriptLog.Directory,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseStoredDuration(name, value string) (time.Duration, error) {
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse %s %q: %w", name, value, err)
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigRoundTripUsesReadableDurations(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "config.json")
|
||||
want := DefaultAppConfig()
|
||||
want.Settings.ChunkDuration = 750 * time.Millisecond
|
||||
want.Settings.ModelsDir = "models with spaces"
|
||||
want.Outputs.StdoutCaptions = true
|
||||
want.GUI.MainWindowAlwaysOnTop = true
|
||||
want.TranscriptLog.Enabled = true
|
||||
want.TranscriptLog.Directory = filepath.Join(t.TempDir(), "transcripts")
|
||||
|
||||
if err := Save(path, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"chunk_duration": "750ms"`) {
|
||||
t.Fatalf("duration was not stored readably:\n%s", data)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("round trip = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingConfigUsesDefaults(t *testing.T) {
|
||||
got, err := Load(filepath.Join(t.TempDir(), "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := DefaultAppConfig()
|
||||
if got != want {
|
||||
t.Fatalf("missing config = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialConfigKeepsOtherDefaults(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"recognition":{"threads":6}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Settings.Threads != 6 || got.Settings.Mode != ModeVAD || got.Overlay.FontSize != 28 {
|
||||
t.Fatalf("partial config did not inherit defaults: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidStoredDurationReturnsDefaultsAndError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"recognition":{"chunk_duration":"soon"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "chunk duration") {
|
||||
t.Fatalf("invalid duration error = %v", err)
|
||||
}
|
||||
if got != DefaultAppConfig() {
|
||||
t.Fatalf("invalid config fallback = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package diagnostics provides bounded structured application logging.
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
Debug Severity = "debug"
|
||||
Info Severity = "info"
|
||||
Warning Severity = "warning"
|
||||
Error Severity = "error"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
At time.Time
|
||||
Severity Severity
|
||||
Category string
|
||||
Message string
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
mu sync.Mutex
|
||||
capacity int
|
||||
entries []Entry
|
||||
mirror io.Writer
|
||||
subscribers map[chan Entry]struct{}
|
||||
}
|
||||
|
||||
func New(capacity int, mirror io.Writer) *Log {
|
||||
if capacity < 1 {
|
||||
capacity = 1
|
||||
}
|
||||
if mirror == nil {
|
||||
mirror = io.Discard
|
||||
}
|
||||
return &Log{
|
||||
capacity: capacity,
|
||||
mirror: mirror,
|
||||
subscribers: make(map[chan Entry]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Log) Add(severity Severity, category, message string) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return
|
||||
}
|
||||
entry := Entry{At: time.Now(), Severity: severity, Category: category, Message: message}
|
||||
l.mu.Lock()
|
||||
l.entries = append(l.entries, entry)
|
||||
if len(l.entries) > l.capacity {
|
||||
copy(l.entries, l.entries[len(l.entries)-l.capacity:])
|
||||
l.entries = l.entries[:l.capacity]
|
||||
}
|
||||
fmt.Fprintf(l.mirror, "%s %-7s [%s] %s\n", entry.At.Format(time.RFC3339), strings.ToUpper(string(severity)), category, message)
|
||||
for subscriber := range l.subscribers {
|
||||
select {
|
||||
case subscriber <- entry:
|
||||
default:
|
||||
select {
|
||||
case <-subscriber:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case subscriber <- entry:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *Log) Entries() []Entry {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]Entry(nil), l.entries...)
|
||||
}
|
||||
|
||||
func (l *Log) Clear() {
|
||||
l.mu.Lock()
|
||||
l.entries = nil
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *Log) SetMirror(mirror io.Writer) {
|
||||
if mirror == nil {
|
||||
mirror = io.Discard
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.mirror = mirror
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *Log) Subscribe(buffer int) (<-chan Entry, func()) {
|
||||
if buffer < 1 {
|
||||
buffer = 1
|
||||
}
|
||||
updates := make(chan Entry, buffer)
|
||||
l.mu.Lock()
|
||||
l.subscribers[updates] = struct{}{}
|
||||
l.mu.Unlock()
|
||||
return updates, func() {
|
||||
l.mu.Lock()
|
||||
if _, ok := l.subscribers[updates]; ok {
|
||||
delete(l.subscribers, updates)
|
||||
close(updates)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Log) Writer(severity Severity, category string) io.Writer {
|
||||
return &lineWriter{log: l, severity: severity, category: category}
|
||||
}
|
||||
|
||||
type lineWriter struct {
|
||||
mu sync.Mutex
|
||||
log *Log
|
||||
severity Severity
|
||||
category string
|
||||
buffer bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *lineWriter) Write(data []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.buffer.Write(data)
|
||||
for {
|
||||
line, err := w.buffer.ReadString('\n')
|
||||
if err != nil {
|
||||
w.buffer.WriteString(line)
|
||||
break
|
||||
}
|
||||
severity := w.severity
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "warning:") {
|
||||
severity = Warning
|
||||
}
|
||||
w.log.Add(severity, w.category, trimmed)
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLogBoundsEntriesAndClassifiesWarningLines(t *testing.T) {
|
||||
var mirror bytes.Buffer
|
||||
log := New(2, &mirror)
|
||||
writer := log.Writer(Info, "engine")
|
||||
_, _ = writer.Write([]byte("first\nwarning: overloaded\nthird\n"))
|
||||
entries := log.Entries()
|
||||
if len(entries) != 2 || entries[0].Severity != Warning || entries[1].Message != "third" {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
if mirror.Len() == 0 {
|
||||
t.Fatal("log was not mirrored")
|
||||
}
|
||||
}
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
//go:build gtk
|
||||
|
||||
// Package gui implements Captioneer's GTK4 desktop application.
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/diamondburned/gotk4/pkg/gdk/v4"
|
||||
"github.com/diamondburned/gotk4/pkg/gio/v2"
|
||||
"github.com/diamondburned/gotk4/pkg/glib/v2"
|
||||
"github.com/diamondburned/gotk4/pkg/gtk/v4"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/app"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output/history"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output/overlay"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output/terminal"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output/transcript"
|
||||
)
|
||||
|
||||
const (
|
||||
applicationID = "com.chunkbyte.captioneer"
|
||||
historyCapacity = 500
|
||||
consoleCapacity = 2000
|
||||
)
|
||||
|
||||
//go:embed style.css
|
||||
var applicationCSS string
|
||||
|
||||
type Application struct {
|
||||
launch config.LaunchConfig
|
||||
config config.AppConfig
|
||||
applied config.AppConfig
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
|
||||
gtkApp *gtk.Application
|
||||
window *gtk.ApplicationWindow
|
||||
stack *gtk.Stack
|
||||
|
||||
router *output.Router
|
||||
history *history.Sink
|
||||
log *diagnostics.Log
|
||||
controller *app.Controller
|
||||
overlay *overlay.Sink
|
||||
transcript *transcript.Sink
|
||||
|
||||
statusLabel *gtk.Label
|
||||
statusDot *gtk.Label
|
||||
startStopButton *gtk.Button
|
||||
statusSource *gtk.Label
|
||||
statusSpeech *gtk.Label
|
||||
statusOutputs *gtk.Label
|
||||
captionBuffer *gtk.TextBuffer
|
||||
captionView *gtk.TextView
|
||||
captionScroll *gtk.ScrolledWindow
|
||||
captionMetaTag *gtk.TextTag
|
||||
captionHintTag *gtk.TextTag
|
||||
provisionalLabel *gtk.Label
|
||||
consoleBuffer *gtk.TextBuffer
|
||||
consoleView *gtk.TextView
|
||||
consoleScroll *gtk.ScrolledWindow
|
||||
consoleMetaTag *gtk.TextTag
|
||||
consoleWarnTag *gtk.TextTag
|
||||
consoleErrorTag *gtk.TextTag
|
||||
settings settingsControls
|
||||
settingsError *gtk.Label
|
||||
applyButton *gtk.Button
|
||||
lastShownError string
|
||||
|
||||
statusUpdates <-chan app.Status
|
||||
stopStatus func()
|
||||
historyUpdates <-chan history.Snapshot
|
||||
stopHistory func()
|
||||
logUpdates <-chan diagnostics.Entry
|
||||
stopLog func()
|
||||
uiDone chan struct{}
|
||||
|
||||
closingMu sync.Mutex
|
||||
closing bool
|
||||
}
|
||||
|
||||
func Run(launch config.LaunchConfig, stdout, stderr io.Writer) int {
|
||||
application := newApplication(launch, stdout, stderr)
|
||||
application.gtkApp.ConnectActivate(application.activate)
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(signals)
|
||||
go func() {
|
||||
select {
|
||||
case <-signals:
|
||||
glib.IdleAdd(application.shutdown)
|
||||
case <-application.uiDone:
|
||||
}
|
||||
}()
|
||||
return application.gtkApp.Run([]string{os.Args[0]})
|
||||
}
|
||||
|
||||
func newApplication(launch config.LaunchConfig, stdout, stderr io.Writer) *Application {
|
||||
mirror := io.Discard
|
||||
if launch.Config.Outputs.StderrDiagnostics {
|
||||
mirror = stderr
|
||||
}
|
||||
log := diagnostics.New(consoleCapacity, mirror)
|
||||
router := output.NewRouter()
|
||||
historySink := history.New(historyCapacity, launch.Config.Outputs.History)
|
||||
_ = router.Set("history", historySink)
|
||||
controller := app.NewController(router, log.Writer(diagnostics.Info, "engine"))
|
||||
return &Application{
|
||||
launch: launch,
|
||||
config: launch.Config,
|
||||
applied: launch.Config,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
gtkApp: gtk.NewApplication(applicationID, gio.ApplicationFlagsNone),
|
||||
router: router,
|
||||
history: historySink,
|
||||
log: log,
|
||||
controller: controller,
|
||||
uiDone: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) activate() {
|
||||
if a.window != nil {
|
||||
a.window.Present()
|
||||
return
|
||||
}
|
||||
a.installStyle()
|
||||
a.buildWindow()
|
||||
a.installActions()
|
||||
a.subscribeModels()
|
||||
if a.launch.Warning != nil {
|
||||
a.log.Add(diagnostics.Warning, "configuration", fmt.Sprintf("%v; using built-in defaults", a.launch.Warning))
|
||||
}
|
||||
if err := a.configureOutputs(a.config, true); err != nil {
|
||||
a.log.Add(diagnostics.Error, "outputs", err.Error())
|
||||
}
|
||||
a.window.Present()
|
||||
a.applyAlwaysOnTop(a.config.GUI.MainWindowAlwaysOnTop)
|
||||
if a.config.GUI.StartCaptions {
|
||||
a.startCaptions()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) installStyle() {
|
||||
provider := gtk.NewCSSProvider()
|
||||
provider.LoadFromString(applicationCSS)
|
||||
gtk.StyleContextAddProviderForDisplay(
|
||||
gdk.DisplayGetDefault(),
|
||||
provider,
|
||||
gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *Application) buildWindow() {
|
||||
a.window = gtk.NewApplicationWindow(a.gtkApp)
|
||||
a.window.SetTitle("Captioneer")
|
||||
a.window.SetDefaultSize(980, 680)
|
||||
a.window.SetSizeRequest(720, 480)
|
||||
a.window.AddCSSClass("captioneer-main")
|
||||
a.window.ConnectCloseRequest(func() bool {
|
||||
a.shutdown()
|
||||
return true
|
||||
})
|
||||
|
||||
header := gtk.NewHeaderBar()
|
||||
header.AddCSSClass("captioneer-header")
|
||||
header.SetShowTitleButtons(true)
|
||||
|
||||
brand := gtk.NewBox(gtk.OrientationHorizontal, 7)
|
||||
if iconPath := findIconPath(); iconPath != "" {
|
||||
icon := gtk.NewImageFromFile(iconPath)
|
||||
icon.SetPixelSize(20)
|
||||
brand.Append(icon)
|
||||
}
|
||||
title := gtk.NewLabel("CAPTIONEER")
|
||||
title.AddCSSClass("captioneer-brand")
|
||||
brand.Append(title)
|
||||
header.PackStart(brand)
|
||||
|
||||
statusBox := gtk.NewBox(gtk.OrientationHorizontal, 6)
|
||||
statusBox.SetHAlign(gtk.AlignCenter)
|
||||
statusBox.AddCSSClass("status-cell")
|
||||
a.statusDot = gtk.NewLabel("●")
|
||||
a.statusDot.AddCSSClass("status-dot")
|
||||
a.statusLabel = gtk.NewLabel("STOPPED · VAD")
|
||||
a.statusLabel.AddCSSClass("status-text")
|
||||
statusBox.Append(a.statusDot)
|
||||
statusBox.Append(a.statusLabel)
|
||||
header.SetTitleWidget(statusBox)
|
||||
|
||||
a.startStopButton = gtk.NewButtonWithLabel("START")
|
||||
a.startStopButton.AddCSSClass("primary-action")
|
||||
a.startStopButton.SetTooltipText("Start or stop caption processing")
|
||||
a.startStopButton.ConnectClicked(a.toggleCaptions)
|
||||
header.PackEnd(a.startStopButton)
|
||||
a.window.SetTitlebar(header)
|
||||
|
||||
root := gtk.NewBox(gtk.OrientationVertical, 0)
|
||||
root.Append(a.buildMenuBar())
|
||||
a.stack = gtk.NewStack()
|
||||
a.stack.SetTransitionType(gtk.StackTransitionTypeNone)
|
||||
a.stack.SetHExpand(true)
|
||||
a.stack.SetVExpand(true)
|
||||
|
||||
tabs := gtk.NewStackSwitcher()
|
||||
tabs.SetStack(a.stack)
|
||||
tabs.SetHAlign(gtk.AlignStart)
|
||||
tabStrip := gtk.NewBox(gtk.OrientationHorizontal, 0)
|
||||
tabStrip.AddCSSClass("captioneer-tabs")
|
||||
tabStrip.Append(tabs)
|
||||
root.Append(tabStrip)
|
||||
|
||||
a.stack.AddTitled(a.buildCaptionsPage(), "captions", "CAPTIONS")
|
||||
a.stack.AddTitled(a.buildConsolePage(), "console", "CONSOLE")
|
||||
a.stack.AddTitled(a.buildSettingsPage(), "settings", "SETTINGS")
|
||||
root.Append(a.stack)
|
||||
|
||||
statusbar := gtk.NewBox(gtk.OrientationHorizontal, 4)
|
||||
statusbar.AddCSSClass("statusbar")
|
||||
a.statusSource = statusSegment("Source: not connected")
|
||||
a.statusSource.SetHExpand(true)
|
||||
a.statusSource.SetXAlign(0)
|
||||
a.statusSource.SetEllipsize(3)
|
||||
a.statusSpeech = statusSegment("Speech: quiet")
|
||||
a.statusOutputs = statusSegment("Outputs: preparing")
|
||||
statusbar.Append(a.statusSource)
|
||||
statusbar.Append(a.statusSpeech)
|
||||
statusbar.Append(a.statusOutputs)
|
||||
root.Append(statusbar)
|
||||
a.window.SetChild(root)
|
||||
}
|
||||
|
||||
func (a *Application) buildMenuBar() *gtk.PopoverMenuBar {
|
||||
root := gio.NewMenu()
|
||||
file := gio.NewMenu()
|
||||
file.Append("Start Captions", "app.start")
|
||||
file.Append("Stop Captions", "app.stop")
|
||||
file.Append("Restart Captions", "app.restart")
|
||||
file.Append("Clear Caption History", "app.clear-history")
|
||||
file.Append("Exit", "app.exit")
|
||||
root.AppendSubmenu("File", file)
|
||||
|
||||
view := gio.NewMenu()
|
||||
view.Append("Captions", "app.view-captions")
|
||||
view.Append("Console", "app.view-console")
|
||||
view.Append("Settings", "app.view-settings")
|
||||
root.AppendSubmenu("View", view)
|
||||
|
||||
help := gio.NewMenu()
|
||||
help.Append("Copy Diagnostics", "app.copy-diagnostics")
|
||||
help.Append("Open Configuration Folder", "app.open-config-folder")
|
||||
help.Append("About Captioneer", "app.about")
|
||||
root.AppendSubmenu("Help", help)
|
||||
bar := gtk.NewPopoverMenuBarFromModel(root)
|
||||
bar.AddCSSClass("captioneer-menu")
|
||||
return bar
|
||||
}
|
||||
|
||||
func (a *Application) installActions() {
|
||||
a.addAction("start", a.startCaptions)
|
||||
a.addAction("stop", a.stopCaptions)
|
||||
a.addAction("restart", a.restartCaptions)
|
||||
a.addAction("clear-history", a.history.Clear)
|
||||
a.addAction("exit", a.shutdown)
|
||||
a.addAction("view-captions", func() { a.stack.SetVisibleChildName("captions") })
|
||||
a.addAction("view-console", func() { a.stack.SetVisibleChildName("console") })
|
||||
a.addAction("view-settings", func() { a.stack.SetVisibleChildName("settings") })
|
||||
a.addAction("copy-diagnostics", a.copyDiagnostics)
|
||||
a.addAction("open-config-folder", a.openConfigFolder)
|
||||
a.addAction("about", a.showAbout)
|
||||
a.gtkApp.SetAccelsForAction("app.start", []string{"F5"})
|
||||
a.gtkApp.SetAccelsForAction("app.stop", []string{"<Shift>F5"})
|
||||
a.gtkApp.SetAccelsForAction("app.restart", []string{"<Primary>r"})
|
||||
a.gtkApp.SetAccelsForAction("app.view-captions", []string{"<Alt>1"})
|
||||
a.gtkApp.SetAccelsForAction("app.view-console", []string{"<Alt>2"})
|
||||
a.gtkApp.SetAccelsForAction("app.view-settings", []string{"<Alt>3"})
|
||||
a.gtkApp.SetAccelsForAction("app.exit", []string{"<Primary>q"})
|
||||
}
|
||||
|
||||
func (a *Application) addAction(name string, callback func()) {
|
||||
action := gio.NewSimpleAction(name, nil)
|
||||
action.ConnectActivate(func(*glib.Variant) { callback() })
|
||||
a.gtkApp.AddAction(action)
|
||||
}
|
||||
|
||||
func (a *Application) subscribeModels() {
|
||||
a.statusUpdates, a.stopStatus = a.controller.Subscribe(32)
|
||||
a.historyUpdates, a.stopHistory = a.history.Subscribe(8)
|
||||
a.logUpdates, a.stopLog = a.log.Subscribe(128)
|
||||
go a.forwardStatus()
|
||||
go a.forwardHistory()
|
||||
go a.forwardLog()
|
||||
}
|
||||
|
||||
func (a *Application) forwardStatus() {
|
||||
for {
|
||||
select {
|
||||
case <-a.uiDone:
|
||||
return
|
||||
case status, ok := <-a.statusUpdates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
copy := status
|
||||
glib.IdleAdd(func() { a.renderStatus(copy) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) forwardHistory() {
|
||||
for {
|
||||
select {
|
||||
case <-a.uiDone:
|
||||
return
|
||||
case snapshot, ok := <-a.historyUpdates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
copy := snapshot
|
||||
glib.IdleAdd(func() { a.renderHistory(copy) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) forwardLog() {
|
||||
for {
|
||||
select {
|
||||
case <-a.uiDone:
|
||||
return
|
||||
case _, ok := <-a.logUpdates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
glib.IdleAdd(a.renderConsole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) toggleCaptions() {
|
||||
status := a.controller.Status()
|
||||
switch status.State {
|
||||
case app.StateStopped, app.StateError:
|
||||
a.startCaptions()
|
||||
default:
|
||||
a.stopCaptions()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) startCaptions() {
|
||||
if err := a.controller.Start(a.applied.Settings); err != nil {
|
||||
a.log.Add(diagnostics.Error, "lifecycle", err.Error())
|
||||
return
|
||||
}
|
||||
a.log.Add(diagnostics.Info, "lifecycle", "Starting caption processing")
|
||||
}
|
||||
|
||||
func (a *Application) stopCaptions() {
|
||||
go func() {
|
||||
timeout := a.applied.Settings.ModelShutdownTimeout + time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := a.controller.Stop(ctx); err != nil {
|
||||
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Stop captions: %v", err))
|
||||
return
|
||||
}
|
||||
a.log.Add(diagnostics.Info, "lifecycle", "Caption processing stopped")
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *Application) restartCaptions() {
|
||||
go func() {
|
||||
timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := a.controller.Restart(ctx, a.applied.Settings); err != nil {
|
||||
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Restart captions: %v", err))
|
||||
return
|
||||
}
|
||||
a.log.Add(diagnostics.Info, "lifecycle", "Caption processing restarted")
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *Application) configureOutputs(next config.AppConfig, initial bool) error {
|
||||
var outputErr error
|
||||
a.history.SetEnabled(next.Outputs.History)
|
||||
if next.Outputs.StdoutCaptions {
|
||||
outputErr = joinError(outputErr, a.router.Set("stdout", terminal.New(a.stdout)))
|
||||
} else {
|
||||
outputErr = joinError(outputErr, a.router.Remove("stdout"))
|
||||
}
|
||||
|
||||
if next.TranscriptLog.Enabled {
|
||||
if initial || a.transcript == nil || next.TranscriptLog != a.applied.TranscriptLog {
|
||||
sink, err := transcript.New(next.TranscriptLog.Directory)
|
||||
if err != nil {
|
||||
outputErr = joinError(outputErr, err)
|
||||
} else {
|
||||
a.transcript = sink
|
||||
outputErr = joinError(outputErr, a.router.Set("transcript", sink))
|
||||
a.log.Add(diagnostics.Info, "transcript", "Saving captions to "+sink.Path())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.transcript = nil
|
||||
outputErr = joinError(outputErr, a.router.Remove("transcript"))
|
||||
}
|
||||
|
||||
if next.Outputs.Overlay {
|
||||
settings := next.Overlay
|
||||
if !initial && settings.Backend != a.applied.Overlay.Backend {
|
||||
settings.Backend = a.applied.Overlay.Backend
|
||||
}
|
||||
if initial || a.overlay == nil || settings != a.applied.Overlay {
|
||||
sink, warning, err := overlay.New(settings)
|
||||
if warning != "" {
|
||||
a.log.Add(diagnostics.Warning, "overlay", warning)
|
||||
}
|
||||
if err != nil {
|
||||
outputErr = joinError(outputErr, err)
|
||||
} else {
|
||||
a.overlay = sink
|
||||
outputErr = joinError(outputErr, a.router.Set("overlay", sink))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.overlay = nil
|
||||
outputErr = joinError(outputErr, a.router.Remove("overlay"))
|
||||
}
|
||||
|
||||
if next.Outputs.StderrDiagnostics {
|
||||
a.log.SetMirror(a.stderr)
|
||||
} else {
|
||||
a.log.SetMirror(io.Discard)
|
||||
}
|
||||
return outputErr
|
||||
}
|
||||
|
||||
func (a *Application) renderStatus(status app.Status) {
|
||||
a.updateApplyButton()
|
||||
state := strings.ToUpper(string(status.State))
|
||||
overloaded := !status.OverloadedAt.IsZero() && time.Since(status.OverloadedAt) < 5*time.Second
|
||||
if overloaded {
|
||||
state = "OVERLOADED"
|
||||
}
|
||||
mode := strings.ToUpper(string(status.Mode))
|
||||
if mode == "" {
|
||||
mode = strings.ToUpper(string(a.applied.Settings.Mode))
|
||||
}
|
||||
a.statusLabel.SetText(state + " · " + mode)
|
||||
a.statusDot.RemoveCSSClass("status-stopped")
|
||||
a.statusDot.RemoveCSSClass("status-running")
|
||||
a.statusDot.RemoveCSSClass("status-warning")
|
||||
a.statusDot.RemoveCSSClass("status-error")
|
||||
active := status.State != app.StateStopped && status.State != app.StateError
|
||||
switch {
|
||||
case status.State == app.StateError:
|
||||
a.statusDot.AddCSSClass("status-error")
|
||||
case overloaded:
|
||||
a.statusDot.AddCSSClass("status-warning")
|
||||
case active:
|
||||
a.statusDot.AddCSSClass("status-running")
|
||||
default:
|
||||
a.statusDot.AddCSSClass("status-stopped")
|
||||
}
|
||||
if active {
|
||||
a.startStopButton.SetLabel("STOP")
|
||||
a.startStopButton.RemoveCSSClass("primary-action")
|
||||
a.startStopButton.AddCSSClass("danger-action")
|
||||
} else {
|
||||
a.startStopButton.SetLabel("START")
|
||||
a.startStopButton.RemoveCSSClass("danger-action")
|
||||
a.startStopButton.AddCSSClass("primary-action")
|
||||
}
|
||||
speech := "quiet"
|
||||
if status.SpeechActive {
|
||||
speech = "detected"
|
||||
}
|
||||
source := status.Source
|
||||
if source == "" {
|
||||
source = "not connected"
|
||||
}
|
||||
if overloaded {
|
||||
speech += " · OVERLOADED"
|
||||
a.statusSpeech.AddCSSClass("warning-text")
|
||||
} else {
|
||||
a.statusSpeech.RemoveCSSClass("warning-text")
|
||||
}
|
||||
a.statusSource.SetText("Source: " + source)
|
||||
a.statusSpeech.SetText("Speech: " + speech)
|
||||
a.statusOutputs.SetText("Outputs: " + a.outputSummary())
|
||||
if status.LastError != "" && status.State == app.StateError && status.LastError != a.lastShownError {
|
||||
a.log.Add(diagnostics.Error, "engine", status.LastError)
|
||||
a.lastShownError = status.LastError
|
||||
} else if status.LastError == "" {
|
||||
a.lastShownError = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) outputSummary() string {
|
||||
var active []string
|
||||
if a.config.Outputs.Overlay {
|
||||
active = append(active, "overlay")
|
||||
}
|
||||
if a.config.Outputs.History {
|
||||
active = append(active, "history")
|
||||
}
|
||||
if a.config.Outputs.StdoutCaptions {
|
||||
active = append(active, "stdout")
|
||||
}
|
||||
if a.config.TranscriptLog.Enabled {
|
||||
active = append(active, "log file")
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(active, ", ")
|
||||
}
|
||||
|
||||
func (a *Application) copyDiagnostics() {
|
||||
var text strings.Builder
|
||||
status := a.controller.Status()
|
||||
fmt.Fprintf(&text, "Captioneer diagnostics\nState: %s\nMode: %s\nSource: %s\nConfig: %s\n\n", status.State, status.Mode, status.Source, a.launch.Path)
|
||||
for _, entry := range a.log.Entries() {
|
||||
fmt.Fprintf(&text, "%s %-7s [%s] %s\n", entry.At.Format(time.RFC3339), strings.ToUpper(string(entry.Severity)), entry.Category, entry.Message)
|
||||
}
|
||||
gdk.DisplayGetDefault().Clipboard().SetText(text.String())
|
||||
a.log.Add(diagnostics.Info, "gui", "Diagnostics copied to clipboard")
|
||||
}
|
||||
|
||||
func (a *Application) openConfigFolder() {
|
||||
if err := exec.Command("xdg-open", filepath.Dir(a.launch.Path)).Start(); err != nil {
|
||||
a.log.Add(diagnostics.Error, "gui", fmt.Sprintf("Open configuration folder: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) showAbout() {
|
||||
dialog := gtk.NewAboutDialog()
|
||||
dialog.SetTransientFor(&a.window.Window)
|
||||
dialog.SetModal(true)
|
||||
dialog.SetProgramName("Captioneer")
|
||||
dialog.SetComments("Local live captions for Linux")
|
||||
dialog.SetLicenseType(gtk.LicenseApache20)
|
||||
dialog.SetWebsite("https://tea.chunkbyte.com/kato/captioneer")
|
||||
dialog.Present()
|
||||
}
|
||||
|
||||
func (a *Application) shutdown() {
|
||||
a.closingMu.Lock()
|
||||
if a.closing {
|
||||
a.closingMu.Unlock()
|
||||
return
|
||||
}
|
||||
a.closing = true
|
||||
a.closingMu.Unlock()
|
||||
a.log.Add(diagnostics.Info, "lifecycle", "Shutting down Captioneer")
|
||||
go func() {
|
||||
timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
_ = a.controller.Stop(ctx)
|
||||
cancel()
|
||||
glib.IdleAdd(func() {
|
||||
close(a.uiDone)
|
||||
if a.stopStatus != nil {
|
||||
a.stopStatus()
|
||||
a.stopHistory()
|
||||
a.stopLog()
|
||||
}
|
||||
_ = a.router.Close()
|
||||
a.gtkApp.Quit()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func statusSegment(text string) *gtk.Label {
|
||||
label := gtk.NewLabel(text)
|
||||
label.AddCSSClass("status-segment")
|
||||
return label
|
||||
}
|
||||
|
||||
func joinError(current, next error) error {
|
||||
if current == nil {
|
||||
return next
|
||||
}
|
||||
if next == nil {
|
||||
return current
|
||||
}
|
||||
return fmt.Errorf("%v; %w", current, next)
|
||||
}
|
||||
|
||||
func findIconPath() string {
|
||||
candidates := []string{filepath.Join("assets", "icon", "icon.png")}
|
||||
if executable, err := os.Executable(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(filepath.Dir(executable), "assets", "icon", "icon.png"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//go:build gtk
|
||||
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/diamondburned/gotk4/pkg/gdk/v4"
|
||||
"github.com/diamondburned/gotk4/pkg/gtk/v4"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/output/history"
|
||||
)
|
||||
|
||||
func (a *Application) buildCaptionsPage() *gtk.Box {
|
||||
page := gtk.NewBox(gtk.OrientationVertical, 7)
|
||||
page.AddCSSClass("captioneer-content")
|
||||
|
||||
toolbar := gtk.NewBox(gtk.OrientationHorizontal, 5)
|
||||
toolbar.AddCSSClass("toolbar")
|
||||
toolbar.Append(labelWithClass("CAPTION HISTORY", "section-title"))
|
||||
spacer := gtk.NewBox(gtk.OrientationHorizontal, 0)
|
||||
spacer.SetHExpand(true)
|
||||
toolbar.Append(spacer)
|
||||
toolbar.Append(toolButton("Copy Selected", "Copy the selected caption text", func() {
|
||||
copyBufferSelection(a.captionBuffer)
|
||||
}))
|
||||
toolbar.Append(toolButton("Copy Transcript", "Copy all final captions", func() {
|
||||
gdk.DisplayGetDefault().Clipboard().SetText(a.history.Transcript())
|
||||
}))
|
||||
toolbar.Append(toolButton("Clear", "Clear visible caption history", a.history.Clear))
|
||||
toolbar.Append(toolButton("Jump to Latest", "Resume at the newest caption", func() {
|
||||
scrollTextViewToEnd(a.captionView, a.captionScroll)
|
||||
}))
|
||||
page.Append(toolbar)
|
||||
|
||||
a.captionBuffer = gtk.NewTextBuffer(nil)
|
||||
a.captionMetaTag = bufferTag(a.captionBuffer, "meta", "#8A9384")
|
||||
a.captionHintTag = bufferTag(a.captionBuffer, "hint", "#6E7763")
|
||||
a.captionView = gtk.NewTextViewWithBuffer(a.captionBuffer)
|
||||
a.captionView.SetEditable(false)
|
||||
a.captionView.SetCursorVisible(true)
|
||||
a.captionView.SetWrapMode(gtk.WrapWordChar)
|
||||
a.captionView.SetTopMargin(9)
|
||||
a.captionView.SetBottomMargin(9)
|
||||
a.captionView.SetLeftMargin(10)
|
||||
a.captionView.SetRightMargin(10)
|
||||
a.captionView.AddCSSClass("caption-view")
|
||||
a.captionScroll = gtk.NewScrolledWindow()
|
||||
a.captionScroll.SetPolicy(gtk.PolicyAutomatic, gtk.PolicyAutomatic)
|
||||
a.captionScroll.SetHExpand(true)
|
||||
a.captionScroll.SetVExpand(true)
|
||||
a.captionScroll.SetChild(a.captionView)
|
||||
a.captionScroll.AddCSSClass("inset-panel")
|
||||
page.Append(a.captionScroll)
|
||||
|
||||
provisional := gtk.NewBox(gtk.OrientationHorizontal, 8)
|
||||
provisional.AddCSSClass("provisional-row")
|
||||
caption := labelWithClass("LIVE", "live-tag")
|
||||
caption.SetVAlign(gtk.AlignStart)
|
||||
caption.SetMarginTop(2)
|
||||
provisional.Append(caption)
|
||||
a.provisionalLabel = gtk.NewLabel("Waiting for speech…")
|
||||
a.provisionalLabel.SetXAlign(0)
|
||||
a.provisionalLabel.SetWrap(true)
|
||||
a.provisionalLabel.SetHExpand(true)
|
||||
provisional.Append(a.provisionalLabel)
|
||||
page.Append(provisional)
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
func (a *Application) buildConsolePage() *gtk.Box {
|
||||
page := gtk.NewBox(gtk.OrientationVertical, 7)
|
||||
page.AddCSSClass("captioneer-content")
|
||||
|
||||
toolbar := gtk.NewBox(gtk.OrientationHorizontal, 5)
|
||||
toolbar.AddCSSClass("toolbar")
|
||||
toolbar.Append(labelWithClass("APPLICATION CONSOLE", "section-title"))
|
||||
spacer := gtk.NewBox(gtk.OrientationHorizontal, 0)
|
||||
spacer.SetHExpand(true)
|
||||
toolbar.Append(spacer)
|
||||
toolbar.Append(toolButton("Copy Selected", "Copy selected console output", func() {
|
||||
copyBufferSelection(a.consoleBuffer)
|
||||
}))
|
||||
toolbar.Append(toolButton("Copy All", "Copy all buffered console output", func() {
|
||||
copyBuffer(a.consoleBuffer)
|
||||
}))
|
||||
toolbar.Append(toolButton("Clear", "Clear the console display", func() {
|
||||
a.log.Clear()
|
||||
a.renderConsole()
|
||||
}))
|
||||
toolbar.Append(toolButton("Jump to Latest", "Resume at the newest message", func() {
|
||||
scrollTextViewToEnd(a.consoleView, a.consoleScroll)
|
||||
}))
|
||||
page.Append(toolbar)
|
||||
|
||||
a.consoleBuffer = gtk.NewTextBuffer(nil)
|
||||
a.consoleMetaTag = bufferTag(a.consoleBuffer, "meta", "#79826F")
|
||||
a.consoleWarnTag = bufferTag(a.consoleBuffer, "warn", "#C79C4E")
|
||||
a.consoleErrorTag = bufferTag(a.consoleBuffer, "error", "#CF826D")
|
||||
a.consoleView = gtk.NewTextViewWithBuffer(a.consoleBuffer)
|
||||
a.consoleView.SetEditable(false)
|
||||
a.consoleView.SetCursorVisible(true)
|
||||
a.consoleView.SetWrapMode(gtk.WrapWordChar)
|
||||
a.consoleView.SetTopMargin(8)
|
||||
a.consoleView.SetBottomMargin(8)
|
||||
a.consoleView.SetLeftMargin(9)
|
||||
a.consoleView.SetRightMargin(9)
|
||||
a.consoleView.AddCSSClass("console-view")
|
||||
a.consoleScroll = gtk.NewScrolledWindow()
|
||||
a.consoleScroll.SetPolicy(gtk.PolicyAutomatic, gtk.PolicyAutomatic)
|
||||
a.consoleScroll.SetHExpand(true)
|
||||
a.consoleScroll.SetVExpand(true)
|
||||
a.consoleScroll.SetChild(a.consoleView)
|
||||
a.consoleScroll.AddCSSClass("inset-panel")
|
||||
page.Append(a.consoleScroll)
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
// tagSpan marks a character range of freshly rendered buffer text so a
|
||||
// TextTag can be applied after SetText replaces the whole buffer.
|
||||
type tagSpan struct {
|
||||
tag *gtk.TextTag
|
||||
start, end int
|
||||
}
|
||||
|
||||
func applyTagSpans(buffer *gtk.TextBuffer, spans []tagSpan) {
|
||||
for _, span := range spans {
|
||||
buffer.ApplyTag(span.tag, buffer.IterAtOffset(span.start), buffer.IterAtOffset(span.end))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) renderHistory(snapshot history.Snapshot) {
|
||||
follow := isAtBottom(a.captionScroll)
|
||||
var text strings.Builder
|
||||
var spans []tagSpan
|
||||
offset := 0
|
||||
if len(snapshot.Finals) == 0 {
|
||||
hint := "No captions yet. Press START to begin listening."
|
||||
text.WriteString(hint)
|
||||
spans = append(spans, tagSpan{a.captionHintTag, 0, utf8.RuneCountInString(hint)})
|
||||
}
|
||||
for _, entry := range snapshot.Finals {
|
||||
meta := fmt.Sprintf("%s [%s–%s] ",
|
||||
entry.CreatedAt.Format("15:04:05"),
|
||||
formatCaptionTime(entry.StartedAt),
|
||||
formatCaptionTime(entry.EndedAt),
|
||||
)
|
||||
text.WriteString(meta)
|
||||
spans = append(spans, tagSpan{a.captionMetaTag, offset, offset + utf8.RuneCountInString(meta)})
|
||||
offset += utf8.RuneCountInString(meta)
|
||||
line := entry.Text + "\n"
|
||||
text.WriteString(line)
|
||||
offset += utf8.RuneCountInString(line)
|
||||
}
|
||||
a.captionBuffer.SetText(text.String())
|
||||
applyTagSpans(a.captionBuffer, spans)
|
||||
if snapshot.Provisional == nil || strings.TrimSpace(snapshot.Provisional.Text) == "" {
|
||||
a.provisionalLabel.SetText("Waiting for speech…")
|
||||
} else {
|
||||
a.provisionalLabel.SetText(snapshot.Provisional.Text)
|
||||
}
|
||||
if follow {
|
||||
scrollTextViewToEnd(a.captionView, a.captionScroll)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) renderConsole() {
|
||||
follow := isAtBottom(a.consoleScroll)
|
||||
var text strings.Builder
|
||||
var spans []tagSpan
|
||||
offset := 0
|
||||
for _, entry := range a.log.Entries() {
|
||||
var severityTag *gtk.TextTag
|
||||
switch entry.Severity {
|
||||
case diagnostics.Warning:
|
||||
severityTag = a.consoleWarnTag
|
||||
case diagnostics.Error:
|
||||
severityTag = a.consoleErrorTag
|
||||
}
|
||||
meta := fmt.Sprintf("%s %-7s %-14s ",
|
||||
entry.At.Format("15:04:05.000"),
|
||||
strings.ToUpper(string(entry.Severity)),
|
||||
"["+entry.Category+"]",
|
||||
)
|
||||
text.WriteString(meta)
|
||||
spans = append(spans, tagSpan{a.consoleMetaTag, offset, offset + utf8.RuneCountInString(meta)})
|
||||
offset += utf8.RuneCountInString(meta)
|
||||
line := entry.Message + "\n"
|
||||
text.WriteString(line)
|
||||
if severityTag != nil {
|
||||
spans = append(spans, tagSpan{severityTag, offset, offset + utf8.RuneCountInString(entry.Message)})
|
||||
}
|
||||
offset += utf8.RuneCountInString(line)
|
||||
}
|
||||
a.consoleBuffer.SetText(text.String())
|
||||
applyTagSpans(a.consoleBuffer, spans)
|
||||
if follow {
|
||||
scrollTextViewToEnd(a.consoleView, a.consoleScroll)
|
||||
}
|
||||
}
|
||||
|
||||
func formatCaptionTime(value time.Duration) string {
|
||||
seconds := value.Seconds()
|
||||
return fmt.Sprintf("%02d:%05.2f", int(seconds)/60, seconds-float64(int(seconds)/60*60))
|
||||
}
|
||||
|
||||
func isAtBottom(scroll *gtk.ScrolledWindow) bool {
|
||||
if scroll == nil {
|
||||
return true
|
||||
}
|
||||
adjustment := scroll.VAdjustment()
|
||||
return adjustment.Value()+adjustment.PageSize() >= adjustment.Upper()-4
|
||||
}
|
||||
|
||||
func scrollTextViewToEnd(view *gtk.TextView, scroll *gtk.ScrolledWindow) {
|
||||
if view == nil || scroll == nil {
|
||||
return
|
||||
}
|
||||
view.ScrollToIter(view.Buffer().EndIter(), 0, false, 0, 1)
|
||||
adjustment := scroll.VAdjustment()
|
||||
adjustment.SetValue(adjustment.Upper())
|
||||
}
|
||||
|
||||
func copyBufferSelection(buffer *gtk.TextBuffer) {
|
||||
if buffer == nil {
|
||||
return
|
||||
}
|
||||
start, end, ok := buffer.SelectionBounds()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gdk.DisplayGetDefault().Clipboard().SetText(buffer.Text(start, end, true))
|
||||
}
|
||||
|
||||
func copyBuffer(buffer *gtk.TextBuffer) {
|
||||
if buffer == nil {
|
||||
return
|
||||
}
|
||||
start, end := buffer.Bounds()
|
||||
gdk.DisplayGetDefault().Clipboard().SetText(buffer.Text(start, end, true))
|
||||
}
|
||||
|
||||
func bufferTag(buffer *gtk.TextBuffer, name, foreground string) *gtk.TextTag {
|
||||
tag := gtk.NewTextTag(name)
|
||||
tag.SetObjectProperty("foreground", foreground)
|
||||
buffer.TagTable().Add(tag)
|
||||
return tag
|
||||
}
|
||||
|
||||
func labelWithClass(text, class string) *gtk.Label {
|
||||
label := gtk.NewLabel(text)
|
||||
label.AddCSSClass(class)
|
||||
label.SetXAlign(0)
|
||||
return label
|
||||
}
|
||||
|
||||
func toolButton(text, tooltip string, callback func()) *gtk.Button {
|
||||
button := gtk.NewButtonWithLabel(text)
|
||||
button.SetTooltipText(tooltip)
|
||||
button.ConnectClicked(callback)
|
||||
return button
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build gtk
|
||||
|
||||
package gui
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk4 gtk4-x11 gtk4-wayland x11
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
#include <gdk/x11/gdkx.h>
|
||||
#include <gdk/wayland/gdkwayland.h>
|
||||
#include <X11/Xlib.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
|
||||
// Returns 1 when the X11 hint was sent, 0 when the compositor controls this
|
||||
// policy (Wayland), and -1 for an unavailable/unknown backend.
|
||||
static int captioneer_set_main_window_above(GtkWindow *window, gboolean enabled) {
|
||||
gtk_widget_realize(GTK_WIDGET(window));
|
||||
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(window));
|
||||
if (surface == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (GDK_IS_WAYLAND_SURFACE(surface)) {
|
||||
return 0;
|
||||
}
|
||||
if (!GDK_IS_X11_SURFACE(surface)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
Display *display = GDK_SURFACE_XDISPLAY(surface);
|
||||
Window xid = GDK_SURFACE_XID(surface);
|
||||
Window root = DefaultRootWindow(display);
|
||||
Atom state = XInternAtom(display, "_NET_WM_STATE", False);
|
||||
Atom above = XInternAtom(display, "_NET_WM_STATE_ABOVE", False);
|
||||
XEvent event = {0};
|
||||
event.xclient.type = ClientMessage;
|
||||
event.xclient.window = xid;
|
||||
event.xclient.message_type = state;
|
||||
event.xclient.format = 32;
|
||||
event.xclient.data.l[0] = enabled ? 1 : 0;
|
||||
event.xclient.data.l[1] = above;
|
||||
event.xclient.data.l[2] = None;
|
||||
event.xclient.data.l[3] = 1;
|
||||
XSendEvent(display, root, False,
|
||||
SubstructureRedirectMask | SubstructureNotifyMask, &event);
|
||||
XFlush(display);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int captioneer_set_main_window_above_native(uintptr_t native, gboolean enabled) {
|
||||
return captioneer_set_main_window_above((GtkWindow *)native, enabled);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/gotk4/pkg/glib/v2"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
|
||||
)
|
||||
|
||||
func (a *Application) applyAlwaysOnTop(enabled bool) {
|
||||
if a.window == nil {
|
||||
return
|
||||
}
|
||||
glib.IdleAdd(func() {
|
||||
result := int(C.captioneer_set_main_window_above_native(
|
||||
C.uintptr_t(a.window.Native()),
|
||||
C.gboolean(boolToInt(enabled)),
|
||||
))
|
||||
if enabled && result == 0 {
|
||||
a.log.Add(diagnostics.Warning, "window", "Always on top is compositor-controlled on Wayland and could not be forced")
|
||||
} else if enabled && result < 0 {
|
||||
a.log.Add(diagnostics.Warning, "window", "Always on top is unavailable on this display backend")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
//go:build gtk
|
||||
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/diamondburned/gotk4/pkg/gio/v2"
|
||||
"github.com/diamondburned/gotk4/pkg/gtk/v4"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/app"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
|
||||
)
|
||||
|
||||
type settingsControls struct {
|
||||
startCaptions *gtk.CheckButton
|
||||
alwaysOnTop *gtk.CheckButton
|
||||
|
||||
mode *gtk.ComboBoxText
|
||||
chunkDuration *gtk.Entry
|
||||
modelsDirectory *gtk.Entry
|
||||
threads *gtk.SpinButton
|
||||
previewThresholdDB *gtk.SpinButton
|
||||
vadThreshold *gtk.SpinButton
|
||||
|
||||
overlayEnabled *gtk.CheckButton
|
||||
historyEnabled *gtk.CheckButton
|
||||
stdoutEnabled *gtk.CheckButton
|
||||
stderrEnabled *gtk.CheckButton
|
||||
transcriptEnabled *gtk.CheckButton
|
||||
transcriptDirectory *gtk.Entry
|
||||
|
||||
overlayOpacity *gtk.SpinButton
|
||||
overlayFont *gtk.FontButton
|
||||
overlayFontSize *gtk.SpinButton
|
||||
overlayBottomMargin *gtk.SpinButton
|
||||
overlayMaxWidth *gtk.SpinButton
|
||||
overlayMonitor *gtk.Entry
|
||||
overlayFinalTimeout *gtk.Entry
|
||||
overlayClickThrough *gtk.CheckButton
|
||||
|
||||
overlayBackend *gtk.ComboBoxText
|
||||
modelAutoRestart *gtk.CheckButton
|
||||
modelTimeout *gtk.Entry
|
||||
modelShutdownTimeout *gtk.Entry
|
||||
}
|
||||
|
||||
func (a *Application) buildSettingsPage() *gtk.Box {
|
||||
page := gtk.NewBox(gtk.OrientationVertical, 7)
|
||||
page.AddCSSClass("captioneer-content")
|
||||
|
||||
content := gtk.NewBox(gtk.OrientationHorizontal, 8)
|
||||
content.SetHExpand(true)
|
||||
content.SetVExpand(true)
|
||||
settingsStack := gtk.NewStack()
|
||||
settingsStack.SetTransitionType(gtk.StackTransitionTypeNone)
|
||||
settingsStack.SetHExpand(true)
|
||||
settingsStack.SetVExpand(true)
|
||||
sidebar := gtk.NewStackSidebar()
|
||||
sidebar.SetStack(settingsStack)
|
||||
sidebar.SetSizeRequest(155, -1)
|
||||
sidebar.AddCSSClass("settings-sidebar")
|
||||
content.Append(sidebar)
|
||||
|
||||
a.createSettingsControls()
|
||||
settingsStack.AddTitled(a.generalSettingsPage(), "general", "General")
|
||||
settingsStack.AddTitled(a.recognitionSettingsPage(), "recognition", "Recognition")
|
||||
settingsStack.AddTitled(a.outputSettingsPage(), "outputs", "Outputs")
|
||||
settingsStack.AddTitled(a.overlaySettingsPage(), "overlay", "Caption Overlay")
|
||||
settingsStack.AddTitled(a.advancedSettingsPage(), "advanced", "Advanced")
|
||||
content.Append(settingsStack)
|
||||
page.Append(content)
|
||||
|
||||
footer := gtk.NewBox(gtk.OrientationHorizontal, 7)
|
||||
footer.AddCSSClass("toolbar")
|
||||
a.settingsError = gtk.NewLabel("")
|
||||
a.settingsError.SetXAlign(0)
|
||||
a.settingsError.SetWrap(true)
|
||||
a.settingsError.SetHExpand(true)
|
||||
a.settingsError.AddCSSClass("validation-error")
|
||||
footer.Append(a.settingsError)
|
||||
reset := toolButton("Reset to Defaults", "Load defaults into the form without saving them", func() {
|
||||
a.populateSettings(config.DefaultAppConfig())
|
||||
a.updateApplyButton()
|
||||
})
|
||||
footer.Append(reset)
|
||||
a.applyButton = gtk.NewButtonWithLabel("APPLY SETTINGS")
|
||||
a.applyButton.AddCSSClass("primary-action")
|
||||
a.applyButton.SetTooltipText("Validate and save all settings")
|
||||
a.applyButton.ConnectClicked(a.applySettings)
|
||||
footer.Append(a.applyButton)
|
||||
page.Append(footer)
|
||||
|
||||
a.populateSettings(a.config)
|
||||
a.connectSettingsChanged()
|
||||
return page
|
||||
}
|
||||
|
||||
func (a *Application) createSettingsControls() {
|
||||
a.settings.startCaptions = gtk.NewCheckButton()
|
||||
a.settings.alwaysOnTop = gtk.NewCheckButton()
|
||||
a.settings.mode = combo([]option{{"vad", "Voice activity detection (VAD)"}, {"fixed", "Fixed chunks"}})
|
||||
a.settings.chunkDuration = gtk.NewEntry()
|
||||
a.settings.modelsDirectory = gtk.NewEntry()
|
||||
a.settings.threads = spin(1, 128, 1, 0)
|
||||
a.settings.previewThresholdDB = spin(-100, 0, 1, 1)
|
||||
a.settings.vadThreshold = spin(0, 1, 0.01, 2)
|
||||
|
||||
a.settings.overlayEnabled = gtk.NewCheckButton()
|
||||
a.settings.historyEnabled = gtk.NewCheckButton()
|
||||
a.settings.stdoutEnabled = gtk.NewCheckButton()
|
||||
a.settings.stderrEnabled = gtk.NewCheckButton()
|
||||
a.settings.transcriptEnabled = gtk.NewCheckButton()
|
||||
a.settings.transcriptDirectory = gtk.NewEntry()
|
||||
|
||||
a.settings.overlayOpacity = spin(0, 1, 0.01, 2)
|
||||
a.settings.overlayFont = gtk.NewFontButton()
|
||||
a.settings.overlayFont.SetUseFont(true)
|
||||
a.settings.overlayFontSize = spin(8, 96, 1, 0)
|
||||
a.settings.overlayBottomMargin = spin(0, 2000, 5, 0)
|
||||
a.settings.overlayMaxWidth = spin(100, 5000, 10, 0)
|
||||
a.settings.overlayMonitor = gtk.NewEntry()
|
||||
a.settings.overlayFinalTimeout = gtk.NewEntry()
|
||||
a.settings.overlayClickThrough = gtk.NewCheckButton()
|
||||
|
||||
a.settings.overlayBackend = combo([]option{{"auto", "Automatic"}, {"wayland", "Wayland"}, {"x11", "X11"}})
|
||||
a.settings.modelAutoRestart = gtk.NewCheckButton()
|
||||
a.settings.modelTimeout = gtk.NewEntry()
|
||||
a.settings.modelShutdownTimeout = gtk.NewEntry()
|
||||
}
|
||||
|
||||
func (a *Application) generalSettingsPage() *gtk.ScrolledWindow {
|
||||
box := settingsBox("GENERAL", "Desktop startup and main-window behavior.")
|
||||
box.Append(settingRow("Start captions when the GUI opens", "The saved setting is used unless an explicit command-line flag overrides it.", a.settings.startCaptions))
|
||||
box.Append(settingRow("Keep main window above others", "Best effort on X11. Wayland compositors normally do not allow applications to force this.", a.settings.alwaysOnTop))
|
||||
return settingsScroller(box)
|
||||
}
|
||||
|
||||
func (a *Application) recognitionSettingsPage() *gtk.ScrolledWindow {
|
||||
box := settingsBox("RECOGNITION", "These settings require the caption pipeline to restart while it is running.")
|
||||
box.Append(settingRow("Processing mode", "VAD waits for detected speech; fixed mode recognizes regular chunks.", a.settings.mode))
|
||||
box.Append(settingRow("Chunk / preview interval", "Go duration such as 1s or 750ms.", a.settings.chunkDuration))
|
||||
box.Append(pathSettingRow(a, "Models directory", "Directory containing the Sherpa-ONNX model files.", a.settings.modelsDirectory, "Select models directory"))
|
||||
box.Append(settingRow("Recognition threads", "CPU threads used by the recognizer.", a.settings.threads))
|
||||
box.Append(settingRow("Preview threshold (dBFS)", "Suppress fixed-mode previews below this level.", a.settings.previewThresholdDB))
|
||||
box.Append(settingRow("VAD threshold", "Speech detector sensitivity from 0.00 to 1.00.", a.settings.vadThreshold))
|
||||
return settingsScroller(box)
|
||||
}
|
||||
|
||||
func (a *Application) outputSettingsPage() *gtk.ScrolledWindow {
|
||||
box := settingsBox("OUTPUTS", "Caption destinations and diagnostic output are independent.")
|
||||
box.Append(settingRow("Caption overlay", "Show captions in the desktop overlay.", a.settings.overlayEnabled))
|
||||
box.Append(settingRow("GUI caption history", "Keep final captions and one live provisional row in this window.", a.settings.historyEnabled))
|
||||
box.Append(settingRow("Caption text on stdout", "Useful when launching the desktop application from a terminal or script.", a.settings.stdoutEnabled))
|
||||
box.Append(settingRow("Diagnostics on stderr", "Mirror Console messages to the launching terminal.", a.settings.stderrEnabled))
|
||||
box.Append(settingRow("Save timestamped caption log", "Write final captions to a dated session file, flushing each line immediately.", a.settings.transcriptEnabled))
|
||||
box.Append(pathSettingRow(a, "Caption log directory", "A new session file is created here each time Captioneer starts logging.", a.settings.transcriptDirectory, "Select caption log directory"))
|
||||
return settingsScroller(box)
|
||||
}
|
||||
|
||||
func (a *Application) overlaySettingsPage() *gtk.ScrolledWindow {
|
||||
box := settingsBox("CAPTION OVERLAY", "Appearance changes apply without restarting recognition.")
|
||||
box.Append(settingRow("Opacity", "Background opacity from 0.00 to 1.00.", a.settings.overlayOpacity))
|
||||
box.Append(settingRow("Font family", "Font used for overlay captions.", a.settings.overlayFont))
|
||||
box.Append(settingRow("Font size", "Caption text size in points.", a.settings.overlayFontSize))
|
||||
box.Append(settingRow("Bottom margin", "Distance from the bottom edge in pixels.", a.settings.overlayBottomMargin))
|
||||
box.Append(settingRow("Maximum width", "Maximum overlay width in pixels.", a.settings.overlayMaxWidth))
|
||||
box.Append(settingRow("Monitor", "Use auto or the configured monitor connector/name.", a.settings.overlayMonitor))
|
||||
box.Append(settingRow("Final caption lifetime", "Zero keeps final text visible; otherwise use 3s or longer.", a.settings.overlayFinalTimeout))
|
||||
box.Append(settingRow("Click through overlay", "Allow pointer input to pass through the caption surface.", a.settings.overlayClickThrough))
|
||||
return settingsScroller(box)
|
||||
}
|
||||
|
||||
func (a *Application) advancedSettingsPage() *gtk.ScrolledWindow {
|
||||
box := settingsBox("ADVANCED", "Low-level recovery and platform settings. Leave these at their defaults unless troubleshooting.")
|
||||
expander := gtk.NewExpander("Show advanced settings")
|
||||
expander.SetExpanded(false)
|
||||
advanced := gtk.NewBox(gtk.OrientationVertical, 8)
|
||||
advanced.SetMarginTop(8)
|
||||
advanced.Append(settingRow("Overlay backend", "Changing the backend is saved now and takes effect after restarting the application.", a.settings.overlayBackend))
|
||||
advanced.Append(settingRow("Restart model worker automatically", "Recover automatically if the isolated recognition worker crashes or stalls.", a.settings.modelAutoRestart))
|
||||
advanced.Append(settingRow("Recognition timeout", "Maximum busy time before a stalled model worker is replaced; zero disables it.", a.settings.modelTimeout))
|
||||
advanced.Append(settingRow("Worker shutdown timeout", "How long to wait for clean worker shutdown before forcing it.", a.settings.modelShutdownTimeout))
|
||||
expander.SetChild(advanced)
|
||||
box.Append(expander)
|
||||
return settingsScroller(box)
|
||||
}
|
||||
|
||||
func (a *Application) populateSettings(value config.AppConfig) {
|
||||
a.settings.startCaptions.SetActive(value.GUI.StartCaptions)
|
||||
a.settings.alwaysOnTop.SetActive(value.GUI.MainWindowAlwaysOnTop)
|
||||
a.settings.mode.SetActiveID(string(value.Settings.Mode))
|
||||
a.settings.chunkDuration.SetText(value.Settings.ChunkDuration.String())
|
||||
a.settings.modelsDirectory.SetText(value.Settings.ModelsDir)
|
||||
a.settings.threads.SetValue(float64(value.Settings.Threads))
|
||||
a.settings.previewThresholdDB.SetValue(value.Settings.PreviewThresholdDB)
|
||||
a.settings.vadThreshold.SetValue(value.Settings.VADThreshold)
|
||||
|
||||
a.settings.overlayEnabled.SetActive(value.Outputs.Overlay)
|
||||
a.settings.historyEnabled.SetActive(value.Outputs.History)
|
||||
a.settings.stdoutEnabled.SetActive(value.Outputs.StdoutCaptions)
|
||||
a.settings.stderrEnabled.SetActive(value.Outputs.StderrDiagnostics)
|
||||
a.settings.transcriptEnabled.SetActive(value.TranscriptLog.Enabled)
|
||||
a.settings.transcriptDirectory.SetText(value.TranscriptLog.Directory)
|
||||
|
||||
a.settings.overlayOpacity.SetValue(value.Overlay.Opacity)
|
||||
a.settings.overlayFont.SetFont(value.Overlay.FontFamily)
|
||||
a.settings.overlayFontSize.SetValue(float64(value.Overlay.FontSize))
|
||||
a.settings.overlayBottomMargin.SetValue(float64(value.Overlay.BottomMargin))
|
||||
a.settings.overlayMaxWidth.SetValue(float64(value.Overlay.MaxWidth))
|
||||
a.settings.overlayMonitor.SetText(value.Overlay.Monitor)
|
||||
a.settings.overlayFinalTimeout.SetText(value.Overlay.FinalTimeout.String())
|
||||
a.settings.overlayClickThrough.SetActive(value.Overlay.ClickThrough)
|
||||
|
||||
a.settings.overlayBackend.SetActiveID(string(value.Overlay.Backend))
|
||||
a.settings.modelAutoRestart.SetActive(value.Settings.ModelAutoRestart)
|
||||
a.settings.modelTimeout.SetText(value.Settings.ModelTimeout.String())
|
||||
a.settings.modelShutdownTimeout.SetText(value.Settings.ModelShutdownTimeout.String())
|
||||
}
|
||||
|
||||
func (a *Application) readSettings() (config.AppConfig, error) {
|
||||
chunk, err := settingDuration("chunk / preview interval", a.settings.chunkDuration.Text())
|
||||
if err != nil {
|
||||
return config.AppConfig{}, err
|
||||
}
|
||||
finalTimeout, err := settingDuration("final caption lifetime", a.settings.overlayFinalTimeout.Text())
|
||||
if err != nil {
|
||||
return config.AppConfig{}, err
|
||||
}
|
||||
modelTimeout, err := settingDuration("recognition timeout", a.settings.modelTimeout.Text())
|
||||
if err != nil {
|
||||
return config.AppConfig{}, err
|
||||
}
|
||||
shutdownTimeout, err := settingDuration("worker shutdown timeout", a.settings.modelShutdownTimeout.Text())
|
||||
if err != nil {
|
||||
return config.AppConfig{}, err
|
||||
}
|
||||
|
||||
next := config.AppConfig{
|
||||
Version: config.CurrentConfigVersion,
|
||||
GUI: config.GUISettings{
|
||||
StartCaptions: a.settings.startCaptions.Active(),
|
||||
MainWindowAlwaysOnTop: a.settings.alwaysOnTop.Active(),
|
||||
},
|
||||
Settings: config.Settings{
|
||||
Mode: config.Mode(a.settings.mode.ActiveID()),
|
||||
ChunkDuration: chunk,
|
||||
ModelsDir: strings.TrimSpace(a.settings.modelsDirectory.Text()),
|
||||
Threads: a.settings.threads.ValueAsInt(),
|
||||
PreviewThresholdDB: a.settings.previewThresholdDB.Value(),
|
||||
VADThreshold: a.settings.vadThreshold.Value(),
|
||||
ModelAutoRestart: a.settings.modelAutoRestart.Active(),
|
||||
ModelTimeout: modelTimeout,
|
||||
ModelShutdownTimeout: shutdownTimeout,
|
||||
},
|
||||
Outputs: config.OutputSettings{
|
||||
Overlay: a.settings.overlayEnabled.Active(),
|
||||
History: a.settings.historyEnabled.Active(),
|
||||
StdoutCaptions: a.settings.stdoutEnabled.Active(),
|
||||
StderrDiagnostics: a.settings.stderrEnabled.Active(),
|
||||
},
|
||||
Overlay: config.OverlaySettings{
|
||||
Backend: config.OverlayBackend(a.settings.overlayBackend.ActiveID()),
|
||||
Opacity: a.settings.overlayOpacity.Value(),
|
||||
FontSize: a.settings.overlayFontSize.ValueAsInt(),
|
||||
FontFamily: a.settings.overlayFont.FontDesc().Family(),
|
||||
BottomMargin: a.settings.overlayBottomMargin.ValueAsInt(),
|
||||
MaxWidth: a.settings.overlayMaxWidth.ValueAsInt(),
|
||||
Monitor: strings.TrimSpace(a.settings.overlayMonitor.Text()),
|
||||
FinalTimeout: finalTimeout,
|
||||
ClickThrough: a.settings.overlayClickThrough.Active(),
|
||||
},
|
||||
TranscriptLog: config.TranscriptLogSettings{
|
||||
Enabled: a.settings.transcriptEnabled.Active(),
|
||||
Directory: strings.TrimSpace(a.settings.transcriptDirectory.Text()),
|
||||
},
|
||||
}
|
||||
if err := next.Validate(); err != nil {
|
||||
return config.AppConfig{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (a *Application) applySettings() {
|
||||
next, err := a.readSettings()
|
||||
if err != nil {
|
||||
a.settingsError.SetText(err.Error())
|
||||
return
|
||||
}
|
||||
if err := config.Save(a.launch.Path, next); err != nil {
|
||||
a.settingsError.SetText(err.Error())
|
||||
a.log.Add(diagnostics.Error, "configuration", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
previous := a.applied
|
||||
engineChanged := next.Settings != previous.Settings
|
||||
backendChanged := next.Overlay.Backend != previous.Overlay.Backend
|
||||
if err := a.configureOutputs(next, false); err != nil {
|
||||
a.log.Add(diagnostics.Error, "outputs", err.Error())
|
||||
}
|
||||
a.config = next
|
||||
a.applied = next
|
||||
if backendChanged {
|
||||
a.applied.Overlay.Backend = previous.Overlay.Backend
|
||||
a.log.Add(diagnostics.Warning, "overlay", "Overlay backend change is saved and will apply after restarting Captioneer")
|
||||
}
|
||||
a.applyAlwaysOnTop(next.GUI.MainWindowAlwaysOnTop)
|
||||
a.settingsError.SetText("")
|
||||
a.log.Add(diagnostics.Info, "configuration", "Settings saved to "+a.launch.Path)
|
||||
|
||||
status := a.controller.Status()
|
||||
if engineChanged && status.State != app.StateStopped && status.State != app.StateError {
|
||||
go func(settings config.Settings) {
|
||||
timeout := previous.Settings.ModelShutdownTimeout + 2*time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := a.controller.Restart(ctx, settings); err != nil {
|
||||
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Apply and restart captions: %v", err))
|
||||
}
|
||||
}(next.Settings)
|
||||
}
|
||||
a.updateApplyButton()
|
||||
a.renderStatus(a.controller.Status())
|
||||
}
|
||||
|
||||
func (a *Application) updateApplyButton() {
|
||||
if a.applyButton == nil {
|
||||
return
|
||||
}
|
||||
next, err := a.readSettings()
|
||||
if err != nil {
|
||||
a.applyButton.SetLabel("APPLY SETTINGS")
|
||||
return
|
||||
}
|
||||
status := a.controller.Status()
|
||||
if next.Settings != a.applied.Settings && status.State != app.StateStopped && status.State != app.StateError {
|
||||
a.applyButton.SetLabel("APPLY & RESTART CAPTIONS")
|
||||
} else {
|
||||
a.applyButton.SetLabel("APPLY SETTINGS")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) connectSettingsChanged() {
|
||||
changed := a.updateApplyButton
|
||||
entries := []*gtk.Entry{
|
||||
a.settings.chunkDuration, a.settings.modelsDirectory, a.settings.transcriptDirectory,
|
||||
a.settings.overlayMonitor, a.settings.overlayFinalTimeout, a.settings.modelTimeout,
|
||||
a.settings.modelShutdownTimeout,
|
||||
}
|
||||
for _, entry := range entries {
|
||||
entry.ConnectChanged(changed)
|
||||
}
|
||||
spins := []*gtk.SpinButton{
|
||||
a.settings.threads, a.settings.previewThresholdDB, a.settings.vadThreshold,
|
||||
a.settings.overlayOpacity, a.settings.overlayFontSize, a.settings.overlayBottomMargin,
|
||||
a.settings.overlayMaxWidth,
|
||||
}
|
||||
for _, control := range spins {
|
||||
control.ConnectValueChanged(changed)
|
||||
}
|
||||
combos := []*gtk.ComboBoxText{a.settings.mode, a.settings.overlayBackend}
|
||||
for _, control := range combos {
|
||||
control.ConnectChanged(changed)
|
||||
}
|
||||
a.settings.overlayFont.ConnectFontSet(changed)
|
||||
checks := []*gtk.CheckButton{
|
||||
a.settings.startCaptions, a.settings.alwaysOnTop, a.settings.overlayEnabled,
|
||||
a.settings.historyEnabled, a.settings.stdoutEnabled, a.settings.stderrEnabled,
|
||||
a.settings.transcriptEnabled, a.settings.overlayClickThrough, a.settings.modelAutoRestart,
|
||||
}
|
||||
for _, control := range checks {
|
||||
control.ConnectToggled(changed)
|
||||
}
|
||||
}
|
||||
|
||||
type option struct{ id, label string }
|
||||
|
||||
func combo(options []option) *gtk.ComboBoxText {
|
||||
control := gtk.NewComboBoxText()
|
||||
for _, option := range options {
|
||||
control.Append(option.id, option.label)
|
||||
}
|
||||
return control
|
||||
}
|
||||
|
||||
func spin(minimum, maximum, step float64, digits uint) *gtk.SpinButton {
|
||||
control := gtk.NewSpinButtonWithRange(minimum, maximum, step)
|
||||
control.SetDigits(digits)
|
||||
control.SetNumeric(true)
|
||||
return control
|
||||
}
|
||||
|
||||
func settingsBox(title, description string) *gtk.Box {
|
||||
box := gtk.NewBox(gtk.OrientationVertical, 8)
|
||||
box.SetMarginTop(12)
|
||||
box.SetMarginBottom(12)
|
||||
box.SetMarginStart(12)
|
||||
box.SetMarginEnd(12)
|
||||
box.Append(labelWithClass(title, "section-title"))
|
||||
intro := labelWithClass(description, "description")
|
||||
intro.SetWrap(true)
|
||||
box.Append(intro)
|
||||
box.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
|
||||
return box
|
||||
}
|
||||
|
||||
func settingsScroller(child gtk.Widgetter) *gtk.ScrolledWindow {
|
||||
scroll := gtk.NewScrolledWindow()
|
||||
scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
|
||||
scroll.SetHExpand(true)
|
||||
scroll.SetVExpand(true)
|
||||
scroll.SetChild(child)
|
||||
scroll.AddCSSClass("settings-panel")
|
||||
return scroll
|
||||
}
|
||||
|
||||
func settingRow(title, description string, control gtk.Widgetter) *gtk.Box {
|
||||
row := gtk.NewBox(gtk.OrientationHorizontal, 12)
|
||||
row.AddCSSClass("setting-row")
|
||||
text := gtk.NewBox(gtk.OrientationVertical, 2)
|
||||
text.SetHExpand(true)
|
||||
name := gtk.NewLabel(title)
|
||||
name.SetXAlign(0)
|
||||
name.SetMnemonicWidget(control)
|
||||
text.Append(name)
|
||||
detail := labelWithClass(description, "description")
|
||||
detail.SetWrap(true)
|
||||
text.Append(detail)
|
||||
row.Append(text)
|
||||
widget := gtk.BaseWidget(control)
|
||||
widget.SetVAlign(gtk.AlignCenter)
|
||||
row.Append(control)
|
||||
return row
|
||||
}
|
||||
|
||||
func pathSettingRow(a *Application, title, description string, entry *gtk.Entry, dialogTitle string) *gtk.Box {
|
||||
chooser := gtk.NewBox(gtk.OrientationHorizontal, 4)
|
||||
entry.SetHExpand(true)
|
||||
chooser.Append(entry)
|
||||
browse := gtk.NewButtonWithLabel("Browse…")
|
||||
browse.ConnectClicked(func() { a.selectFolder(dialogTitle, entry) })
|
||||
chooser.Append(browse)
|
||||
return settingRow(title, description, chooser)
|
||||
}
|
||||
|
||||
func (a *Application) selectFolder(title string, entry *gtk.Entry) {
|
||||
dialog := gtk.NewFileDialog()
|
||||
dialog.SetTitle(title)
|
||||
dialog.SelectFolder(context.Background(), &a.window.Window, func(result gio.AsyncResulter) {
|
||||
folder, err := dialog.SelectFolderFinish(result)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
entry.SetText(folder.Path())
|
||||
})
|
||||
}
|
||||
|
||||
func settingDuration(name, value string) (time.Duration, error) {
|
||||
duration, err := time.ParseDuration(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a duration such as 1s or 750ms", name)
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/*
|
||||
* Captioneer visual theme, modeled on the 2003-2005 Steam client:
|
||||
* olive-gray raised chrome, sunken near-black wells, khaki/gold accents,
|
||||
* 1px bevels (light top/left, dark bottom/right), no soft modern gradients.
|
||||
*
|
||||
* Palette:
|
||||
* chrome #3E4637 (gradient stop #333A2D)
|
||||
* raised panel #4C5844
|
||||
* sunken well #1E221B
|
||||
* body text #D8DED3 dim text #8A9384
|
||||
* khaki accent #C4B550 selection #958831
|
||||
* error #B05C4A warning #C79C4E
|
||||
* bevel light #6A7862 bevel dark #242921
|
||||
*/
|
||||
|
||||
window.captioneer-main {
|
||||
background: #3E4637;
|
||||
color: #D8DED3;
|
||||
font-size: 9.5pt;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Titlebar chrome */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
headerbar.captioneer-header {
|
||||
min-height: 34px;
|
||||
padding: 2px 6px;
|
||||
background-image: linear-gradient(to bottom, #454E3C, #333A2D);
|
||||
border-bottom: 1px solid #242921;
|
||||
box-shadow: inset 0 1px #6A7862;
|
||||
color: #D8DED3;
|
||||
}
|
||||
|
||||
headerbar.captioneer-header windowcontrols button {
|
||||
min-height: 17px;
|
||||
min-width: 19px;
|
||||
padding: 1px 3px;
|
||||
margin: 0 1px;
|
||||
border-radius: 0;
|
||||
background-image: none;
|
||||
background: #4C5844;
|
||||
border: 1px solid #242921;
|
||||
border-top-color: #6A7862;
|
||||
border-left-color: #6A7862;
|
||||
color: #B9C2AD;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
headerbar.captioneer-header windowcontrols button:hover {
|
||||
background: #5B684D;
|
||||
color: #E8EDD8;
|
||||
}
|
||||
|
||||
headerbar.captioneer-header windowcontrols button:active {
|
||||
background: #333A2D;
|
||||
border-top-color: #242921;
|
||||
border-left-color: #242921;
|
||||
border-bottom-color: #6A7862;
|
||||
border-right-color: #6A7862;
|
||||
}
|
||||
|
||||
.captioneer-brand {
|
||||
color: #C4B550;
|
||||
font-weight: 700;
|
||||
font-size: 10pt;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
min-height: 20px;
|
||||
padding: 1px 12px;
|
||||
background: #2C3227;
|
||||
border: 1px solid #242921;
|
||||
border-bottom-color: #58644E;
|
||||
border-right-color: #58644E;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #B9C2AD;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-dot.status-running { color: #C4B550; }
|
||||
.status-dot.status-stopped { color: #79826F; }
|
||||
.status-dot.status-warning { color: #C79C4E; }
|
||||
.status-dot.status-error { color: #B05C4A; }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Menu bar */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.captioneer-menu {
|
||||
min-height: 22px;
|
||||
padding: 0 4px;
|
||||
background: #3E4637;
|
||||
border-bottom: 1px solid #242921;
|
||||
}
|
||||
|
||||
.captioneer-menu > item {
|
||||
padding: 2px 9px;
|
||||
border-radius: 0;
|
||||
color: #C3CBB6;
|
||||
}
|
||||
|
||||
.captioneer-menu > item:hover,
|
||||
.captioneer-menu > item:selected {
|
||||
background: #958831;
|
||||
color: #1E221B;
|
||||
}
|
||||
|
||||
popover.menu contents {
|
||||
background: #3E4637;
|
||||
border: 1px solid #242921;
|
||||
border-top-color: #6A7862;
|
||||
border-left-color: #6A7862;
|
||||
border-radius: 0;
|
||||
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35);
|
||||
color: #D8DED3;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
popover.menu modelbutton {
|
||||
min-height: 22px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 0;
|
||||
color: #D8DED3;
|
||||
}
|
||||
|
||||
popover.menu modelbutton:hover {
|
||||
background: #958831;
|
||||
color: #1E221B;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tab strip */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.captioneer-tabs {
|
||||
min-height: 27px;
|
||||
padding: 5px 8px 0;
|
||||
background: #383F30;
|
||||
border-bottom: 1px solid #242921;
|
||||
}
|
||||
|
||||
.captioneer-tabs button {
|
||||
min-height: 22px;
|
||||
padding: 3px 16px;
|
||||
margin-right: 2px;
|
||||
margin-bottom: -1px;
|
||||
border-radius: 1px 1px 0 0;
|
||||
background-image: none;
|
||||
background: #434D3B;
|
||||
border: 1px solid #242921;
|
||||
border-top-color: #6A7862;
|
||||
border-left-color: #6A7862;
|
||||
color: #A8B29B;
|
||||
font-size: 8.5pt;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.captioneer-tabs button:hover {
|
||||
color: #D8DED3;
|
||||
background: #4A553F;
|
||||
}
|
||||
|
||||
.captioneer-tabs button:checked {
|
||||
background: #4C5844;
|
||||
color: #C4B550;
|
||||
border-bottom-color: #4C5844;
|
||||
}
|
||||
|
||||
.captioneer-content {
|
||||
padding: 8px;
|
||||
background: #4C5844;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sunken wells and text views */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.inset-panel,
|
||||
textview,
|
||||
entry,
|
||||
spinbutton,
|
||||
combobox button.combo,
|
||||
fontbutton button {
|
||||
background-image: none;
|
||||
background: #1E221B;
|
||||
color: #D8DED3;
|
||||
border: 1px solid #191D16;
|
||||
border-bottom-color: #6A7862;
|
||||
border-right-color: #58644E;
|
||||
border-radius: 1px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
textview text {
|
||||
background: #1E221B;
|
||||
color: #D8DED3;
|
||||
}
|
||||
|
||||
textview text selection {
|
||||
background: #958831;
|
||||
color: #1E221B;
|
||||
}
|
||||
|
||||
.console-view text {
|
||||
background: #191D16;
|
||||
color: #B9C4AC;
|
||||
font-family: Monospace;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.provisional-row {
|
||||
min-height: 30px;
|
||||
padding: 5px 8px;
|
||||
background: #2C3227;
|
||||
color: #D3DCBF;
|
||||
border: 1px solid #191D16;
|
||||
border-bottom-color: #58644E;
|
||||
border-right-color: #58644E;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.live-tag {
|
||||
padding: 1px 7px;
|
||||
background: #958831;
|
||||
color: #1E221B;
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toolbars and buttons */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.toolbar {
|
||||
min-height: 28px;
|
||||
padding: 3px 5px;
|
||||
background: #525E48;
|
||||
border: 1px solid #242921;
|
||||
border-top-color: #6A7862;
|
||||
border-left-color: #6A7862;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 21px;
|
||||
min-width: 0;
|
||||
padding: 2px 10px;
|
||||
border-radius: 1px;
|
||||
background-image: none;
|
||||
background: #57634B;
|
||||
color: #D8DED3;
|
||||
font-size: 8.5pt;
|
||||
font-weight: 600;
|
||||
border: 1px solid #242921;
|
||||
border-top-color: #6A7862;
|
||||
border-left-color: #6A7862;
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #63704F;
|
||||
color: #EDF1DD;
|
||||
border-top-color: #83917A;
|
||||
border-left-color: #83917A;
|
||||
}
|
||||
|
||||
button:active,
|
||||
button:checked {
|
||||
background: #3A4233;
|
||||
border-top-color: #242921;
|
||||
border-left-color: #242921;
|
||||
border-bottom-color: #6A7862;
|
||||
border-right-color: #6A7862;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
checkbutton:disabled,
|
||||
entry:disabled,
|
||||
spinbutton:disabled,
|
||||
combobox:disabled {
|
||||
color: #79826F;
|
||||
background: #414A38;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
entry:focus-visible,
|
||||
spinbutton:focus-visible,
|
||||
combobox:focus-visible,
|
||||
checkbutton:focus-visible,
|
||||
textview:focus-visible {
|
||||
outline: 1px dotted #C4B550;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
color: #C4B550;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
border-top-color: #83917A;
|
||||
border-left-color: #7E8C74;
|
||||
}
|
||||
|
||||
.primary-action:hover {
|
||||
color: #DED08A;
|
||||
}
|
||||
|
||||
.danger-action {
|
||||
color: #E0A18E;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.danger-action:hover {
|
||||
color: #F0BCAB;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Typography helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.section-title {
|
||||
color: #C4B550;
|
||||
font-size: 8.5pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #8A9384;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.validation-error {
|
||||
color: #D98D7A;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #C79C4E;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Statusbar */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.statusbar {
|
||||
min-height: 23px;
|
||||
padding: 3px 6px;
|
||||
background: #3E4637;
|
||||
border-top: 1px solid #242921;
|
||||
box-shadow: inset 0 1px #58644E;
|
||||
}
|
||||
|
||||
.status-segment {
|
||||
min-height: 17px;
|
||||
padding: 0 9px;
|
||||
background: #343B2D;
|
||||
color: #98A28C;
|
||||
font-size: 8.5pt;
|
||||
border: 1px solid #242921;
|
||||
border-bottom-color: #58644E;
|
||||
border-right-color: #58644E;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Settings */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.settings-sidebar {
|
||||
background: #434D3B;
|
||||
border: 1px solid #242921;
|
||||
border-bottom-color: #58644E;
|
||||
border-right-color: #58644E;
|
||||
}
|
||||
|
||||
.settings-sidebar list {
|
||||
background: #434D3B;
|
||||
}
|
||||
|
||||
.settings-sidebar row {
|
||||
min-width: 130px;
|
||||
min-height: 24px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 0;
|
||||
color: #B4BEA6;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.settings-sidebar row:hover {
|
||||
background: #4E5943;
|
||||
color: #D8DED3;
|
||||
}
|
||||
|
||||
.settings-sidebar row:selected {
|
||||
background: #958831;
|
||||
color: #1E221B;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
background: #46513D;
|
||||
border: 1px solid #242921;
|
||||
border-bottom-color: #58644E;
|
||||
border-right-color: #58644E;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
min-height: 36px;
|
||||
padding: 4px 8px;
|
||||
background: #4E5A45;
|
||||
border: 1px solid #3A4233;
|
||||
border-top-color: #5C6950;
|
||||
border-left-color: #5C6950;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Form controls */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
checkbutton check {
|
||||
min-height: 13px;
|
||||
min-width: 13px;
|
||||
margin: 2px;
|
||||
border-radius: 1px;
|
||||
background-image: none;
|
||||
background: #1E221B;
|
||||
border: 1px solid #191D16;
|
||||
border-bottom-color: #6A7862;
|
||||
border-right-color: #58644E;
|
||||
color: #C4B550;
|
||||
box-shadow: none;
|
||||
-gtk-icon-size: 11px;
|
||||
}
|
||||
|
||||
checkbutton check:hover {
|
||||
background: #262B22;
|
||||
}
|
||||
|
||||
checkbutton check:checked {
|
||||
background: #1E221B;
|
||||
color: #C4B550;
|
||||
}
|
||||
|
||||
entry,
|
||||
spinbutton {
|
||||
min-height: 22px;
|
||||
padding: 1px 6px;
|
||||
caret-color: #C4B550;
|
||||
}
|
||||
|
||||
entry selection,
|
||||
spinbutton selection {
|
||||
background: #958831;
|
||||
color: #1E221B;
|
||||
}
|
||||
|
||||
spinbutton button {
|
||||
min-height: 0;
|
||||
min-width: 18px;
|
||||
padding: 0 3px;
|
||||
margin: 1px;
|
||||
border-radius: 0;
|
||||
background: #4C5844;
|
||||
border: 1px solid #242921;
|
||||
border-top-color: #6A7862;
|
||||
border-left-color: #6A7862;
|
||||
}
|
||||
|
||||
spinbutton button:hover {
|
||||
background: #5B684D;
|
||||
}
|
||||
|
||||
combobox button.combo,
|
||||
fontbutton button {
|
||||
min-height: 22px;
|
||||
padding: 1px 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
combobox arrow {
|
||||
color: #C4B550;
|
||||
min-width: 14px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Scrollbars */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
scrollbar {
|
||||
background: #2C3227;
|
||||
border: none;
|
||||
}
|
||||
|
||||
scrollbar trough {
|
||||
background: #2C3227;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
scrollbar slider {
|
||||
min-width: 11px;
|
||||
min-height: 24px;
|
||||
margin: 1px;
|
||||
border-radius: 1px;
|
||||
background: #5C6850;
|
||||
border: 1px solid #242921;
|
||||
border-top-color: #6A7862;
|
||||
border-left-color: #6A7862;
|
||||
}
|
||||
|
||||
scrollbar slider:hover {
|
||||
background: #68755A;
|
||||
}
|
||||
|
||||
separator {
|
||||
background: #242921;
|
||||
min-height: 1px;
|
||||
min-width: 1px;
|
||||
}
|
||||
+12
-3
@@ -25,6 +25,11 @@ type Resources struct {
|
||||
vad *sherpa.VoiceActivityDetector
|
||||
}
|
||||
|
||||
func Validate(settings config.Settings) error {
|
||||
_, err := validatePaths(settings.ModelsDir, settings.Mode)
|
||||
return err
|
||||
}
|
||||
|
||||
func New(settings config.Settings) (*Resources, error) {
|
||||
paths, err := validatePaths(settings.ModelsDir, settings.Mode)
|
||||
if err != nil {
|
||||
@@ -52,7 +57,7 @@ func New(settings config.Settings) (*Resources, error) {
|
||||
|
||||
resources := &Resources{recognizer: recognizer}
|
||||
if settings.Mode == config.ModeVAD {
|
||||
resources.vad = newVAD(paths.vad, settings.Threads)
|
||||
resources.vad = newVAD(paths.vad, settings.Threads, settings.VADThreshold)
|
||||
if resources.vad == nil {
|
||||
resources.Close()
|
||||
return nil, fmt.Errorf("create Silero VAD: Sherpa returned nil")
|
||||
@@ -87,6 +92,10 @@ func (r *Resources) AcceptVAD(samples []float32) {
|
||||
r.vad.AcceptWaveform(samples)
|
||||
}
|
||||
|
||||
func (r *Resources) SpeechActive() bool {
|
||||
return r.vad.IsSpeech()
|
||||
}
|
||||
|
||||
func (r *Resources) FlushVAD() {
|
||||
r.vad.Flush()
|
||||
}
|
||||
@@ -127,11 +136,11 @@ func validatePaths(modelsDir string, mode config.Mode) (modelPaths, error) {
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func newVAD(model string, threads int) *sherpa.VoiceActivityDetector {
|
||||
func newVAD(model string, threads int, threshold float64) *sherpa.VoiceActivityDetector {
|
||||
return sherpa.NewVoiceActivityDetector(&sherpa.VadModelConfig{
|
||||
SileroVad: sherpa.SileroVadModelConfig{
|
||||
Model: model,
|
||||
Threshold: 0.5,
|
||||
Threshold: float32(threshold),
|
||||
// A 500 ms pause ends the current caption line and starts the next one.
|
||||
MinSilenceDuration: 0.5,
|
||||
MinSpeechDuration: 0.25,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Package history retains a bounded in-memory caption transcript for the GUI.
|
||||
package history
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
CreatedAt time.Time
|
||||
Text string
|
||||
StartedAt time.Duration
|
||||
EndedAt time.Duration
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Finals []Entry
|
||||
Provisional *Entry
|
||||
}
|
||||
|
||||
type Sink struct {
|
||||
mu sync.Mutex
|
||||
capacity int
|
||||
enabled bool
|
||||
finals []Entry
|
||||
provisional *Entry
|
||||
subscribers map[chan Snapshot]struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(capacity int, enabled bool) *Sink {
|
||||
if capacity < 1 {
|
||||
capacity = 1
|
||||
}
|
||||
return &Sink{
|
||||
capacity: capacity,
|
||||
enabled: enabled,
|
||||
subscribers: make(map[chan Snapshot]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sink) Publish(event captions.Event) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return errors.New("caption history is closed")
|
||||
}
|
||||
if !s.enabled {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
switch event.Kind {
|
||||
case captions.Provisional:
|
||||
s.provisional = &Entry{CreatedAt: now, Text: event.Text, StartedAt: event.StartedAt, EndedAt: event.EndedAt}
|
||||
case captions.Final:
|
||||
s.provisional = nil
|
||||
s.finals = append(s.finals, Entry{CreatedAt: now, Text: event.Text, StartedAt: event.StartedAt, EndedAt: event.EndedAt})
|
||||
if len(s.finals) > s.capacity {
|
||||
s.finals = append([]Entry(nil), s.finals[len(s.finals)-s.capacity:]...)
|
||||
}
|
||||
case captions.Hide:
|
||||
s.provisional = nil
|
||||
}
|
||||
s.publishLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Sink) SetEnabled(enabled bool) {
|
||||
s.mu.Lock()
|
||||
s.enabled = enabled
|
||||
if !enabled {
|
||||
s.provisional = nil
|
||||
}
|
||||
s.publishLocked()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Sink) Clear() {
|
||||
s.mu.Lock()
|
||||
s.finals = nil
|
||||
s.provisional = nil
|
||||
s.publishLocked()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Sink) Snapshot() Snapshot {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.snapshotLocked()
|
||||
}
|
||||
|
||||
func (s *Sink) Transcript() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
lines := make([]string, len(s.finals))
|
||||
for index, entry := range s.finals {
|
||||
lines[index] = entry.Text
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (s *Sink) Subscribe(buffer int) (<-chan Snapshot, func()) {
|
||||
if buffer < 1 {
|
||||
buffer = 1
|
||||
}
|
||||
updates := make(chan Snapshot, buffer)
|
||||
s.mu.Lock()
|
||||
s.subscribers[updates] = struct{}{}
|
||||
updates <- s.snapshotLocked()
|
||||
s.mu.Unlock()
|
||||
return updates, func() {
|
||||
s.mu.Lock()
|
||||
if _, ok := s.subscribers[updates]; ok {
|
||||
delete(s.subscribers, updates)
|
||||
close(updates)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sink) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
for subscriber := range s.subscribers {
|
||||
close(subscriber)
|
||||
}
|
||||
s.subscribers = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Sink) snapshotLocked() Snapshot {
|
||||
snapshot := Snapshot{Finals: append([]Entry(nil), s.finals...)}
|
||||
if s.provisional != nil {
|
||||
copy := *s.provisional
|
||||
snapshot.Provisional = ©
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (s *Sink) publishLocked() {
|
||||
snapshot := s.snapshotLocked()
|
||||
for subscriber := range s.subscribers {
|
||||
select {
|
||||
case subscriber <- snapshot:
|
||||
default:
|
||||
select {
|
||||
case <-subscriber:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case subscriber <- snapshot:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
func TestHistoryKeepsFinalsBoundedAndProvisionalSeparate(t *testing.T) {
|
||||
sink := New(2, true)
|
||||
_ = sink.Publish(captions.Event{Kind: captions.Provisional, Text: "draft"})
|
||||
if sink.Snapshot().Provisional == nil {
|
||||
t.Fatal("provisional caption was not retained")
|
||||
}
|
||||
for _, text := range []string{"one", "two", "three"} {
|
||||
_ = sink.Publish(captions.Event{Kind: captions.Final, Text: text})
|
||||
}
|
||||
snapshot := sink.Snapshot()
|
||||
if snapshot.Provisional != nil || len(snapshot.Finals) != 2 || snapshot.Finals[0].Text != "two" {
|
||||
t.Fatalf("snapshot = %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledHistoryKeepsExistingFinalsButStopsRecording(t *testing.T) {
|
||||
sink := New(10, true)
|
||||
_ = sink.Publish(captions.Event{Kind: captions.Final, Text: "kept"})
|
||||
sink.SetEnabled(false)
|
||||
_ = sink.Publish(captions.Event{Kind: captions.Final, Text: "ignored"})
|
||||
if got := sink.Transcript(); got != "kept" {
|
||||
t.Fatalf("transcript = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
package overlay
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk4 gtk4-x11 gtk4-wayland gtk4-layer-shell-0
|
||||
#cgo LDFLAGS: -lX11
|
||||
#cgo pkg-config: gtk4-layer-shell-0
|
||||
#cgo pkg-config: gtk4 gtk4-x11 gtk4-wayland
|
||||
#cgo LDFLAGS: -Wl,--no-as-needed -lgtk4-layer-shell -lX11
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
#include <gtk4-layer-shell.h>
|
||||
@@ -13,6 +14,7 @@ package overlay
|
||||
#include <gdk/wayland/gdkwayland.h>
|
||||
#include <X11/Xlib.h>
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/Xutil.h>
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -188,10 +190,24 @@ static gboolean captioneer_reposition_x11(gpointer data) {
|
||||
gdk_monitor_get_geometry(overlay->monitor, &geometry);
|
||||
int width = gdk_surface_get_width(surface);
|
||||
int height = gdk_surface_get_height(surface);
|
||||
if (width <= 1 || height <= 1) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
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);
|
||||
Display *display = GDK_SURFACE_XDISPLAY(surface);
|
||||
Window xid = GDK_SURFACE_XID(surface);
|
||||
XSizeHints *size_hints = XAllocSizeHints();
|
||||
if (size_hints != NULL) {
|
||||
size_hints->flags = USPosition | PPosition;
|
||||
size_hints->x = x;
|
||||
size_hints->y = y;
|
||||
XSetWMNormalHints(display, xid, size_hints);
|
||||
XFree(size_hints);
|
||||
}
|
||||
XMoveWindow(display, xid, x, y);
|
||||
XFlush(display);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
@@ -276,7 +292,9 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
||||
) {
|
||||
*warning_message = NULL;
|
||||
*error_message = NULL;
|
||||
if (g_get_prgname() == NULL) {
|
||||
g_set_prgname("com.chunkbyte.captioneer.overlay");
|
||||
}
|
||||
if (g_strcmp0(backend, "auto") != 0) {
|
||||
g_setenv("GDK_BACKEND", backend, TRUE);
|
||||
}
|
||||
@@ -325,6 +343,11 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
||||
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);
|
||||
// Stretch across the bottom edge so the compositor pins the surface
|
||||
// there (single-edge anchoring can leave it floating mid-screen on
|
||||
// Plasma). The bubble itself stays content-width and centered.
|
||||
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_LEFT, TRUE);
|
||||
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_RIGHT, TRUE);
|
||||
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);
|
||||
@@ -334,7 +357,7 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
||||
}
|
||||
} 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.");
|
||||
"Layer Shell is unavailable in this process (often a libwayland load-order problem); using a normal window without guaranteed always-on-top or placement. Try rebuilding so libgtk4-layer-shell loads before libwayland-client, or set overlay backend to x11.");
|
||||
}
|
||||
|
||||
GdkMonitor *size_monitor = overlay->monitor;
|
||||
@@ -361,6 +384,9 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
||||
overlay->bubble = box;
|
||||
gtk_widget_add_css_class(box, "captioneer-bubble");
|
||||
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
|
||||
gtk_widget_set_valign(box, GTK_ALIGN_END);
|
||||
gtk_widget_set_hexpand(box, FALSE);
|
||||
gtk_widget_set_vexpand(box, FALSE);
|
||||
gtk_widget_set_size_request(box, effective_width, -1);
|
||||
|
||||
overlay->final_label = gtk_label_new("");
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
mu sync.RWMutex
|
||||
sinks map[string]Sink
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewRouter() *Router {
|
||||
return &Router{sinks: make(map[string]Sink)}
|
||||
}
|
||||
|
||||
func (r *Router) Set(name string, sink Sink) error {
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
if sink != nil {
|
||||
_ = sink.Close()
|
||||
}
|
||||
return errors.New("output router is closed")
|
||||
}
|
||||
previous := r.sinks[name]
|
||||
if sink == nil {
|
||||
delete(r.sinks, name)
|
||||
} else {
|
||||
r.sinks[name] = sink
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if previous != nil && previous != sink {
|
||||
return previous.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) Remove(name string) error {
|
||||
return r.Set(name, nil)
|
||||
}
|
||||
|
||||
func (r *Router) Publish(event captions.Event) error {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
names := make([]string, 0, len(r.sinks))
|
||||
for name := range r.sinks {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var publishErr error
|
||||
for _, name := range names {
|
||||
publishErr = errors.Join(publishErr, r.sinks[name].Publish(event))
|
||||
}
|
||||
return publishErr
|
||||
}
|
||||
|
||||
func (r *Router) Close() error {
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
r.closed = true
|
||||
names := make([]string, 0, len(r.sinks))
|
||||
for name := range r.sinks {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(names)))
|
||||
sinks := make([]Sink, 0, len(names))
|
||||
for _, name := range names {
|
||||
sinks = append(sinks, r.sinks[name])
|
||||
}
|
||||
r.sinks = nil
|
||||
r.mu.Unlock()
|
||||
|
||||
var closeErr error
|
||||
for _, sink := range sinks {
|
||||
closeErr = errors.Join(closeErr, sink.Close())
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
@@ -47,3 +47,33 @@ func TestMultiSinkStillPublishesToOtherSinksAfterError(t *testing.T) {
|
||||
t.Fatalf("unexpected result: err=%v second=%d", err, len(second.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterCanReplaceAndRemoveOutputsWhileRunning(t *testing.T) {
|
||||
router := NewRouter()
|
||||
first := &recordingSink{}
|
||||
second := &recordingSink{}
|
||||
if err := router.Set("history", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := router.Publish(captions.Event{Kind: captions.Final, Text: "first"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := router.Set("history", second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.closed {
|
||||
t.Fatal("replaced sink was not closed")
|
||||
}
|
||||
if err := router.Publish(captions.Event{Kind: captions.Final, Text: "second"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first.events) != 1 || len(second.events) != 1 {
|
||||
t.Fatalf("unexpected routed events: first=%d second=%d", len(first.events), len(second.events))
|
||||
}
|
||||
if err := router.Remove("history"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !second.closed {
|
||||
t.Fatal("removed sink was not closed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Package transcript writes finalized captions to timestamped session logs.
|
||||
package transcript
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
type Sink struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
now func() time.Time
|
||||
closed bool
|
||||
path string
|
||||
}
|
||||
|
||||
func New(directory string) (*Sink, error) {
|
||||
return newWithClock(directory, time.Now)
|
||||
}
|
||||
|
||||
func newWithClock(directory string, now func() time.Time) (*Sink, error) {
|
||||
if strings.TrimSpace(directory) == "" {
|
||||
return nil, errors.New("transcript directory cannot be empty")
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create transcript directory: %w", err)
|
||||
}
|
||||
startedAt := now()
|
||||
base := "captioneer-" + startedAt.Format("2006-01-02_15-04-05.000")
|
||||
var file *os.File
|
||||
var path string
|
||||
for suffix := 0; suffix < 1000; suffix++ {
|
||||
name := base + ".log"
|
||||
if suffix > 0 {
|
||||
name = fmt.Sprintf("%s-%d.log", base, suffix)
|
||||
}
|
||||
path = filepath.Join(directory, name)
|
||||
candidate, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create transcript: %w", err)
|
||||
}
|
||||
file = candidate
|
||||
break
|
||||
}
|
||||
if file == nil {
|
||||
return nil, errors.New("create transcript: too many files share this timestamp")
|
||||
}
|
||||
return &Sink{file: file, writer: bufio.NewWriter(file), now: now, path: path}, nil
|
||||
}
|
||||
|
||||
func (s *Sink) Path() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s *Sink) Publish(event captions.Event) error {
|
||||
if event.Kind != captions.Final {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return errors.New("transcript is closed")
|
||||
}
|
||||
text := strings.Join(strings.Fields(event.Text), " ")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := fmt.Fprintf(
|
||||
s.writer,
|
||||
"%s [%.2fs-%.2fs] %s\n",
|
||||
s.now().Format(time.RFC3339Nano),
|
||||
event.StartedAt.Seconds(),
|
||||
event.EndedAt.Seconds(),
|
||||
text,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writer.Flush()
|
||||
}
|
||||
|
||||
func (s *Sink) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return errors.Join(s.writer.Flush(), s.file.Close())
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package transcript
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
func TestTranscriptWritesOnlyFinalCaptionsWithWallClockAndOffsets(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 12, 34, 56, 123000000, time.UTC)
|
||||
sink, err := newWithClock(t.TempDir(), func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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: "hello\nthere", StartedAt: time.Second, EndedAt: 2500 * time.Millisecond,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(sink.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "2026-07-17T12:34:56.123Z [1.00s-2.50s] hello there\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("transcript = %q, want %q", data, want)
|
||||
}
|
||||
if !strings.Contains(sink.Path(), "captioneer-2026-07-17_12-34-56.123.log") {
|
||||
t.Fatalf("unexpected transcript path %q", sink.Path())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user