Compare commits

3 Commits
Author SHA1 Message Date
server2 a8cd23546d 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
2026-10-06 15:41:48 +02:00
server2 c55e4cfbed island: cover gradient background, unmirrored pill levels
- the pill, the card and the fade veils share one 150-degree gradient from
  the cover's palette (most common, second, background), each darkened to
  a fixed brightness so white text stays readable; veils span the whole
  shape so their gradient lines up with the backdrop's
- measured on the desktop: card open 3.1 ms gpu (solid fill was ~3), pill
  1.7 ms (was 1.0), open/close animation unchanged (1 and 0 slow frames)
- pill levels: lows left, highs right, no mirroring; every bar still grows
  up and down from the middle line
2026-10-06 15:25:13 +02:00
server2 e7ff054fb1 island: open in two phases, live levels in the pill
- one linear timeline: the pill fades out, the empty shape grows, then the
  card fades in on the settled shape (and backwards on close)
- fades use a surface-coloured veil instead of .opacity(), and the shape
  has no clip, so neither costs a gpu layer
- measured on the desktop: open went from 11 frames over 20 ms to 1,
  close from 4 to 0
- the spectrum capture runs whenever the shown player plays, and the pill
  shows it as 7 mirrored, glowing bars (about 9% of one core while playing)
2026-10-06 03:44:00 +02:00
7 changed files with 748 additions and 68 deletions
+4
View File
@@ -0,0 +1,4 @@
# written by amane compile / amane startup, with paths for one machine only
Cargo.toml
Cargo.lock
target/
+117
View File
@@ -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".
+77
View File
@@ -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.
+148 -56
View File
@@ -3,7 +3,7 @@ use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use amane::{ use amane::{
Animation, Arc, Argument, Bus, Canvas, Cap, Center, Color, Column, End, Full, Animation, Arc, Argument, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient,
Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Padding, Palette, Parent, Path, Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Padding, Palette, Parent, Path,
Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack,
Start, Text, Vertical, Weight, Widget, Zone, children, shapes, Start, Text, Vertical, Weight, Widget, Zone, children, shapes,
@@ -14,8 +14,17 @@ use crate::parse::{self, BANDS, Sink};
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound}; use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound};
use crate::spectrum::Spectrum; use crate::spectrum::Spectrum;
/*
* opening is one linear timeline in two phases, so the shape never resizes with the
* whole card in it: the pill fades out, the empty shape grows, then the card fades in
* on the settled shape. closing runs the same timeline backwards
*/
const OPEN_MS: u64 = 560;
const PILL_GONE: f32 = 0.18;
const SHAPE_DONE: f32 = 0.6;
// sizes, in pixels // sizes, in pixels
const COMPACT_W: f32 = 200.0; const COMPACT_W: f32 = 220.0;
const COMPACT_H: f32 = 38.0; const COMPACT_H: f32 = 38.0;
const WIDE_W: f32 = 560.0; const WIDE_W: f32 = 560.0;
const WIDE_RADIUS: f32 = 28.0; const WIDE_RADIUS: f32 = 28.0;
@@ -83,7 +92,6 @@ const CHIP: Color = Color::rgba(255, 255, 255, 18);
const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34); const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34);
const TRACK: Color = Color::rgba(255, 255, 255, 24); const TRACK: Color = Color::rgba(255, 255, 255, 24);
const EDGE: Color = Color::rgba(255, 255, 255, 26); const EDGE: Color = Color::rgba(255, 255, 255, 26);
const SURFACE_TOP: Color = Color::rgb(30, 21, 38);
const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18); const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18);
const MENU_BG: Color = Color::rgb(35, 26, 44); const MENU_BG: Color = Color::rgb(35, 26, 44);
@@ -111,7 +119,7 @@ pub struct Island {
impl Service for Island { impl Service for Island {
fn new() -> Self { fn new() -> Self {
Self { Self {
open: Animation::new(0.0).duration(Duration::from_millis(420)), open: Animation::new(0.0).duration(Duration::from_millis(OPEN_MS)).easing(Easing::Linear),
expanded: false, expanded: false,
selected: None, selected: None,
menu: None, menu: None,
@@ -124,12 +132,6 @@ impl Service for Island {
fn listen() {} fn listen() {}
} }
impl Island {
pub fn expanded(&self) -> bool {
self.expanded
}
}
// the picked player while it's still open, else the one Media calls active // the picked player while it's still open, else the one Media calls active
pub fn shown<'a>(media: &'a Media, selected: Option<&str>) -> Option<&'a MediaPlayer> { pub fn shown<'a>(media: &'a Media, selected: Option<&str>) -> Option<&'a MediaPlayer> {
let picked = selected.and_then(|name| media.players().iter().find(|player| player.name() == name)); let picked = selected.and_then(|name| media.players().iter().find(|player| player.name() == name));
@@ -170,23 +172,25 @@ pub fn view() -> LayerWindow {
return window.visible(false); return window.visible(false);
}; };
let open = island.open.value(); let (grow, pill, reveal) = phases(island.open.value());
/* /*
* a live ring keeps frames coming the way an animation does: a service change * live levels keep frames coming the way an animation does: a service change
* alone asks with an empty commit, which hyprland only answers on its next repaint * alone asks with an empty commit, which hyprland only answers on its next repaint
*/ */
if island.expanded && !spectrum.levels.is_empty() { if !spectrum.levels.is_empty() {
amane::request_frame(); amane::request_frame();
} }
let width = mix(COMPACT_W, WIDE_W, open); let width = mix(COMPACT_W, WIDE_W, grow);
let height = mix(COMPACT_H, WIDE_H, open); let height = mix(COMPACT_H, WIDE_H, grow);
let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow);
// the two faces cross-fade at the halfway point, while the shape keeps growing // a gradient of the cover's own colours, for the pill and the card alike
let face = if open < 0.5 { let colors = backdrop_colors(&palette);
compact(player, &art.path).opacity(1.0 - 2.0 * open)
} else { // at most one face at a time, each faded by a veil instead of a gpu layer
let face = if reveal > 0.0 {
let scene = Scene { let scene = Scene {
player, player,
players: media.players(), players: media.players(),
@@ -196,34 +200,31 @@ pub fn view() -> LayerWindow {
levels: &spectrum.levels, levels: &spectrum.levels,
}; };
// an opacity of 1 still costs a gpu layer, so the settled card has none veiled(card(&scene), width, height, radius, reveal, &colors)
if open >= 1.0 { card(&scene) } else { card(&scene).opacity(2.0 * open - 1.0) } } else if pill > 0.0 {
veiled(compact(player, &art.path, &spectrum.levels), width, height, radius, pill, &colors)
} else {
// mid-grow: the empty shape alone, the cheapest frames of the animation
Rectangle::new().width(0.0).height(0.0)
}; };
let shape = Rectangle::new() let shape = Rectangle::new()
.width(width) .width(width)
.height(height) .height(height)
.radius(mix(COMPACT_H / 2.0, WIDE_RADIUS, open)) .radius(radius)
/* .fill(backdrop(&colors, 255))
* solid, tinted toward the cover: gradients turned out to be this iGPU's most
* expensive fill (60 fps without them, 30 with), and the card is redrawn every frame
*/
.fill(tint(palette.dominant()))
.border(1.0, EDGE); .border(1.0, EDGE);
/* /*
* amane redraws the whole window every frame, so big static effects are paid * amane redraws the whole window every frame, so big static effects are paid
* 60 times a second: the settled card goes without a drop shadow (its rim and * 60 times a second: the shadow belongs to the pill and the grow, and fades out
* tinted gradient set it apart), the pill and the morph keep theirs * with the card coming in. no clip anywhere: neither face ever overflows the shape
*/ */
let shape = if open < 1.0 { shape.shadow(Shadow::drop("#000000").opacity(0.55).blur(24.0).offset(0.0, 10.0)) } else { shape }; let shape = if reveal < 1.0 {
shape.shadow(Shadow::drop("#000000").opacity(0.55 * (1.0 - reveal)).blur(24.0).offset(0.0, 10.0))
/* } else {
* the clip is only needed while the shape morphs and the face overflows it; a clip shape
* over the whole card took this iGPU from 47 to 9 frames a second, so the settled };
* card goes without (its glows stay inside on their own)
*/
let shape = if open < 1.0 { shape.clip() } else { shape };
let shape = shape let shape = shape
.align_child(Center, Start) .align_child(Center, Start)
@@ -365,16 +366,77 @@ fn mix(from: f32, to: f32, amount: f32) -> f32 {
from + (to - from) * amount from + (to - from) * amount
} }
// one point on the open timeline as (shape grown, pill shown, card shown), each 0 to 1
fn phases(timeline: f32) -> (f32, f32, f32) {
let grow = (timeline / SHAPE_DONE).clamp(0.0, 1.0);
let pill = 1.0 - (timeline / PILL_GONE).clamp(0.0, 1.0);
let reveal = ((timeline - SHAPE_DONE) / (1.0 - SHAPE_DONE)).clamp(0.0, 1.0);
// the shape eases out (cubic), the card in and out (smoothstep)
(1.0 - (1.0 - grow).powi(3), pill, reveal * reveal * (3.0 - 2.0 * reveal))
}
/*
* fades a face in over the island's backdrop by laying the same gradient over it,
* thinning as `shown` rises: the same picture as .opacity(shown), without a gpu layer.
* the veil spans the whole shape (minus the 1 px rim), so its gradient lines up with
* the backdrop's exactly; amane places gradients by each rectangle's own bounds
*/
fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, colors: &[Color; 3]) -> Rectangle {
if shown >= 1.0 {
return face;
}
let veil = Rectangle::new()
.width(width - 2.0)
.height(height - 2.0)
.radius(radius - 1.0)
.fill(backdrop(colors, ((1.0 - shown) * 255.0).round() as u8))
.translate(1.0, 1.0);
let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face);
Rectangle::new().width(width).height(height).child(Stack::new(children![face, veil]).width(width).height(height))
}
// the cover's colours for the backdrop: most common, second most common, darkest
fn backdrop_colors(palette: &Palette) -> [Color; 3] {
let first = palette.dominant();
let second = palette.colors().get(1).copied().unwrap_or(first);
// darker toward the bottom right, where the equalizer and presets sit
[deepen(first, 0.22), deepen(second, 0.15), deepen(palette.background(), 0.07)]
}
/*
* one gradient for the pill, the card and the veils; 150 degrees runs from the
* top left to the bottom right, like the reference's light
*/
fn backdrop(colors: &[Color; 3], alpha: u8) -> Gradient {
Gradient::linear(150.0, [(0.0, faded(colors[0], alpha)), (0.55, faded(colors[1], alpha)), (1.0, faded(colors[2], alpha))])
}
// scaled down, keeping its hue, until its brightness is at most `max` (0 to 1), so white text reads on it
fn deepen(color: Color, max: f32) -> Color {
let (red, green, blue) = (f32::from(color.red()), f32::from(color.green()), f32::from(color.blue()));
let brightness = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0;
let scale = if brightness > max { max / brightness } else { 1.0 };
Color::rgb((red * scale) as u8, (green * scale) as u8, (blue * scale) as u8)
}
fn faded(color: Color, alpha: u8) -> Color { fn faded(color: Color, alpha: u8) -> Color {
Color::rgba(color.red(), color.green(), color.blue(), alpha) Color::rgba(color.red(), color.green(), color.blue(), alpha)
} }
// the pill: cover, title, level bars // the pill: cover, title, the player's live levels
fn compact(player: &MediaPlayer, art: &str) -> Rectangle { fn compact(player: &MediaPlayer, art: &str, levels: &[f32]) -> Rectangle {
Rectangle::new() Rectangle::new()
.width(COMPACT_W) .width(COMPACT_W)
.height(COMPACT_H) .height(COMPACT_H)
.padding(Padding { top: 0.0, right: 14.0, bottom: 0.0, left: 6.0 }) .padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 })
.child( .child(
Row::new(children![ Row::new(children![
cover(art, 26.0, 1.5, false), cover(art, 26.0, 1.5, false),
@@ -383,7 +445,7 @@ fn compact(player: &MediaPlayer, art: &str) -> Rectangle {
.height(COMPACT_H) .height(COMPACT_H)
.align_child(Start, Center) .align_child(Start, Center)
.child(Text::new(title(player)).size(12.0).color(TEXT).elide()), .child(Text::new(title(player)).size(12.0).color(TEXT).elide()),
levels(player.playing()), pill_levels(player.playing(), levels),
]) ])
.gap(10.0) .gap(10.0)
.align(Center), .align(Center),
@@ -400,13 +462,6 @@ fn card(scene: &Scene) -> Rectangle {
Rectangle::new().width(WIDE_W).height(WIDE_H).child(Stack::new(layers).width(WIDE_W).height(WIDE_H)) Rectangle::new().width(WIDE_W).height(WIDE_H).child(Stack::new(layers).width(WIDE_W).height(WIDE_H))
} }
// the surface colour leaning a little toward the cover's
fn tint(color: Color) -> Color {
let lean = |surface: u8, cover: u8| (f32::from(surface) * 0.7 + f32::from(cover) * 0.3) as u8;
Color::rgb(lean(SURFACE_TOP.red(), color.red()), lean(SURFACE_TOP.green(), color.green()), lean(SURFACE_TOP.blue(), color.blue()))
}
fn content(scene: &Scene) -> Rectangle { fn content(scene: &Scene) -> Rectangle {
Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child( Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child(
Column::new(children![ Column::new(children![
@@ -1032,23 +1087,60 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
frame.child(picture) frame.child(picture)
} }
// four bars while playing, a small pause sign otherwise (flat bars read as a "⋯" menu) /*
fn levels(playing: bool) -> Canvas { * the pill's visualiser: seven bars from the shown player's own sound, glowing when
* loud; a pause sign while paused, and still bars while the capture starts or the
* player has no stream to listen to
*/
fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
const BARS: usize = 7;
const WIDTH: f32 = 36.0;
const HEIGHT: f32 = 26.0;
if !playing { if !playing {
return icon(Glyph::Pause, 13.0, DIM); return icon(Glyph::Pause, 13.0, DIM);
} }
let mut bars: Vec<Box<dyn Shape>> = Vec::new(); // lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line
let middle = HEIGHT / 2.0;
for (index, height) in [6.0, 13.0, 9.0, 11.0].into_iter().enumerate() { let mut glow = Path::new();
let x = 2.5 + index as f32 * 5.0; let mut body = Path::new();
let bar = Line::new().from(x, 8.0 - height / 2.0).to(x, 8.0 + height / 2.0); for index in 0..BARS {
let level = if levels.is_empty() {
[0.55, 0.85, 0.6, 0.75, 0.45, 0.6, 0.35][index]
} else {
/*
* the loudest band of this bar's seventh of the spectrum, linear with a little
* gain: typical music sits at 0.15 to 0.35 here, and a lifting curve made every
* bar look nearly full
*/
(band(levels, index, BARS) * 1.4).min(1.0)
};
bars.push(Box::new(bar.stroke(3.0, PINK).cap(Cap::Round))); let half = 1.5 + level * (middle - 3.0);
let x = 3.0 + index as f32 * (WIDTH - 6.0) / (BARS - 1) as f32;
body = body.move_to(x, middle - half).line_to(x, middle + half);
if level > 0.3 {
glow = glow.move_to(x, middle - half).line_to(x, middle + half);
}
} }
Canvas::new().width(20.0).height(16.0).shapes(bars) Canvas::new().width(WIDTH).height(HEIGHT).shapes(shapes![
glow.stroke(7.0, faded(PINK, 55)).cap(Cap::Round),
body.stroke(3.2, PINK).cap(Cap::Round),
])
}
// the loudest level in one of `groups` equal slices of the spectrum, low to high
fn band(levels: &[f32], group: usize, groups: usize) -> f32 {
let start = group * levels.len() / groups;
let end = ((group + 1) * levels.len() / groups).max(start + 1).min(levels.len());
levels[start..end].iter().copied().fold(0.0, f32::max)
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
+4 -12
View File
@@ -16,8 +16,8 @@ pub const BARS: usize = 24;
/* /*
* the shown player's own sound as bars: `pw-record --target <its stream>` * the shown player's own sound as bars: `pw-record --target <its stream>`
* piped into cava. only runs while the card is open and the player plays, * piped into cava. runs while that player plays, open card or pill, and stops
* since every frame it sends redraws the island * when it pauses, since every frame it sends redraws the island
*/ */
#[derive(Default)] #[derive(Default)]
pub struct Spectrum { pub struct Spectrum {
@@ -66,17 +66,9 @@ fn cava_config() -> String {
) )
} }
// the first stream of the shown player, while the card is open and it plays // the first stream of the shown player, while it plays
fn wanted_stream() -> Option<u32> { fn wanted_stream() -> Option<u32> {
let (expanded, selected) = { let selected = Island::read().selected.clone();
let island = Island::read();
(island.expanded(), island.selected.clone())
};
if !expanded {
return None;
}
let player = { let player = {
let media = Media::read(); let media = Media::read();
+142
View File
@@ -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
+256
View File
@@ -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()