Files
voice_linux/src/gui_main.c
T

649 lines
21 KiB
C

#include "audio.h"
#include "config.h"
#include "transcribe.h"
#include "typer.h"
#include "version.h"
#include <gtk/gtk.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
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
? "<span foreground='#2e7d32'><b>● Recording</b></span>"
: "<span foreground='#777777'>● Recording</span>";
const char *tr_markup = s->transcribing
? "<span foreground='#ef6c00'><b>● Transcribing</b></span>"
: "<span foreground='#777777'>● Transcribing</span>";
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;
}