Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions contrib/gnome-shell-extension/README.md
Original file line number Diff line number Diff line change
@@ -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) <!-- bottom-centre pill while recording -->

## 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 <uuid> && gnome-extensions enable <uuid>`
(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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
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_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)
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);

// 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._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() {
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();

// 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 = (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.
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_ACTIVE_MS + 25,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}

const preview = readPreview();
if (preview && preview !== this._preview.text)
this._preview.text = preview;

return GLib.SOURCE_CONTINUE;
}

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;
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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;
}
51 changes: 51 additions & 0 deletions contrib/gnome-shell-extension/install.sh
Original file line number Diff line number Diff line change
@@ -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."
Loading