M README.md => README.md +4 -3
@@ 3,12 3,11 @@
levee is a statusbar for the [river] wayland compositor, written in [zig]
without any UI toolkit. It currently provides full support for workspace tags
-and displays battery capacity and screen brightness.
+and displays pulseaudio volume, battery capacity and screen brightness.
Some important things are not implemented yet:
* configuration via config file
-* volume (alsa/pulseaudio/pipewire) module
* cpu module
## Build
@@ 24,7 23,7 @@ zig build -Drelease-safe --prefix ~/.local install
Add the following toward the end of `$XDG_CONFIG_HOME/river/init`:
```
-riverctl spawn levee -m backlight -m battery
+riverctl spawn levee -m pulse -m backlight -m battery
```
## Dependencies
@@ 33,6 32,7 @@ riverctl spawn levee -m backlight -m battery
* [wayland] 1.20.0
* [pixman] 0.40.0
* [fcft] 3.0.1
+* [libpulse] 15.0
## Contributing
@@ 48,6 48,7 @@ or learn about it following this excellent [tutorial].
[wayland]: https://wayland.freedesktop.org/
[pixman]: http://pixman.org/
[fcft]: https://codeberg.org/dnkl/fcft/
+[libpulse]: https://www.freedesktop.org/wiki/Software/PulseAudio/
[mailing list]: https://lists.sr.ht/~andreafeletto/public-inbox
[issue tracker]: https://todo.sr.ht/~andreafeletto/levee
[web interface]: https://git.sr.ht/~andreafeletto/levee/send-email
M build.zig => build.zig +1 -1
@@ 49,10 49,10 @@ pub fn build(b: *std.build.Builder) void {
exe.addPackage(wayland);
exe.linkLibC();
- exe.linkSystemLibrary("alsa");
exe.linkSystemLibrary("fcft");
exe.linkSystemLibrary("libudev");
exe.linkSystemLibrary("pixman-1");
+ exe.linkSystemLibrary("libpulse");
exe.linkSystemLibrary("wayland-client");
exe.install();
M src/Modules.zig => src/Modules.zig +1 -1
@@ 8,9 8,9 @@ const Modules = @This();
state: *State,
modules: ArrayList(Module),
-pub const Alsa = @import("modules/Alsa.zig");
pub const Backlight = @import("modules/Backlight.zig");
pub const Battery = @import("modules/Battery.zig");
+pub const Pulse = @import("modules/Pulse.zig");
pub const Module = struct {
impl: *anyopaque,
M src/main.zig => src/main.zig +3 -3
@@ 49,12 49,12 @@ pub fn main() anyerror!void {
// modules
for (args.options("--module")) |module_name| {
- if (mem.eql(u8, module_name, "alsa")) {
- try state.modules.register(Modules.Alsa);
- } else if (mem.eql(u8, module_name, "backlight")) {
+ if (mem.eql(u8, module_name, "backlight")) {
try state.modules.register(Modules.Backlight);
} else if (mem.eql(u8, module_name, "battery")) {
try state.modules.register(Modules.Battery);
+ } else if (mem.eql(u8, module_name, "pulse")) {
+ try state.modules.register(Modules.Pulse);
} else {
std.log.err("unknown module: {s}", .{ module_name });
os.exit(1);
D src/modules/Alsa.zig => src/modules/Alsa.zig +0 -150
@@ 1,150 0,0 @@
-const std = @import("std");
-const fmt = std.fmt;
-const math = std.math;
-const mem = std.mem;
-const os = std.os;
-
-const alsa = @cImport(@cInclude("alsa/asoundlib.h"));
-
-const Module = @import("../Modules.zig").Module;
-const Event = @import("../Loop.zig").Event;
-const render = @import("../render.zig");
-const State = @import("../main.zig").State;
-const utils = @import("../utils.zig");
-const Alsa = @This();
-
-state: *State,
-devices: DeviceList,
-
-const Device = struct {
- ctl: *alsa.snd_ctl_t,
- name: []const u8,
-};
-
-const DeviceList = std.ArrayList(Device);
-
-pub fn create(state: *State) !*Alsa {
- const self = try state.gpa.create(Alsa);
- self.* = .{
- .state = state,
- .devices = DeviceList.init(state.gpa),
- };
-
- var card: i32 = -1;
- while(alsa.snd_card_next(&card) >= 0 and card >= 0) {
- const name = try fmt.allocPrintZ(state.gpa, "hw:{d}", .{ card });
-
- var ctl: ?*alsa.snd_ctl_t = null;
- _ = alsa.snd_ctl_open(&ctl, name.ptr, alsa.SND_CTL_READONLY);
- _ = alsa.snd_ctl_subscribe_events(ctl, 1);
-
- try self.devices.append(.{ .ctl = ctl.?, .name = name });
- }
-
- return self;
-}
-
-pub fn module(self: *Alsa) !Module {
- return Module{
- .impl = @ptrCast(*anyopaque, self),
- .funcs = .{
- .getEvent = getEvent,
- .print = print,
- .destroy = destroy,
- },
- };
-}
-
-fn getEvent(self_opaque: *anyopaque) !Event {
- const self = utils.cast(Alsa)(self_opaque);
-
- var fd = mem.zeroes(alsa.pollfd);
- const device = &self.devices.items[0];
- _ = alsa.snd_ctl_poll_descriptors(device.ctl, &fd, 1);
-
- return Event{
- .fd = @bitCast(os.pollfd, fd),
- .data = self_opaque,
- .callbackIn = callbackIn,
- .callbackOut = Event.noop,
- };
-}
-
-fn print(self_opaque: *anyopaque, writer: Module.StringWriter) !void {
- const self = utils.cast(Alsa)(self_opaque);
- _ = self;
-
- var handle: ?*alsa.snd_mixer_t = null;
- _ = alsa.snd_mixer_open(&handle, 0);
- _ = alsa.snd_mixer_attach(handle, "default");
- _ = alsa.snd_mixer_selem_register(handle, null, null);
- _ = alsa.snd_mixer_load(handle);
-
- var sid: ?*alsa.snd_mixer_selem_id_t = null;
- _ = alsa.snd_mixer_selem_id_malloc(&sid);
- defer alsa.snd_mixer_selem_id_free(sid);
- alsa.snd_mixer_selem_id_set_index(sid, 0);
- alsa.snd_mixer_selem_id_set_name(sid, "Master");
- const elem = alsa.snd_mixer_find_selem(handle, sid);
-
- var unmuted: i32 = 0;
- _ = alsa.snd_mixer_selem_get_playback_switch(
- elem,
- alsa.SND_MIXER_SCHN_MONO,
- &unmuted,
- );
- if (unmuted == 0) {
- return writer.print(" 🔇 ", .{});
- }
-
- var min: i64 = 0;
- var max: i64 = 0;
- _ = alsa.snd_mixer_selem_get_playback_volume_range(elem, &min, &max);
-
- var volume: i64 = 0;
- _ = alsa.snd_mixer_selem_get_playback_volume(
- elem,
- alsa.SND_MIXER_SCHN_MONO,
- &volume,
- );
-
- const percent = percent: {
- var x = @intToFloat(f64, volume) / @intToFloat(f64, max);
- x = math.tanh(math.sqrt(x) * 0.65) * 180.0;
- break :percent @floatToInt(u8, @round(x));
- };
- return writer.print("🔊 {d}%", .{ percent });
-}
-
-fn callbackIn(self_opaque: *anyopaque) error{Terminate}!void {
- const self = utils.cast(Alsa)(self_opaque);
-
- var event: ?*alsa.snd_ctl_event_t = null;
- _ = alsa.snd_ctl_event_malloc(&event);
- defer alsa.snd_ctl_event_free(event);
-
- const device = &self.devices.items[0];
- _ = alsa.snd_ctl_read(device.ctl, event);
-
- for (self.state.wayland.monitors.items) |monitor| {
- if (monitor.bar) |bar| {
- if (bar.configured) {
- render.renderClock(bar) catch continue;
- render.renderModules(bar) catch continue;
- bar.clock.surface.commit();
- bar.modules.surface.commit();
- bar.background.surface.commit();
- }
- }
- }
-}
-
-fn destroy(self_opaque: *anyopaque) void {
- const self = utils.cast(Alsa)(self_opaque);
-
- for (self.devices.items) |*device| {
- self.state.gpa.free(device.name);
- }
- self.devices.deinit();
- self.state.gpa.destroy(self);
-}
A src/modules/Pulse.zig => src/modules/Pulse.zig +192 -0
@@ 0,0 1,192 @@
+const std = @import("std");
+const mem = std.mem;
+const os = std.os;
+
+const pulse = @cImport(@cInclude("pulse/pulseaudio.h"));
+
+const Module = @import("../Modules.zig").Module;
+const Event = @import("../Loop.zig").Event;
+const render = @import("../render.zig");
+const State = @import("../main.zig").State;
+const utils = @import("../utils.zig");
+const Pulse = @This();
+
+state: *State,
+mainloop: *pulse.pa_threaded_mainloop,
+api: *pulse.pa_mainloop_api,
+context: *pulse.pa_context,
+fd: os.fd_t,
+sink_name: []const u8,
+volume: u8,
+muted: bool,
+
+pub fn create(state: *State) !*Pulse {
+ const self = try state.gpa.create(Pulse);
+ self.state = state;
+
+ self.mainloop = pulse.pa_threaded_mainloop_new() orelse {
+ return error.InitFailed;
+ };
+ self.api = pulse.pa_threaded_mainloop_get_api(self.mainloop);
+ self.context = pulse.pa_context_new(self.api, "levee") orelse {
+ return error.InitFailed;
+ };
+ const connected = pulse.pa_context_connect(
+ self.context,
+ null,
+ pulse.PA_CONTEXT_NOFAIL,
+ null,
+ );
+ if (connected < 0) return error.InitFailed;
+ pulse.pa_context_set_state_callback(
+ self.context,
+ contextStateCallback,
+ @ptrCast(*anyopaque, self),
+ );
+ const started = pulse.pa_threaded_mainloop_start(self.mainloop);
+ if (started < 0) return error.InitFailed;
+
+ const fd = try os.eventfd(0, os.linux.EFD.NONBLOCK);
+ self.fd = @intCast(os.fd_t, fd);
+ return self;
+}
+
+pub fn module(self: *Pulse) !Module {
+ return Module{
+ .impl = @ptrCast(*anyopaque, self),
+ .funcs = .{
+ .getEvent = getEvent,
+ .print = print,
+ .destroy = destroy,
+ },
+ };
+}
+
+fn getEvent(self_opaque: *anyopaque) !Event {
+ const self = utils.cast(Pulse)(self_opaque);
+
+ return Event{
+ .fd = .{ .fd = self.fd, .events = os.POLL.IN, .revents = undefined },
+ .data = self_opaque,
+ .callbackIn = callbackIn,
+ .callbackOut = Event.noop,
+ };
+}
+
+fn print(self_opaque: *anyopaque, writer: Module.StringWriter) !void {
+ const self = utils.cast(Pulse)(self_opaque);
+
+ return writer.print("🔊 {d}%", .{ self.volume });
+}
+
+fn callbackIn(self_opaque: *anyopaque) error{Terminate}!void {
+ const self = utils.cast(Pulse)(self_opaque);
+
+ for (self.state.wayland.monitors.items) |monitor| {
+ if (monitor.bar) |bar| {
+ if (bar.configured) {
+ render.renderClock(bar) catch continue;
+ render.renderModules(bar) catch continue;
+ bar.clock.surface.commit();
+ bar.modules.surface.commit();
+ bar.background.surface.commit();
+ }
+ }
+ }
+}
+
+fn destroy(self_opaque: *anyopaque) void {
+ const self = utils.cast(Pulse)(self_opaque);
+
+ self.api.quit.?(self.api, 0);
+ pulse.pa_threaded_mainloop_stop(self.mainloop);
+ pulse.pa_threaded_mainloop_free(self.mainloop);
+ self.state.gpa.destroy(self);
+}
+
+export fn contextStateCallback(
+ ctx: ?*pulse.pa_context,
+ self_opaque: ?*anyopaque,
+) void {
+ const self = utils.cast(Pulse)(self_opaque.?);
+
+ const ctx_state = pulse.pa_context_get_state(ctx);
+ switch (ctx_state) {
+ pulse.PA_CONTEXT_READY => {
+ _ = pulse.pa_context_get_server_info(
+ ctx,
+ serverInfoCallback,
+ self_opaque,
+ );
+ pulse.pa_context_set_subscribe_callback(
+ ctx,
+ subscribeCallback,
+ self_opaque,
+ );
+ const mask = pulse.PA_SUBSCRIPTION_MASK_SERVER |
+ pulse.PA_SUBSCRIPTION_MASK_SINK |
+ pulse.PA_SUBSCRIPTION_MASK_SINK_INPUT |
+ pulse.PA_SUBSCRIPTION_MASK_SOURCE |
+ pulse.PA_SUBSCRIPTION_MASK_SOURCE_OUTPUT;
+ _ = pulse.pa_context_subscribe(ctx, mask, null, null);
+ },
+ pulse.PA_CONTEXT_TERMINATED => self.api.quit.?(self.api, 0),
+ pulse.PA_CONTEXT_FAILED => self.api.quit.?(self.api, 0),
+ else => {},
+ }
+}
+
+export fn serverInfoCallback(
+ ctx: ?*pulse.pa_context,
+ info: ?*const pulse.pa_server_info,
+ self_opaque: ?*anyopaque,
+) void {
+ const self = utils.cast(Pulse)(self_opaque.?);
+
+ self.sink_name = mem.span(info.?.default_sink_name);
+ _ = pulse.pa_context_get_sink_info_list(ctx, sinkInfoCallback, self_opaque);
+}
+
+export fn subscribeCallback(
+ ctx: ?*pulse.pa_context,
+ event_type: pulse.pa_subscription_event_type_t,
+ index: u32,
+ self_opaque: ?*anyopaque,
+) void {
+ const operation = event_type & pulse.PA_SUBSCRIPTION_EVENT_TYPE_MASK;
+ if (operation != pulse.PA_SUBSCRIPTION_EVENT_CHANGE) return;
+
+ const facility = event_type & pulse.PA_SUBSCRIPTION_EVENT_FACILITY_MASK;
+ if (facility == pulse.PA_SUBSCRIPTION_EVENT_SINK) {
+ _ = pulse.pa_context_get_sink_info_by_index(
+ ctx,
+ index,
+ sinkInfoCallback,
+ self_opaque,
+ );
+ }
+}
+
+export fn sinkInfoCallback(
+ _: ?*pulse.pa_context,
+ maybe_info: ?*const pulse.pa_sink_info,
+ _: c_int,
+ self_opaque: ?*anyopaque,
+) void {
+ const self = utils.cast(Pulse)(self_opaque.?);
+ const info = maybe_info orelse return;
+
+ const sink_name = mem.span(info.name);
+ if (!mem.eql(u8, self.sink_name, sink_name)) return;
+
+ self.volume = volume: {
+ const avg = pulse.pa_cvolume_avg(&info.volume);
+ const norm = @intToFloat(f64, pulse.PA_VOLUME_NORM);
+ const ratio = 100 * @intToFloat(f64, avg) / norm;
+ break :volume @floatToInt(u8, @round(ratio));
+ };
+ self.muted = info.mute != 0;
+
+ const increment = mem.asBytes(&@as(u64, 1));
+ _ = os.write(self.fd, increment) catch return;
+}