#include "audio.h" #include "config.h" #include "transcribe.h" #include "typer.h" #include "version.h" #include #include #include #include #include typedef struct { GtkApplication *app; GtkWidget *window; GtkWidget *status_label; GtkWidget *record_btn; GtkWidget *always_on_btn; GtkWidget *type_check; GtkWidget *transcript_view; GtkWidget *record_light; GtkWidget *transcribe_light; GtkWidget *level_area; GtkWidget *threshold_scale; GtkWidget *trigger_scale; GtkWidget *release_scale; GtkWidget *preroll_scale; GtkWidget *min_speech_scale; GtkWidget *max_speech_scale; GtkWidget *debug_label; voice_config_t cfg; int use_gpu; int recording; int transcribing; int always_on_running; unsigned int meter_timer_id; float live_peak; } gui_state_t; static float clampf(float v, float lo, float hi) { if (v < lo) return lo; if (v > hi) return hi; return v; } static void set_status(gui_state_t *s, const char *text) { gtk_label_set_text(GTK_LABEL(s->status_label), text); } static void append_transcript(gui_state_t *s, const char *text) { if (!text || text[0] == '\0') return; GtkTextBuffer *buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(s->transcript_view)); GtkTextIter end; gtk_text_buffer_get_end_iter(buf, &end); int has_text = gtk_text_buffer_get_char_count(buf) > 0; if (has_text) { gtk_text_buffer_insert(buf, &end, "\n", -1); } gtk_text_buffer_insert(buf, &end, text, -1); } static void update_activity_lights(gui_state_t *s) { if (!s->record_light || !s->transcribe_light) return; const int rec_on = (s->recording || s->always_on_running) ? 1 : 0; const char *rec_markup = rec_on ? "● Recording" : "● Recording"; const char *tr_markup = s->transcribing ? "● Transcribing" : "● Transcribing"; gtk_label_set_markup(GTK_LABEL(s->record_light), rec_markup); gtk_label_set_markup(GTK_LABEL(s->transcribe_light), tr_markup); } static void update_record_button(gui_state_t *s) { GtkStyleContext *ctx = gtk_widget_get_style_context(s->record_btn); gtk_style_context_remove_class(ctx, "idle"); gtk_style_context_remove_class(ctx, "recording"); gtk_style_context_remove_class(ctx, "busy"); if (s->transcribing) { gtk_widget_set_sensitive(s->record_btn, FALSE); gtk_button_set_label(GTK_BUTTON(s->record_btn), "Transcribing..."); gtk_style_context_add_class(ctx, "busy"); update_activity_lights(s); return; } if (s->always_on_running) { gtk_widget_set_sensitive(s->record_btn, FALSE); gtk_button_set_label(GTK_BUTTON(s->record_btn), "Manual Disabled (Always-On)"); gtk_style_context_add_class(ctx, "busy"); update_activity_lights(s); return; } gtk_widget_set_sensitive(s->record_btn, TRUE); if (s->recording) { gtk_button_set_label(GTK_BUTTON(s->record_btn), "Stop Recording"); gtk_style_context_add_class(ctx, "recording"); } else { gtk_button_set_label(GTK_BUTTON(s->record_btn), "Start Recording"); gtk_style_context_add_class(ctx, "idle"); } update_activity_lights(s); } static void update_always_on_button(gui_state_t *s) { if (!s->always_on_btn) return; gtk_button_set_label(GTK_BUTTON(s->always_on_btn), s->always_on_running ? "Stop Always-On" : "Start Always-On"); } static void log_audio_stats(const float *samples, size_t count, int sample_rate, const char *device_name, char *out, size_t out_sz) { if (!samples || count == 0 || sample_rate <= 0) { snprintf(out, out_sz, "Empty capture"); return; } float peak_abs = 0.0f; double sum_abs = 0.0; for (size_t i = 0; i < count; ++i) { float v = samples[i]; float a = v < 0.0f ? -v : v; if (a > peak_abs) peak_abs = a; sum_abs += (double)a; } double mean_abs = sum_abs / (double)count; double seconds = (double)count / (double)sample_rate; if (peak_abs < 0.005f) { snprintf(out, out_sz, "Near-silent audio (%.2fs, peak %.6f) from '%.120s'", seconds, peak_abs, device_name ? device_name : "(default)"); } else { snprintf(out, out_sz, "Audio OK (%.2fs, peak %.6f, mean %.6f)", seconds, peak_abs, mean_abs); } } static int peak_is_speech(const float *samples, size_t count, float threshold) { float peak_abs = 0.0f; for (size_t i = 0; i < count; ++i) { float v = samples[i]; float a = v < 0.0f ? -v : v; if (a > peak_abs) peak_abs = a; } return peak_abs >= threshold; } static void process_segment(gui_state_t *s, float *samples, size_t count) { if (!samples || count == 0) { free(samples); return; } char audio_msg[256]; log_audio_stats(samples, count, s->cfg.sample_rate, s->cfg.audio_device, audio_msg, sizeof(audio_msg)); if (!peak_is_speech(samples, count, s->cfg.vad_peak_threshold)) { set_status(s, "Mic muted or near-silent"); free(samples); return; } if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(s->type_check))) { if (typer_capture_focus() != 0) { fprintf(stderr, "gui: failed to capture focus target for autotype\n"); } } s->transcribing = 1; update_record_button(s); set_status(s, "Transcribing... (please wait)"); while (gtk_events_pending()) gtk_main_iteration(); char *text = transcribe_buffer(samples, count); free(samples); s->transcribing = 0; update_record_button(s); if (!text) { set_status(s, "Transcription failed"); return; } append_transcript(s, text); if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(s->type_check))) { if (typer_type_text(text) != 0) { fprintf(stderr, "gui: autotype injection failed\n"); set_status(s, "Autotype failed (see terminal logs)"); free(text); return; } } set_status(s, audio_msg); free(text); } static int read_scale_int(GtkWidget *scale) { return (int)gtk_range_get_value(GTK_RANGE(scale)); } static float read_scale_float(GtkWidget *scale) { return (float)gtk_range_get_value(GTK_RANGE(scale)); } static void apply_vad_params(gui_state_t *s) { audio_vad_params_t v; v.threshold = read_scale_float(s->threshold_scale); v.trigger_ms = read_scale_int(s->trigger_scale); v.release_ms = read_scale_int(s->release_scale); v.preroll_ms = read_scale_int(s->preroll_scale); v.min_speech_ms = read_scale_int(s->min_speech_scale); v.max_speech_ms = read_scale_int(s->max_speech_scale); if (v.max_speech_ms < v.min_speech_ms) { v.max_speech_ms = v.min_speech_ms; gtk_range_set_value(GTK_RANGE(s->max_speech_scale), (double)v.max_speech_ms); } s->cfg.vad_peak_threshold = v.threshold; s->cfg.vad_trigger_ms = v.trigger_ms; s->cfg.vad_release_ms = v.release_ms; s->cfg.vad_preroll_ms = v.preroll_ms; s->cfg.vad_min_speech_ms = v.min_speech_ms; s->cfg.vad_max_speech_ms = v.max_speech_ms; audio_set_vad_params(&v); gtk_widget_queue_draw(s->level_area); } static void on_vad_value_changed(GtkRange *range, gpointer user_data) { (void)range; gui_state_t *s = (gui_state_t *)user_data; apply_vad_params(s); } static gboolean on_level_draw(GtkWidget *widget, cairo_t *cr, gpointer user_data) { gui_state_t *s = (gui_state_t *)user_data; GtkAllocation a; gtk_widget_get_allocation(widget, &a); const double w = (double)a.width; const double h = (double)a.height; cairo_set_source_rgb(cr, 0.10, 0.10, 0.10); cairo_rectangle(cr, 0.0, 0.0, w, h); cairo_fill(cr); double peak = (double)clampf(s->live_peak, 0.0f, 1.0f); double threshold = (double)clampf(s->cfg.vad_peak_threshold, 0.0f, 1.0f); double fill_w = peak * w; if (fill_w > 0.0) { if (peak >= threshold) { cairo_set_source_rgb(cr, 0.18, 0.72, 0.22); } else { cairo_set_source_rgb(cr, 0.20, 0.52, 0.85); } cairo_rectangle(cr, 0.0, 0.0, fill_w, h); cairo_fill(cr); } double tx = threshold * w; cairo_set_source_rgb(cr, 0.95, 0.25, 0.25); cairo_set_line_width(cr, 2.0); cairo_move_to(cr, tx, 0.0); cairo_line_to(cr, tx, h); cairo_stroke(cr); cairo_set_source_rgb(cr, 0.75, 0.75, 0.75); cairo_set_line_width(cr, 1.0); cairo_rectangle(cr, 0.5, 0.5, w - 1.0, h - 1.0); cairo_stroke(cr); return FALSE; } static const char *vad_state_text(int s) { switch (s) { case 0: return "IDLE"; case 1: return "ONSET"; case 2: return "SPEECH"; case 3: return "TRAILING"; default: return "?"; } } static gboolean meter_tick_cb(gpointer user_data) { gui_state_t *s = (gui_state_t *)user_data; audio_debug_info_t di; if (audio_get_debug_info(&di) == 0) { s->live_peak = di.peak_level; char dbg[256]; snprintf(dbg, sizeof(dbg), "Debug: always_on=%d state=%s peak=%.4f thr=%.4f onset=%d trail=%d speech=%d ready=%zu", di.always_on_enabled, vad_state_text(di.vad_state), di.peak_level, di.threshold, di.onset_samples, di.trailing_samples, di.speech_samples, di.ready_samples); gtk_label_set_text(GTK_LABEL(s->debug_label), dbg); } else { s->live_peak = audio_get_peak_level(); } gtk_widget_queue_draw(s->level_area); if (s->always_on_running && !s->transcribing) { float *samples = NULL; size_t count = 0; int rc = audio_get_speech_segment(&samples, &count); if (rc == 0) { set_status(s, "Speech segment detected, transcribing..."); process_segment(s, samples, count); } } return G_SOURCE_CONTINUE; } static void on_record_clicked(GtkButton *btn, gpointer user_data) { (void)btn; gui_state_t *s = (gui_state_t *)user_data; if (s->always_on_running || s->transcribing) { return; } if (!s->recording) { if (audio_start_recording() != 0) { set_status(s, "Failed to start recording"); return; } s->recording = 1; update_record_button(s); set_status(s, "Recording..."); return; } float *samples = NULL; size_t count = 0; if (audio_stop_recording(&samples, &count) != 0) { set_status(s, "Failed to stop recording"); return; } s->recording = 0; update_record_button(s); process_segment(s, samples, count); } static void on_always_on_clicked(GtkButton *btn, gpointer user_data) { (void)btn; gui_state_t *s = (gui_state_t *)user_data; if (s->transcribing || s->recording) return; if (s->always_on_running) { audio_set_always_on_enabled(0); s->always_on_running = 0; update_always_on_button(s); update_record_button(s); set_status(s, "Always-on stopped"); return; } apply_vad_params(s); audio_set_always_on_enabled(1); s->always_on_running = 1; update_always_on_button(s); update_record_button(s); set_status(s, "Always-on active (continuous monitor)"); } static GtkWidget *add_scale_row(GtkWidget *grid, int row, const char *label_text, double min, double max, double step, double value, int digits, gui_state_t *s, GtkWidget **out_scale) { GtkWidget *label = gtk_label_new(label_text); gtk_label_set_xalign(GTK_LABEL(label), 0.0f); gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1); GtkWidget *scale = gtk_scale_new_with_range(GTK_ORIENTATION_HORIZONTAL, min, max, step); gtk_scale_set_digits(GTK_SCALE(scale), digits); gtk_scale_set_draw_value(GTK_SCALE(scale), TRUE); gtk_range_set_value(GTK_RANGE(scale), value); gtk_widget_set_hexpand(scale, TRUE); gtk_grid_attach(GTK_GRID(grid), scale, 1, row, 1, 1); g_signal_connect(scale, "value-changed", G_CALLBACK(on_vad_value_changed), s); *out_scale = scale; return scale; } static void on_app_activate(GtkApplication *app, gpointer user_data) { gui_state_t *s = (gui_state_t *)user_data; s->window = gtk_application_window_new(app); gtk_window_set_title(GTK_WINDOW(s->window), "voice_linux"); gtk_window_set_default_size(GTK_WINDOW(s->window), 760, 640); GtkWidget *outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_container_set_border_width(GTK_CONTAINER(outer), 12); gtk_container_add(GTK_CONTAINER(s->window), outer); char info[1024]; snprintf(info, sizeof(info), "Version: %s\nDevice: %s\nModel: %s\nLanguage: %s\nGPU: %s", VOICE_LINUX_VERSION, s->cfg.audio_device, s->cfg.model_path, s->cfg.language, s->use_gpu ? "on" : "off"); GtkWidget *info_label = gtk_label_new(info); gtk_label_set_xalign(GTK_LABEL(info_label), 0.0f); gtk_box_pack_start(GTK_BOX(outer), info_label, FALSE, FALSE, 0); s->status_label = gtk_label_new("Idle"); gtk_label_set_xalign(GTK_LABEL(s->status_label), 0.0f); gtk_box_pack_start(GTK_BOX(outer), s->status_label, FALSE, FALSE, 0); GtkWidget *lights_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 14); s->record_light = gtk_label_new(NULL); s->transcribe_light = gtk_label_new(NULL); gtk_label_set_use_markup(GTK_LABEL(s->record_light), TRUE); gtk_label_set_use_markup(GTK_LABEL(s->transcribe_light), TRUE); gtk_box_pack_start(GTK_BOX(lights_row), s->record_light, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(lights_row), s->transcribe_light, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(outer), lights_row, FALSE, FALSE, 0); s->record_btn = gtk_button_new_with_label("Start Recording"); gtk_widget_set_name(s->record_btn, "record-btn"); GtkCssProvider *provider = gtk_css_provider_new(); gtk_css_provider_load_from_data( provider, "#record-btn { font-weight: 700; color: #ffffff; }" "#record-btn.idle { background-image: none; background-color: #c62828; }" "#record-btn.recording { background-image: none; background-color: #2e7d32; }" "#record-btn.busy { background-image: none; background-color: #ef6c00; }" "#record-btn:backdrop { opacity: 0.92; }", -1, NULL); gtk_style_context_add_provider_for_screen( gdk_screen_get_default(), GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); g_object_unref(provider); s->recording = 0; update_record_button(s); g_signal_connect(s->record_btn, "clicked", G_CALLBACK(on_record_clicked), s); gtk_box_pack_start(GTK_BOX(outer), s->record_btn, FALSE, FALSE, 0); update_activity_lights(s); s->always_on_btn = gtk_button_new_with_label("Start Always-On"); g_signal_connect(s->always_on_btn, "clicked", G_CALLBACK(on_always_on_clicked), s); gtk_box_pack_start(GTK_BOX(outer), s->always_on_btn, FALSE, FALSE, 0); s->type_check = gtk_check_button_new_with_label("Type transcript into focused window"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->type_check), s->cfg.autotype_enabled ? TRUE : FALSE); gtk_box_pack_start(GTK_BOX(outer), s->type_check, FALSE, FALSE, 0); GtkWidget *meter_frame = gtk_frame_new("Live Microphone Level"); gtk_box_pack_start(GTK_BOX(outer), meter_frame, FALSE, FALSE, 0); GtkWidget *meter_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_container_set_border_width(GTK_CONTAINER(meter_box), 8); gtk_container_add(GTK_CONTAINER(meter_frame), meter_box); s->level_area = gtk_drawing_area_new(); gtk_widget_set_size_request(s->level_area, -1, 30); g_signal_connect(s->level_area, "draw", G_CALLBACK(on_level_draw), s); gtk_box_pack_start(GTK_BOX(meter_box), s->level_area, FALSE, FALSE, 0); GtkWidget *grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(grid), 6); gtk_grid_set_column_spacing(GTK_GRID(grid), 10); gtk_box_pack_start(GTK_BOX(meter_box), grid, FALSE, FALSE, 0); add_scale_row(grid, 0, "Threshold (0.0-1.0)", 0.0, 1.0, 0.001, s->cfg.vad_peak_threshold, 3, s, &s->threshold_scale); add_scale_row(grid, 1, "Trigger ms (above threshold)", 10.0, 2000.0, 10.0, (double)s->cfg.vad_trigger_ms, 0, s, &s->trigger_scale); add_scale_row(grid, 2, "Release ms (below threshold)", 10.0, 4000.0, 10.0, (double)s->cfg.vad_release_ms, 0, s, &s->release_scale); add_scale_row(grid, 3, "Preroll ms (audio before trigger)", 0.0, 3000.0, 10.0, (double)s->cfg.vad_preroll_ms, 0, s, &s->preroll_scale); add_scale_row(grid, 4, "Min speech ms", 50.0, 5000.0, 10.0, (double)s->cfg.vad_min_speech_ms, 0, s, &s->min_speech_scale); add_scale_row(grid, 5, "Max speech ms", 200.0, 60000.0, 100.0, (double)s->cfg.vad_max_speech_ms, 0, s, &s->max_speech_scale); GtkWidget *hint = gtk_label_new( "Green bar means level >= threshold. Red line is threshold.\n" "Always-On uses continuous capture + preroll so word starts are not clipped."); gtk_label_set_xalign(GTK_LABEL(hint), 0.0f); gtk_box_pack_start(GTK_BOX(meter_box), hint, FALSE, FALSE, 0); s->debug_label = gtk_label_new("Debug: waiting for audio..."); gtk_label_set_xalign(GTK_LABEL(s->debug_label), 0.0f); gtk_box_pack_start(GTK_BOX(meter_box), s->debug_label, FALSE, FALSE, 0); GtkWidget *frame = gtk_frame_new("Transcript Log"); gtk_box_pack_start(GTK_BOX(outer), frame, TRUE, TRUE, 0); GtkWidget *scroll = gtk_scrolled_window_new(NULL, NULL); gtk_container_add(GTK_CONTAINER(frame), scroll); s->transcript_view = gtk_text_view_new(); gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(s->transcript_view), GTK_WRAP_WORD_CHAR); gtk_container_add(GTK_CONTAINER(scroll), s->transcript_view); apply_vad_params(s); update_always_on_button(s); gtk_widget_show_all(s->window); s->meter_timer_id = g_timeout_add(60, meter_tick_cb, s); if (s->cfg.always_on) { audio_set_always_on_enabled(1); s->always_on_running = 1; update_always_on_button(s); update_record_button(s); set_status(s, "Always-on active (continuous monitor)"); } } static const char *arg_value(int argc, char **argv, const char *name) { for (int i = 1; i + 1 < argc; ++i) { if (strcmp(argv[i], name) == 0) { return argv[i + 1]; } } return NULL; } static int arg_flag(int argc, char **argv, const char *name) { for (int i = 1; i < argc; ++i) { if (strcmp(argv[i], name) == 0) { return 1; } } return 0; } int main(int argc, char **argv) { gui_state_t st; memset(&st, 0, sizeof(st)); config_set_defaults(&st.cfg); const char *cfg_path = arg_value(argc, argv, "--config"); if (!cfg_path) cfg_path = "./config.ini"; if (config_load_file(cfg_path, &st.cfg) != 0) { fprintf(stderr, "warning: failed to load %s, using defaults\n", cfg_path); } st.use_gpu = arg_flag(argc, argv, "--gpu") ? 1 : 0; if (arg_flag(argc, argv, "--cpu")) st.use_gpu = 0; audio_params_t ap = {0}; snprintf(ap.device, sizeof(ap.device), "%s", st.cfg.audio_device); ap.sample_rate = st.cfg.sample_rate; ap.max_seconds = st.cfg.max_record_seconds; transcribe_params_t tp = {0}; snprintf(tp.model_path, sizeof(tp.model_path), "%s", st.cfg.model_path); snprintf(tp.language, sizeof(tp.language), "%s", st.cfg.language); tp.use_gpu = st.use_gpu; tp.n_threads = (int)sysconf(_SC_NPROCESSORS_ONLN); if (audio_init(&ap) != 0) { fprintf(stderr, "fatal: audio init failed\n"); return 1; } audio_vad_params_t vp = { .threshold = st.cfg.vad_peak_threshold, .trigger_ms = st.cfg.vad_trigger_ms, .release_ms = st.cfg.vad_release_ms, .preroll_ms = st.cfg.vad_preroll_ms, .min_speech_ms = st.cfg.vad_min_speech_ms, .max_speech_ms = st.cfg.vad_max_speech_ms, }; audio_set_vad_params(&vp); if (transcribe_init(&tp) != 0) { fprintf(stderr, "fatal: transcribe init failed\n"); audio_cleanup(); return 1; } if (typer_init(st.cfg.type_delay_us) != 0) { fprintf(stderr, "fatal: typer init failed\n"); transcribe_cleanup(); audio_cleanup(); return 1; } st.app = gtk_application_new("local.voice_linux", G_APPLICATION_DEFAULT_FLAGS); g_signal_connect(st.app, "activate", G_CALLBACK(on_app_activate), &st); int gtk_argc = 1; char *gtk_argv[] = { argv[0], NULL }; int rc = g_application_run(G_APPLICATION(st.app), gtk_argc, gtk_argv); if (st.meter_timer_id != 0) { g_source_remove(st.meter_timer_id); st.meter_timer_id = 0; } audio_set_always_on_enabled(0); g_object_unref(st.app); typer_cleanup(); transcribe_cleanup(); audio_cleanup(); return rc; }