/* * fdv-volctl.c * FreeDV Volume Control - GTK4/C * * Reads ~/.config/pipewire/saved-volumes.db on startup. * Live adjustments are applied via wpctl and written to a temp file only. * The real db is only overwritten when the user presses Save. * Revert re-reads the real db and re-applies all volumes via wpctl. * * Build: * gcc $(pkg-config --cflags gtk4) -o fdv-volctl fdv-volctl.c $(pkg-config --libs gtk4) */ #include #include #include #include #include #define MAX_DEVICES 64 #define MAX_NAME_LEN 128 #define MAX_CLASS_LEN 64 #define DB_PATH "%s/.config/pipewire/saved-volumes.db" #define TEMP_PATH "%s/.config/pipewire/saved-volumes.tmp" /* ── Data model ─────────────────────────────────────────────────────────── */ typedef struct { char name[MAX_NAME_LEN]; char class[MAX_CLASS_LEN]; double vol; /* 0.0 – 1.0 */ } Device; static Device devices[MAX_DEVICES]; static int n_devices = 0; static char db_path[256]; static char temp_path[256]; /* ── Application state ───────────────────────────────────────────────────── */ typedef struct { GtkWidget *window; GtkWidget *list_box; GtkWidget *lbl_name; GtkWidget *lbl_class; GtkWidget *lbl_pct; GtkWidget *slider; GtkWidget *toggle_btn; GtkWidget *save_btn; GtkWidget *revert_btn; GtkWidget *status_lbl; gboolean show_all; gboolean unsaved; int selected_idx; /* -1 if none */ } AppData; /* ── Path helpers ────────────────────────────────────────────────────────── */ static void build_paths(void) { const char *home = g_get_home_dir(); snprintf(db_path, sizeof(db_path), DB_PATH, home); snprintf(temp_path, sizeof(temp_path), TEMP_PATH, home); } /* ── File I/O ────────────────────────────────────────────────────────────── */ static int load_file(const char *path) { n_devices = 0; FILE *f = fopen(path, "r"); if (!f) { g_warning("Cannot open: %s", path); return 0; } char line[512]; while (fgets(line, sizeof(line), f) && n_devices < MAX_DEVICES) { line[strcspn(line, "\r\n")] = '\0'; if (line[0] == '\0') continue; char *p1 = strchr(line, '|'); if (!p1) continue; char *p2 = strchr(p1 + 1, '|'); if (!p2) continue; *p1 = '\0'; *p2 = '\0'; g_strlcpy(devices[n_devices].name, line, MAX_NAME_LEN); g_strlcpy(devices[n_devices].class, p1 + 1, MAX_CLASS_LEN); devices[n_devices].vol = atof(p2 + 1); n_devices++; } fclose(f); return n_devices; } static void write_file(const char *path) { FILE *f = fopen(path, "w"); if (!f) { g_warning("Cannot write: %s", path); return; } for (int i = 0; i < n_devices; i++) fprintf(f, "%s|%s|%.2f\n", devices[i].name, devices[i].class, devices[i].vol); fclose(f); } /* Load from real db and seed the temp file */ static void load_db(void) { load_file(db_path); write_file(temp_path); } /* Write current state to temp only */ static void save_temp(void) { write_file(temp_path); } /* Copy current state to real db (explicit Save) */ static void save_to_db(void) { write_file(db_path); write_file(temp_path); /* keep in sync */ } /* ── wpctl integration ───────────────────────────────────────────────────── */ static int find_wpctl_id(const char *name, const char *class) { FILE *fp = popen("wpctl status", "r"); if (!fp) return -1; int ids[256]; int n_ids = 0; char line[512]; while (fgets(line, sizeof(line), fp) && n_ids < 256) { if (!strstr(line, "[vol:")) continue; char clean[512]; int ci = 0; for (int i = 0; line[i] && ci < 510; i++) { unsigned char c = (unsigned char)line[i]; if (c < 0x80 && (c >= 0x20 || c == '\t')) clean[ci++] = line[i]; } clean[ci] = '\0'; char *dot = strchr(clean, '.'); if (!dot) continue; int len = dot - clean; if (len <= 0 || len >= 16) continue; char id_str[16] = {0}; memcpy(id_str, clean, len); id_str[len] = '\0'; char *s = id_str; while (*s == ' ' || *s == '*') s++; if (*s < '0' || *s > '9') continue; ids[n_ids++] = atoi(s); } pclose(fp); for (int i = 0; i < n_ids; i++) { char cmd[64]; snprintf(cmd, sizeof(cmd), "wpctl inspect %d 2>/dev/null", ids[i]); FILE *fp2 = popen(cmd, "r"); if (!fp2) continue; char node_name[MAX_NAME_LEN] = {0}; char node_class[MAX_CLASS_LEN] = {0}; while (fgets(line, sizeof(line), fp2)) { if (strstr(line, "node.name")) { char *q1 = strchr(line, '"'); if (q1) { char *q2 = strchr(q1+1,'"'); if (q2) { *q2='\0'; g_strlcpy(node_name, q1+1, MAX_NAME_LEN); } } } if (strstr(line, "media.class")) { char *q1 = strchr(line, '"'); if (q1) { char *q2 = strchr(q1+1,'"'); if (q2) { *q2='\0'; g_strlcpy(node_class, q1+1, MAX_CLASS_LEN); } } } } pclose(fp2); if (strcmp(node_name, name) == 0 && strcmp(node_class, class) == 0) return ids[i]; } return -1; } static void apply_volume(int idx) { if (idx < 0 || idx >= n_devices) return; Device *d = &devices[idx]; int id = find_wpctl_id(d->name, d->class); if (id < 0) { g_warning("wpctl ID not found for %s", d->name); return; } char cmd[256]; snprintf(cmd, sizeof(cmd), "wpctl set-volume %d %.2f", id, d->vol); system(cmd); } /* Re-apply every device via wpctl — used by Revert */ static void apply_all_volumes(void) { for (int i = 0; i < n_devices; i++) apply_volume(i); } /* ── GUI helpers ─────────────────────────────────────────────────────────── */ static void short_name(const char *full, char *out, int outlen) { if (g_str_has_prefix(full, "FDV_") || g_str_has_prefix(full, "FF_")) { g_strlcpy(out, full, outlen); return; } const char *dot1 = strchr(full, '.'); if (!dot1) { g_strlcpy(out, full, outlen); return; } dot1++; const char *dot2 = strchr(dot1, '.'); if (!dot2) { g_strlcpy(out, dot1, outlen); return; } int len = dot2 - dot1; if (len >= outlen) len = outlen - 1; memcpy(out, dot1, len); out[len] = '\0'; } static void set_unsaved(AppData *app, gboolean unsaved) { app->unsaved = unsaved; gtk_widget_set_sensitive(app->save_btn, unsaved); gtk_widget_set_sensitive(app->revert_btn, unsaved); gtk_label_set_text(GTK_LABEL(app->status_lbl), unsaved ? "⚠ Unsaved changes" : ""); } static void populate_list(AppData *app) { GtkWidget *child; while ((child = gtk_widget_get_first_child(app->list_box)) != NULL) gtk_list_box_remove(GTK_LIST_BOX(app->list_box), child); for (int i = 0; i < n_devices; i++) { if (!app->show_all && devices[i].vol >= 0.995) continue; char sname[MAX_NAME_LEN]; short_name(devices[i].name, sname, sizeof(sname)); int pct = (int)round(devices[i].vol * 100.0); char label[256]; snprintf(label, sizeof(label), "%s [%d%%]", sname, pct); GtkWidget *row_lbl = gtk_label_new(label); gtk_label_set_xalign(GTK_LABEL(row_lbl), 0.0f); gtk_widget_set_margin_start(row_lbl, 8); gtk_widget_set_margin_end(row_lbl, 8); gtk_widget_set_margin_top(row_lbl, 4); gtk_widget_set_margin_bottom(row_lbl, 4); GtkWidget *row = gtk_list_box_row_new(); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), row_lbl); g_object_set_data(G_OBJECT(row), "device-idx", GINT_TO_POINTER(i)); gtk_list_box_append(GTK_LIST_BOX(app->list_box), row); } } static void update_right_panel(AppData *app, int idx) { app->selected_idx = idx; if (idx < 0) { gtk_label_set_text(GTK_LABEL(app->lbl_name), "No device selected"); gtk_label_set_text(GTK_LABEL(app->lbl_class), ""); gtk_label_set_text(GTK_LABEL(app->lbl_pct), ""); gtk_widget_set_sensitive(app->slider, FALSE); return; } Device *d = &devices[idx]; char sname[MAX_NAME_LEN]; short_name(d->name, sname, sizeof(sname)); gtk_label_set_text(GTK_LABEL(app->lbl_name), sname); gtk_label_set_text(GTK_LABEL(app->lbl_class), d->class); char pct_str[16]; snprintf(pct_str, sizeof(pct_str), "%d%%", (int)round(d->vol * 100.0)); gtk_label_set_text(GTK_LABEL(app->lbl_pct), pct_str); g_signal_handlers_block_matched(app->slider, G_SIGNAL_MATCH_DATA, 0, 0, NULL, NULL, app); gtk_range_set_value(GTK_RANGE(app->slider), d->vol * 100.0); g_signal_handlers_unblock_matched(app->slider, G_SIGNAL_MATCH_DATA, 0, 0, NULL, NULL, app); gtk_widget_set_sensitive(app->slider, TRUE); } /* ── Signal callbacks ────────────────────────────────────────────────────── */ static void on_row_selected(GtkListBox *lb, GtkListBoxRow *row, gpointer user_data) { (void)lb; AppData *app = (AppData *)user_data; if (!row) { update_right_panel(app, -1); return; } int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "device-idx")); update_right_panel(app, idx); } static void on_slider_value_changed(GtkRange *range, gpointer user_data) { AppData *app = (AppData *)user_data; if (app->selected_idx < 0) return; double val = gtk_range_get_value(range) / 100.0; devices[app->selected_idx].vol = val; char pct_str[16]; snprintf(pct_str, sizeof(pct_str), "%d%%", (int)round(val * 100.0)); gtk_label_set_text(GTK_LABEL(app->lbl_pct), pct_str); GtkListBoxRow *row = gtk_list_box_get_selected_row(GTK_LIST_BOX(app->list_box)); if (row) { GtkWidget *lbl = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row)); char sname[MAX_NAME_LEN]; short_name(devices[app->selected_idx].name, sname, sizeof(sname)); char label[256]; snprintf(label, sizeof(label), "%s [%d%%]", sname, (int)round(val*100.0)); gtk_label_set_text(GTK_LABEL(lbl), label); } } /* On mouse release: apply via wpctl, write to temp only, mark unsaved */ static gboolean on_slider_released(GtkWidget *widget, GdkEvent *event, gpointer user_data) { (void)widget; AppData *app = (AppData *)user_data; if (gdk_event_get_event_type(event) != GDK_BUTTON_RELEASE) return FALSE; apply_volume(app->selected_idx); save_temp(); set_unsaved(app, TRUE); return FALSE; } static gboolean on_slider_key_released(GtkEventController *ctrl, guint keyval, guint keycode, GdkModifierType state, gpointer user_data) { (void)ctrl; (void)keycode; (void)state; AppData *app = (AppData *)user_data; if (keyval == GDK_KEY_Left || keyval == GDK_KEY_Right || keyval == GDK_KEY_Up || keyval == GDK_KEY_Down || keyval == GDK_KEY_Home || keyval == GDK_KEY_End) { apply_volume(app->selected_idx); save_temp(); set_unsaved(app, TRUE); } return FALSE; } static void on_toggle_show_all(GtkButton *btn, gpointer user_data) { (void)btn; AppData *app = (AppData *)user_data; app->show_all = !app->show_all; gtk_button_set_label(GTK_BUTTON(app->toggle_btn), app->show_all ? "Show active only" : "Show all devices"); populate_list(app); update_right_panel(app, -1); } /* Save: write current state to real db */ static void on_save(GtkButton *btn, gpointer user_data) { (void)btn; AppData *app = (AppData *)user_data; save_to_db(); set_unsaved(app, FALSE); gtk_label_set_text(GTK_LABEL(app->status_lbl), "Saved."); } /* Revert: reload real db, re-apply all levels via wpctl */ static void on_revert(GtkButton *btn, gpointer user_data) { (void)btn; AppData *app = (AppData *)user_data; load_db(); apply_all_volumes(); populate_list(app); update_right_panel(app, -1); set_unsaved(app, FALSE); gtk_label_set_text(GTK_LABEL(app->status_lbl), "Reverted."); } /* Reload from real db without applying volumes — for after FreeDV scripts have run */ static void on_reload(GtkButton *btn, gpointer user_data) { (void)btn; AppData *app = (AppData *)user_data; load_db(); populate_list(app); update_right_panel(app, -1); set_unsaved(app, FALSE); gtk_label_set_text(GTK_LABEL(app->status_lbl), "Reloaded from db."); } /* Response handler for the unsaved-changes dialog */ static void on_close_dialog_response(GtkDialog *dialog, int response, gpointer user_data) { AppData *app = (AppData *)user_data; gtk_window_destroy(GTK_WINDOW(dialog)); switch (response) { case GTK_RESPONSE_ACCEPT: /* Save and close */ save_to_db(); /* fall through */ case GTK_RESPONSE_YES: /* Close anyway */ app->unsaved = FALSE; /* prevent re-triggering on_close_request */ gtk_window_destroy(GTK_WINDOW(app->window)); break; default: /* Cancel -- do nothing */ break; } } /* Warn on close if there are unsaved changes */ static gboolean on_close_request(GtkWindow *window, gpointer user_data) { (void)window; AppData *app = (AppData *)user_data; if (!app->unsaved) return FALSE; /* allow close */ GtkWidget *dialog = gtk_message_dialog_new( GTK_WINDOW(app->window), GTK_DIALOG_MODAL, GTK_MESSAGE_QUESTION, GTK_BUTTONS_NONE, "You have unsaved volume changes.\n\n" "Live levels will remain as adjusted.\n" "The saved db will be unchanged if you close without saving."); gtk_dialog_add_button(GTK_DIALOG(dialog), "Cancel", GTK_RESPONSE_CANCEL); gtk_dialog_add_button(GTK_DIALOG(dialog), "Close anyway", GTK_RESPONSE_YES); gtk_dialog_add_button(GTK_DIALOG(dialog), "Save and close", GTK_RESPONSE_ACCEPT); g_signal_connect(dialog, "response", G_CALLBACK(on_close_dialog_response), app); gtk_window_present(GTK_WINDOW(dialog)); return TRUE; /* intercept until dialog is answered */ } /* ── CSS ─────────────────────────────────────────────────────────────────── */ static const char *APP_CSS = "window {" " background-color: #2b2b2b;" "}" ".device-name {" " font-size: 15px;" " font-weight: bold;" " color: #e0e0e0;" "}" ".device-class {" " font-size: 11px;" " color: #888888;" "}" ".pct-label {" " font-size: 24px;" " font-weight: bold;" " color: #4fc3f7;" "}" ".status-label {" " font-size: 11px;" " color: #ffb74d;" "}" "list {" " background-color: #2b2b2b;" "}" "row {" " color: #cccccc;" " font-size: 12px;" "}" "row:selected {" " background-color: #1565c0;" " color: #ffffff;" "}" "scale trough {" " background-color: #555555;" " min-height: 8px;" "}" "scale highlight {" " background-color: #4fc3f7;" "}" ".btn-save {" " background: #1b5e20;" " color: #e0e0e0;" " border: none;" " border-radius: 4px;" " padding: 6px 12px;" "}" ".btn-save:hover { background: #2e7d32; }" ".btn-save:disabled { background: #333333; color: #555555; }" ".btn-revert {" " background: #b71c1c;" " color: #e0e0e0;" " border: none;" " border-radius: 4px;" " padding: 6px 12px;" "}" ".btn-revert:hover { background: #c62828; }" ".btn-revert:disabled { background: #333333; color: #555555; }" "button {" " background: #444444;" " color: #e0e0e0;" " border: none;" " border-radius: 4px;" " padding: 6px 12px;" "}" "button:hover { background: #555555; }"; /* ── App activate ────────────────────────────────────────────────────────── */ static void activate(GtkApplication *gtk_app, gpointer user_data) { (void)user_data; GtkCssProvider *css = gtk_css_provider_new(); gtk_css_provider_load_from_string(css, APP_CSS); gtk_style_context_add_provider_for_display( gdk_display_get_default(), GTK_STYLE_PROVIDER(css), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); AppData *app = g_new0(AppData, 1); app->show_all = FALSE; app->unsaved = FALSE; app->selected_idx = -1; build_paths(); load_db(); /* ── Window ── */ app->window = gtk_application_window_new(gtk_app); gtk_window_set_title(GTK_WINDOW(app->window), "FreeDV Volume Control"); gtk_window_set_default_size(GTK_WINDOW(app->window), 560, 340); gtk_window_set_resizable(GTK_WINDOW(app->window), FALSE); g_signal_connect(app->window, "close-request", G_CALLBACK(on_close_request), app); /* ── Root vbox (content + status bar) ── */ GtkWidget *root_vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_window_set_child(GTK_WINDOW(app->window), root_vbox); /* ── Main hbox ── */ GtkWidget *hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_widget_set_margin_start(hbox, 10); gtk_widget_set_margin_end(hbox, 10); gtk_widget_set_margin_top(hbox, 10); gtk_widget_set_margin_bottom(hbox, 6); gtk_widget_set_vexpand(hbox, TRUE); gtk_box_append(GTK_BOX(root_vbox), hbox); /* ── Left panel ── */ GtkWidget *left_vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_widget_set_hexpand(left_vbox, FALSE); gtk_widget_set_size_request(left_vbox, 240, -1); gtk_box_append(GTK_BOX(hbox), left_vbox); GtkWidget *scroll = gtk_scrolled_window_new(); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_widget_set_vexpand(scroll, TRUE); gtk_box_append(GTK_BOX(left_vbox), scroll); app->list_box = gtk_list_box_new(); gtk_list_box_set_selection_mode(GTK_LIST_BOX(app->list_box), GTK_SELECTION_SINGLE); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), app->list_box); g_signal_connect(app->list_box, "row-selected", G_CALLBACK(on_row_selected), app); app->toggle_btn = gtk_button_new_with_label("Show all devices"); gtk_box_append(GTK_BOX(left_vbox), app->toggle_btn); g_signal_connect(app->toggle_btn, "clicked", G_CALLBACK(on_toggle_show_all), app); GtkWidget *reload_btn = gtk_button_new_with_label("Reload from db"); gtk_box_append(GTK_BOX(left_vbox), reload_btn); g_signal_connect(reload_btn, "clicked", G_CALLBACK(on_reload), app); /* ── Right panel ── */ GtkWidget *right_vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); gtk_widget_set_hexpand(right_vbox, TRUE); gtk_widget_set_valign(right_vbox, GTK_ALIGN_CENTER); gtk_widget_set_margin_start(right_vbox, 16); gtk_box_append(GTK_BOX(hbox), right_vbox); app->lbl_name = gtk_label_new("No device selected"); gtk_widget_add_css_class(app->lbl_name, "device-name"); gtk_label_set_wrap(GTK_LABEL(app->lbl_name), TRUE); gtk_label_set_xalign(GTK_LABEL(app->lbl_name), 0.0f); gtk_box_append(GTK_BOX(right_vbox), app->lbl_name); app->lbl_class = gtk_label_new(""); gtk_widget_add_css_class(app->lbl_class, "device-class"); gtk_label_set_xalign(GTK_LABEL(app->lbl_class), 0.0f); gtk_box_append(GTK_BOX(right_vbox), app->lbl_class); app->lbl_pct = gtk_label_new(""); gtk_widget_add_css_class(app->lbl_pct, "pct-label"); gtk_label_set_xalign(GTK_LABEL(app->lbl_pct), 0.0f); gtk_box_append(GTK_BOX(right_vbox), app->lbl_pct); app->slider = gtk_scale_new_with_range(GTK_ORIENTATION_HORIZONTAL, 0.0, 100.0, 1.0); gtk_scale_set_draw_value(GTK_SCALE(app->slider), FALSE); gtk_widget_set_sensitive(app->slider, FALSE); gtk_widget_set_hexpand(app->slider, TRUE); gtk_scale_add_mark(GTK_SCALE(app->slider), 0, GTK_POS_BOTTOM, "0%"); gtk_scale_add_mark(GTK_SCALE(app->slider), 25, GTK_POS_BOTTOM, NULL); gtk_scale_add_mark(GTK_SCALE(app->slider), 50, GTK_POS_BOTTOM, "50%"); gtk_scale_add_mark(GTK_SCALE(app->slider), 75, GTK_POS_BOTTOM, NULL); gtk_scale_add_mark(GTK_SCALE(app->slider), 100, GTK_POS_BOTTOM, "100%"); gtk_box_append(GTK_BOX(right_vbox), app->slider); g_signal_connect(app->slider, "value-changed", G_CALLBACK(on_slider_value_changed), app); GtkEventController *legacy = gtk_event_controller_legacy_new(); g_signal_connect(legacy, "event", G_CALLBACK(on_slider_released), app); gtk_widget_add_controller(app->slider, legacy); GtkEventController *key_ctrl = gtk_event_controller_key_new(); g_signal_connect(key_ctrl, "key-released", G_CALLBACK(on_slider_key_released), app); gtk_widget_add_controller(app->slider, key_ctrl); /* ── Save / Revert buttons ── */ GtkWidget *action_hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_box_append(GTK_BOX(right_vbox), action_hbox); app->save_btn = gtk_button_new_with_label("Save to db"); gtk_widget_add_css_class(app->save_btn, "btn-save"); gtk_widget_set_sensitive(app->save_btn, FALSE); gtk_box_append(GTK_BOX(action_hbox), app->save_btn); g_signal_connect(app->save_btn, "clicked", G_CALLBACK(on_save), app); app->revert_btn = gtk_button_new_with_label("Revert all"); gtk_widget_add_css_class(app->revert_btn, "btn-revert"); gtk_widget_set_sensitive(app->revert_btn, FALSE); gtk_box_append(GTK_BOX(action_hbox), app->revert_btn); g_signal_connect(app->revert_btn, "clicked", G_CALLBACK(on_revert), app); /* ── Status bar ── */ app->status_lbl = gtk_label_new(""); gtk_widget_add_css_class(app->status_lbl, "status-label"); gtk_label_set_xalign(GTK_LABEL(app->status_lbl), 1.0f); gtk_widget_set_margin_end(app->status_lbl, 10); gtk_widget_set_margin_bottom(app->status_lbl, 6); gtk_box_append(GTK_BOX(root_vbox), app->status_lbl); populate_list(app); gtk_window_present(GTK_WINDOW(app->window)); } /* ── main ────────────────────────────────────────────────────────────────── */ int main(int argc, char *argv[]) { GtkApplication *app = gtk_application_new( "uk.radio.fdv-volctl", G_APPLICATION_DEFAULT_FLAGS); g_signal_connect(app, "activate", G_CALLBACK(activate), NULL); int status = g_application_run(G_APPLICATION(app), argc, argv); g_object_unref(app); return status; }