SwBr git · main
SwBr - status bar for sway
C 95.9% Markdown 3.7%git clone https://git.christianimmanuel.de/sway/SwBr.gitwget https://git.christianimmanuel.de/sway/SwBr/archive/SwBr.tar.gzUpdate sound notification min vol
README.md | 29 ++++++++- config.advanced.example | 28 +++++++- config.example | 8 ++- swbr.c | 168 ++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 221 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 767f1fc..d9622f0 100644 --- a/README.md +++ b/README.md @@ -202,9 +202,10 @@ default. ## Going flat ``` -battery.alert=30,20,10,5 +battery.alert=30,20,10,5,1 battery.alert_msg=BATTERY %c%% battery.alert_tone=250 +battery.alert_cmd=[ %c -le 1 ] && poweroff ``` Each level fires once on the way down and rearms when the charge comes back @@ -212,6 +213,32 @@ above it, or when the cable goes in — so 19% does not nag every ten seconds. The message goes through the bar's own message system, as a warning except for the last level, which is an error. +`alert_cmd` runs at every level and **only** at a level, so a guard like +`[ %c -le 1 ]` needs `1` in the list — otherwise it is evaluated at 5% and +never again. `poweroff` also needs root. + +`swbr --alert-test [CELL]` fires an alert now: it prints the levels, plays the +tone through your own player and reports its exit status, and shows what +`alert_cmd` would run at each level without running it. A player that fails +during a real alert now says so on the bar instead of failing silently. + +`alert_gain` (35%) is how loud the tone itself is, which costs nothing and +moves no mixer. When that is not enough, `alert_vol_min` raises the mixer for +the tone and puts it back: + +``` +battery.alert_vol_min=50 +battery.alert_vol_get=amixer -M get Master | grep -o '[0-9]*%' | head -1 +battery.alert_vol_set=amixer -M -q set Master %v% +``` + +Any pair of commands works — the first prints a number, the second takes `%v`. +The level is only raised if it is already below the floor, and the restore is +the shell's own EXIT trap, so it happens even if the alert is killed part way +through. ALSA has no per-stream volume, so this is the master control: other +audio is louder for the second or two the tone lasts. A muted output stays +muted; append `unmute` to the set command if you would rather it spoke anyway. + The tone is a sine with a raised-cosine edge either side, written as raw PCM straight into `alert_play` (`aplay -q -f cd -` by default, or `paplay --raw --rate=44100 --format=s16le --channels=2` on pipewire). Soft diff --git a/config.advanced.example b/config.advanced.example index 8412abc..0d21940 100644 --- a/config.advanced.example +++ b/config.advanced.example @@ -285,7 +285,7 @@ battery.slim=bar battery.slim_w=56 # same again: wide enough to count the blocks # --- shouting when it is going flat ----------------------------------------- -battery.alert=30,20,10,5 # percentages. Each fires once on the way down and +battery.alert=30,20,10,5,1 # percentages. Each fires once on the way down and # rearms when the charge comes back above it, or when the # cable goes in — no counter file, no repeats at 19% battery.alert_msg=BATTERY %c%% # taken over the bar like any other message; @@ -293,12 +293,34 @@ battery.alert_msg=BATTERY %c%% # taken over the bar like any other message; battery.alert_tone=250 # Hz. A sine with soft edges, written straight to battery.alert_ms=1050 # the player — no click, unlike a test tone. battery.alert_beeps=2 # 0 Hz for silence +battery.alert_gain=35 # how loud the tone itself is, percent of full + # scale. Costs nothing and touches no mixer, so try this + # before alert_vol_min below. 80 is loud but still clean battery.alert_play=aplay -q -f cd - # on pipewire/pulse instead: # paplay --raw --rate=44100 --format=s16le --channels=2 + +# An alert nobody hears is not an alert. If the mixer is below this, swbr +# raises it for the tone and puts it straight back — even if the alert is +# killed half way through. ALSA has no per-stream level, so this is the master +# control: music gets louder too, for the second or two the tone lasts. +# 0 = never touch the mixer. +battery.alert_vol_min=50 +battery.alert_vol_get=amixer -M get Master | grep -o '[0-9]*%' | head -1 +battery.alert_vol_set=amixer -M -q set Master %v% + # any pair of commands works: the first prints a number, + # the second takes %v. Your own alsa_control, pactl, + # wpctl -- whatever you already use + # A muted output stays muted: swbr cannot tell deliberate + # silence from an accident. Append `unmute` to the set + # command if you would rather it spoke anyway battery.alert_cmd=[ %c -le 1 ] && poweroff - # run at every level; %c is the percentage, so guard it. - # At one percent there is nothing left to warn about + # run at every level, and ONLY at a level: the guard can + # only ever be true if 1 is in the list above, which is why + # it is. %c is the percentage. Check it works without + # waiting for a flat battery: swbr --alert-test + # poweroff needs root — give your user a sudo rule for it, + # or the command silently does nothing # --- folding --------------------------------------------------------------- # Put the pointer over the bar and press the key: the bar rolls up into a thin diff --git a/config.example b/config.example index b39d2fd..c1f44e5 100644 --- a/config.example +++ b/config.example @@ -72,11 +72,15 @@ battery.hover_click=1 # it can shout, too. Each level fires once on the way down and rearms when # the charge comes back or the cable goes in -battery.alert=30,20,10,5 +battery.alert=30,20,10,5,1 battery.alert_msg=BATTERY %c%% battery.alert_tone=250 # Hz, 0 for silence. The last levels beep longer +battery.alert_gain=35 # loudness of the tone, percent of full scale battery.alert_play=aplay -q -f cd - -# battery.alert_cmd=[ %c -le 1 ] && poweroff +# battery.alert_vol_min=50 # if the mixer is lower, raise it for the tone only +# # and put it back. 0 = leave the mixer alone +# battery.alert_cmd=[ %c -le 1 ] && poweroff # only runs AT a level, so 1 + # has to be in the list. Needs root for poweroff # --- music, if cmus is running ---------------------------------------------- cell=music diff --git a/swbr.c b/swbr.c index 095c5b7..c0adc81 100644 --- a/swbr.c +++ b/swbr.c @@ -1334,6 +1334,9 @@ typedef struct { int alert_lv[8], alert_n, alert_fired; char alert_msg[192], alert_cmd[256], alert_play[128]; int alert_tone, alert_ms, alert_beeps, alert_escalate; + int alert_gain; /* loudness of the tone itself, percent */ + int alert_vol_min; /* lift the mixer to this for the alert */ + char alert_vol_get[160], alert_vol_set[160]; int cal_off; /* months away from this one, from the arrows */ char out_hover[512]; /* that, already filled in */ int hover_slim; /* folded, hovering it opens the bar */ @@ -1562,7 +1565,12 @@ static Cell *cell_add(Config *c, const char *name) e->alert_ms = 1050; e->alert_beeps = 2; e->alert_escalate = 1; + e->alert_gain = 35; + e->alert_vol_min = 0; /* nobody's mixer is touched unless asked */ str_set(e->alert_play, sizeof(e->alert_play), "aplay -q -f cd -"); + str_set(e->alert_vol_get, sizeof(e->alert_vol_get), + "amixer -M get Master | grep -o '[0-9]*%' | head -1"); + str_set(e->alert_vol_set, sizeof(e->alert_vol_set), "amixer -M -q set Master %v%"); str_set(e->align, sizeof(e->align), "right"); str_set(e->pos, sizeof(e->pos), "right"); return e; @@ -1624,6 +1632,10 @@ static void cell_set(Config *c, const char *name, const char *k, const char *v) else if (!strcmp(k, "alert_ms")) e->alert_ms = atoi(v); else if (!strcmp(k, "alert_beeps")) e->alert_beeps = atoi(v); else if (!strcmp(k, "alert_escalate")) e->alert_escalate = atoi(v); + else if (!strcmp(k, "alert_gain")) e->alert_gain = atoi(v); + else if (!strcmp(k, "alert_vol_min")) e->alert_vol_min = atoi(v); + else if (!strcmp(k, "alert_vol_get")) str_set(e->alert_vol_get, sizeof(e->alert_vol_get), v); + else if (!strcmp(k, "alert_vol_set")) str_set(e->alert_vol_set, sizeof(e->alert_vol_set), v); else if (!strcmp(k, "hover_slim")) e->hover_slim = atoi(v); else if (!strcmp(k, "source")) { if (!strcasecmp(v, "cmus")) { @@ -3000,6 +3012,54 @@ static double bat_num(const char *base, const char *leaf) /* urgency 0 is an ordinary level, 1 the one before last, 2 the last: longer * and more of them, so the difference is audible from the next room without * having to count. */ +/* What the tone is piped into. Normally just alert_play. With alert_vol_min + * set it is wrapped in a few lines of shell: read the mixer, and only if it is + * below the floor, raise it, arrange to put it back, then hand over to the + * player. The restore is the shell's own EXIT trap, so it runs even if the + * alert is killed half way through — nothing should be able to leave the + * volume up. ALSA has no per-stream level, so this is the master control and + * it does move other audio for the second or two the tone lasts; alert_gain + * is the knob that touches nothing at all. A muted output stays muted: swbr + * cannot tell deliberate silence from an accident, and unmuting someone's + * speakers is the worse mistake. Add `unmute` to alert_vol_set to override + * that. */ +static void alert_player_cmd(const Cell *e, char *out, size_t cap) +{ + if (e->alert_vol_min <= 0 || !*e->alert_vol_get || !*e->alert_vol_set) { + snprintf(out, cap, "%s", e->alert_play); + return; + } + char lo[16]; + snprintf(lo, sizeof(lo), "%d", e->alert_vol_min); + char up[256], back[256]; + const char *vmin[] = { lo }, *vold[] = { "$_v" }; + expand_fmt(up, sizeof(up), e->alert_vol_set, "v", vmin); + expand_fmt(back, sizeof(back), e->alert_vol_set, "v", vold); + snprintf(out, cap, + "_v=$(%s 2>/dev/null | tr -cs '0-9' ' ' | awk '{print $1; exit}'); " + "case \"$_v\" in ''|*[!0-9]*) _v= ;; esac; " + "if [ -n \"$_v\" ] && [ \"$_v\" -lt %d ]; then " + "_back() { %s; }; trap _back EXIT INT TERM HUP; %s; fi; " + /* not exec: that replaces the shell and takes the EXIT trap with + * it, so the volume was raised and never put back */ + "%s", + e->alert_vol_get, e->alert_vol_min, back, up, e->alert_play); +} + +/* In the detached grandchild, so it cannot use msg_set: it goes back in + * through the bar's own fifo, the same way `swbr --msg` does. */ +static void alert_player_failed(const Cell *e, int status) +{ + char line[400]; + if (status == -1) + snprintf(line, sizeof(line), "error: %s alert: cannot run '%s'", + e->name, e->alert_play); + else + snprintf(line, sizeof(line), "error: %s alert: '%s' exited %d", + e->name, e->alert_play, WIFEXITED(status) ? WEXITSTATUS(status) : status); + msg_send(line); +} + static void alert_play_tone(const Cell *e, int urgency) { if (e->alert_tone <= 0 || !*e->alert_play) return; @@ -3010,8 +3070,10 @@ static void alert_play_tone(const Cell *e, int urgency) if (fork() > 0) _exit(0); /* orphan it, no zombie */ - FILE *out = popen(e->alert_play, "w"); - if (!out) _exit(0); + char player[1024]; + alert_player_cmd(e, player, sizeof(player)); + FILE *out = popen(player, "w"); + if (!out) { alert_player_failed(e, -1); _exit(0); } const int rate = 44100; int ms = e->alert_ms > 0 ? e->alert_ms : 1050; @@ -3022,15 +3084,16 @@ static void alert_play_tone(const Cell *e, int urgency) int n = rate * ms / 1000; int edge = rate * 15 / 1000; /* 15 ms in and out */ int gap = rate * 130 / 1000; + double gain = clampf((float)e->alert_gain / 100.0f, 0.02f, 1.0f); for (int b = 0; b < beeps; ++b) { for (int i = 0; i < n; ++i) { double env = 1.0; if (i < edge) env = 0.5 - 0.5 * cos(M_PI * i / edge); else if (i > n - edge) env = 0.5 - 0.5 * cos(M_PI * (n - i) / edge); - double v = sin(2.0 * M_PI * e->alert_tone * i / rate) * env * 0.35; + double v = sin(2.0 * M_PI * e->alert_tone * i / rate) * env * gain; int16_t sm = (int16_t)(v * 32767.0); - fwrite(&sm, 2, 1, out); + if (fwrite(&sm, 2, 1, out) != 1) { b = beeps; break; } /* it died */ fwrite(&sm, 2, 1, out); /* both ears */ } if (b + 1 < beeps) { @@ -3038,7 +3101,11 @@ static void alert_play_tone(const Cell *e, int urgency) for (int i = 0; i < gap; ++i) { fwrite(&z, 2, 1, out); fwrite(&z, 2, 1, out); } } } - pclose(out); + int st = pclose(out); + /* A tone nobody hears is indistinguishable from an alert that never + * fired, and the player's own error goes nowhere when the bar was started + * by sway. So say it on the bar. */ + if (st != 0 && st != -1) alert_player_failed(e, st); _exit(0); } @@ -5820,6 +5887,8 @@ static void usage(void) " keyboard grab, no fifo. For finding out which part of\n" " swbr a compositor is unhappy about\n" " --probe print outputs, sizes, font metrics and cell values\n" +" --alert-test [CELL] fire a cell's alert here and now: the tone through\n" +" its own player, and what alert_cmd would run\n" " --slim start folded (same as start_collapsed=1)\n" " --toggle fold or unfold the running bar. Also --fold/--unfold\n" " bindsym $mod+b exec swbr --toggle\n" @@ -5925,7 +5994,9 @@ static void usage(void) " NAME.alert=30,20,10,5 percentages that fire once on the way down and\n" " rearm on the way up. alert_msg= the text, alert_cmd=\n" " a command (%c is the percentage), alert_tone= Hz,\n" -" alert_ms=, alert_beeps=, alert_play= the player\n" +" alert_ms=, alert_beeps=, alert_play= the player,\n" +" alert_gain=35 how loud the tone itself is, and\n" +" alert_vol_min=0 a mixer floor just for the alert\n" " NAME.hover_slim=0 1 = folded, resting on this cell opens the bar for as\n" " long as you stay on it\n" " NAME.slim_color2= second colour: the minutes of a clock cell, and its\n" @@ -6063,6 +6134,7 @@ static void dump_config(void) /* ------------------------------------------------------------------ main */ static int probe = 0; +static int alert_test_arg = 0; /* argv index of --alert-test */ static int do_replace = 0; static int minimal = 0; static int cfg_start_collapsed_arg = 0; @@ -6243,6 +6315,82 @@ static void on_signal(int s) else sig_quit = 1; } +/* `swbr --alert-test [cell]`: everything a level does except the command, + * printed and played here and now, so you find out whether the player works + * before the battery is flat. The command is only shown — a test that powers + * the machine off is not a test. */ +static int alert_test(const char *name) +{ + Cell *e = NULL; + for (int i = 0; i < cfg.cell_count; ++i) { + if (name && *name) { if (!strcmp(cfg.cell[i].name, name)) { e = &cfg.cell[i]; break; } } + else if (cfg.cell[i].alert_n) { e = &cfg.cell[i]; break; } + } + if (!e) { fprintf(stderr, "swbr: no cell '%s' with alert= set\n", name ? name : ""); return 1; } + if (!e->alert_n) { fprintf(stderr, "swbr: cell '%s' has no alert= levels\n", e->name); return 1; } + signal(SIGPIPE, SIG_IGN); /* a player that dies must not kill the test */ + + printf("cell %s\n", e->name); + printf("levels "); + for (int i = 0; i < e->alert_n; ++i) printf("%d%s", e->alert_lv[i], i + 1 < e->alert_n ? "," : "\n"); + printf("message %s\n", *e->alert_msg ? e->alert_msg : "(none)"); + printf("player %s\n", *e->alert_play ? e->alert_play : "(none)"); + printf("tone %d Hz, %d ms, %d beeps%s, gain %d%%\n", e->alert_tone, + e->alert_ms, e->alert_beeps, e->alert_escalate ? ", escalating" : "", + e->alert_gain); + if (e->alert_vol_min > 0) + printf("volume lifted to at least %d%% for the tone, then put back\n", + e->alert_vol_min); + else + puts("volume the mixer is left alone (alert_vol_min=0)"); + + if (*e->alert_cmd) { + printf("\nalert_cmd runs at every level, and only at a level:\n"); + for (int i = 0; i < e->alert_n; ++i) { + char buf[320], cs[16]; + snprintf(cs, sizeof(cs), "%d", e->alert_lv[i]); + const char *vals[] = { cs }; + expand_fmt(buf, sizeof(buf), e->alert_cmd, "c", vals); + printf(" %3d%% sh -c \"%s\"\n", e->alert_lv[i], buf); + } + printf(" (not run by this test)\n"); + } + + if (e->alert_tone <= 0 || !*e->alert_play) { puts("\nno tone configured"); return 0; } + + /* Straight to the player, in this process, so its own errors land on the + * terminal and the exit status is the answer. */ + char player[1024]; + alert_player_cmd(e, player, sizeof(player)); + if (e->alert_vol_min > 0) printf("\nplayer %s\n", player); + + printf("\nplaying... "); + fflush(stdout); + FILE *out = popen(player, "w"); + if (!out) { printf("cannot run it: %s\n", strerror(errno)); return 1; } + const int rate = 44100; + int n = rate * (e->alert_ms > 0 ? e->alert_ms : 1050) / 1000; + int edge = rate * 15 / 1000; + double gain = clampf((float)e->alert_gain / 100.0f, 0.02f, 1.0f); + for (int b = 0; b < (e->alert_beeps > 0 ? e->alert_beeps : 1); ++b) { + for (int i = 0; i < n; ++i) { + double env = 1.0; + if (i < edge) env = 0.5 - 0.5 * cos(M_PI * i / edge); + else if (i > n - edge) env = 0.5 - 0.5 * cos(M_PI * (n - i) / edge); + double v = sin(2.0 * M_PI * e->alert_tone * i / rate) * env * gain; + int16_t sm = (int16_t)(v * 32767.0); + if (fwrite(&sm, 2, 1, out) != 1) break; + fwrite(&sm, 2, 1, out); + } + if (ferror(out)) break; + } + int st = pclose(out); + if (st == 0) { puts("done."); return 0; } + printf("the player failed (exit %d). Heard nothing? That is why.\n", + WIFEXITED(st) ? WEXITSTATUS(st) : st); + return 1; +} + int main(int argc, char **argv) { char cfgpath[PATH_MAX]; @@ -6258,6 +6406,7 @@ int main(int argc, char **argv) printf("SwBr %s (build %s)\n", SWBR_VERSION, SWBR_BUILD); return 0; } else if (!strcmp(argv[i], "--dump-config")) { dump_config(); return 0; } else if (!strcmp(argv[i], "--probe")) probe = 1; + else if (!strcmp(argv[i], "--alert-test")) alert_test_arg = i; else if (!strcmp(argv[i], "--replace")) do_replace = 1; else if (!strcmp(argv[i], "--minimal")) minimal = 1; else if (!strcmp(argv[i], "--slim") || !strcmp(argv[i], "--collapsed")) @@ -6284,6 +6433,7 @@ int main(int argc, char **argv) for (int i = 1; i < argc; ++i) { /* command line wins */ const char *a = argv[i]; if (!strcmp(a, "--config")) { i++; continue; } + if (!strcmp(a, "--alert-test")) { if (i + 1 < argc && argv[i+1][0] != '-') i++; continue; } if (!strcmp(a, "--probe") || !strcmp(a, "--slim") || !strcmp(a, "--replace") || !strcmp(a, "--minimal") || !strcmp(a, "--collapsed")) continue; @@ -6303,6 +6453,12 @@ int main(int argc, char **argv) config_set(&cfg, trim(buf), trim(eq + 1)); } + if (alert_test_arg) { + const char *who = alert_test_arg + 1 < argc && argv[alert_test_arg + 1][0] != '-' + ? argv[alert_test_arg + 1] : NULL; + return alert_test(who); + } + signal(SIGUSR1, on_signal); signal(SIGINT, on_signal); signal(SIGTERM, on_signal);