From 3df5a483b6a49d4730eb424c4f5428e15c1a164c Mon Sep 17 00:00:00 2001 From: Nutchanon Ninyawee Date: Thu, 18 Jun 2026 18:27:37 +0700 Subject: [PATCH 1/2] feat(contrib): GNOME Shell waveform OSD for the mic-osd gap on Mutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hyprwhspr's animated mic-osd needs gtk4-layer-shell, which GNOME/Mutter does not implement, so GNOME users only get notification status. This adds a small GNOME Shell extension that draws the overlay inside gnome-shell instead — focus-safe (never steals focus from the dictation target) and audio-reactive. It is a pure status consumer of the files hyprwhspr already writes (recording_status, audio_level, transcript_preview) — no audio capture, no extra runtime deps. Ships under contrib/ with an install.sh and README; not wired into the main install. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LjyBMNZ4CxXbNHcYM4pzoA --- contrib/gnome-shell-extension/README.md | 58 +++++ .../extension.js | 199 ++++++++++++++++++ .../metadata.json | 8 + .../stylesheet.css | 34 +++ contrib/gnome-shell-extension/install.sh | 51 +++++ 5 files changed, 350 insertions(+) create mode 100644 contrib/gnome-shell-extension/README.md create mode 100644 contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/extension.js create mode 100644 contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/metadata.json create mode 100644 contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/stylesheet.css create mode 100755 contrib/gnome-shell-extension/install.sh diff --git a/contrib/gnome-shell-extension/README.md b/contrib/gnome-shell-extension/README.md new file mode 100644 index 0000000..6bf3e6b --- /dev/null +++ b/contrib/gnome-shell-extension/README.md @@ -0,0 +1,58 @@ +# hyprwhspr Waveform OSD (GNOME Shell extension) + +A floating, audio-reactive **waveform overlay** for hyprwhspr dictation on +**GNOME/Mutter** — the one compositor where hyprwhspr's built-in `mic-osd` +visualizer can't run (it needs the `gtk4-layer-shell` protocol, which Mutter +does not implement, so hyprwhspr falls back to plain notifications there). + +This extension fills that gap by drawing the overlay **inside gnome-shell** +itself, so it is **focus-safe** — it never steals keyboard focus and therefore +never disturbs the window your dictation is being typed into. + +![placement](https://github.com/goodroot/hyprwhspr) + +## How it works + +It is a pure status consumer — it reads the files hyprwhspr already writes and +animates accordingly. No audio capture of its own, no extra dependencies: + +| File | Meaning | +|------|---------| +| `~/.config/hyprwhspr/recording_status` | present ⇒ recording (show the pill) | +| `~/.config/hyprwhspr/audio_level` | `0.000`–`1.000`, drives the bar heights | +| `$XDG_RUNTIME_DIR/hyprwhspr/transcript_preview` | optional live transcript line | + +A pulsing record dot, 27 cyan→violet equalizer bars reacting to the live mic +level, and (when available) the live transcript text underneath. + +## Install + +```bash +./install.sh +``` + +Then **log out and back in** if it doesn't appear immediately (on Wayland a new +extension can't be hot-loaded). Start dictation and the pill fades in at the +bottom-centre of the screen. + +Manual install (equivalent): + +```bash +UUID="hyprwhspr-waveform@ninyawee.github.io" +cp -r "$UUID" ~/.local/share/gnome-shell/extensions/ +gnome-extensions enable "$UUID" # or log out/in, then enable +``` + +## Tweak the look + +- **Colours / size / bar count:** the constants at the top of `extension.js` + (`N_BARS`, `BAR_MIN`, `BAR_MAX`, `C1`, `C2`). +- **Pill style:** `stylesheet.css`. + +After editing, reload with `gnome-extensions disable && gnome-extensions enable ` +(no logout needed once it's been registered once). + +## Requirements + +- GNOME Shell 45–48 (Wayland or X11). +- hyprwhspr running (any backend) — the extension only visualizes its state. diff --git a/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/extension.js b/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/extension.js new file mode 100644 index 0000000..fe96b0e --- /dev/null +++ b/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/extension.js @@ -0,0 +1,199 @@ +import St from 'gi://St'; +import GLib from 'gi://GLib'; +import Clutter from 'gi://Clutter'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +const N_BARS = 27; // number of equalizer bars +const TICK_MS = 55; // poll/animate interval +const BAR_MIN = 4; // px, idle bar height +const BAR_MAX = 56; // px, loudest bar height +const C1 = [34, 211, 238]; // cyan (left) +const C2 = [167, 139, 250]; // violet (right) + +const CONFIG_DIR = GLib.build_filenamev([GLib.get_user_config_dir(), 'hyprwhspr']); +const REC_FILE = GLib.build_filenamev([CONFIG_DIR, 'recording_status']); +const LEVEL_FILE = GLib.build_filenamev([CONFIG_DIR, 'audio_level']); +const RUNTIME = GLib.getenv('XDG_RUNTIME_DIR') || GLib.get_tmp_dir(); +const PREVIEW_FILE = GLib.build_filenamev([RUNTIME, 'hyprwhspr', 'transcript_preview']); + +function lerpColor(t) { + const r = Math.round(C1[0] + (C2[0] - C1[0]) * t); + const g = Math.round(C1[1] + (C2[1] - C1[1]) * t); + const b = Math.round(C1[2] + (C2[2] - C1[2]) * t); + return `rgb(${r},${g},${b})`; +} + +function fileExists(p) { + return GLib.file_test(p, GLib.FileTest.EXISTS); +} + +function readLevel() { + try { + const [ok, bytes] = GLib.file_get_contents(LEVEL_FILE); + if (!ok) return 0; + const v = parseFloat(new TextDecoder().decode(bytes).trim()); + return Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0; + } catch (_e) { + return 0; + } +} + +function readPreview() { + try { + if (!fileExists(PREVIEW_FILE)) return ''; + const [ok, bytes] = GLib.file_get_contents(PREVIEW_FILE); + if (!ok) return ''; + return new TextDecoder().decode(bytes).trim(); + } catch (_e) { + return ''; + } +} + +export default class HyprwhsprWaveformExtension extends Extension { + enable() { + this._visible = false; + this._phase = 0; + this._bars = []; + + // The pill container — drawn inside gnome-shell, never grabs focus. + this._osd = new St.BoxLayout({ + vertical: true, + style_class: 'hw-osd', + reactive: false, + track_hover: false, + can_focus: false, + opacity: 0, + visible: false, + }); + + const row = new St.BoxLayout({vertical: false, style_class: 'hw-osd-row'}); + + this._dot = new St.Widget({style_class: 'hw-osd-dot'}); + row.add_child(this._dot); + + const barsBox = new St.BoxLayout({vertical: false, style_class: 'hw-osd-bars'}); + barsBox.set_height(BAR_MAX); + for (let i = 0; i < N_BARS; i++) { + const bar = new St.Widget({ + style_class: 'hw-osd-bar', + y_align: Clutter.ActorAlign.CENTER, + y_expand: true, + }); + bar.set_width(4); + bar.set_height(BAR_MIN); + bar.set_style(`background-color: ${lerpColor(i / (N_BARS - 1))};`); + barsBox.add_child(bar); + this._bars.push(bar); + } + row.add_child(barsBox); + this._osd.add_child(row); + + this._preview = new St.Label({style_class: 'hw-osd-preview', text: ''}); + this._preview.clutter_text.line_wrap = false; + this._preview.clutter_text.ellipsize = 3; // PANGO_ELLIPSIZE_END + this._osd.add_child(this._preview); + + Main.layoutManager.addChrome(this._osd, {affectsInputRegion: false}); + + this._reposition(); + this._monitorsId = Main.layoutManager.connect('monitors-changed', () => this._reposition()); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, TICK_MS, () => { + this._tick(); + return GLib.SOURCE_CONTINUE; + }); + } + + _reposition() { + const m = Main.layoutManager.primaryMonitor; + if (!m) return; + const w = this._osd.width || 320; + this._osd.set_position( + m.x + Math.round((m.width - w) / 2), + m.y + m.height - this._osd.height - 72 + ); + } + + _show() { + if (this._visible) return; + this._visible = true; + this._osd.show(); + this._osd.set_pivot_point(0.5, 1.0); + this._osd.scale_y = 0.85; + this._osd.ease({ + opacity: 255, + scale_y: 1.0, + duration: 180, + mode: Clutter.AnimationMode.EASE_OUT_BACK, + }); + this._reposition(); + } + + _hide() { + if (!this._visible) return; + this._visible = false; + this._osd.ease({ + opacity: 0, + duration: 220, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._osd.hide(), + }); + for (const bar of this._bars) + bar.ease({height: BAR_MIN, duration: 200, mode: Clutter.AnimationMode.EASE_OUT_QUAD}); + this._preview.text = ''; + } + + _tick() { + const recording = fileExists(REC_FILE); + if (recording) + this._show(); + else + this._hide(); + + if (!this._visible) return; + this._reposition(); + + const level = readLevel(); + + // Pulsing record dot. + this._phase += 0.32; + this._dot.opacity = Math.round(150 + 105 * (0.5 + 0.5 * Math.sin(this._phase))); + + // Center-weighted, lively bar envelope driven by the live level. + for (let i = 0; i < N_BARS; i++) { + const env = Math.sin((Math.PI * i) / (N_BARS - 1)); // 0 at edges, 1 center + const jitter = 0.55 + 0.45 * Math.random(); + const target = BAR_MIN + (BAR_MAX - BAR_MIN) * level * env * jitter; + this._bars[i].ease({ + height: Math.max(BAR_MIN, Math.round(target)), + duration: TICK_MS + 25, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + const preview = readPreview(); + if (preview && preview !== this._preview.text) + this._preview.text = preview; + } + + disable() { + if (this._timeoutId) { + GLib.source_remove(this._timeoutId); + this._timeoutId = null; + } + if (this._monitorsId) { + Main.layoutManager.disconnect(this._monitorsId); + this._monitorsId = null; + } + if (this._osd) { + Main.layoutManager.removeChrome(this._osd); + this._osd.destroy(); + this._osd = null; + } + this._bars = []; + this._dot = null; + this._preview = null; + } +} diff --git a/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/metadata.json b/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/metadata.json new file mode 100644 index 0000000..541d8c0 --- /dev/null +++ b/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "hyprwhspr-waveform@ninyawee.github.io", + "name": "hyprwhspr Waveform OSD", + "description": "Floating, audio-reactive waveform overlay for hyprwhspr dictation on GNOME/Mutter, where the built-in layer-shell mic-osd is unavailable. Focus-safe (drawn inside gnome-shell), driven by ~/.config/hyprwhspr/{recording_status,audio_level}.", + "shell-version": ["45", "46", "47", "48"], + "url": "https://github.com/goodroot/hyprwhspr", + "version": 1 +} diff --git a/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/stylesheet.css b/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/stylesheet.css new file mode 100644 index 0000000..15ae97a --- /dev/null +++ b/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/stylesheet.css @@ -0,0 +1,34 @@ +.hw-osd { + background-color: rgba(14, 17, 26, 0.84); + border: 1px solid rgba(130, 150, 255, 0.28); + border-radius: 22px; + padding: 14px 20px; + spacing: 8px; +} + +.hw-osd-row { + spacing: 12px; +} + +.hw-osd-bars { + spacing: 3px; +} + +.hw-osd-bar { + border-radius: 3px; +} + +.hw-osd-dot { + width: 11px; + height: 11px; + border-radius: 999px; + background-color: rgb(248, 81, 96); + border: 2px solid rgba(248, 81, 96, 0.35); +} + +.hw-osd-preview { + color: rgba(226, 232, 240, 0.92); + font-size: 12px; + max-width: 320px; + padding-top: 2px; +} diff --git a/contrib/gnome-shell-extension/install.sh b/contrib/gnome-shell-extension/install.sh new file mode 100755 index 0000000..14fe14c --- /dev/null +++ b/contrib/gnome-shell-extension/install.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Install the hyprwhspr Waveform OSD GNOME Shell extension. +# +# Copies the extension into ~/.local/share/gnome-shell/extensions and enables it. +# On Wayland a brand-new extension can't be hot-loaded, so if `enable` can't see +# it yet we add it to the enabled list and it activates on your next login. + +set -euo pipefail + +UUID="hyprwhspr-waveform@ninyawee.github.io" +SRC="$(cd "$(dirname "$0")" && pwd)/$UUID" +DEST="${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions/$UUID" + +if [[ ! -d "$SRC" ]]; then + echo "error: extension source not found at $SRC" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$DEST")" +rm -rf "$DEST" +cp -r "$SRC" "$DEST" +echo "Installed -> $DEST" + +if gnome-extensions enable "$UUID" 2>/dev/null; then + echo "Enabled. If you don't see the overlay, log out and back in." +else + # Shell hasn't scanned the new dir yet (typical on Wayland): queue it so it + # auto-enables after the next login. + python3 - "$UUID" <<'PY' +import sys, ast, subprocess +uuid = sys.argv[1] +cur = subprocess.run(["gsettings", "get", "org.gnome.shell", "enabled-extensions"], + capture_output=True, text=True).stdout.strip() +try: + lst = ast.literal_eval(cur) if cur and cur not in ("@as []", "") else [] + if not isinstance(lst, list): + lst = [] +except Exception: + lst = [] +if uuid not in lst: + lst.append(uuid) +val = "[" + ", ".join("'%s'" % x for x in lst) + "]" +subprocess.run(["gsettings", "set", "org.gnome.shell", "enabled-extensions", val], check=True) +PY + echo "Queued for activation — log out and back in (Wayland can't hot-load a new extension)." +fi + +echo +echo "Then start dictation (your hyprwhspr shortcut) and the waveform pill" +echo "appears at the bottom-centre of the screen while recording." From 30d8cd89912bfc629637d7c64cbdf634f2ab5ca3 Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:05:33 -0700 Subject: [PATCH 2/2] chore: maintainer nits --- .../extension.js | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/extension.js b/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/extension.js index fe96b0e..bb859fe 100644 --- a/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/extension.js +++ b/contrib/gnome-shell-extension/hyprwhspr-waveform@ninyawee.github.io/extension.js @@ -6,7 +6,8 @@ import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; const N_BARS = 27; // number of equalizer bars -const TICK_MS = 55; // poll/animate interval +const TICK_ACTIVE_MS = 55; // poll/animate interval while recording +const TICK_IDLE_MS = 200; // slower poll while idle — fewer wakeups, still <200ms to show const BAR_MIN = 4; // px, idle bar height const BAR_MAX = 56; // px, loudest bar height const C1 = [34, 211, 238]; // cyan (left) @@ -95,15 +96,25 @@ export default class HyprwhsprWaveformExtension extends Extension { this._preview.clutter_text.ellipsize = 3; // PANGO_ELLIPSIZE_END this._osd.add_child(this._preview); - Main.layoutManager.addChrome(this._osd, {affectsInputRegion: false}); + // trackFullscreen:false keeps the pill visible when dictating into a + // fullscreen window (browser video, fullscreen editor) — the common case. + Main.layoutManager.addChrome(this._osd, { + affectsInputRegion: false, + trackFullscreen: false, + }); this._reposition(); this._monitorsId = Main.layoutManager.connect('monitors-changed', () => this._reposition()); - this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, TICK_MS, () => { - this._tick(); - return GLib.SOURCE_CONTINUE; - }); + this._tickMs = null; + this._scheduleTick(TICK_IDLE_MS); + } + + // (Re)arm the poll timer at the given interval. Called once from enable() and + // again from _tick() when switching between idle and active rates. + _scheduleTick(intervalMs) { + this._tickMs = intervalMs; + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, intervalMs, () => this._tick()); } _reposition() { @@ -152,13 +163,21 @@ export default class HyprwhsprWaveformExtension extends Extension { else this._hide(); - if (!this._visible) return; + // Poll fast while the pill is up, slow while idle. When the rate needs to + // change, re-arm at the new interval and let this (old) source expire. + const wantMs = this._visible ? TICK_ACTIVE_MS : TICK_IDLE_MS; + if (wantMs !== this._tickMs) { + this._scheduleTick(wantMs); + return GLib.SOURCE_REMOVE; + } + + if (!this._visible) return GLib.SOURCE_CONTINUE; this._reposition(); const level = readLevel(); // Pulsing record dot. - this._phase += 0.32; + this._phase = (this._phase + 0.32) % (2 * Math.PI); this._dot.opacity = Math.round(150 + 105 * (0.5 + 0.5 * Math.sin(this._phase))); // Center-weighted, lively bar envelope driven by the live level. @@ -168,7 +187,7 @@ export default class HyprwhsprWaveformExtension extends Extension { const target = BAR_MIN + (BAR_MAX - BAR_MIN) * level * env * jitter; this._bars[i].ease({ height: Math.max(BAR_MIN, Math.round(target)), - duration: TICK_MS + 25, + duration: TICK_ACTIVE_MS + 25, mode: Clutter.AnimationMode.EASE_OUT_QUAD, }); } @@ -176,6 +195,8 @@ export default class HyprwhsprWaveformExtension extends Extension { const preview = readPreview(); if (preview && preview !== this._preview.text) this._preview.text = preview; + + return GLib.SOURCE_CONTINUE; } disable() {