diff --git a/README.md b/README.md index a2af95d..a6f0867 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ transui # same as record ``` Bind `transui record` to a shortcut in your desktop environment. Transui does -not register a global shortcut itself. +not register a global shortcut itself. Recording stops automatically after two +continuous seconds without detected speech. The **Hosted first, local fallback** setting tries the Hosted endpoint, then the Local endpoint when the first request fails. With it disabled, Transui uses diff --git a/src/main.cpp b/src/main.cpp index 86b04a3..b6c9806 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,8 @@ namespace { constexpr const char* kAppId = "io.github.transui"; constexpr const char* kConfigGroup = "transui"; +constexpr gint64 kSilenceTimeoutUs = 2 * G_USEC_PER_SEC; +constexpr double kSoundThresholdDb = -42.0; const SecretSchema kSecretSchema = { "io.github.transui.credentials", SECRET_SCHEMA_NONE, @@ -43,7 +45,10 @@ struct App { GtkWidget* processing = nullptr; GtkWidget* elapsed_label = nullptr; guint elapsed_source = 0; + guint silence_source = 0; + guint level_watch = 0; guint elapsed_seconds = 0; + gint64 last_sound_us = 0; GstElement* pipeline = nullptr; std::string recording_path; std::atomic cancelled{false}; @@ -285,6 +290,55 @@ gboolean tick_elapsed(gpointer data) { return G_SOURCE_CONTINUE; } +void stop_recording(GtkButton*, gpointer data); + +gboolean monitor_audio_level(GstBus*, GstMessage* message, gpointer data) { + if (GST_MESSAGE_TYPE(message) != GST_MESSAGE_ELEMENT) return G_SOURCE_CONTINUE; + const GstStructure* structure = gst_message_get_structure(message); + if (!structure || !gst_structure_has_name(structure, "level")) return G_SOURCE_CONTINUE; + const GValue* rms = gst_structure_get_value(structure, "rms"); + if (!rms || !GST_VALUE_HOLDS_LIST(rms)) return G_SOURCE_CONTINUE; + + auto* app = static_cast(data); + for (guint index = 0; index < gst_value_list_get_size(rms); ++index) { + const GValue* channel = gst_value_list_get_value(rms, index); + if (G_VALUE_HOLDS_DOUBLE(channel) && g_value_get_double(channel) > kSoundThresholdDb) { + app->last_sound_us = g_get_monotonic_time(); + break; + } + } + return G_SOURCE_CONTINUE; +} + +gboolean detect_silence(gpointer data) { + auto* app = static_cast(data); + if (!app->pipeline) { + app->silence_source = 0; + return G_SOURCE_REMOVE; + } + if (g_get_monotonic_time() - app->last_sound_us < kSilenceTimeoutUs) + return G_SOURCE_CONTINUE; + + app->silence_source = 0; + stop_recording(nullptr, app); + return G_SOURCE_REMOVE; +} + +void remove_recording_sources(App* app) { + if (app->elapsed_source) { + g_source_remove(app->elapsed_source); + app->elapsed_source = 0; + } + if (app->silence_source) { + g_source_remove(app->silence_source); + app->silence_source = 0; + } + if (app->level_watch) { + g_source_remove(app->level_watch); + app->level_watch = 0; + } +} + void remove_recording(App* app) { if (!app->recording_path.empty()) { g_remove(app->recording_path.c_str()); @@ -293,10 +347,7 @@ void remove_recording(App* app) { } void close_recorder(App* app) { - if (app->elapsed_source) { - g_source_remove(app->elapsed_source); - app->elapsed_source = 0; - } + remove_recording_sources(app); if (app->pipeline) { gst_element_set_state(app->pipeline, GST_STATE_NULL); gst_object_unref(app->pipeline); @@ -506,10 +557,7 @@ void show_processing(App* app) { void stop_recording(GtkButton*, gpointer data) { auto* app = static_cast(data); if (!app->pipeline) return; - if (app->elapsed_source) { - g_source_remove(app->elapsed_source); - app->elapsed_source = 0; - } + remove_recording_sources(app); gst_element_send_event(app->pipeline, gst_event_new_eos()); GstBus* bus = gst_element_get_bus(app->pipeline); GstMessage* message = gst_bus_timed_pop_filtered(bus, 3 * GST_SECOND, @@ -537,10 +585,7 @@ void cancel_recording(GtkButton*, gpointer data) { void recorder_closed(GtkWidget*, gpointer data) { auto* app = static_cast(data); app->recorder = nullptr; - if (app->elapsed_source) { - g_source_remove(app->elapsed_source); - app->elapsed_source = 0; - } + remove_recording_sources(app); if (app->pipeline) { gst_element_set_state(app->pipeline, GST_STATE_NULL); gst_object_unref(app->pipeline); @@ -562,7 +607,10 @@ void start_recording(App* app) { close(fd); app->recording_path = template_path; g_free(template_path); - gchar* pipeline_text = g_strdup_printf("autoaudiosrc ! audioconvert ! audioresample ! wavenc ! filesink location=\"%s\"", app->recording_path.c_str()); + gchar* pipeline_text = g_strdup_printf( + "autoaudiosrc ! audioconvert ! audioresample ! " + "level interval=100000000 post-messages=true ! wavenc ! filesink location=\"%s\"", + app->recording_path.c_str()); GError* error = nullptr; app->pipeline = gst_parse_launch(pipeline_text, &error); g_free(pipeline_text); @@ -572,6 +620,9 @@ void start_recording(App* app) { remove_recording(app); return; } + GstBus* level_bus = gst_element_get_bus(app->pipeline); + app->level_watch = gst_bus_add_watch(level_bus, monitor_audio_level, app); + gst_object_unref(level_bus); if (gst_element_set_state(app->pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { show_error(nullptr, "Could not access an audio input."); close_recorder(app); @@ -608,7 +659,9 @@ void start_recording(App* app) { gtk_box_pack_start(GTK_BOX(actions), cancel, false, false, 0); gtk_box_pack_start(GTK_BOX(actions), stop, false, false, 0); app->elapsed_seconds = 0; + app->last_sound_us = g_get_monotonic_time(); app->elapsed_source = g_timeout_add_seconds(1, tick_elapsed, app); + app->silence_source = g_timeout_add(100, detect_silence, app); g_signal_connect(cancel, "clicked", G_CALLBACK(cancel_recording), app); g_signal_connect(stop, "clicked", G_CALLBACK(stop_recording), app); g_signal_connect(window, "destroy", G_CALLBACK(recorder_closed), app); @@ -618,83 +671,36 @@ void start_recording(App* app) { void install_css() { const char* css = R"CSS( - window { - background-color: #181b1a; - color: #eef0e8; - font-family: Cantarell, sans-serif; - } - headerbar.transui-header { - background: #202421; - color: #eef0e8; - border-bottom: 1px solid #3b413c; - box-shadow: none; - } .settings-panel { - background-color: #202421; - border: 1px solid #3b413c; - border-radius: 8px; - padding: 20px; + padding: 12px 4px; } .field-label { - color: #aeb7ae; font-size: 12px; font-weight: 700; + opacity: 0.72; } - entry, combobox button { - background: #2a2f2b; - color: #eef0e8; - border: 1px solid #4a514b; - border-radius: 4px; - padding: 7px 9px; - } - entry:focus, combobox button:focus, button:focus { - border-color: #d4a44c; - box-shadow: 0 0 0 1px #d4a44c; - } - label { color: #d8ddd5; } - checkbutton { color: #d8ddd5; } .record-panel, .process-panel { - background-color: #202421; - border: 1px solid #3b413c; - border-radius: 12px; padding: 28px 36px; } .record-light { - background: #e64a3c; - border: 6px solid #552a25; + background: #df493e; + border: 6px solid alpha(#df493e, 0.22); border-radius: 50%; - box-shadow: 0 0 0 1px #f17a6f; } .record-state { - color: #f17a6f; + color: #c93e35; font-weight: 800; letter-spacing: 2px; font-size: 11px; } .elapsed { - color: #eef0e8; font-family: "DejaVu Sans Mono", monospace; font-size: 42px; font-weight: 700; letter-spacing: 1px; } - .state-title { color: #eef0e8; font-size: 17px; font-weight: 700; } - .signal-spinner { color: #d4a44c; } - button { - background: #2a2f2b; - color: #eef0e8; - border: 1px solid #4a514b; - border-radius: 4px; - padding: 7px 12px; - } - button:hover { background: #343a35; } - button.suggested-action { - background: #d4a44c; - color: #181b1a; - border-color: #d4a44c; - font-weight: 800; - } - button.suggested-action:hover { background: #e1b760; } + .state-title { font-size: 17px; font-weight: 700; } + button.suggested-action { font-weight: 700; } )CSS"; GtkCssProvider* provider = gtk_css_provider_new(); gtk_css_provider_load_from_data(provider, css, -1, nullptr);