From 54fb100d3c5871db61d5a2303c2020c327bad0b5 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Fri, 17 Jul 2026 14:41:10 +0300 Subject: [PATCH] docs: update README with detailed desktop features and setup requirements --- README.md | 85 +++- assets/icon/icon.png | Bin 0 -> 96956 bytes go.mod | 11 +- go.sum | 8 + scripts/ci/package-release.sh | 2 + scripts/distrobox/common.sh | 1 + src/app/controller.go | 215 ++++++++ src/app/controller_test.go | 81 +++ src/app/run.go | 34 +- src/app/status.go | 37 ++ src/app/supervisor.go | 37 +- src/app/worker_protocol.go | 34 +- src/captions/processor.go | 38 +- src/capture/pulse.go | 22 +- src/cmd/captioneer-desktop/main.go | 66 +-- src/cmd/captioneer/main.go | 14 +- src/config/cli.go | 192 ++++++++ src/config/cli_test.go | 80 +++ src/config/config.go | 157 +++--- src/config/store.go | 235 +++++++++ src/config/store_test.go | 77 +++ src/diagnostics/diagnostics.go | 150 ++++++ src/diagnostics/diagnostics_test.go | 20 + src/gui/app.go | 597 +++++++++++++++++++++++ src/gui/pages.go | 208 ++++++++ src/gui/platform_gtk.go | 84 ++++ src/gui/settings.go | 470 ++++++++++++++++++ src/gui/style.css | 218 +++++++++ src/output/history/history.go | 164 +++++++ src/output/history/history_test.go | 32 ++ src/output/overlay/overlay_gtk.go | 4 +- src/output/router.go | 86 ++++ src/output/sink_test.go | 30 ++ src/output/transcript/transcript.go | 102 ++++ src/output/transcript/transcript_test.go | 40 ++ 35 files changed, 3469 insertions(+), 162 deletions(-) create mode 100644 assets/icon/icon.png create mode 100644 src/app/controller.go create mode 100644 src/app/controller_test.go create mode 100644 src/app/status.go create mode 100644 src/config/cli.go create mode 100644 src/config/cli_test.go create mode 100644 src/config/store.go create mode 100644 src/config/store_test.go create mode 100644 src/diagnostics/diagnostics.go create mode 100644 src/diagnostics/diagnostics_test.go create mode 100644 src/gui/app.go create mode 100644 src/gui/pages.go create mode 100644 src/gui/platform_gtk.go create mode 100644 src/gui/settings.go create mode 100644 src/gui/style.css create mode 100644 src/output/history/history.go create mode 100644 src/output/history/history_test.go create mode 100644 src/output/router.go create mode 100644 src/output/transcript/transcript.go create mode 100644 src/output/transcript/transcript_test.go diff --git a/README.md b/README.md index f041e70..fe4ccfc 100644 --- a/README.md +++ b/README.md @@ -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`. Building the desktop command from source additionally needs GTK4 and GTK4 Layer Shell development libraries. Published release archives carry the desktop runtime libraries. PipeWire works through its PulseAudio compatibility layer. +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: @@ -58,19 +58,27 @@ kill -HUP An automatic or manual restart begins a fresh capture/transcription session; audio being processed by the failed worker cannot be recovered. -## Desktop overlay +## 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. @@ -78,11 +86,11 @@ The expensive Parakeet recognizer stays idle during silence and audio that Siler 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 @@ -97,13 +105,20 @@ 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. | @@ -117,7 +132,13 @@ Layer Shell and X11 hints are requests, not universal guarantees. Same-layer win | 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. | @@ -132,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 @@ -143,10 +164,28 @@ Examples: --output=overlay --overlay-monitor=DP-1 # Be more conservative when instrumental music causes false speech detections -./scripts/distrobox/run.sh --mode=vad --output=overlay \ +./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. @@ -154,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: @@ -173,7 +212,7 @@ 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 @@ -183,7 +222,10 @@ Captioneer itself has no environment-based application configuration; use the co - **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. +- **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. @@ -194,8 +236,13 @@ The transcription pipeline emits presentation-neutral events. `output/terminal` ```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 ``` @@ -240,4 +287,4 @@ The release workflow uses Gitea's built-in `GITEA_TOKEN`; no personal token is r 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. diff --git a/assets/icon/icon.png b/assets/icon/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f864958855cf4f81c2274697ecc973aee27d0b6f GIT binary patch literal 96956 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 Y#-T?Sst17v5sDvd_N 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 p.settings.PreviewThresholdDB) return p.acceptFixed(samples) } return p.acceptVAD(samples) @@ -84,6 +98,7 @@ func (p *Processor) acceptVAD(samples []float32) error { p.transcriber.AcceptVAD(samples) speechActive := p.transcriber.SpeechActive() + p.setSpeechActive(speechActive) if speechActive && (p.previewActive || audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB) { if !p.previewActive { p.previewActive = true @@ -144,7 +159,7 @@ func (p *Processor) resetPreview() { } func (p *Processor) emitProvisional() error { - text := strings.TrimSpace(p.transcriber.Decode(p.previewSamples)) + text := strings.TrimSpace(p.decode(p.previewSamples)) if text == "" { return nil } @@ -157,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}) } @@ -169,6 +184,25 @@ func (p *Processor) emitFinal(start, end int, samples []float32) error { }) } +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}) diff --git a/src/capture/pulse.go b/src/capture/pulse.go index 961ec0f..e4e7760 100644 --- a/src/capture/pulse.go +++ b/src/capture/pulse.go @@ -33,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", @@ -41,7 +50,7 @@ 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 { @@ -65,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 } @@ -78,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 @@ -93,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() + } } diff --git a/src/cmd/captioneer-desktop/main.go b/src/cmd/captioneer-desktop/main.go index bdf899d..7836ce2 100644 --- a/src/cmd/captioneer-desktop/main.go +++ b/src/cmd/captioneer-desktop/main.go @@ -3,20 +3,15 @@ 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() { @@ -24,58 +19,15 @@ func main() { os.Exit(app.RunModelWorker(os.Stderr)) } - 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) - } + launch, err := config.ParseDesktop(os.Args[1:], os.Stderr) + if errors.Is(err, flag.ErrHelp) { 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) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) } - 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) - } + runtime.LockOSThread() + os.Exit(gui.Run(launch, os.Stdout, os.Stderr)) } diff --git a/src/cmd/captioneer/main.go b/src/cmd/captioneer/main.go index f2a024b..ca0d47d 100644 --- a/src/cmd/captioneer/main.go +++ b/src/cmd/captioneer/main.go @@ -2,6 +2,9 @@ package main import ( "context" + "errors" + "flag" + "fmt" "log" "os" "os/signal" @@ -17,10 +20,17 @@ func main() { os.Exit(app.RunModelWorker(os.Stderr)) } - settings := config.Parse() - if err := settings.Validate(); err != nil { + 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() diff --git a/src/config/cli.go b/src/config/cli.go new file mode 100644 index 0000000..cbf4107 --- /dev/null +++ b/src/config/cli.go @@ -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 + } +} diff --git a/src/config/cli_test.go b/src/config/cli_test.go new file mode 100644 index 0000000..b248a53 --- /dev/null +++ b/src/config/cli_test.go @@ -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) + } +} diff --git a/src/config/config.go b/src/config/config.go index 009d466..4b7f10b 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -3,12 +3,12 @@ package config import ( "errors" - "flag" "strings" "time" ) const ( + CurrentConfigVersion = 1 DefaultModelsDir = "models" DefaultVADThreshold = 0.5 DefaultOverlayFontFamily = "Sans" @@ -36,21 +36,18 @@ type Settings struct { 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 gate below which recognition stays idle") - flag.Float64Var(&settings.VADThreshold, "vad-threshold", DefaultVADThreshold, "speech detection sensitivity from 0 to 1; higher rejects more non-speech") - flag.BoolVar(&settings.ModelAutoRestart, "model-auto-restart", true, "restart the isolated model worker after a crash or timeout") - flag.DurationVar(&settings.ModelTimeout, "model-timeout", DefaultModelTimeout, "maximum model startup or processing time; zero disables hang detection") - flag.DurationVar(&settings.ModelShutdownTimeout, "model-shutdown-timeout", DefaultModelShutdownTimeout, "time allowed for model flush and cleanup before force-killing the worker") - flag.Parse() - 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 @@ -81,44 +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 gate below which recognition stays idle") - flags.Float64Var(&desktop.VADThreshold, "vad-threshold", DefaultVADThreshold, "speech detection sensitivity from 0 to 1; higher rejects more non-speech") - flags.BoolVar(&desktop.ModelAutoRestart, "model-auto-restart", true, "restart the isolated model worker after a crash or timeout") - flags.DurationVar(&desktop.ModelTimeout, "model-timeout", DefaultModelTimeout, "maximum model startup or processing time; zero disables hang detection") - flags.DurationVar(&desktop.ModelShutdownTimeout, "model-shutdown-timeout", DefaultModelShutdownTimeout, "time allowed for model flush and cleanup before force-killing the worker") - flags.StringVar(&output, "output", "", "caption output: terminal, overlay, or both (required)") - flags.StringVar(&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") @@ -148,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 +} diff --git a/src/config/store.go b/src/config/store.go new file mode 100644 index 0000000..912b504 --- /dev/null +++ b/src/config/store.go @@ -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 +} diff --git a/src/config/store_test.go b/src/config/store_test.go new file mode 100644 index 0000000..8b73071 --- /dev/null +++ b/src/config/store_test.go @@ -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) + } +} diff --git a/src/diagnostics/diagnostics.go b/src/diagnostics/diagnostics.go new file mode 100644 index 0000000..6e6b409 --- /dev/null +++ b/src/diagnostics/diagnostics.go @@ -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 +} diff --git a/src/diagnostics/diagnostics_test.go b/src/diagnostics/diagnostics_test.go new file mode 100644 index 0000000..4566059 --- /dev/null +++ b/src/diagnostics/diagnostics_test.go @@ -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") + } +} diff --git a/src/gui/app.go b/src/gui/app.go new file mode 100644 index 0000000..fc7c37f --- /dev/null +++ b/src/gui/app.go @@ -0,0 +1,597 @@ +//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 + statusbarLabel *gtk.Label + captionBuffer *gtk.TextBuffer + captionView *gtk.TextView + captionScroll *gtk.ScrolledWindow + provisionalLabel *gtk.Label + consoleBuffer *gtk.TextBuffer + consoleView *gtk.TextView + consoleScroll *gtk.ScrolledWindow + 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(27) + 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) + 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.AddCSSClass("captioneer-tabs") + tabs.SetHAlign(gtk.AlignFill) + root.Append(tabs) + + 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, 8) + statusbar.AddCSSClass("statusbar") + a.statusbarLabel = gtk.NewLabel("Source: not connected · Speech: quiet · Outputs: preparing") + a.statusbarLabel.SetXAlign(0) + a.statusbarLabel.SetEllipsize(3) + statusbar.Append(a.statusbarLabel) + 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{"F5"}) + a.gtkApp.SetAccelsForAction("app.restart", []string{"r"}) + a.gtkApp.SetAccelsForAction("app.view-captions", []string{"1"}) + a.gtkApp.SetAccelsForAction("app.view-console", []string{"2"}) + a.gtkApp.SetAccelsForAction("app.view-settings", []string{"3"}) + a.gtkApp.SetAccelsForAction("app.exit", []string{"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" + } + overload := "" + if overloaded { + overload = " · OVERLOADED" + } + a.statusbarLabel.SetText(fmt.Sprintf( + "Source: %s · Speech: %s · Outputs: %s%s", + source, + speech, + a.outputSummary(), + overload, + )) + 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 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 "" +} diff --git a/src/gui/pages.go b/src/gui/pages.go new file mode 100644 index 0000000..7db98be --- /dev/null +++ b/src/gui/pages.go @@ -0,0 +1,208 @@ +//go:build gtk + +package gui + +import ( + "fmt" + "strings" + "time" + + "github.com/diamondburned/gotk4/pkg/gdk/v4" + "github.com/diamondburned/gotk4/pkg/gtk/v4" + "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.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", "section-title") + caption.SetVAlign(gtk.AlignStart) + 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.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 +} + +func (a *Application) renderHistory(snapshot history.Snapshot) { + follow := isAtBottom(a.captionScroll) + var text strings.Builder + for _, entry := range snapshot.Finals { + fmt.Fprintf(&text, "%s [%s–%s] %s\n", + entry.CreatedAt.Format("15:04:05"), + formatCaptionTime(entry.StartedAt), + formatCaptionTime(entry.EndedAt), + entry.Text, + ) + } + a.captionBuffer.SetText(text.String()) + 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 + for _, entry := range a.log.Entries() { + fmt.Fprintf(&text, "%s %-7s %-14s %s\n", + entry.At.Format("15:04:05.000"), + strings.ToUpper(string(entry.Severity)), + "["+entry.Category+"]", + entry.Message, + ) + } + a.consoleBuffer.SetText(text.String()) + 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 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 +} diff --git a/src/gui/platform_gtk.go b/src/gui/platform_gtk.go new file mode 100644 index 0000000..56f44c8 --- /dev/null +++ b/src/gui/platform_gtk.go @@ -0,0 +1,84 @@ +//go:build gtk + +package gui + +/* +#cgo pkg-config: gtk4 gtk4-x11 gtk4-wayland x11 + +#include +#include +#include +#include +#include + +#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 +} diff --git a/src/gui/settings.go b/src/gui/settings.go new file mode 100644 index 0000000..3ad0e25 --- /dev/null +++ b/src/gui/settings.go @@ -0,0 +1,470 @@ +//go:build gtk + +package gui + +import ( + "context" + "fmt" + "strings" + "time" + + "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" +) + +type settingsControls struct { + startCaptions *gtk.Switch + alwaysOnTop *gtk.Switch + + mode *gtk.ComboBoxText + chunkDuration *gtk.Entry + modelsDirectory *gtk.Entry + threads *gtk.SpinButton + previewThresholdDB *gtk.SpinButton + vadThreshold *gtk.SpinButton + + overlayEnabled *gtk.Switch + historyEnabled *gtk.Switch + stdoutEnabled *gtk.Switch + stderrEnabled *gtk.Switch + transcriptEnabled *gtk.Switch + 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.Switch + + overlayBackend *gtk.ComboBoxText + modelAutoRestart *gtk.Switch + 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.NewSwitch() + a.settings.alwaysOnTop = gtk.NewSwitch() + 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.NewSwitch() + a.settings.historyEnabled = gtk.NewSwitch() + a.settings.stdoutEnabled = gtk.NewSwitch() + a.settings.stderrEnabled = gtk.NewSwitch() + a.settings.transcriptEnabled = gtk.NewSwitch() + 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.NewSwitch() + + a.settings.overlayBackend = combo([]option{{"auto", "Automatic"}, {"wayland", "Wayland"}, {"x11", "X11"}}) + a.settings.modelAutoRestart = gtk.NewSwitch() + 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) + switches := []*gtk.Switch{ + 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 switches { + control.ConnectStateSet(func(bool) bool { + glib.IdleAdd(changed) + return false + }) + } +} + +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) + 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 +} diff --git a/src/gui/style.css b/src/gui/style.css new file mode 100644 index 0000000..d7010c1 --- /dev/null +++ b/src/gui/style.css @@ -0,0 +1,218 @@ +window.captioneer-main { + background: #30372f; + color: #e0e5d7; +} + +headerbar.captioneer-header { + min-height: 40px; + padding: 4px 7px; + background-image: linear-gradient(to bottom, #4a5543, #2b332c); + border-bottom: 1px solid #171d18; + box-shadow: inset 0 1px #748069; +} + +.captioneer-brand { + color: #e8ebdf; + font-weight: 700; + letter-spacing: 1px; +} + +.status-text { + color: #cbd3bd; + font-weight: 600; +} + +.status-dot { + font-size: 16px; +} + +.status-dot.status-running { color: #9eb96b; } +.status-dot.status-stopped { color: #7f8977; } +.status-dot.status-warning { color: #dfc078; } +.status-dot.status-error { color: #e58c7c; } + +.captioneer-menu { + min-height: 24px; + padding: 0 4px; + background: #3a4337; + border-bottom: 1px solid #171d18; +} + +.captioneer-tabs { + min-height: 34px; + padding: 3px 8px 0; + background: #353d33; + border-bottom: 1px solid #171d18; +} + +.captioneer-tabs button { + min-height: 26px; + padding: 3px 16px; + border-radius: 2px 2px 0 0; + background-image: linear-gradient(to bottom, #56604d, #414a3d); + border: 1px solid #1d241e; + border-top-color: #748069; + border-left-color: #68745e; + color: #c9d0bf; + box-shadow: none; +} + +.captioneer-tabs button:checked { + background: #515b49; + color: #f0f2ea; + border-bottom-color: #515b49; +} + +.captioneer-content { + padding: 10px; + background: #515b49; +} + +.inset-panel, +textview, +entry, +spinbutton, +combobox, +fontbutton { + background: #252c26; + color: #e0e5d7; + border: 1px solid #171d18; + border-bottom-color: #748069; + border-right-color: #68745e; + border-radius: 1px; +} + +textview text { + background: #252c26; + color: #e0e5d7; +} + +.console-view text { + background: #202621; + color: #c5cfb9; + font-family: Monospace; + font-size: 10pt; +} + +.provisional-row { + min-height: 34px; + padding: 6px 10px; + background: #394237; + color: #cbd5b8; + border: 1px solid #1a201b; + font-style: italic; +} + +.toolbar { + padding: 4px; + background: #424b3e; + border: 1px solid #202720; +} + +button { + min-height: 24px; + padding: 3px 9px; + border-radius: 2px; + background-image: linear-gradient(to bottom, #657058, #46503f); + color: #edf0e6; + border: 1px solid #1a201b; + border-top-color: #7d896f; + border-left-color: #748069; + box-shadow: none; +} + +button:hover { + background-image: linear-gradient(to bottom, #758064, #515c48); +} + +button:active, +button:checked { + background: #354033; + border-top-color: #171d18; + border-left-color: #171d18; + border-bottom-color: #748069; + border-right-color: #68745e; +} + +button:disabled, +switch:disabled, +entry:disabled, +spinbutton:disabled, +combobox:disabled { + color: #7f8977; + background: #363d35; +} + +button:focus-visible, +entry:focus-visible, +spinbutton:focus-visible, +combobox:focus-visible, +switch:focus-visible, +textview:focus-visible { + outline: 2px solid #d5dea7; + outline-offset: -2px; +} + +.primary-action { + background-image: linear-gradient(to bottom, #899b5f, #637344); + color: #ffffff; + font-weight: 700; +} + +.danger-action { + background-image: linear-gradient(to bottom, #9e6556, #71463e); +} + +.section-title { + color: #eef1e8; + font-weight: 700; + letter-spacing: 0.5px; +} + +.description { + color: #adb7a1; + font-size: 9.5pt; +} + +.validation-error { + color: #e5a192; + font-weight: 600; +} + +.warning-text { + color: #dfc078; +} + +.statusbar { + min-height: 25px; + padding: 3px 8px; + background: #343c32; + color: #adb7a1; + border-top: 1px solid #748069; +} + +.settings-sidebar row { + min-width: 130px; + padding: 7px 10px; + border-radius: 1px; +} + +.settings-panel { + background: #454f41; + border: 1px solid #1d241e; +} + +.setting-row { + min-height: 48px; + padding: 6px 8px; + background: #4b5546; + border: 1px solid #252c26; +} + +scrollbar slider { + min-width: 12px; + min-height: 12px; + border-radius: 1px; + background: #68745e; + border: 1px solid #222921; +} diff --git a/src/output/history/history.go b/src/output/history/history.go new file mode 100644 index 0000000..8ce9dd6 --- /dev/null +++ b/src/output/history/history.go @@ -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: + } + } + } +} diff --git a/src/output/history/history_test.go b/src/output/history/history_test.go new file mode 100644 index 0000000..f761106 --- /dev/null +++ b/src/output/history/history_test.go @@ -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) + } +} diff --git a/src/output/overlay/overlay_gtk.go b/src/output/overlay/overlay_gtk.go index b235283..08fb51d 100644 --- a/src/output/overlay/overlay_gtk.go +++ b/src/output/overlay/overlay_gtk.go @@ -276,7 +276,9 @@ static CaptioneerOverlay *captioneer_overlay_new( ) { *warning_message = NULL; *error_message = NULL; - g_set_prgname("com.chunkbyte.captioneer.overlay"); + if (g_get_prgname() == NULL) { + g_set_prgname("com.chunkbyte.captioneer.overlay"); + } if (g_strcmp0(backend, "auto") != 0) { g_setenv("GDK_BACKEND", backend, TRUE); } diff --git a/src/output/router.go b/src/output/router.go new file mode 100644 index 0000000..745f0d9 --- /dev/null +++ b/src/output/router.go @@ -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 +} diff --git a/src/output/sink_test.go b/src/output/sink_test.go index 915cb7f..2e74a58 100644 --- a/src/output/sink_test.go +++ b/src/output/sink_test.go @@ -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") + } +} diff --git a/src/output/transcript/transcript.go b/src/output/transcript/transcript.go new file mode 100644 index 0000000..475cb74 --- /dev/null +++ b/src/output/transcript/transcript.go @@ -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()) +} diff --git a/src/output/transcript/transcript_test.go b/src/output/transcript/transcript_test.go new file mode 100644 index 0000000..1fb185b --- /dev/null +++ b/src/output/transcript/transcript_test.go @@ -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()) + } +}