docs: README, AGENTS.md, test tools in tools/
- README: features, layout, requirements, install, IPC, runtime files, limits - AGENTS.md: the user's ground rules (branch + approval, never touch real players' sound), architecture, measured performance rules, type-check and test loop, desktop test driver and its pitfalls, open threads - tools/: amane-remote.sh and fake_mpris.py, so the test loop doesn't depend on the Hermes skill - .gitignore: the Cargo files amane generates
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# written by amane compile / amane startup, with paths for one machine only
|
||||||
|
Cargo.toml
|
||||||
|
Cargo.lock
|
||||||
|
target/
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Notes for agents continuing work on the Magic Island shell. Read README.md first for what it does and how it's laid out.
|
||||||
|
|
||||||
|
## Ground rules from the user
|
||||||
|
|
||||||
|
- **Branch per big change, and ask before merging.** Create a new branch for each big change, show the result, and merge into `master` only after the user explicitly approves. Small follow-ups the user asks for in the same breath can go on the same branch.
|
||||||
|
- **Never touch the user's real players' sound.** Cider and Firefox are real; their volume, equalizer and output are the user's. Before any `island preset|output` call or volume change, run `amane ipc call island status` and make sure `player=` is a fake test player. Test audio goes to inaudible null sinks (see Testing).
|
||||||
|
- **The desktop is reached only by `ssh -o BatchMode=yes fabian@192.168.2.169`** (Hyprland, Intel iGPU, monitors DP-2 and eDP-1). Don't probe the network, SSH keys or other hosts; if SSH fails, report it and wait.
|
||||||
|
- **Leave the desktop clean:** stop fake players and null sinks after testing, and restart the shell without frame logging (see "After testing").
|
||||||
|
- Be honest in summaries about what was and wasn't checked on the real desktop.
|
||||||
|
|
||||||
|
## Constraints of Amane itself
|
||||||
|
|
||||||
|
- The shell can only depend on `amane` and the Rust standard library: `amane compile` generates `Cargo.toml` and no other crates can be added. External tools run as processes (`std::process::Command`, `amane::output`).
|
||||||
|
- Amane docs: `/root/amane/docs/src/*.md` on the dev host; library source in `/root/amane/src/`.
|
||||||
|
- Amane redraws the whole window every frame, so every effect in the view is paid at the frame rate.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Piece | Kind | Job |
|
||||||
|
|---|---|---|
|
||||||
|
| `island.rs` `Island` | input-only Service | `open: Animation` (0 pill, 1 card), `expanded`, `selected` player (bus name, `None` follows the playing one), open dropdown, time-bar scrub. Also the view and the `island` IPC handler (`command`). |
|
||||||
|
| `sound.rs` `Sound` | Service, 1 s poll | `pactl list sinks` + `sink-inputs` → each player's streams (matched by process ancestry, then app name), volume, mute, output, equalizer. Curves saved by player identity to `~/.config/amane/island-eq`. |
|
||||||
|
| `spectrum.rs` `Spectrum` | Service, 200 ms check | While the shown player plays: `pw-record --target <its stream> \| cava -p $XDG_RUNTIME_DIR/magic-island/cava.conf` → 24 levels. Stops when it pauses. |
|
||||||
|
| `art.rs` `Art` | Service, 1 s poll | Cover URL → local file (curl for https), copied to `palette-cover`, which `Palette` (opened in `main.rs`, 8 colours) watches. |
|
||||||
|
| `parse.rs` | plain functions, no amane | pactl/cava/proc parsing, filter-chain config, `pw-cli` params, EQ store, middle ellipsis. Unit-tested on its own. |
|
||||||
|
|
||||||
|
The equalizer: one `setsid -f pipewire -c eq-<key>.conf` process per player with `libpipewire-module-filter-chain`, 10 biquads (low shelf 31 Hz, peaking 63 Hz–8 kHz, high shelf 16 kHz). The player's streams are moved into sink `magic_island_eq.<key>`; gains change live with `pw-cli set-param magic_island_eq.<key> Props '{ params = [ "b1:Gain" 6.0 ... ] }'`. A player only gets one when its curve or output is first changed, or it has a saved non-flat curve.
|
||||||
|
|
||||||
|
### The view (island.rs `view`)
|
||||||
|
|
||||||
|
- One `LayerWindow` (namespace `magic-island`, `Layer::Overlay` because the user's windows are fullscreen) sized for the open card; an `InputArea` lets clicks outside the island through.
|
||||||
|
- `phases(open)` splits the one linear 560 ms timeline: pill fades out over 0–0.18, the empty shape grows over 0–0.6 (cubic ease-out), the card fades in over 0.6–1 (smoothstep). Closing runs it backwards. At most one face (pill or card) is built per frame.
|
||||||
|
- `veiled()` fades a face by laying the backdrop gradient over it with alpha `1 - shown` instead of `.opacity()`: same picture, no GPU layer.
|
||||||
|
- `backdrop_colors()` / `backdrop()`: one 150° three-stop gradient from the palette (dominant, `colors()[1]`, `background()`), each darkened by `deepen()` to a max brightness of 0.22 / 0.15 / 0.07.
|
||||||
|
- `pill_levels()`: 7 bars, bar *i* = loudest of spectrum slice *i* of 7, × 1.4, linear, drawn up and down from the middle line.
|
||||||
|
- `ring()`: 40 candles around the CD, levels from `mirrored()` (bass at the top, highs down both sides), `powf(0.75)`. Drawn as 3 paths (glow, body, flame tips).
|
||||||
|
- Moving the pointer off the island collapses it (`on_hover`).
|
||||||
|
|
||||||
|
## Performance rules (measured on the Intel iGPU)
|
||||||
|
|
||||||
|
Current numbers: card open with music 60 fps, about 3.1 ms GPU per frame; pill with music 60 fps, about 1.7 ms GPU, about 9% of one core for shell + cava; paused, about 1 frame a second. Opening has 1 frame over 20 ms (where the card first appears), closing 0.
|
||||||
|
|
||||||
|
- **Measure before and after every visual change** with `tools/amane-remote.sh HOST fps` and `anim island`, baseline first.
|
||||||
|
- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in.
|
||||||
|
- `.clip()` and `.opacity(<1)` each add a GPU layer. A clip over the whole card once took the iGPU from 47 to 9 fps.
|
||||||
|
- Gradients are expensive in numbers: a gradient background plus 10 gradient slider fills halved the frame rate; the single backdrop gradient is affordable.
|
||||||
|
- A veil over a gradient must be the same gradient at the same size as the shape (inset 1 px for the rim): Amane places a gradient by each rectangle's own bounds, so a smaller veil shows patches.
|
||||||
|
- Blurred shadows cost every frame: the drop shadow only exists on the pill and during the animation.
|
||||||
|
- Many shapes cost: draw repeated marks (candles, bars) as one `Path` per look.
|
||||||
|
- Streaming data needs `amane::request_frame()` in the view while levels are live; a Service write alone only got about 11 redraws a second from Hyprland.
|
||||||
|
- `Rectangle::child` holds one child; stack layers with `Stack`.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
1. Type-check on the dev host without the desktop. A scratch crate outside the repo (don't commit a `Cargo.toml`, Amane generates its own):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "amane-shell"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "amane-shell"
|
||||||
|
path = '/root/amane-config/src/main.rs'
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
amane = { path = '/root/amane' }
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `cargo clippy` in that crate. (It is at `/root/.hermes/cache/scratch/amane-check/` on the dev host.)
|
||||||
|
|
||||||
|
2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (5 tests).
|
||||||
|
|
||||||
|
## Testing on the desktop
|
||||||
|
|
||||||
|
`tools/amane-remote.sh HOST COMMAND`; the full command list is in the comment at the top of the script. The main ones:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
H=fabian@192.168.2.169
|
||||||
|
tools/amane-remote.sh $H deploy src # back up the remote src once, then replace it
|
||||||
|
tools/amane-remote.sh $H build # amane compile
|
||||||
|
tools/amane-remote.sh $H start # runs the shell WITH frame logging (AMANE_FRAMES=1)
|
||||||
|
tools/amane-remote.sh $H fps 3 # frames, average gap, gpu wait
|
||||||
|
tools/amane-remote.sh $H anim island # open/close 3x, every frame gap
|
||||||
|
tools/amane-remote.sh $H shot magic-island out.png
|
||||||
|
tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b
|
||||||
|
tools/amane-remote.sh $H players start 4 # fake MPRIS players (tools/fake_mpris.py), "Synth Test" makes real sound into island_test_a
|
||||||
|
tools/amane-remote.sh $H ipc island status
|
||||||
|
tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical session's environment
|
||||||
|
```
|
||||||
|
|
||||||
|
- Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound.
|
||||||
|
- The island follows the focused monitor: read its geometry from `hyprctl layers -j` (namespace `magic-island`) right before each `grim` capture.
|
||||||
|
- The user's pointer may be over the island and open or collapse it during a test; check `status` and capture right after `open`.
|
||||||
|
- To tune a visualiser, sample the real levels read-only (`pw-record --target <stream> | cava`, with `timeout -s KILL` on each stage; a plain `timeout` on a pipeline orphans cava) and look at the distribution first. Typical music sits at 0.15–0.35.
|
||||||
|
- In `pkill -f` patterns over SSH, use `[p]ython3 ...` so the pattern doesn't match the remote shell itself.
|
||||||
|
|
||||||
|
### After testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/amane-remote.sh $H players stop
|
||||||
|
tools/amane-remote.sh $H sinks stop
|
||||||
|
tools/amane-remote.sh $H sh 'kill $(cat shell.pid); sleep 0.5; rm -f shell.log
|
||||||
|
setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/project/target/debug/amane-shell" > /dev/null 2>&1'
|
||||||
|
```
|
||||||
|
|
||||||
|
The desktop's `amane` CLI is older than the docs: no `~/.cache/amane/amane-shell` is saved and `amane run` doesn't apply, so the shell runs from `~/.cache/amane/project/target/debug/amane-shell`.
|
||||||
|
|
||||||
|
## Open threads
|
||||||
|
|
||||||
|
- The candle ring is still mirrored (`mirrored()`); the user had the pill bars unmirrored and may want the same for the ring.
|
||||||
|
- See README "Known limits".
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Magic Island: an Amane media widget
|
||||||
|
|
||||||
|
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
|
||||||
|
|
||||||
|
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign.
|
||||||
|
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
|
||||||
|
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
|
||||||
|
- **Output switcher:** moves only the shown player's audio to another device.
|
||||||
|
- **Per-player volume** (scroll the volume chip, click to mute), set on that player's own PulseAudio/PipeWire streams, so it also works for browsers that ignore MPRIS volume.
|
||||||
|
- **Real 10-band equalizer per player** (31 Hz to 16 kHz, ±12 dB): a PipeWire filter-chain per player, with 8 presets (Flat, Bass, Treble, Vocal, Pop, Rock, Jazz, Classic). Curves are saved per player.
|
||||||
|
- **Background:** a diagonal gradient from the cover's own colours, darkened so white text stays readable.
|
||||||
|
- **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main.rs app setup: Fira Code font, the `island` IPC handler, palette from the cover
|
||||||
|
src/island.rs the view: pill, card, ring, equalizer, dropdowns, animation, IPC commands
|
||||||
|
src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli, pipewire)
|
||||||
|
src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava)
|
||||||
|
src/art.rs cover download (curl) and the palette file
|
||||||
|
src/parse.rs pure text parsing with unit tests, no amane dependency
|
||||||
|
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
|
||||||
|
tools/ remote test driver and fake MPRIS players, see AGENTS.md
|
||||||
|
src.before-test/ the original hello-world config, kept as a backup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
On the desktop that runs the shell:
|
||||||
|
|
||||||
|
- the `amane` CLI and Rust toolchain (`amane compile` builds `src/`)
|
||||||
|
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
|
||||||
|
- `cava`, `curl`
|
||||||
|
- the Fira Code font
|
||||||
|
|
||||||
|
## Install and run
|
||||||
|
|
||||||
|
The shell lives in `~/.config/amane/`. Copy (or clone) this repo's `src/` there, then build and start:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
amane compile
|
||||||
|
~/.cache/amane/project/target/debug/amane-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
The installed CLI on the current desktop is older than the Amane docs: it doesn't save `~/.cache/amane/amane-shell`, so `amane run` has nothing to start. Run the binary above directly.
|
||||||
|
|
||||||
|
## Control it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
amane ipc call island toggle # also: open, close
|
||||||
|
amane ipc call island next # show the next player
|
||||||
|
amane ipc call island menu output # open a dropdown: output | player | close
|
||||||
|
amane ipc call island preset Rock # apply an equalizer preset to the shown player
|
||||||
|
amane ipc call island output <sink> # move the shown player to a sink (pactl sink name)
|
||||||
|
amane ipc call island status # player=<identity> expanded=<bool> menu=<bool>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example Hyprland keybind:
|
||||||
|
|
||||||
|
```
|
||||||
|
bind = SUPER, M, exec, amane ipc call island toggle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime files
|
||||||
|
|
||||||
|
- `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`.
|
||||||
|
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
|
||||||
|
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- The time bar moves in 1 s steps (the MPRIS poll rate).
|
||||||
|
- Every slider step starts one `pactl` or `pw-cli` process.
|
||||||
|
- An equalizer keeps running, and keeps that player routed through its last curve, if the shell stops while the player is open.
|
||||||
|
- The candle ring around the CD is still mirrored left against right; the pill's bars are not.
|
||||||
|
- Dropdowns have no keyboard navigation.
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Visual test loop for an amane shell on a remote Wayland desktop, driven over ssh.
|
||||||
|
#
|
||||||
|
# usage: amane-remote.sh HOST COMMAND [ARGS...]
|
||||||
|
# deploy SRC_DIR back up the remote ~/.config/amane/src once, then replace it with SRC_DIR
|
||||||
|
# build `amane compile` on the remote (backs up a saved amane-shell once)
|
||||||
|
# start | stop run / stop the compiled shell, with AMANE_FRAMES=1 logging
|
||||||
|
# log [N] last N lines of the shell's log (default 30)
|
||||||
|
# fps [SECONDS] frames drawn over SECONDS (default 3), with average gap and gpu wait
|
||||||
|
# anim HANDLER 3x `amane ipc call HANDLER open|close`: frames, gaps over 20 ms, every gap
|
||||||
|
# ipc ARGS... amane ipc call ARGS...
|
||||||
|
# shot NAMESPACE FILE [PAD] screenshot the layer window with that namespace (Hyprland) into local FILE
|
||||||
|
# players start N | stop run N fake MPRIS players (fake_mpris.py, next to this script)
|
||||||
|
# sinks start | stop two inaudible null sinks (island_test_a/_b); fake players play into _a
|
||||||
|
# sh 'COMMAND' any command inside the remote session environment
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
host=${1:?host}
|
||||||
|
command=${2:?command}
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
here=$(dirname "$(readlink -f "$0")")
|
||||||
|
work='$HOME/.cache/amane-remote-test'
|
||||||
|
|
||||||
|
# what a terminal inside the graphical session would have
|
||||||
|
session='uid=$(id -u)
|
||||||
|
export XDG_RUNTIME_DIR=/run/user/$uid
|
||||||
|
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
||||||
|
export WAYLAND_DISPLAY=$(ls "$XDG_RUNTIME_DIR" | grep -m1 -x "wayland-[0-9]*")
|
||||||
|
export HYPRLAND_INSTANCE_SIGNATURE=$(ls -t "$XDG_RUNTIME_DIR/hypr" 2>/dev/null | head -1)
|
||||||
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
|
mkdir -p '"$work"' && cd '"$work"'
|
||||||
|
'
|
||||||
|
|
||||||
|
remote() {
|
||||||
|
ssh -o BatchMode=yes "$host" "$session$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
case $command in
|
||||||
|
deploy)
|
||||||
|
source_dir=${1:?source folder}
|
||||||
|
remote 'test -d ~/.config/amane/src.before-test || cp -a ~/.config/amane/src ~/.config/amane/src.before-test 2>/dev/null || true
|
||||||
|
mkdir -p ~/.config/amane/src && find ~/.config/amane/src -maxdepth 1 -name "*.rs" -delete'
|
||||||
|
# -m: the two clocks may disagree, and future timestamps only cause warnings
|
||||||
|
tar -C "$source_dir" -cf - . | ssh -o BatchMode=yes "$host" 'tar -C ~/.config/amane/src -xmf -'
|
||||||
|
scp -q "$here/fake_mpris.py" "$host:.cache/amane-remote-test/"
|
||||||
|
remote 'ls ~/.config/amane/src'
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
remote 'test -f ~/.cache/amane/amane-shell.before-test || cp -a ~/.cache/amane/amane-shell ~/.cache/amane/amane-shell.before-test 2>/dev/null || true
|
||||||
|
amane compile 2>&1 | tail -40'
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
# newer CLIs save ~/.cache/amane/amane-shell; older ones only leave cargo's output
|
||||||
|
remote 'test -f shell.pid && kill $(cat shell.pid) 2>/dev/null; sleep 0.5
|
||||||
|
binary=~/.cache/amane/amane-shell
|
||||||
|
test -x "$binary" || binary=~/.cache/amane/project/target/debug/amane-shell
|
||||||
|
AMANE_FRAMES=1 setsid -f sh -c "echo \$\$ > shell.pid; exec $binary" > shell.log 2>&1
|
||||||
|
sleep 2; pgrep -F shell.pid > /dev/null && echo "running, pid $(cat shell.pid)" || { echo "exited:"; tail -20 shell.log; }'
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
remote 'test -f shell.pid && kill $(cat shell.pid) 2>/dev/null; rm -f shell.pid; echo stopped'
|
||||||
|
;;
|
||||||
|
log)
|
||||||
|
remote "tail -n ${1:-30} shell.log"
|
||||||
|
;;
|
||||||
|
fps)
|
||||||
|
# AMANE_FRAMES lines: frame <window> <w>x<h> gap <ms> view <ms> draw <ms> gpu <ms>
|
||||||
|
remote "a=\$(wc -l < shell.log); sleep ${1:-3}; tail -n +\$((a + 1)) shell.log | grep '^frame' |
|
||||||
|
awk '{gap+=\$5; view+=\$7; gpu+=\$11; n++} END {if (n) printf \"%d frames in ${1:-3} s, gap %.1f ms, view %.2f ms, gpu wait %.2f ms\n\", n, gap/n, view/n, gpu/n; else print \"no frames\"}'"
|
||||||
|
;;
|
||||||
|
anim)
|
||||||
|
# a smooth animation shows only ~17 ms gaps; 33 ms runs mean it fell to 30 fps
|
||||||
|
handler=${1:?ipc handler with open and close}
|
||||||
|
remote "for round in 1 2 3; do for action in open close; do
|
||||||
|
a=\$(wc -l < shell.log); amane ipc call $handler \$action > /dev/null; sleep 1
|
||||||
|
tail -n +\$((a + 1)) shell.log | grep '^frame' | awk -v act=\$action '
|
||||||
|
{gap[n++]=\$5; gpu+=\$11}
|
||||||
|
END {slow=0; for (i=1;i<n;i++) if (gap[i]>20) slow++;
|
||||||
|
printf \"%-5s frames %2d over 20 ms %2d gpu wait %5.2f ms gaps:\", act, n, slow, gpu/n;
|
||||||
|
for (i=1;i<n && i<26;i++) printf \" %.0f\", gap[i]; print \"\"}'
|
||||||
|
done; done"
|
||||||
|
;;
|
||||||
|
ipc)
|
||||||
|
remote "amane ipc call $*"
|
||||||
|
;;
|
||||||
|
shot)
|
||||||
|
namespace=${1:?namespace}
|
||||||
|
file=${2:?local file}
|
||||||
|
pad=${3:-0}
|
||||||
|
# hyprctl layers -j: {monitor: {levels: {n: [{namespace, x, y, w, h}]}}}, x and y in global layout pixels
|
||||||
|
remote "geometry=\$(hyprctl layers -j | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
for monitor in json.load(sys.stdin).values():
|
||||||
|
for level in monitor[\"levels\"].values():
|
||||||
|
for layer in level:
|
||||||
|
if layer[\"namespace\"] == \"$namespace\":
|
||||||
|
p = $pad
|
||||||
|
print(f\"{layer[\"x\"] - p},{max(0, layer[\"y\"] - p)} {layer[\"w\"] + 2 * p}x{layer[\"h\"] + 2 * p}\")
|
||||||
|
sys.exit()
|
||||||
|
sys.exit(\"no layer named $namespace\")
|
||||||
|
') && grim -g \"\$geometry\" shot.png && echo \"\$geometry\" >&2" && ssh -o BatchMode=yes "$host" 'cat ~/.cache/amane-remote-test/shot.png' > "$file"
|
||||||
|
echo "saved $file"
|
||||||
|
;;
|
||||||
|
players)
|
||||||
|
case ${1:?start N or stop} in
|
||||||
|
start)
|
||||||
|
count=${2:-3}
|
||||||
|
remote "sink=\$(pactl list sinks short | grep -q island_test_a && echo island_test_a)
|
||||||
|
for index in \$(seq 0 $((count - 1))); do
|
||||||
|
FAKE_MPRIS_SINK=\$sink setsid -f python3 fake_mpris.py \$index >> players.log 2>&1
|
||||||
|
done
|
||||||
|
sleep 1.5; playerctl -l"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
# [p]: the remote shell's own command line holds this text, and must not match it
|
||||||
|
remote 'pkill -TERM -f "[p]ython3 fake_mpris.py" || true; sleep 0.5; playerctl -l 2>&1'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
sinks)
|
||||||
|
case ${1:?start or stop} in
|
||||||
|
start)
|
||||||
|
remote 'for name in island_test_a island_test_b; do
|
||||||
|
pactl list sinks short | grep -q "$name" ||
|
||||||
|
pactl load-module module-null-sink sink_name=$name sink_properties=device.description=$name > /dev/null
|
||||||
|
done
|
||||||
|
pactl list sinks short | grep island_test'
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
remote 'pactl list modules short | awk "/module-null-sink/ && /island_test/ {print \$1}" | xargs -r -n1 pactl unload-module; pactl list sinks short | grep -c island_test'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
sh)
|
||||||
|
remote "${1:?command}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
sed -n '2,15p' "$0"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+256
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fake MPRIS players for visual tests of media widgets.
|
||||||
|
|
||||||
|
usage: fake_mpris.py PROFILE (0, 1, 2, ... see PROFILES)
|
||||||
|
|
||||||
|
Each process owns org.mpris.MediaPlayer2.fake<PROFILE>, answers the calls a bar
|
||||||
|
makes (GetAll, PlayPause, Next, Previous, Seek) and, when the profile says so,
|
||||||
|
plays through `paplay` as its own child, so mixers that match sound streams to
|
||||||
|
players by process ancestry find it: "silence" (zeros) or "synth" (a beat, a
|
||||||
|
chord and a sweep, for visualisers and equalizers). Set FAKE_MPRIS_SINK to a
|
||||||
|
null sink (`pactl load-module module-null-sink sink_name=...`) so the synth is
|
||||||
|
never audible. Covers are generated PNGs in a folder with a space in its name
|
||||||
|
(exercises percent-encoded file:// links). Profiles cover: long title +
|
||||||
|
playing, >1 h paused podcast, live stream without cover or sound, synth.
|
||||||
|
Needs PyGObject (gi) and paplay. SIGTERM stops paplay too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import array
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
from gi.repository import Gio, GLib
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RATE = 44100
|
||||||
|
|
||||||
|
# identity, playing, sound ("silence", "synth" or None), tracks of (title, artist, seconds, cover colours or None)
|
||||||
|
PROFILES = [
|
||||||
|
("Lo-Fi Lab", True, "silence", [
|
||||||
|
("Late Night Tape Hiss (Extended Rain Mix) feat. Somebody With A Long Name",
|
||||||
|
"Nujabes Tribute Collective", 214, ((255, 140, 60), (200, 40, 140))),
|
||||||
|
("Second Track", "Lo-Fi Lab Band", 180, ((250, 210, 70), (60, 160, 120))),
|
||||||
|
]),
|
||||||
|
("Podcast Deck", False, "silence", [
|
||||||
|
("Episode 142: Shipping Small", "The Lazy Dev Show", 3725, ((40, 190, 200), (20, 30, 90))),
|
||||||
|
]),
|
||||||
|
("Radio Stream", True, None, [
|
||||||
|
("Live from the Basement", "Night FM", 0, None),
|
||||||
|
]),
|
||||||
|
("Synth Test", True, "synth", [
|
||||||
|
("Chest Pain (I Love)", "Malcolm Todd", 200, ((120, 110, 190), (30, 20, 60))),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def synth(out, stop):
|
||||||
|
"""Endless s16le stereo: a kick every 0.5 s, hats, a breathing chord and a slow sweep."""
|
||||||
|
frame = 0
|
||||||
|
chunk = 2048
|
||||||
|
while not stop.is_set():
|
||||||
|
samples = array.array("h")
|
||||||
|
for _ in range(chunk):
|
||||||
|
t = frame / RATE
|
||||||
|
beat = t % 0.5
|
||||||
|
kick = math.sin(2 * math.pi * (50 + 90 * math.exp(-beat * 30)) * beat) * math.exp(-beat * 8)
|
||||||
|
hat = (random.random() * 2 - 1) * math.exp(-(t % 0.25) * 60) * 0.25
|
||||||
|
chord = sum(math.sin(2 * math.pi * f * t) for f in (220.0, 277.2, 329.6)) / 3 * (0.3 + 0.2 * math.sin(t))
|
||||||
|
sweep = math.sin(2 * math.pi * (400 + 3000 * (0.5 + 0.5 * math.sin(t * 0.7))) * t) * 0.15
|
||||||
|
value = int(max(-1.0, min(1.0, 0.5 * kick + hat + chord + sweep)) * 20000)
|
||||||
|
samples.append(value)
|
||||||
|
samples.append(value)
|
||||||
|
frame += 1
|
||||||
|
try:
|
||||||
|
out.write(samples.tobytes())
|
||||||
|
out.flush()
|
||||||
|
except (BrokenPipeError, ValueError):
|
||||||
|
return
|
||||||
|
|
||||||
|
INTERFACES = """
|
||||||
|
<node>
|
||||||
|
<interface name="org.mpris.MediaPlayer2">
|
||||||
|
<method name="Raise"/>
|
||||||
|
<method name="Quit"/>
|
||||||
|
<property name="Identity" type="s" access="read"/>
|
||||||
|
<property name="CanQuit" type="b" access="read"/>
|
||||||
|
<property name="CanRaise" type="b" access="read"/>
|
||||||
|
<property name="HasTrackList" type="b" access="read"/>
|
||||||
|
<property name="SupportedUriSchemes" type="as" access="read"/>
|
||||||
|
<property name="SupportedMimeTypes" type="as" access="read"/>
|
||||||
|
</interface>
|
||||||
|
<interface name="org.mpris.MediaPlayer2.Player">
|
||||||
|
<method name="PlayPause"/>
|
||||||
|
<method name="Play"/>
|
||||||
|
<method name="Pause"/>
|
||||||
|
<method name="Stop"/>
|
||||||
|
<method name="Next"/>
|
||||||
|
<method name="Previous"/>
|
||||||
|
<method name="Seek"><arg name="Offset" type="x" direction="in"/></method>
|
||||||
|
<method name="SetPosition">
|
||||||
|
<arg name="TrackId" type="o" direction="in"/>
|
||||||
|
<arg name="Position" type="x" direction="in"/>
|
||||||
|
</method>
|
||||||
|
<property name="PlaybackStatus" type="s" access="read"/>
|
||||||
|
<property name="Metadata" type="a{sv}" access="read"/>
|
||||||
|
<property name="Position" type="x" access="read"/>
|
||||||
|
<property name="Rate" type="d" access="read"/>
|
||||||
|
<property name="Volume" type="d" access="read"/>
|
||||||
|
<property name="CanGoNext" type="b" access="read"/>
|
||||||
|
<property name="CanGoPrevious" type="b" access="read"/>
|
||||||
|
<property name="CanPlay" type="b" access="read"/>
|
||||||
|
<property name="CanPause" type="b" access="read"/>
|
||||||
|
<property name="CanSeek" type="b" access="read"/>
|
||||||
|
<property name="CanControl" type="b" access="read"/>
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def png(path, colours, size=256):
|
||||||
|
"""A diagonal two-colour gradient with a lighter disc, written with zlib only."""
|
||||||
|
(r0, g0, b0), (r1, g1, b1) = colours
|
||||||
|
rows = []
|
||||||
|
for y in range(size):
|
||||||
|
row = bytearray(b"\x00")
|
||||||
|
for x in range(size):
|
||||||
|
t = (x + y) / (2 * size - 2)
|
||||||
|
disc = (x - size * 0.62) ** 2 + (y - size * 0.38) ** 2 < (size * 0.22) ** 2
|
||||||
|
lift = 60 if disc else 0
|
||||||
|
row += bytes(min(255, int(a + (b - a) * t) + lift) for a, b in ((r0, r1), (g0, g1), (b0, b1)))
|
||||||
|
rows.append(bytes(row))
|
||||||
|
|
||||||
|
def chunk(kind, data):
|
||||||
|
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
|
||||||
|
|
||||||
|
header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
|
||||||
|
with open(path, "wb") as out:
|
||||||
|
out.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(b"".join(rows))) + chunk(b"IEND", b""))
|
||||||
|
|
||||||
|
|
||||||
|
class Player:
|
||||||
|
def __init__(self, index):
|
||||||
|
self.index = index
|
||||||
|
self.identity, self.playing, sound, self.tracks = PROFILES[index]
|
||||||
|
self.track = 0
|
||||||
|
self.offset = 0.0 # seconds into the track at `since`
|
||||||
|
self.since = time.monotonic()
|
||||||
|
self.sound = None
|
||||||
|
self.stop_synth = threading.Event()
|
||||||
|
if sound:
|
||||||
|
command = ["paplay", "--raw", "--format=s16le", f"--rate={RATE}", "--channels=2",
|
||||||
|
f"--client-name={self.identity}"]
|
||||||
|
if os.environ.get("FAKE_MPRIS_SINK"):
|
||||||
|
command.append(f"--device={os.environ['FAKE_MPRIS_SINK']}")
|
||||||
|
if sound == "silence":
|
||||||
|
self.sound = subprocess.Popen(command + ["/dev/zero"])
|
||||||
|
else:
|
||||||
|
# paplay reads stdin when no file is given
|
||||||
|
self.sound = subprocess.Popen(command, stdin=subprocess.PIPE)
|
||||||
|
threading.Thread(target=synth, args=(self.sound.stdin, self.stop_synth), daemon=True).start()
|
||||||
|
|
||||||
|
covers = os.path.join(HERE, "fake covers")
|
||||||
|
os.makedirs(covers, exist_ok=True)
|
||||||
|
self.covers = []
|
||||||
|
for number, (_, _, _, colours) in enumerate(self.tracks):
|
||||||
|
if colours is None:
|
||||||
|
self.covers.append("")
|
||||||
|
continue
|
||||||
|
path = os.path.join(covers, f"player{index}-track{number}.png")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
png(path, colours)
|
||||||
|
self.covers.append("file://" + urllib.parse.quote(path))
|
||||||
|
|
||||||
|
def position(self):
|
||||||
|
length = self.tracks[self.track][2]
|
||||||
|
moved = time.monotonic() - self.since if self.playing else 0.0
|
||||||
|
seconds = self.offset + moved
|
||||||
|
return min(seconds, length) if length else seconds
|
||||||
|
|
||||||
|
def rebase(self, seconds):
|
||||||
|
self.offset = max(0.0, seconds)
|
||||||
|
self.since = time.monotonic()
|
||||||
|
|
||||||
|
def metadata(self):
|
||||||
|
title, artist, length, _ = self.tracks[self.track]
|
||||||
|
data = {
|
||||||
|
"mpris:trackid": GLib.Variant("o", f"/org/mpris/MediaPlayer2/fake/track{self.track}"),
|
||||||
|
"xesam:title": GLib.Variant("s", title),
|
||||||
|
"xesam:artist": GLib.Variant("as", [artist]),
|
||||||
|
"mpris:artUrl": GLib.Variant("s", self.covers[self.track]),
|
||||||
|
}
|
||||||
|
if length:
|
||||||
|
data["mpris:length"] = GLib.Variant("x", length * 1_000_000)
|
||||||
|
return GLib.Variant("a{sv}", data)
|
||||||
|
|
||||||
|
def get(self, _connection, _sender, _path, _interface, name):
|
||||||
|
constant = {
|
||||||
|
"Identity": GLib.Variant("s", self.identity),
|
||||||
|
"CanQuit": GLib.Variant("b", False), "CanRaise": GLib.Variant("b", False),
|
||||||
|
"HasTrackList": GLib.Variant("b", False),
|
||||||
|
"SupportedUriSchemes": GLib.Variant("as", []), "SupportedMimeTypes": GLib.Variant("as", []),
|
||||||
|
"Rate": GLib.Variant("d", 1.0), "Volume": GLib.Variant("d", 1.0),
|
||||||
|
}
|
||||||
|
if name in constant:
|
||||||
|
return constant[name]
|
||||||
|
if name == "PlaybackStatus":
|
||||||
|
return GLib.Variant("s", "Playing" if self.playing else "Paused")
|
||||||
|
if name == "Metadata":
|
||||||
|
return self.metadata()
|
||||||
|
if name == "Position":
|
||||||
|
return GLib.Variant("x", int(self.position() * 1_000_000))
|
||||||
|
return GLib.Variant("b", True) # every Can* capability
|
||||||
|
|
||||||
|
def call(self, _connection, _sender, _path, _interface, method, parameters, invocation):
|
||||||
|
if method in ("PlayPause", "Play", "Pause", "Stop"):
|
||||||
|
here = self.position()
|
||||||
|
self.playing = {"PlayPause": not self.playing, "Play": True}.get(method, False)
|
||||||
|
self.rebase(here)
|
||||||
|
elif method in ("Next", "Previous"):
|
||||||
|
step = 1 if method == "Next" else -1
|
||||||
|
self.track = (self.track + step) % len(self.tracks)
|
||||||
|
self.rebase(0)
|
||||||
|
elif method == "Seek":
|
||||||
|
self.rebase(self.position() + parameters.unpack()[0] / 1_000_000)
|
||||||
|
elif method == "SetPosition":
|
||||||
|
self.rebase(parameters.unpack()[1] / 1_000_000)
|
||||||
|
print(f"{self.identity}: {method} {parameters.unpack() if parameters else ''} "
|
||||||
|
f"-> {'playing' if self.playing else 'paused'} at {self.position():.1f}s", flush=True)
|
||||||
|
invocation.return_value(None)
|
||||||
|
|
||||||
|
def stop(self, *_):
|
||||||
|
self.stop_synth.set()
|
||||||
|
if self.sound:
|
||||||
|
self.sound.terminate()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
player = Player(int(sys.argv[1]))
|
||||||
|
signal.signal(signal.SIGTERM, player.stop)
|
||||||
|
signal.signal(signal.SIGINT, player.stop)
|
||||||
|
|
||||||
|
node = Gio.DBusNodeInfo.new_for_xml(INTERFACES)
|
||||||
|
|
||||||
|
def acquired(connection, _name):
|
||||||
|
for interface in node.interfaces:
|
||||||
|
connection.register_object("/org/mpris/MediaPlayer2", interface, player.call, player.get, None)
|
||||||
|
|
||||||
|
Gio.bus_own_name(Gio.BusType.SESSION, f"org.mpris.MediaPlayer2.fake{player.index}",
|
||||||
|
Gio.BusNameOwnerFlags.NONE, acquired, None, None)
|
||||||
|
|
||||||
|
# GLib's loop ignores Python signal handlers unless it wakes up now and then
|
||||||
|
GLib.timeout_add(250, lambda: True)
|
||||||
|
GLib.MainLoop().run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user