feat: sound detection
This commit is contained in:
1 parent
51751b5dbf
commit
05a7c1cd83
2 files changed
+75
-68
No files matched your search
@@ -35,7 +35,8 @@ transui # same as record
|
|||||||
```
|
```
|
||||||
|
|
||||||
Bind `transui record` to a shortcut in your desktop environment. Transui does
|
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 **Hosted first, local fallback** setting tries the Hosted endpoint, then
|
||||||
the Local endpoint when the first request fails. With it disabled, Transui uses
|
the Local endpoint when the first request fails. With it disabled, Transui uses
|
||||||
|
|||||||
+73
-67
@@ -19,6 +19,8 @@ namespace {
|
|||||||
|
|
||||||
constexpr const char* kAppId = "io.github.transui";
|
constexpr const char* kAppId = "io.github.transui";
|
||||||
constexpr const char* kConfigGroup = "transui";
|
constexpr const char* kConfigGroup = "transui";
|
||||||
|
constexpr gint64 kSilenceTimeoutUs = 2 * G_USEC_PER_SEC;
|
||||||
|
constexpr double kSoundThresholdDb = -42.0;
|
||||||
|
|
||||||
const SecretSchema kSecretSchema = {
|
const SecretSchema kSecretSchema = {
|
||||||
"io.github.transui.credentials", SECRET_SCHEMA_NONE,
|
"io.github.transui.credentials", SECRET_SCHEMA_NONE,
|
||||||
@@ -43,7 +45,10 @@ struct App {
|
|||||||
GtkWidget* processing = nullptr;
|
GtkWidget* processing = nullptr;
|
||||||
GtkWidget* elapsed_label = nullptr;
|
GtkWidget* elapsed_label = nullptr;
|
||||||
guint elapsed_source = 0;
|
guint elapsed_source = 0;
|
||||||
|
guint silence_source = 0;
|
||||||
|
guint level_watch = 0;
|
||||||
guint elapsed_seconds = 0;
|
guint elapsed_seconds = 0;
|
||||||
|
gint64 last_sound_us = 0;
|
||||||
GstElement* pipeline = nullptr;
|
GstElement* pipeline = nullptr;
|
||||||
std::string recording_path;
|
std::string recording_path;
|
||||||
std::atomic<bool> cancelled{false};
|
std::atomic<bool> cancelled{false};
|
||||||
@@ -285,6 +290,55 @@ gboolean tick_elapsed(gpointer data) {
|
|||||||
return G_SOURCE_CONTINUE;
|
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<App*>(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<App*>(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) {
|
void remove_recording(App* app) {
|
||||||
if (!app->recording_path.empty()) {
|
if (!app->recording_path.empty()) {
|
||||||
g_remove(app->recording_path.c_str());
|
g_remove(app->recording_path.c_str());
|
||||||
@@ -293,10 +347,7 @@ void remove_recording(App* app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void close_recorder(App* app) {
|
void close_recorder(App* app) {
|
||||||
if (app->elapsed_source) {
|
remove_recording_sources(app);
|
||||||
g_source_remove(app->elapsed_source);
|
|
||||||
app->elapsed_source = 0;
|
|
||||||
}
|
|
||||||
if (app->pipeline) {
|
if (app->pipeline) {
|
||||||
gst_element_set_state(app->pipeline, GST_STATE_NULL);
|
gst_element_set_state(app->pipeline, GST_STATE_NULL);
|
||||||
gst_object_unref(app->pipeline);
|
gst_object_unref(app->pipeline);
|
||||||
@@ -506,10 +557,7 @@ void show_processing(App* app) {
|
|||||||
void stop_recording(GtkButton*, gpointer data) {
|
void stop_recording(GtkButton*, gpointer data) {
|
||||||
auto* app = static_cast<App*>(data);
|
auto* app = static_cast<App*>(data);
|
||||||
if (!app->pipeline) return;
|
if (!app->pipeline) return;
|
||||||
if (app->elapsed_source) {
|
remove_recording_sources(app);
|
||||||
g_source_remove(app->elapsed_source);
|
|
||||||
app->elapsed_source = 0;
|
|
||||||
}
|
|
||||||
gst_element_send_event(app->pipeline, gst_event_new_eos());
|
gst_element_send_event(app->pipeline, gst_event_new_eos());
|
||||||
GstBus* bus = gst_element_get_bus(app->pipeline);
|
GstBus* bus = gst_element_get_bus(app->pipeline);
|
||||||
GstMessage* message = gst_bus_timed_pop_filtered(bus, 3 * GST_SECOND,
|
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) {
|
void recorder_closed(GtkWidget*, gpointer data) {
|
||||||
auto* app = static_cast<App*>(data);
|
auto* app = static_cast<App*>(data);
|
||||||
app->recorder = nullptr;
|
app->recorder = nullptr;
|
||||||
if (app->elapsed_source) {
|
remove_recording_sources(app);
|
||||||
g_source_remove(app->elapsed_source);
|
|
||||||
app->elapsed_source = 0;
|
|
||||||
}
|
|
||||||
if (app->pipeline) {
|
if (app->pipeline) {
|
||||||
gst_element_set_state(app->pipeline, GST_STATE_NULL);
|
gst_element_set_state(app->pipeline, GST_STATE_NULL);
|
||||||
gst_object_unref(app->pipeline);
|
gst_object_unref(app->pipeline);
|
||||||
@@ -562,7 +607,10 @@ void start_recording(App* app) {
|
|||||||
close(fd);
|
close(fd);
|
||||||
app->recording_path = template_path;
|
app->recording_path = template_path;
|
||||||
g_free(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;
|
GError* error = nullptr;
|
||||||
app->pipeline = gst_parse_launch(pipeline_text, &error);
|
app->pipeline = gst_parse_launch(pipeline_text, &error);
|
||||||
g_free(pipeline_text);
|
g_free(pipeline_text);
|
||||||
@@ -572,6 +620,9 @@ void start_recording(App* app) {
|
|||||||
remove_recording(app);
|
remove_recording(app);
|
||||||
return;
|
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) {
|
if (gst_element_set_state(app->pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) {
|
||||||
show_error(nullptr, "Could not access an audio input.");
|
show_error(nullptr, "Could not access an audio input.");
|
||||||
close_recorder(app);
|
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), cancel, false, false, 0);
|
||||||
gtk_box_pack_start(GTK_BOX(actions), stop, false, false, 0);
|
gtk_box_pack_start(GTK_BOX(actions), stop, false, false, 0);
|
||||||
app->elapsed_seconds = 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->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(cancel, "clicked", G_CALLBACK(cancel_recording), app);
|
||||||
g_signal_connect(stop, "clicked", G_CALLBACK(stop_recording), app);
|
g_signal_connect(stop, "clicked", G_CALLBACK(stop_recording), app);
|
||||||
g_signal_connect(window, "destroy", G_CALLBACK(recorder_closed), app);
|
g_signal_connect(window, "destroy", G_CALLBACK(recorder_closed), app);
|
||||||
@@ -618,83 +671,36 @@ void start_recording(App* app) {
|
|||||||
|
|
||||||
void install_css() {
|
void install_css() {
|
||||||
const char* css = R"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 {
|
.settings-panel {
|
||||||
background-color: #202421;
|
padding: 12px 4px;
|
||||||
border: 1px solid #3b413c;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
.field-label {
|
.field-label {
|
||||||
color: #aeb7ae;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
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 {
|
.record-panel, .process-panel {
|
||||||
background-color: #202421;
|
|
||||||
border: 1px solid #3b413c;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 28px 36px;
|
padding: 28px 36px;
|
||||||
}
|
}
|
||||||
.record-light {
|
.record-light {
|
||||||
background: #e64a3c;
|
background: #df493e;
|
||||||
border: 6px solid #552a25;
|
border: 6px solid alpha(#df493e, 0.22);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 0 1px #f17a6f;
|
|
||||||
}
|
}
|
||||||
.record-state {
|
.record-state {
|
||||||
color: #f17a6f;
|
color: #c93e35;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.elapsed {
|
.elapsed {
|
||||||
color: #eef0e8;
|
|
||||||
font-family: "DejaVu Sans Mono", monospace;
|
font-family: "DejaVu Sans Mono", monospace;
|
||||||
font-size: 42px;
|
font-size: 42px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
.state-title { color: #eef0e8; font-size: 17px; font-weight: 700; }
|
.state-title { font-size: 17px; font-weight: 700; }
|
||||||
.signal-spinner { color: #d4a44c; }
|
button.suggested-action { font-weight: 700; }
|
||||||
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; }
|
|
||||||
)CSS";
|
)CSS";
|
||||||
GtkCssProvider* provider = gtk_css_provider_new();
|
GtkCssProvider* provider = gtk_css_provider_new();
|
||||||
gtk_css_provider_load_from_data(provider, css, -1, nullptr);
|
gtk_css_provider_load_from_data(provider, css, -1, nullptr);
|
||||||
|
|||||||
Reference in new issue
Block a user