Something like the following.
vu.h: A simple thread-safe interface for VU metering
#ifndef VU_H
#define VU_H
/**
* Initialize VU measurements
*
* @param server PulseAudio server; NULL for default
* @param appname Application name
* @param devname Source name; NULL for default
* @param stream Descriptive stream name
* @param channels Number of channels
* @param rate Samples per second per channel
* @param samples Samples per update
* @return Zero if success, nonzero if error.
*/
int vu_start(const char *server,
const char *appname,
const char *devname,
const char *stream,
int channels,
int rate,
int samples);
/**
* Convert vu_start() return value to a string
*/
const char *vu_error(int);
/**
* Stop VU measurements
*/
void vu_stop(void);
/**
* Wait for the next VU update
*/
void vu_wait(void);
/**
* Get latest VU peaks per channel; thread-safe
*
* @param peak Array of floats to be populated
* @param channels Number of channels in peak array
* @return Zero if no new data available,
* number of channels available if updated,
* negative if an error occurred.
*/
int vu_peak(float *to, int channels);
/**
* Check if new VU peaks are available; thread-safe
*/
int vu_peak_available(void);
#endif /* VU_H */
vu.c: Implementing the above
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <stdint.h>
#include <pthread.h>
#include <limits.h>
#include <pulse/simple.h>
#include <pulse/error.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
static volatile int done = 0;
static pa_simple *audio = NULL;
static size_t audio_channels = 0;
static size_t audio_samples = 0;
static int32_t *audio_buffer = NULL; /* audio_buffer[audio_samples][audio_channels] */
static int32_t *audio_min = NULL; /* audio_min[audio_channels] */
static int32_t *audio_max = NULL; /* audio_max[audio_channels] */
static pthread_t audio_thread;
static pthread_mutex_t peak_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t peak_update = PTHREAD_COND_INITIALIZER;
static float *peak_amplitude = NULL;
static volatile int peak_available = 0;
int vu_peak_available(void)
{
return peak_available;
}
static void *worker(void *unused)
{
(void)unused; /* Silence warning about unused parameter. */
while (!done) {
int err = 0;
if (pa_simple_read(audio, audio_buffer, audio_channels * audio_samples * sizeof audio_buffer[0], &err) < 0) {
done = -EIO;
break;
}
for (size_t c = 0; c < audio_channels; c++) {
audio_min[c] = (int32_t)( 2147483647);
audio_max[c] = (int32_t)(-2147483648);
}
int32_t *const end = audio_buffer + audio_channels * audio_samples;
int32_t *ptr = audio_buffer;
/* Min-max peak detect. */
while (ptr < end) {
for (size_t c = 0; c < audio_channels; c++) {
const int32_t s = *(ptr++);
audio_min[c] = (audio_min[c] < s) ? audio_min[c] : s;
audio_max[c] = (audio_max[c] > s) ? audio_max[c] : s;
}
}
/* absolute values. */
for (size_t c = 0; c < audio_channels; c++) {
if (audio_min[c] == (int32_t)(-2147483648))
audio_min[c] = (int32_t)( 2147483647);
else
if (audio_min[c] < 0)
audio_min[c] = -audio_min[c];
else
audio_min[c] = 0;
if (audio_max[c] < 0)
audio_max[c] = 0;
}
/* Update peak amplitudes. */
pthread_mutex_lock(&peak_lock);
if (peak_available++) {
for (size_t c = 0; c < audio_channels; c++) {
const float amplitude = (audio_max[c] > audio_min[c]) ? audio_max[c] / 2147483647.0f : audio_min[c] / 2147483647.0f;
peak_amplitude[c] = (peak_amplitude[c] > amplitude) ? peak_amplitude[c] : amplitude;
}
} else {
for (size_t c = 0; c < audio_channels; c++) {
const float amplitude = (audio_max[c] > audio_min[c]) ? audio_max[c] / 2147483647.0f : audio_min[c] / 2147483647.0f;
peak_amplitude[c] = amplitude;
}
}
pthread_cond_broadcast(&peak_update);
pthread_mutex_unlock(&peak_lock);
}
/* Wake up all waiters on the peak update, too. */
pthread_cond_broadcast(&peak_update);
return NULL;
}
const char *vu_error(int err)
{
if (err < 0)
return strerror(-err);
else
if (err > 0)
return pa_strerror(err);
else
return "OK";
}
void vu_stop(void)
{
if (audio) {
if (!done)
done = 1;
pthread_join(audio_thread, NULL);
pa_simple_free(audio);
audio_thread = pthread_self();
audio = NULL;
}
free(audio_buffer); /* Note: free(NULL) is OK. */
free(audio_min);
free(audio_max);
audio_buffer = NULL;
audio_min = NULL;
audio_max = NULL;
audio_channels = 0;
audio_samples = 0;
pthread_mutex_lock(&peak_lock);
free(peak_amplitude);
peak_amplitude = NULL;
peak_available = 0;
pthread_mutex_unlock(&peak_lock);
}
void vu_wait(void)
{
pthread_mutex_lock(&peak_lock);
if (audio && !done)
pthread_cond_wait(&peak_update, &peak_lock);
pthread_mutex_unlock(&peak_lock);
}
int vu_peak(float *to, int num)
{
pthread_mutex_lock(&peak_lock);
if (!peak_amplitude || !peak_available || audio_channels < 1) {
pthread_mutex_unlock(&peak_lock);
return 0;
}
const int have = (int)audio_channels;
if (num > 0) {
const int cmax = (num < have) ? num : have;
for (int c = 0; c < cmax; c++)
to[c] = peak_amplitude[c];
}
peak_available = 0;
pthread_mutex_unlock(&peak_lock);
return have;
}
int vu_start(const char *server,
const char *appname,
const char *devname,
const char *stream,
int channels,
int rate,
int samples)
{
pa_sample_spec samplespec;
pa_buffer_attr bufferspec;
pthread_attr_t attrs;
int err;
if (!appname || !*appname || !stream || !*stream ||
channels < 1 || channels > 128 || rate < 1 || rate > 1000000 || samples < 1 || samples > 1000000) {
return -EINVAL;
}
/* Empty or "default" server maps to NULL. */
if (server && (!*server || !strcmp(server, "default")))
server = NULL;
/* Empty or "default" devname maps to NULL. */
if (devname && (!*devname || !strcmp(devname, "default")))
devname = NULL;
/* If already running, stop. */
if (audio) {
vu_stop();
}
done = 0;
pthread_mutex_lock(&peak_lock);
samplespec.format = PA_SAMPLE_S32NE;
samplespec.rate = rate;
samplespec.channels = channels;
bufferspec.maxlength = (uint32_t)(-1);
bufferspec.tlength = (uint32_t)(-1);
bufferspec.prebuf = (uint32_t)(-1);
bufferspec.minreq = (uint32_t)(-1);
bufferspec.fragsize = (uint32_t)channels * (uint32_t)samples * (uint32_t)sizeof audio_buffer[0];
err = PA_OK;
audio = pa_simple_new(server, appname, PA_STREAM_RECORD, devname, stream, &samplespec, NULL, &bufferspec, &err);
if (!audio) {
pthread_mutex_unlock(&peak_lock);
return err;
}
/* Allocate memory for the various buffers. */
audio_buffer = calloc((size_t)channels * sizeof audio_buffer[0], (size_t)samples);
audio_min = malloc((size_t)channels * sizeof audio_min[0]);
audio_max = malloc((size_t)channels * sizeof audio_max[0]);
peak_amplitude = malloc((size_t)channels * sizeof peak_amplitude[0]);
if (!audio_buffer || !audio_min || !audio_max || !peak_amplitude) {
free(peak_amplitude);
free(audio_max);
free(audio_min);
free(audio_buffer);
pa_simple_free(audio);
audio = NULL;
audio_buffer = NULL;
audio_min = NULL;
audio_max = NULL;
peak_amplitude = NULL;
pthread_mutex_unlock(&peak_lock);
return -ENOMEM;
}
for (int c = 0; c < channels; c++)
peak_amplitude[c] = 0.0f;
peak_available = 0;
audio_channels = channels;
audio_samples = samples;
pthread_attr_init(&attrs);
pthread_attr_setstacksize(&attrs, 2 * PTHREAD_STACK_MIN);
err = pthread_create(&audio_thread, &attrs, worker, NULL);
if (err) {
pa_simple_free(audio);
free(peak_amplitude);
free(audio_max);
free(audio_min);
free(audio_buffer);
audio = NULL;
audio_buffer = NULL;
audio_min = NULL;
audio_max = NULL;
peak_amplitude = NULL;
audio_channels = 0;
audio_samples = 0;
pthread_mutex_unlock(&peak_lock);
return -err;
}
pthread_attr_destroy(&attrs);
pthread_mutex_unlock(&peak_lock);
return 0;
}
gui.c: A simple Gtk+ UI for the VU meters
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <unistd.h>
#include <locale.h>
#include <signal.h>
#include <gtk/gtk.h>
#include <ctype.h>
#include <string.h>
#include <strings.h>
#include <errno.h>
#include "vu.h"
#ifndef MAX_CHANNELS
#define MAX_CHANNELS 32
#endif
#ifndef MAX_RATE
#define MAX_RATE 250000
#endif
static volatile sig_atomic_t done = 0;
static void handle_done(int signum)
{
if (!done)
done = signum;
}
static int install_done(int signum)
{
struct sigaction act;
memset(&act, 0, sizeof act);
sigemptyset(&act.sa_mask);
act.sa_handler = handle_done;
act.sa_flags = SA_RESTART;
if (sigaction(signum, &act, NULL) == -1)
return errno;
return 0;
}
struct tickmark {
float amplitude;
float red;
float green;
float blue;
};
enum placement {
PLACEMENT_LEFT = 1,
PLACEMENT_RIGHT = 2,
PLACEMENT_TOP = 3,
PLACEMENT_BOTTOM = 4
};
static const char *server = NULL;
static const char *device = NULL;
static int channels = 2;
static int rate = 48000;
static int updates = 60;
static int bar_size = 4;
static int bar_space = 3;
static int display_monitor = -1;
static enum placement display_placement = PLACEMENT_RIGHT;
static float *peak = NULL;
static float decay = 0.95f;
static float red_limit = 0.891251f; /* Amplitude within 1 dB of clipping */
static float green_limit = 0.707946f; /* Amplitude within 3 dB of clipping */
static struct tickmark tickmarks[] = {
{ .amplitude = 0.891251f, .red = 1.0f, .green = 0.00f, .blue = 0.0f }, /* 1 dB */
{ .amplitude = 0.794328f, .red = 1.0f, .green = 1.00f, .blue = 0.0f }, /* 2 dB */
{ .amplitude = 0.707946f, .red = 0.0f, .green = 1.00f, .blue = 0.0f }, /* 3 dB */
{ .amplitude = 0.630957f, .red = 0.0f, .green = 0.95f, .blue = 0.0f }, /* 4 dB */
{ .amplitude = 0.562341f, .red = 0.0f, .green = 0.90f, .blue = 0.0f }, /* 5 dB */
{ .amplitude = 0.501187f, .red = 0.0f, .green = 0.85f, .blue = 0.0f }, /* 6 dB */
{ .amplitude = 0.446684f, .red = 0.0f, .green = 0.80f, .blue = 0.0f }, /* 7 dB */
{ .amplitude = 0.398107f, .red = 0.0f, .green = 0.75f, .blue = 0.0f }, /* 8 dB */
{ .amplitude = 0.354813f, .red = 0.0f, .green = 0.70f, .blue = 0.0f }, /* 9 dB */
{ .amplitude = 0.316228f, .red = 0.0f, .green = 0.65f, .blue = 0.0f }, /* 10 dB */
{ .amplitude = -1.0f }
};
static gboolean draw(GtkWidget *widget, cairo_t *cr, gpointer user_data)
{
(void)user_data; /* Silence unused parameter warning; generates no code */
GdkRectangle area;
gtk_widget_get_clip(widget, &area);
cairo_save(cr);
cairo_set_source_rgb(cr, 0.0,0.0,0.0);
cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE);
cairo_paint(cr);
for (int i = 0; i < channels; i++) {
if (peak[i] >= red_limit)
cairo_set_source_rgb(cr, 1.0, 0.0, 0.0);
else
if (peak[i] <= green_limit)
cairo_set_source_rgb(cr, 0.0, 0.5 + 0.5*peak[i]/green_limit, 0.0);
else {
const double c = (peak[i] - green_limit) / (red_limit - green_limit);
cairo_set_source_rgb(cr, c, 1.0-c, 0.0);
}
const double c = (peak[i] < 0.0f) ? 0.0 : (peak[i] < 1.0f) ? peak[i] : 1.0;
if (display_placement == PLACEMENT_LEFT || display_placement == PLACEMENT_RIGHT) {
cairo_rectangle(cr, area.x + bar_space + i * (bar_space + bar_size),
area.y + bar_space + (1.0 - c)*(area.height - 2*bar_space),
bar_size, c*area.height);
cairo_fill(cr);
} else
if (display_placement == PLACEMENT_TOP || display_placement == PLACEMENT_BOTTOM) {
cairo_rectangle(cr, area.x + bar_space,
area.y + bar_space + i * (bar_space + bar_size),
c * (area.width - 2*bar_space), bar_size);
cairo_fill(cr);
}
}
cairo_set_line_width(cr, 1.0);
if (display_placement == PLACEMENT_LEFT || display_placement == PLACEMENT_RIGHT) {
for (int i = 0; tickmarks[i].amplitude >= 0.0f; i++) {
const int y = area.y + bar_space + (1.0f - tickmarks[i].amplitude)*(area.height - 2*bar_space);
cairo_set_source_rgb(cr, tickmarks[i].red, tickmarks[i].green, tickmarks[i].blue);
cairo_move_to(cr, area.x + 2, y);
cairo_line_to(cr, area.x + area.width - 2, y);
cairo_stroke(cr);
}
} else
if (display_placement == PLACEMENT_TOP || display_placement == PLACEMENT_BOTTOM) {
for (int i = 0; tickmarks[i].amplitude >= 0.0f; i++) {
const int x = area.x + bar_space + tickmarks[i].amplitude*(area.width - 2*bar_space);
cairo_set_source_rgb(cr, tickmarks[i].red, tickmarks[i].green, tickmarks[i].blue);
cairo_move_to(cr, x, area.y + 2);
cairo_line_to(cr, x, area.y + area.width - 2);
cairo_stroke(cr);
}
}
cairo_restore(cr);
return TRUE;
}
static gboolean tick(GtkWidget *widget, GdkFrameClock *fclk, gpointer user_data)
{
(void)user_data; /* Silence unused parameter warning; generates no code */
(void)fclk;
if (done) {
gtk_window_close(GTK_WINDOW(widget));
return G_SOURCE_REMOVE;
}
float new_peak[channels];
if (vu_peak(new_peak, channels) == channels) {
for (int c = 0; c < channels; c++) {
peak[c] *= decay;
peak[c] = (new_peak[c] > peak[c]) ? new_peak[c] : peak[c];
}
gtk_widget_queue_draw(widget);
}
return G_SOURCE_CONTINUE;
}
static void screen_changed(GtkWidget *widget, GdkScreen *old_screen, gpointer user_data)
{
(void)user_data; (void)old_screen; /* Silence unused parameter warning; generates no code */
gtk_widget_set_visual(widget, gdk_screen_get_system_visual(gtk_widget_get_screen(widget)));
}
static void place(GdkRectangle *to)
{
if (!to)
return;
to->x = 0;
to->y = 0;
to->width = bar_space + (bar_size + bar_space) * channels;
to->height = bar_space + (bar_size + bar_space) * channels;
GdkDisplay *d = gdk_display_get_default();
GdkMonitor *m = gdk_display_get_monitor(d, display_monitor);
GdkRectangle w;
if (!m)
m = gdk_display_get_primary_monitor(d);
if (!m)
return;
gdk_monitor_get_workarea(m, &w);
switch (display_placement) {
case PLACEMENT_LEFT:
to->x = w.x;
to->height = w.height;
return;
case PLACEMENT_RIGHT:
to->x = w.x + w.width - to->width;
to->height = w.height;
return;
case PLACEMENT_TOP:
to->y = w.y;
to->width = w.width;
return;
case PLACEMENT_BOTTOM:
to->y = w.y + w.height - to->height;
to->width = w.width;
return;
}
}
static void activate(GtkApplication *app, gpointer user_data)
{
(void)user_data; /* Silence unused parameter warning; generates no code */
/* Compute where to place the window */
GdkRectangle pos;
place(&pos);
/* Create window */
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "VU-Bar");
gtk_window_set_icon_name(GTK_WINDOW(window), "multimedia-volume-control");
gtk_window_set_default_size(GTK_WINDOW(window), pos.width, pos.height);
gtk_window_set_resizable(GTK_WINDOW(window), TRUE);
gtk_window_move(GTK_WINDOW(window), pos.x, pos.y);
gtk_window_set_decorated(GTK_WINDOW(window), FALSE);
gtk_widget_add_tick_callback(window, tick, NULL, NULL);
gtk_widget_set_app_paintable(window, TRUE);
g_signal_connect(window, "draw", G_CALLBACK(draw), NULL);
g_signal_connect(window, "screen-changed", G_CALLBACK(screen_changed), NULL);
gtk_application_add_window(app, GTK_WINDOW(window));
gtk_widget_show_all(window);
gtk_window_set_keep_above(GTK_WINDOW(window), TRUE);
}
static const char *skip_lws(const char *from)
{
if (!from)
return NULL;
while (isspace((unsigned char)(*from)))
from++;
return from;
}
static const char *parse_int(const char *from, int *to)
{
const char *next = from;
long val;
if (!from || *from == '\0') {
errno = EINVAL;
return NULL;
}
errno = 0;
val = strtol(from, (char **)(&next), 0);
if (errno)
return NULL;
if (next == from) {
errno = EINVAL;
return NULL;
}
if ((long)(int)(val) != val) {
errno = ERANGE;
return NULL;
}
if (to)
*to = val;
errno = 0;
return next;
}
int usage(const char *arg0)
{
fprintf(stderr, "\n");
fprintf(stderr, "Usage: %s -h | --help\n", arg0);
fprintf(stderr, " %s [ OPTIONS ]\n", arg0);
fprintf(stderr, "Options:\n");
fprintf(stderr, " -s SERVER PulseAudio server\n");
fprintf(stderr, " -d DEVICE Source to monitor\n");
fprintf(stderr, " -c CHANNELS Number of channels\n");
fprintf(stderr, " -r RATE Samples per second\n");
fprintf(stderr, " -u COUNT Peak calculations per second\n");
fprintf(stderr, " -m MONITOR Display monitor number\n");
fprintf(stderr, " -p WHERE Meter placement on display\n");
fprintf(stderr, " -B PIXELS Bar thickness in pixels\n");
fprintf(stderr, " -S PIXELS Bar spacing in pixels\n");
fprintf(stderr, "Placement:\n");
fprintf(stderr, " -p left Left edge of monitor\n");
fprintf(stderr, " -p right Right edge of monitor\n");
fprintf(stderr, " -p top Top edge of monitor\n");
fprintf(stderr, " -p bottom Bottom edge of monitor\n");
fprintf(stderr, "\n");
return EXIT_SUCCESS;
}
int main(int argc, char *argv[])
{
const char *arg0 = (argc > 0 && argv && argv[0] && argv[0][0]) ? argv[0] : "(this)";
const char *p;
int opt, val;
setlocale(LC_ALL, "");
if (argc > 1 && !strcmp(argv[1], "--help"))
return usage(arg0);
gtk_init(&argc, &argv);
while ((opt = getopt(argc, argv, "hs:d:c:r:u:m:p:B:S:")) != -1) {
switch (opt) {
case 'h':
return usage(arg0);
case 's':
if (!optarg || optarg[0] == '\0' || !strcmp(optarg, "default"))
server = NULL;
else
server = optarg;
break;
case 'd':
if (!optarg || optarg[0] == '\0' || !strcmp(optarg, "default"))
device = NULL;
else
device = optarg;
break;
case 'c':
p = skip_lws(parse_int(optarg, &val));
if (!p || *p != '\0' || val < 1 || val > MAX_CHANNELS) {
fprintf(stderr, "%s: Invalid number of channels.\n", optarg);
return EXIT_FAILURE;
}
channels = val;
break;
case 'r':
p = skip_lws(parse_int(optarg, &val));
if (!p || *p != '\0' || val < 128 || val > MAX_RATE) {
fprintf(stderr, "%s: Invalid sample rate.\n", optarg);
return EXIT_FAILURE;
}
rate = val;
break;
case 'u':
p = skip_lws(parse_int(optarg, &val));
if (!p || *p != '\0' || val < 1 || val > 200) {
fprintf(stderr, "%s: Invalid number of peak updates per second.\n", optarg);
return EXIT_FAILURE;
}
updates = val;
break;
case 'm':
p = skip_lws(parse_int(optarg, &val));
if (!p || *p != '\0' || val < -1) {
fprintf(stderr, "%s: Invalid monitor number.\n", optarg);
return EXIT_FAILURE;
}
display_monitor = val;
break;
case 'p':
if (!strcasecmp(optarg, "left"))
display_placement = PLACEMENT_LEFT;
else
if (!strcasecmp(optarg, "right"))
display_placement = PLACEMENT_RIGHT;
else
if (!strcasecmp(optarg, "top"))
display_placement = PLACEMENT_TOP;
else
if (!strcasecmp(optarg, "bottom"))
display_placement = PLACEMENT_BOTTOM;
else {
fprintf(stderr, "%s: Unsupported placement.\n", optarg);
return EXIT_FAILURE;
}
break;
case 'B':
p = skip_lws(parse_int(optarg, &val));
if (!p || *p != '\0' || val < 1) {
fprintf(stderr, "%s: Invalid bar thickness in pixels.\n", optarg);
return EXIT_FAILURE;
}
bar_size = val;
break;
case 'S':
p = skip_lws(parse_int(optarg, &val));
if (!p || *p != '\0' || val < 0) {
fprintf(stderr, "%s: Invalid bar spacing in pixels.\n", optarg);
return EXIT_FAILURE;
}
bar_space = val;
break;
case '?':
/* getopt() has already printed an error message. */
return EXIT_FAILURE;
default:
/* Bug catcher: This should never occur. */
fprintf(stderr, "getopt() returned %d ('%c')!\n", opt, opt);
return EXIT_FAILURE;
}
}
if (install_done(SIGINT) ||
install_done(SIGHUP) ||
install_done(SIGTERM) ||
install_done(SIGQUIT)) {
fprintf(stderr, "Cannot install signal handlers: %s.\n", strerror(errno));
return EXIT_FAILURE;
}
if (optind < argc) {
fprintf(stderr, "%s: Unsupported parameter.\n", argv[optind]);
return EXIT_FAILURE;
}
GtkApplication *app = gtk_application_new(NULL, G_APPLICATION_NON_UNIQUE);
if (!app) {
fprintf(stderr, "Cannot start GTK+ application.\n");
return EXIT_FAILURE;
}
g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
size_t samples = rate / updates;
if (samples < 1)
samples = 1;
val = vu_start(server, "vu-bar", device, "VU monitor", channels, rate, samples);
if (val) {
fprintf(stderr, "Cannot monitor audio source: %s.\n", vu_error(val));
g_object_unref(app);
return EXIT_FAILURE;
}
peak = calloc((size_t)channels * sizeof (float), samples);
if (!peak) {
fprintf(stderr, "Out of memory.\n");
g_object_unref(app);
vu_stop();
return EXIT_FAILURE;
}
val = g_application_run(G_APPLICATION(app), 0, NULL);
g_object_unref(app);
vu_stop();
return val;
}
Makefile: You can run sed -e 's|^ *|\t|' -i Makefile to fix indentation, if you have issues.
CC := gcc
CFLAGS := -Wall -Wextra -O2 `pkg-config --cflags gtk+-3.0 libpulse-simple`
LDFLAGS := -pthread -lm `pkg-config --libs gtk+-3.0 libpulse-simple`
PROGS := vu-bar
all: clean $(PROGS)
.PHONY: clean
clean:
rm -f *.o $(PROGS)
%.o: %.c
$(CC) $(CFLAGS) -c $^
vu-bar: gui.o vu.o
$(CC) $(CFLAGS) $^ $(LDFLAGS) -o $@
Run
make clean all
to compile, and then
./vu-bar -h
to see the usage. Normally, ./vu-bar does the right thing, showing the default audio source as vertical bars on the right side of the main display.
The tick marks are at 1 dB intervals. Close it by making sure it is the current application, then Alt+F4 as usual (or right-clicking on the application in your panel, and Close). Or run pkill -HUP vu-bar to send it a HUP signal, which also causes it to exit cleanly.
Note: This is utter crap, since I just wrote it from scratch in one sitting. Licensed under CC0-1.0 (i.e., do what you will, just don't blame me). Needs better organization and refactoring. Almost certainly contains bugs. But should provide ideas.