Nimbin[12]?Sway / SwBr / commits / cb624c8

SwBr git · main

SwBr - status bar for sway

sway wayland c bar · first commit 2026-08-27 · last commit 2026-10-05 (4 days ago) · synced 3 days ago · upstream: github.com/nimbin2/SwBr

C 95.9% Markdown 3.7%
git clone https://git.christianimmanuel.de/sway/SwBr.gitwget https://git.christianimmanuel.de/sway/SwBr/archive/SwBr.tar.gz

Update sound notification min vol

Christian Immanuel · 2026-10-05 09:21 · cb624c8dc34d23f99c5749c8740e71b04636f611

 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);