~andreafeletto/levee

7e2284dde1e4abfd2bbadc44b7c0b83a68e2ad6b — Andrea Feletto 27 days ago f2b7495
replace alsa with pulseaudio
6 files changed, 201 insertions(+), 158 deletions(-)

M README.md
M build.zig
M src/Modules.zig
M src/main.zig
D src/modules/Alsa.zig
A src/modules/Pulse.zig
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;
}