feat: audio - add concurrent mixing, output switching, and device-aware UI
This commit is contained in:
@@ -259,6 +259,8 @@ namespace montauk::abi {
|
||||
static constexpr int AUDIO_CTL_GET_MUTE = 10;
|
||||
static constexpr int AUDIO_CTL_SET_MASTER_MUTE = 11;
|
||||
static constexpr int AUDIO_CTL_GET_MASTER_MUTE = 12;
|
||||
static constexpr int AUDIO_OUTPUT_HDA = 0;
|
||||
static constexpr int AUDIO_OUTPUT_BLUETOOTH = 1;
|
||||
|
||||
static constexpr int SOCK_TCP = 1;
|
||||
static constexpr int SOCK_UDP = 2;
|
||||
|
||||
@@ -188,6 +188,8 @@ struct DesktopState {
|
||||
bool vol_dragging; // slider drag in progress
|
||||
uint64_t vol_last_poll;
|
||||
uint64_t vol_serial; // last seen mixer state serial
|
||||
int vol_output; // 0=HDA, 1=Bluetooth
|
||||
int vol_bt_status; // A2DP state (>=2 means selectable)
|
||||
|
||||
// Temperature monitoring
|
||||
static constexpr int MAX_THERMAL_ZONES = 8;
|
||||
|
||||
@@ -514,6 +514,9 @@ namespace montauk {
|
||||
inline int audio_get_output(int handle) {
|
||||
return audio_ctl(handle, montauk::abi::AUDIO_CTL_GET_OUTPUT, 0);
|
||||
}
|
||||
inline int audio_set_output(int output) {
|
||||
return audio_ctl(-1, montauk::abi::AUDIO_CTL_SET_OUTPUT, output);
|
||||
}
|
||||
inline int audio_bt_status(int handle) {
|
||||
return audio_ctl(handle, montauk::abi::AUDIO_CTL_BT_STATUS, 0);
|
||||
}
|
||||
|
||||
@@ -548,6 +548,7 @@
|
||||
audio_get_pos AUDIO_CTL_GET_POS (2)
|
||||
audio_pause, audio_resume AUDIO_CTL_PAUSE (3)
|
||||
audio_get_output AUDIO_CTL_GET_OUTPUT (4): 0=HDA, 1=Bluetooth
|
||||
audio_set_output AUDIO_CTL_SET_OUTPUT (5): switch all streams
|
||||
(SET_OUTPUT, 5) switch a stream's output route
|
||||
audio_bt_status AUDIO_CTL_BT_STATUS (6)
|
||||
audio_set_master_volume, _get_ AUDIO_CTL_{SET,GET}_MASTER_VOLUME (7/8), 0-100
|
||||
|
||||
@@ -26,7 +26,7 @@ using namespace gui;
|
||||
// ============================================================================
|
||||
|
||||
static constexpr int WIN_W = 380;
|
||||
static constexpr int WIN_H = 460;
|
||||
static constexpr int WIN_H = 590;
|
||||
|
||||
static constexpr int PAD = 22;
|
||||
static constexpr int KNOB_R = 8;
|
||||
@@ -43,6 +43,7 @@ static constexpr int MASTER_BUTTON_GAP = 36; // slider → mute button
|
||||
static constexpr int MASTER_TAIL_GAP = 36; // mute button → separator
|
||||
static constexpr int APPS_HEADER_GAP = 20; // separator → "APPLICATIONS"
|
||||
static constexpr int APPS_FIRST_ROW_GAP = 16; // header → first row
|
||||
static constexpr int OUTPUT_ROW_H = 44;
|
||||
|
||||
// ============================================================================
|
||||
// State
|
||||
@@ -52,6 +53,8 @@ static WsWindow g_win;
|
||||
|
||||
static int g_master_vol = 80;
|
||||
static bool g_master_muted = false;
|
||||
static int g_output = montauk::abi::AUDIO_OUTPUT_HDA;
|
||||
static int g_bt_status = 0; // A2DP State enum
|
||||
|
||||
static montauk::abi::AudioStreamInfo g_streams[8];
|
||||
static int g_stream_count = 0;
|
||||
@@ -116,10 +119,31 @@ static int separator_y(const mtk::Theme& theme) {
|
||||
return master_button_y(theme) + theme.control_h + MASTER_TAIL_GAP;
|
||||
}
|
||||
|
||||
static int apps_header_y(const mtk::Theme& theme) {
|
||||
static int output_header_y(const mtk::Theme& theme) {
|
||||
return separator_y(theme) + APPS_HEADER_GAP;
|
||||
}
|
||||
|
||||
static int output_list_y(const mtk::Theme& theme) {
|
||||
return output_header_y(theme) + system_font_height() + 12;
|
||||
}
|
||||
|
||||
static Rect hda_output_rect(const mtk::Theme& theme) {
|
||||
return {PAD, output_list_y(theme), g_win.width - PAD * 2, OUTPUT_ROW_H};
|
||||
}
|
||||
|
||||
static Rect bt_output_rect(const mtk::Theme& theme) {
|
||||
Rect hda = hda_output_rect(theme);
|
||||
return {hda.x, hda.y + hda.h, hda.w, hda.h};
|
||||
}
|
||||
|
||||
static int apps_separator_y(const mtk::Theme& theme) {
|
||||
return output_list_y(theme) + OUTPUT_ROW_H * 2 + 18;
|
||||
}
|
||||
|
||||
static int apps_header_y(const mtk::Theme& theme) {
|
||||
return apps_separator_y(theme) + APPS_HEADER_GAP;
|
||||
}
|
||||
|
||||
static int stream_list_y(const mtk::Theme& theme) {
|
||||
return apps_header_y(theme) + system_font_height() + APPS_FIRST_ROW_GAP;
|
||||
}
|
||||
@@ -177,6 +201,21 @@ static void refresh_master() {
|
||||
g_master_muted = montauk::audio_get_master_mute() == 1;
|
||||
}
|
||||
|
||||
static void refresh_output() {
|
||||
int output = montauk::audio_get_output(-1);
|
||||
if (output == montauk::abi::AUDIO_OUTPUT_HDA ||
|
||||
output == montauk::abi::AUDIO_OUTPUT_BLUETOOTH) g_output = output;
|
||||
g_bt_status = montauk::audio_bt_status(-1);
|
||||
}
|
||||
|
||||
static bool switch_output(int output) {
|
||||
if (output == g_output) return false;
|
||||
if (montauk::audio_set_output(output) < 0) return false;
|
||||
g_output = output;
|
||||
refresh_output();
|
||||
return true;
|
||||
}
|
||||
|
||||
static void apply_master_volume(int v) {
|
||||
if (v < 0) v = 0;
|
||||
if (v > 100) v = 100;
|
||||
@@ -265,6 +304,33 @@ static void draw_master(Canvas& c, const mtk::Theme& theme) {
|
||||
st, theme);
|
||||
}
|
||||
|
||||
static void draw_outputs(Canvas& c, const mtk::Theme& theme) {
|
||||
c.text(PAD, output_header_y(theme), "OUTPUT DEVICE", theme.text_muted);
|
||||
|
||||
Rect hda = hda_output_rect(theme);
|
||||
Rect bt = bt_output_rect(theme);
|
||||
bool bt_ready = g_bt_status >= 2;
|
||||
bool hda_selected = g_output == montauk::abi::AUDIO_OUTPUT_HDA;
|
||||
bool bt_selected = g_output == montauk::abi::AUDIO_OUTPUT_BLUETOOTH;
|
||||
|
||||
// Keep the choices directly on the page, consistent with the rest of the
|
||||
// app's unboxed sections.
|
||||
Rect hda_radio = {hda.x, hda.y + 6, 18, hda.h - 12};
|
||||
Rect bt_radio = {bt.x, bt.y + 6, 18, bt.h - 12};
|
||||
mtk::draw_radio(c, hda_radio, "", hda_selected, theme);
|
||||
mtk::draw_radio(c, bt_radio, "", bt_selected, theme);
|
||||
|
||||
int text_x = hda.x + 30;
|
||||
c.text(text_x, hda.y + 4, "Built-in Speakers", theme.text);
|
||||
c.text(text_x, hda.y + 23, "High Definition Audio", theme.text_subtle);
|
||||
|
||||
Color bt_text = bt_ready ? theme.text : theme.text_muted;
|
||||
c.text(text_x, bt.y + 4, "Bluetooth", bt_text);
|
||||
c.text(text_x, bt.y + 23,
|
||||
bt_ready ? "Connected audio device" : "No device connected",
|
||||
bt_ready ? theme.text_subtle : theme.text_muted);
|
||||
}
|
||||
|
||||
static void draw_applications(Canvas& c, const mtk::Theme& theme) {
|
||||
int hy = apps_header_y(theme);
|
||||
c.text(PAD, hy, "APPLICATIONS", theme.text_muted);
|
||||
@@ -278,7 +344,8 @@ static void draw_applications(Canvas& c, const mtk::Theme& theme) {
|
||||
}
|
||||
|
||||
int visible = g_stream_count;
|
||||
int rows_room = (g_win.height - stream_list_y(theme) - PAD) / stream_row_h(theme);
|
||||
int rows_room = (g_win.height - stream_list_y(theme) - PAD)
|
||||
/ stream_row_h(theme);
|
||||
if (rows_room < 1) rows_room = 1;
|
||||
if (visible > rows_room) visible = rows_room;
|
||||
|
||||
@@ -320,6 +387,9 @@ static void render() {
|
||||
draw_master(c, theme);
|
||||
mtk::draw_separator(c, PAD, separator_y(theme),
|
||||
g_win.width - PAD * 2, theme);
|
||||
draw_outputs(c, theme);
|
||||
mtk::draw_separator(c, PAD, apps_separator_y(theme),
|
||||
g_win.width - PAD * 2, theme);
|
||||
draw_applications(c, theme);
|
||||
|
||||
host.present();
|
||||
@@ -336,6 +406,14 @@ static bool handle_mouse(int mx, int my, uint8_t buttons, uint8_t prev) {
|
||||
bool down = (buttons & 1) != 0;
|
||||
|
||||
if (clicked) {
|
||||
if (hda_output_rect(theme).contains(mx, my)) {
|
||||
switch_output(montauk::abi::AUDIO_OUTPUT_HDA);
|
||||
return true;
|
||||
}
|
||||
if (bt_output_rect(theme).contains(mx, my) && g_bt_status >= 2) {
|
||||
switch_output(montauk::abi::AUDIO_OUTPUT_BLUETOOTH);
|
||||
return true;
|
||||
}
|
||||
// Master slider.
|
||||
Rect slider = master_slider_rect();
|
||||
if (slider_hit(slider, mx, my)) {
|
||||
@@ -418,6 +496,7 @@ extern "C" void _start() {
|
||||
}
|
||||
|
||||
load_accent();
|
||||
refresh_output();
|
||||
refresh_master();
|
||||
refresh_streams();
|
||||
g_mixer_serial = montauk::audio_wait(0, 0);
|
||||
@@ -443,6 +522,12 @@ extern "C" void _start() {
|
||||
if (now_serial != g_mixer_serial) {
|
||||
g_mixer_serial = now_serial;
|
||||
|
||||
int prev_output = g_output;
|
||||
int prev_bt_status = g_bt_status;
|
||||
refresh_output();
|
||||
if (g_output != prev_output || g_bt_status != prev_bt_status)
|
||||
redraw = true;
|
||||
|
||||
// Master: only refresh when we're not dragging master ourselves,
|
||||
// otherwise a wake mid-drag could snap our knob to the kernel.
|
||||
if (g_drag_target != -1) {
|
||||
|
||||
@@ -390,7 +390,7 @@ void gui::desktop_handle_mouse(DesktopState* ds) {
|
||||
int popup_x = ds->vol_icon_rect.x + ds->vol_icon_rect.w - 200;
|
||||
int popup_y = PANEL_HEIGHT + 2;
|
||||
if (popup_x < 4) popup_x = 4;
|
||||
Rect vol_rect = {popup_x, popup_y, 200, 120};
|
||||
Rect vol_rect = {popup_x, popup_y, 200, 202};
|
||||
|
||||
// Handle drag continuity
|
||||
if (ds->vol_dragging) {
|
||||
@@ -482,6 +482,23 @@ void gui::desktop_handle_mouse(DesktopState* ds) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Output device radio rows.
|
||||
Rect hda_r = {popup_x + 12, popup_y + 137, 200 - 24, 25};
|
||||
Rect bt_r = {hda_r.x, hda_r.y + hda_r.h, hda_r.w, hda_r.h};
|
||||
if (hda_r.contains(mx, my) &&
|
||||
ds->vol_output != montauk::abi::AUDIO_OUTPUT_HDA) {
|
||||
if (montauk::audio_set_output(montauk::abi::AUDIO_OUTPUT_HDA) == 0)
|
||||
ds->vol_output = montauk::abi::AUDIO_OUTPUT_HDA;
|
||||
return;
|
||||
}
|
||||
if (bt_r.contains(mx, my) && ds->vol_bt_status >= 2 &&
|
||||
ds->vol_output != montauk::abi::AUDIO_OUTPUT_BLUETOOTH) {
|
||||
if (montauk::audio_set_output(
|
||||
montauk::abi::AUDIO_OUTPUT_BLUETOOTH) == 0)
|
||||
ds->vol_output = montauk::abi::AUDIO_OUTPUT_BLUETOOTH;
|
||||
return;
|
||||
}
|
||||
return; // click inside popup but not on any control
|
||||
} else if (!ds->vol_icon_rect.contains(mx, my)) {
|
||||
ds->vol_popup_open = false;
|
||||
|
||||
@@ -401,6 +401,8 @@ void gui::desktop_init(DesktopState* ds) {
|
||||
ds->vol_dragging = false;
|
||||
ds->vol_last_poll = montauk::get_milliseconds();
|
||||
ds->vol_serial = montauk::audio_wait(0, 0);
|
||||
ds->vol_output = montauk::audio_get_output(-1);
|
||||
ds->vol_bt_status = montauk::audio_bt_status(-1);
|
||||
|
||||
ds->closing_ext_count = 0;
|
||||
|
||||
@@ -491,6 +493,8 @@ static bool desktop_refresh_panel_state(DesktopState* ds, uint64_t now) {
|
||||
ds->vol_serial = serial;
|
||||
int v = montauk::audio_get_master_volume();
|
||||
bool muted = montauk::audio_get_master_mute() == 1;
|
||||
int output = montauk::audio_get_output(-1);
|
||||
int btStatus = montauk::audio_bt_status(-1);
|
||||
if (v >= 0 && v != ds->vol_level) {
|
||||
ds->vol_level = v;
|
||||
changed = true;
|
||||
@@ -499,6 +503,14 @@ static bool desktop_refresh_panel_state(DesktopState* ds, uint64_t now) {
|
||||
ds->vol_muted = muted;
|
||||
changed = true;
|
||||
}
|
||||
if (output >= 0 && output != ds->vol_output) {
|
||||
ds->vol_output = output;
|
||||
changed = true;
|
||||
}
|
||||
if (btStatus != ds->vol_bt_status) {
|
||||
ds->vol_bt_status = btStatus;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
ds->vol_last_poll = now;
|
||||
}
|
||||
|
||||
@@ -125,6 +125,17 @@ void gui::desktop_draw_panel(DesktopState* ds) {
|
||||
tinted[p] = ((uint32_t)a << 24) | 0x00CC3333;
|
||||
}
|
||||
fb.blit_alpha(vol_icon_x, vol_icon_y, 16, 16, tinted);
|
||||
} else if (ds->vol_output == montauk::abi::AUDIO_OUTPUT_BLUETOOTH) {
|
||||
// The speaker glyph remains recognizable while the accent tint
|
||||
// communicates that audio is routed wirelessly.
|
||||
uint32_t* src = ds->icon_volume.pixels;
|
||||
uint32_t tinted[256];
|
||||
uint32_t rgb = ds->settings.accent_color.to_pixel() & 0x00FFFFFF;
|
||||
for (int p = 0; p < 16 * 16; p++) {
|
||||
uint8_t a = (src[p] >> 24) & 0xFF;
|
||||
tinted[p] = ((uint32_t)a << 24) | rgb;
|
||||
}
|
||||
fb.blit_alpha(vol_icon_x, vol_icon_y, 16, 16, tinted);
|
||||
} else {
|
||||
fb.blit_alpha(vol_icon_x, vol_icon_y, ds->icon_volume.width, ds->icon_volume.height, ds->icon_volume.pixels);
|
||||
}
|
||||
@@ -337,7 +348,7 @@ void desktop_draw_net_popup(DesktopState* ds) {
|
||||
// ============================================================================
|
||||
|
||||
static constexpr int VOL_POPUP_W = 200;
|
||||
static constexpr int VOL_POPUP_H = 120;
|
||||
static constexpr int VOL_POPUP_H = 202;
|
||||
static constexpr int VOL_SLIDER_X = 16;
|
||||
static constexpr int VOL_SLIDER_W = VOL_POPUP_W - 32;
|
||||
static constexpr int VOL_SLIDER_H = 8;
|
||||
@@ -455,4 +466,44 @@ void desktop_draw_vol_popup(DesktopState* ds) {
|
||||
fill_rounded_rect(fb, bx, btn_y, mute_w, btn_h, btn_rad, mute_bg);
|
||||
tw = text_width("Mute");
|
||||
draw_text(fb, bx + (mute_w - tw) / 2, btn_y + (btn_h - system_font_height()) / 2, "Mute", mute_fg);
|
||||
|
||||
// Output is a persistent selection, so present it as a conventional radio
|
||||
// group rather than a second bank of action buttons.
|
||||
int output_label_y = popup_y + 116;
|
||||
draw_text(fb, popup_x + 16, output_label_y, "Output",
|
||||
Color::from_rgb(0x77, 0x77, 0x77));
|
||||
|
||||
auto draw_device_row = [&](const Rect& row, const char* label,
|
||||
bool selected, bool enabled) {
|
||||
bool hovered = enabled && row.contains(ds->mouse.x, ds->mouse.y);
|
||||
if (hovered)
|
||||
fill_rounded_rect(fb, row.x, row.y, row.w, row.h, 5,
|
||||
Color::from_rgb(0xF0, 0xF3, 0xF6));
|
||||
|
||||
int radio_size = 14;
|
||||
int rx = row.x + 4;
|
||||
int ry = row.y + (row.h - radio_size) / 2;
|
||||
fill_rounded_rect(fb, rx, ry, radio_size, radio_size,
|
||||
radio_size / 2, Color::from_rgb(0xAA, 0xAA, 0xAA));
|
||||
fill_rounded_rect(fb, rx + 1, ry + 1, radio_size - 2,
|
||||
radio_size - 2, (radio_size - 2) / 2, colors::WHITE);
|
||||
if (selected)
|
||||
fill_rounded_rect(fb, rx + 4, ry + 4, radio_size - 8,
|
||||
radio_size - 8, (radio_size - 8) / 2,
|
||||
ds->settings.accent_color);
|
||||
|
||||
Color fg = enabled ? colors::TEXT_COLOR
|
||||
: Color::from_rgb(0x99, 0x99, 0x99);
|
||||
draw_text(fb, rx + radio_size + 8,
|
||||
row.y + (row.h - system_font_height()) / 2, label, fg);
|
||||
};
|
||||
|
||||
Rect hda_r = {popup_x + 12, popup_y + 137, VOL_POPUP_W - 24, 25};
|
||||
Rect bt_r = {hda_r.x, hda_r.y + hda_r.h, hda_r.w, hda_r.h};
|
||||
bool bt_ready = ds->vol_bt_status >= 2;
|
||||
draw_device_row(hda_r, "Built-in Speakers",
|
||||
ds->vol_output == montauk::abi::AUDIO_OUTPUT_HDA, true);
|
||||
draw_device_row(bt_r, bt_ready ? "Bluetooth" : "Bluetooth (offline)",
|
||||
ds->vol_output == montauk::abi::AUDIO_OUTPUT_BLUETOOTH,
|
||||
bt_ready);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user