646 lines
25 KiB
C++
646 lines
25 KiB
C++
#include <gtk/gtk.h>
|
|
#include <gst/gst.h>
|
|
#include <curl/curl.h>
|
|
#include <libsecret/secret.h>
|
|
#include <json-glib/json-glib.h>
|
|
|
|
#include <atomic>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <unistd.h>
|
|
#include <glib/gstdio.h>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
constexpr const char* kAppId = "io.github.transui";
|
|
constexpr const char* kConfigGroup = "transui";
|
|
|
|
const SecretSchema kSecretSchema = {
|
|
"io.github.transui.credentials", SECRET_SCHEMA_NONE,
|
|
{{"scope", SECRET_SCHEMA_ATTRIBUTE_STRING}, {nullptr, SECRET_SCHEMA_ATTRIBUTE_STRING}}};
|
|
|
|
struct Config {
|
|
std::string hosted_url;
|
|
std::string local_url = "http://localhost:8080";
|
|
std::string prompt;
|
|
bool hosted_first = true;
|
|
bool clipboard = false;
|
|
};
|
|
|
|
struct App {
|
|
GtkApplication* gtk_app = nullptr;
|
|
Config config;
|
|
std::string hosted_key;
|
|
std::string local_key;
|
|
GtkWidget* settings = nullptr;
|
|
GtkWidget* recorder = nullptr;
|
|
GtkWidget* processing = nullptr;
|
|
GtkWidget* elapsed_label = nullptr;
|
|
guint elapsed_source = 0;
|
|
guint elapsed_seconds = 0;
|
|
GstElement* pipeline = nullptr;
|
|
std::string recording_path;
|
|
std::atomic<bool> cancelled{false};
|
|
};
|
|
|
|
struct SettingsFields {
|
|
GtkWidget* hosted_url;
|
|
GtkWidget* hosted_key;
|
|
GtkWidget* local_url;
|
|
GtkWidget* local_key;
|
|
GtkWidget* prompt;
|
|
GtkWidget* hosted_first;
|
|
GtkWidget* destination;
|
|
};
|
|
|
|
struct Job {
|
|
App* app;
|
|
std::string file;
|
|
Config config;
|
|
std::string hosted_key;
|
|
std::string local_key;
|
|
bool ok = false;
|
|
std::string text;
|
|
std::string error;
|
|
};
|
|
|
|
std::string config_path() {
|
|
return std::string(g_get_user_config_dir()) + "/transui/settings.ini";
|
|
}
|
|
|
|
std::string endpoint_url(std::string url) {
|
|
while (!url.empty() && url.back() == '/') url.pop_back();
|
|
if (url.find("/v1/audio/transcriptions") != std::string::npos) return url;
|
|
return url + "/v1/audio/transcriptions";
|
|
}
|
|
|
|
void show_error(GtkWindow* parent, const std::string& message) {
|
|
GtkWidget* dialog = gtk_message_dialog_new(parent, GTK_DIALOG_MODAL,
|
|
GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "%s", message.c_str());
|
|
gtk_dialog_run(GTK_DIALOG(dialog));
|
|
gtk_widget_destroy(dialog);
|
|
}
|
|
|
|
void show_info(GtkWindow* parent, const std::string& message) {
|
|
GtkWidget* dialog = gtk_message_dialog_new(parent, GTK_DIALOG_MODAL,
|
|
GTK_MESSAGE_INFO, GTK_BUTTONS_CLOSE, "%s", message.c_str());
|
|
gtk_dialog_run(GTK_DIALOG(dialog));
|
|
gtk_widget_destroy(dialog);
|
|
}
|
|
|
|
std::string secret_for(const char* scope) {
|
|
GError* error = nullptr;
|
|
gchar* value = secret_password_lookup_sync(&kSecretSchema, nullptr, &error,
|
|
"scope", scope, nullptr);
|
|
if (error) {
|
|
g_error_free(error);
|
|
return {};
|
|
}
|
|
std::string result = value ? value : "";
|
|
secret_password_free(value);
|
|
return result;
|
|
}
|
|
|
|
bool save_secret(const char* scope, const std::string& value, std::string* err) {
|
|
GError* error = nullptr;
|
|
gboolean ok = secret_password_store_sync(&kSecretSchema, SECRET_COLLECTION_DEFAULT,
|
|
scope, value.c_str(), nullptr, &error, "scope", scope, nullptr);
|
|
if (!ok && error) {
|
|
*err = error->message;
|
|
g_error_free(error);
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
void load_config(App* app) {
|
|
GKeyFile* file = g_key_file_new();
|
|
GError* error = nullptr;
|
|
if (g_key_file_load_from_file(file, config_path().c_str(), G_KEY_FILE_NONE, &error)) {
|
|
gchar* value = nullptr;
|
|
value = g_key_file_get_string(file, kConfigGroup, "hosted_url", nullptr);
|
|
if (value) { app->config.hosted_url = value; g_free(value); }
|
|
value = g_key_file_get_string(file, kConfigGroup, "local_url", nullptr);
|
|
if (value) { app->config.local_url = value; g_free(value); }
|
|
value = g_key_file_get_string(file, kConfigGroup, "prompt", nullptr);
|
|
if (value) { app->config.prompt = value; g_free(value); }
|
|
if (g_key_file_has_key(file, kConfigGroup, "hosted_first", nullptr))
|
|
app->config.hosted_first = g_key_file_get_boolean(file, kConfigGroup, "hosted_first", nullptr);
|
|
if (g_key_file_has_key(file, kConfigGroup, "clipboard", nullptr))
|
|
app->config.clipboard = g_key_file_get_boolean(file, kConfigGroup, "clipboard", nullptr);
|
|
}
|
|
if (error) g_error_free(error);
|
|
g_key_file_unref(file);
|
|
app->hosted_key = secret_for("hosted");
|
|
app->local_key = secret_for("local");
|
|
}
|
|
|
|
bool save_config(App* app, const Config& config, const std::string& hosted_key,
|
|
const std::string& local_key, std::string* err) {
|
|
GKeyFile* file = g_key_file_new();
|
|
g_key_file_set_string(file, kConfigGroup, "hosted_url", config.hosted_url.c_str());
|
|
g_key_file_set_string(file, kConfigGroup, "local_url", config.local_url.c_str());
|
|
g_key_file_set_string(file, kConfigGroup, "prompt", config.prompt.c_str());
|
|
g_key_file_set_boolean(file, kConfigGroup, "hosted_first", config.hosted_first);
|
|
g_key_file_set_boolean(file, kConfigGroup, "clipboard", config.clipboard);
|
|
gsize length = 0;
|
|
gchar* contents = g_key_file_to_data(file, &length, nullptr);
|
|
gchar* folder = g_path_get_dirname(config_path().c_str());
|
|
g_mkdir_with_parents(folder, 0700);
|
|
GError* error = nullptr;
|
|
const bool saved = g_file_set_contents(config_path().c_str(), contents, length, &error);
|
|
g_free(folder);
|
|
g_free(contents);
|
|
g_key_file_unref(file);
|
|
if (!saved) {
|
|
*err = error ? error->message : "Could not save settings";
|
|
if (error) g_error_free(error);
|
|
return false;
|
|
}
|
|
if (!save_secret("hosted", hosted_key, err) || !save_secret("local", local_key, err)) return false;
|
|
app->config = config;
|
|
app->hosted_key = hosted_key;
|
|
app->local_key = local_key;
|
|
return true;
|
|
}
|
|
|
|
GtkWidget* labelled(GtkWidget* grid, const char* text, GtkWidget* control, int row) {
|
|
GtkWidget* label = gtk_label_new(text);
|
|
gtk_label_set_xalign(GTK_LABEL(label), 0);
|
|
gtk_widget_set_valign(label, GTK_ALIGN_CENTER);
|
|
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
|
|
gtk_widget_set_hexpand(control, true);
|
|
gtk_grid_attach(GTK_GRID(grid), control, 1, row, 1, 1);
|
|
return control;
|
|
}
|
|
|
|
void settings_closed(GtkWidget*, gpointer data) {
|
|
static_cast<App*>(data)->settings = nullptr;
|
|
}
|
|
|
|
void save_clicked(GtkButton*, gpointer data) {
|
|
auto* fields = static_cast<SettingsFields*>(data);
|
|
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(fields->hosted_url), "app"));
|
|
Config config;
|
|
config.hosted_url = gtk_entry_get_text(GTK_ENTRY(fields->hosted_url));
|
|
config.local_url = gtk_entry_get_text(GTK_ENTRY(fields->local_url));
|
|
config.prompt = gtk_entry_get_text(GTK_ENTRY(fields->prompt));
|
|
config.hosted_first = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(fields->hosted_first));
|
|
config.clipboard = gtk_combo_box_get_active(GTK_COMBO_BOX(fields->destination)) == 1;
|
|
std::string error;
|
|
if (!save_config(app, config, gtk_entry_get_text(GTK_ENTRY(fields->hosted_key)),
|
|
gtk_entry_get_text(GTK_ENTRY(fields->local_key)), &error)) {
|
|
show_error(GTK_WINDOW(app->settings), error);
|
|
return;
|
|
}
|
|
gtk_widget_destroy(app->settings);
|
|
}
|
|
|
|
void open_settings(App* app) {
|
|
if (app->settings) {
|
|
gtk_window_present(GTK_WINDOW(app->settings));
|
|
return;
|
|
}
|
|
GtkWidget* window = gtk_application_window_new(app->gtk_app);
|
|
app->settings = window;
|
|
gtk_window_set_title(GTK_WINDOW(window), "Transui settings");
|
|
gtk_window_set_default_size(GTK_WINDOW(window), 590, 0);
|
|
gtk_window_set_resizable(GTK_WINDOW(window), false);
|
|
GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
|
gtk_container_add(GTK_CONTAINER(window), root);
|
|
GtkWidget* header = gtk_header_bar_new();
|
|
gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(header), true);
|
|
gtk_header_bar_set_title(GTK_HEADER_BAR(header), "Transui");
|
|
gtk_window_set_titlebar(GTK_WINDOW(window), header);
|
|
|
|
GtkWidget* grid = gtk_grid_new();
|
|
gtk_grid_set_row_spacing(GTK_GRID(grid), 12);
|
|
gtk_grid_set_column_spacing(GTK_GRID(grid), 18);
|
|
gtk_widget_set_margin_start(grid, 28);
|
|
gtk_widget_set_margin_end(grid, 28);
|
|
gtk_widget_set_margin_top(grid, 26);
|
|
gtk_widget_set_margin_bottom(grid, 18);
|
|
gtk_box_pack_start(GTK_BOX(root), grid, true, true, 0);
|
|
|
|
auto* fields = new SettingsFields();
|
|
fields->hosted_url = labelled(grid, "Hosted endpoint", gtk_entry_new(), 0);
|
|
fields->hosted_key = labelled(grid, "Hosted API key", gtk_entry_new(), 1);
|
|
fields->local_url = labelled(grid, "Local endpoint", gtk_entry_new(), 2);
|
|
fields->local_key = labelled(grid, "Local API key", gtk_entry_new(), 3);
|
|
fields->prompt = labelled(grid, "Prompt", gtk_entry_new(), 4);
|
|
fields->hosted_first = gtk_check_button_new_with_label("Hosted first, local fallback");
|
|
gtk_grid_attach(GTK_GRID(grid), fields->hosted_first, 1, 5, 1, 1);
|
|
fields->destination = gtk_combo_box_text_new();
|
|
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Type at cursor");
|
|
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Clipboard");
|
|
labelled(grid, "Output", fields->destination, 6);
|
|
gtk_entry_set_visibility(GTK_ENTRY(fields->hosted_key), false);
|
|
gtk_entry_set_visibility(GTK_ENTRY(fields->local_key), false);
|
|
gtk_entry_set_text(GTK_ENTRY(fields->hosted_url), app->config.hosted_url.c_str());
|
|
gtk_entry_set_text(GTK_ENTRY(fields->hosted_key), app->hosted_key.c_str());
|
|
gtk_entry_set_text(GTK_ENTRY(fields->local_url), app->config.local_url.c_str());
|
|
gtk_entry_set_text(GTK_ENTRY(fields->local_key), app->local_key.c_str());
|
|
gtk_entry_set_text(GTK_ENTRY(fields->prompt), app->config.prompt.c_str());
|
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(fields->hosted_first), app->config.hosted_first);
|
|
gtk_combo_box_set_active(GTK_COMBO_BOX(fields->destination), app->config.clipboard ? 1 : 0);
|
|
g_object_set_data(G_OBJECT(fields->hosted_url), "app", app);
|
|
|
|
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
gtk_widget_set_margin_start(actions, 28);
|
|
gtk_widget_set_margin_end(actions, 28);
|
|
gtk_widget_set_margin_bottom(actions, 24);
|
|
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
|
GtkWidget* save = gtk_button_new_with_label("Save");
|
|
gtk_style_context_add_class(gtk_widget_get_style_context(save), "suggested-action");
|
|
gtk_box_pack_end(GTK_BOX(actions), save, false, false, 0);
|
|
gtk_box_pack_start(GTK_BOX(root), actions, false, false, 0);
|
|
g_signal_connect(save, "clicked", G_CALLBACK(save_clicked), fields);
|
|
g_signal_connect_data(window, "destroy", G_CALLBACK(settings_closed), app,
|
|
nullptr, G_CONNECT_AFTER);
|
|
g_signal_connect_data(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer p) {
|
|
delete static_cast<SettingsFields*>(p);
|
|
}), fields, nullptr, G_CONNECT_AFTER);
|
|
gtk_widget_show_all(window);
|
|
}
|
|
|
|
gboolean tick_elapsed(gpointer data) {
|
|
auto* app = static_cast<App*>(data);
|
|
app->elapsed_seconds++;
|
|
const unsigned minutes = app->elapsed_seconds / 60;
|
|
const unsigned seconds = app->elapsed_seconds % 60;
|
|
gchar* text = g_strdup_printf("%02u:%02u", minutes, seconds);
|
|
gtk_label_set_text(GTK_LABEL(app->elapsed_label), text);
|
|
g_free(text);
|
|
return G_SOURCE_CONTINUE;
|
|
}
|
|
|
|
void remove_recording(App* app) {
|
|
if (!app->recording_path.empty()) {
|
|
g_remove(app->recording_path.c_str());
|
|
app->recording_path.clear();
|
|
}
|
|
}
|
|
|
|
void close_recorder(App* app) {
|
|
if (app->elapsed_source) {
|
|
g_source_remove(app->elapsed_source);
|
|
app->elapsed_source = 0;
|
|
}
|
|
if (app->pipeline) {
|
|
gst_element_set_state(app->pipeline, GST_STATE_NULL);
|
|
gst_object_unref(app->pipeline);
|
|
app->pipeline = nullptr;
|
|
}
|
|
GtkWidget* window = app->recorder;
|
|
app->recorder = nullptr;
|
|
if (window) gtk_widget_destroy(window);
|
|
}
|
|
|
|
size_t curl_write(char* buffer, size_t size, size_t nmemb, void* data) {
|
|
auto* output = static_cast<std::string*>(data);
|
|
output->append(buffer, size * nmemb);
|
|
return size * nmemb;
|
|
}
|
|
|
|
bool transcription_request(const std::string& url, const std::string& key,
|
|
const std::string& path, const std::string& prompt,
|
|
std::string* transcript, std::string* error) {
|
|
CURL* curl = curl_easy_init();
|
|
if (!curl) { *error = "Could not initialize HTTP client"; return false; }
|
|
std::string body;
|
|
curl_mime* mime = curl_mime_init(curl);
|
|
curl_mimepart* part = curl_mime_addpart(mime);
|
|
curl_mime_name(part, "response_format");
|
|
curl_mime_data(part, "json", CURL_ZERO_TERMINATED);
|
|
part = curl_mime_addpart(mime);
|
|
curl_mime_name(part, "file");
|
|
curl_mime_filedata(part, path.c_str());
|
|
if (!prompt.empty()) {
|
|
part = curl_mime_addpart(mime);
|
|
curl_mime_name(part, "prompt");
|
|
curl_mime_data(part, prompt.c_str(), CURL_ZERO_TERMINATED);
|
|
}
|
|
struct curl_slist* headers = nullptr;
|
|
if (!key.empty()) {
|
|
headers = curl_slist_append(headers, ("Authorization: Bearer " + key).c_str());
|
|
}
|
|
curl_easy_setopt(curl, CURLOPT_URL, endpoint_url(url).c_str());
|
|
curl_easy_setopt(curl, CURLOPT_MIMEPOST, mime);
|
|
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 12L);
|
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L);
|
|
CURLcode result = curl_easy_perform(curl);
|
|
long status = 0;
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
|
curl_slist_free_all(headers);
|
|
curl_mime_free(mime);
|
|
curl_easy_cleanup(curl);
|
|
if (result != CURLE_OK) { *error = curl_easy_strerror(result); return false; }
|
|
if (status < 200 || status >= 300) {
|
|
*error = "Server returned HTTP " + std::to_string(status);
|
|
return false;
|
|
}
|
|
JsonParser* parser = json_parser_new();
|
|
GError* parse_error = nullptr;
|
|
bool parsed = json_parser_load_from_data(parser, body.c_str(), body.length(), &parse_error);
|
|
if (!parsed) {
|
|
*error = "Invalid JSON response";
|
|
if (parse_error) g_error_free(parse_error);
|
|
g_object_unref(parser);
|
|
return false;
|
|
}
|
|
JsonNode* root = json_parser_get_root(parser);
|
|
if (!JSON_NODE_HOLDS_OBJECT(root)) {
|
|
*error = "Response does not contain a transcript";
|
|
g_object_unref(parser);
|
|
return false;
|
|
}
|
|
JsonObject* object = json_node_get_object(root);
|
|
if (!json_object_has_member(object, "text")) {
|
|
*error = "Response does not contain a text field";
|
|
g_object_unref(parser);
|
|
return false;
|
|
}
|
|
*transcript = json_object_get_string_member(object, "text");
|
|
g_object_unref(parser);
|
|
return true;
|
|
}
|
|
|
|
void set_clipboard(const std::string& text) {
|
|
GdkDisplay* display = gdk_display_get_default();
|
|
GtkClipboard* clipboard = gtk_clipboard_get_for_display(display, GDK_SELECTION_CLIPBOARD);
|
|
gtk_clipboard_set_text(clipboard, text.c_str(), text.size());
|
|
gtk_clipboard_store(clipboard);
|
|
}
|
|
|
|
bool type_text(const std::string& text, std::string* error) {
|
|
const bool wayland = g_getenv("WAYLAND_DISPLAY") && *g_getenv("WAYLAND_DISPLAY");
|
|
gchar* program = g_find_program_in_path(wayland ? "wtype" : "xdotool");
|
|
if (!program) {
|
|
*error = wayland ? "wtype is not installed; copied to clipboard." :
|
|
"xdotool is not installed; copied to clipboard.";
|
|
return false;
|
|
}
|
|
std::vector<gchar*> argv;
|
|
argv.push_back(program);
|
|
if (!wayland) {
|
|
argv.push_back(g_strdup("type"));
|
|
argv.push_back(g_strdup("--delay"));
|
|
argv.push_back(g_strdup("0"));
|
|
argv.push_back(g_strdup("--"));
|
|
}
|
|
argv.push_back(g_strdup(text.c_str()));
|
|
argv.push_back(nullptr);
|
|
GError* spawn_error = nullptr;
|
|
gboolean ok = g_spawn_async(nullptr, argv.data(), nullptr, G_SPAWN_SEARCH_PATH,
|
|
nullptr, nullptr, nullptr, &spawn_error);
|
|
for (size_t i = 0; i + 1 < argv.size(); ++i) g_free(argv[i]);
|
|
if (!ok) {
|
|
*error = spawn_error ? spawn_error->message : "Could not type text";
|
|
if (spawn_error) g_error_free(spawn_error);
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
gboolean finish_job(gpointer data) {
|
|
std::unique_ptr<Job> job(static_cast<Job*>(data));
|
|
App* app = job->app;
|
|
if (app->processing) {
|
|
GtkWidget* window = app->processing;
|
|
app->processing = nullptr;
|
|
gtk_widget_destroy(window);
|
|
}
|
|
remove_recording(app);
|
|
if (app->cancelled.load()) return G_SOURCE_REMOVE;
|
|
if (!job->ok) {
|
|
show_error(nullptr, job->error);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
if (job->config.clipboard) {
|
|
set_clipboard(job->text);
|
|
show_info(nullptr, "Transcript copied to clipboard.");
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
std::string error;
|
|
if (!type_text(job->text, &error)) {
|
|
set_clipboard(job->text);
|
|
show_error(nullptr, error);
|
|
}
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
gpointer job_thread(gpointer data) {
|
|
auto* job = static_cast<Job*>(data);
|
|
std::string hosted_error;
|
|
if (job->config.hosted_first && !job->config.hosted_url.empty()) {
|
|
job->ok = transcription_request(job->config.hosted_url, job->hosted_key, job->file,
|
|
job->config.prompt, &job->text, &hosted_error);
|
|
if (job->ok) {
|
|
g_main_context_invoke(nullptr, finish_job, job);
|
|
return nullptr;
|
|
}
|
|
}
|
|
job->ok = transcription_request(job->config.local_url, job->local_key, job->file,
|
|
job->config.prompt, &job->text, &job->error);
|
|
if (!job->ok && !hosted_error.empty()) job->error = "Hosted: " + hosted_error + "\nLocal: " + job->error;
|
|
g_main_context_invoke(nullptr, finish_job, job);
|
|
return nullptr;
|
|
}
|
|
|
|
void processing_closed(GtkWidget*, gpointer data) {
|
|
auto* app = static_cast<App*>(data);
|
|
app->processing = nullptr;
|
|
}
|
|
|
|
void cancel_processing(GtkButton* button, gpointer data) {
|
|
auto* app = static_cast<App*>(data);
|
|
app->cancelled.store(true);
|
|
gtk_button_set_label(button, "Cancelling");
|
|
gtk_widget_set_sensitive(GTK_WIDGET(button), false);
|
|
}
|
|
|
|
void show_processing(App* app) {
|
|
GtkWidget* window = gtk_application_window_new(app->gtk_app);
|
|
app->processing = window;
|
|
gtk_window_set_title(GTK_WINDOW(window), "Transui");
|
|
gtk_window_set_default_size(GTK_WINDOW(window), 310, 190);
|
|
gtk_window_set_resizable(GTK_WINDOW(window), false);
|
|
gtk_window_set_deletable(GTK_WINDOW(window), false);
|
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
|
|
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
|
|
gtk_widget_set_valign(box, GTK_ALIGN_CENTER);
|
|
gtk_container_add(GTK_CONTAINER(window), box);
|
|
GtkWidget* spinner = gtk_spinner_new();
|
|
gtk_widget_set_size_request(spinner, 36, 36);
|
|
gtk_spinner_start(GTK_SPINNER(spinner));
|
|
gtk_box_pack_start(GTK_BOX(box), spinner, false, false, 0);
|
|
GtkWidget* label = gtk_label_new("Transcribing");
|
|
gtk_style_context_add_class(gtk_widget_get_style_context(label), "state-title");
|
|
gtk_box_pack_start(GTK_BOX(box), label, false, false, 0);
|
|
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
|
gtk_box_pack_start(GTK_BOX(box), cancel, false, false, 8);
|
|
g_signal_connect(cancel, "clicked", G_CALLBACK(cancel_processing), app);
|
|
g_signal_connect(window, "destroy", G_CALLBACK(processing_closed), app);
|
|
gtk_widget_show_all(window);
|
|
}
|
|
|
|
void stop_recording(GtkButton*, gpointer data) {
|
|
auto* app = static_cast<App*>(data);
|
|
if (!app->pipeline) return;
|
|
if (app->elapsed_source) {
|
|
g_source_remove(app->elapsed_source);
|
|
app->elapsed_source = 0;
|
|
}
|
|
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,
|
|
static_cast<GstMessageType>(GST_MESSAGE_EOS | GST_MESSAGE_ERROR));
|
|
if (message) gst_message_unref(message);
|
|
gst_object_unref(bus);
|
|
gst_element_set_state(app->pipeline, GST_STATE_NULL);
|
|
gst_object_unref(app->pipeline);
|
|
app->pipeline = nullptr;
|
|
if (app->recorder) gtk_widget_destroy(app->recorder);
|
|
app->recorder = nullptr;
|
|
app->cancelled.store(false);
|
|
show_processing(app);
|
|
auto* job = new Job{app, app->recording_path, app->config, app->hosted_key, app->local_key};
|
|
g_thread_unref(g_thread_new("transcription", job_thread, job));
|
|
}
|
|
|
|
void cancel_recording(GtkButton*, gpointer data) {
|
|
auto* app = static_cast<App*>(data);
|
|
close_recorder(app);
|
|
remove_recording(app);
|
|
}
|
|
|
|
void recorder_closed(GtkWidget*, gpointer data) {
|
|
auto* app = static_cast<App*>(data);
|
|
app->recorder = nullptr;
|
|
if (app->elapsed_source) {
|
|
g_source_remove(app->elapsed_source);
|
|
app->elapsed_source = 0;
|
|
}
|
|
if (app->pipeline) {
|
|
gst_element_set_state(app->pipeline, GST_STATE_NULL);
|
|
gst_object_unref(app->pipeline);
|
|
app->pipeline = nullptr;
|
|
remove_recording(app);
|
|
}
|
|
}
|
|
|
|
void start_recording(App* app) {
|
|
if (app->recorder) { gtk_window_present(GTK_WINDOW(app->recorder)); return; }
|
|
if (app->config.local_url.empty() && (!app->config.hosted_first || app->config.hosted_url.empty())) {
|
|
open_settings(app);
|
|
show_error(GTK_WINDOW(app->settings), "Set an endpoint first.");
|
|
return;
|
|
}
|
|
gchar* template_path = g_build_filename(g_get_tmp_dir(), "transui-XXXXXX", nullptr);
|
|
int fd = g_mkstemp(template_path);
|
|
if (fd < 0) { show_error(nullptr, "Could not create a recording file."); g_free(template_path); return; }
|
|
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());
|
|
GError* error = nullptr;
|
|
app->pipeline = gst_parse_launch(pipeline_text, &error);
|
|
g_free(pipeline_text);
|
|
if (!app->pipeline) {
|
|
show_error(nullptr, error ? error->message : "Could not start recording.");
|
|
if (error) g_error_free(error);
|
|
remove_recording(app);
|
|
return;
|
|
}
|
|
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);
|
|
remove_recording(app);
|
|
return;
|
|
}
|
|
GtkWidget* window = gtk_application_window_new(app->gtk_app);
|
|
app->recorder = window;
|
|
gtk_window_set_title(GTK_WINDOW(window), "Transui");
|
|
gtk_window_set_default_size(GTK_WINDOW(window), 330, 250);
|
|
gtk_window_set_resizable(GTK_WINDOW(window), false);
|
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
|
|
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
|
|
gtk_widget_set_valign(box, GTK_ALIGN_CENTER);
|
|
gtk_container_add(GTK_CONTAINER(window), box);
|
|
GtkWidget* dot = gtk_label_new("●");
|
|
gtk_style_context_add_class(gtk_widget_get_style_context(dot), "record-dot");
|
|
gtk_box_pack_start(GTK_BOX(box), dot, false, false, 0);
|
|
GtkWidget* state = gtk_label_new("RECORDING");
|
|
gtk_style_context_add_class(gtk_widget_get_style_context(state), "record-state");
|
|
gtk_box_pack_start(GTK_BOX(box), state, false, false, 0);
|
|
app->elapsed_label = gtk_label_new("00:00");
|
|
gtk_style_context_add_class(gtk_widget_get_style_context(app->elapsed_label), "elapsed");
|
|
gtk_box_pack_start(GTK_BOX(box), app->elapsed_label, false, false, 0);
|
|
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
gtk_box_pack_start(GTK_BOX(box), actions, false, false, 8);
|
|
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
|
GtkWidget* stop = gtk_button_new_with_label("Stop and transcribe");
|
|
gtk_style_context_add_class(gtk_widget_get_style_context(stop), "suggested-action");
|
|
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->elapsed_source = g_timeout_add_seconds(1, tick_elapsed, 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);
|
|
gtk_widget_show_all(window);
|
|
}
|
|
|
|
void install_css() {
|
|
const char* css = R"CSS(
|
|
window { background-color: #111827; color: #edf1f7; }
|
|
entry, combobox button { background: #1e293b; color: #edf1f7; border: 1px solid #334155; border-radius: 5px; }
|
|
label { color: #cbd5e1; }
|
|
.record-dot { color: #fb7185; font-size: 30px; }
|
|
.record-state { color: #fb7185; font-weight: 800; letter-spacing: 2px; font-size: 12px; }
|
|
.elapsed { color: #f8fafc; font-family: monospace; font-size: 38px; font-weight: 700; }
|
|
.state-title { color: #f8fafc; font-size: 18px; font-weight: 700; }
|
|
button.suggested-action { background: #f97316; color: #111827; border-color: #f97316; font-weight: 700; }
|
|
button.suggested-action:hover { background: #fb923c; }
|
|
)CSS";
|
|
GtkCssProvider* provider = gtk_css_provider_new();
|
|
gtk_css_provider_load_from_data(provider, css, -1, nullptr);
|
|
gtk_style_context_add_provider_for_screen(gdk_screen_get_default(),
|
|
GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
|
|
g_object_unref(provider);
|
|
}
|
|
|
|
void activate(GtkApplication*, gpointer data) { start_recording(static_cast<App*>(data)); }
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
const bool settings = argc == 2 && std::strcmp(argv[1], "settings") == 0;
|
|
if (argc > 1 && !settings && std::strcmp(argv[1], "record") != 0) {
|
|
std::fprintf(stderr, "Usage: transui [record|settings]\n");
|
|
return 2;
|
|
}
|
|
gst_init(&argc, &argv);
|
|
curl_global_init(CURL_GLOBAL_DEFAULT);
|
|
App app;
|
|
load_config(&app);
|
|
app.gtk_app = gtk_application_new(kAppId, G_APPLICATION_DEFAULT_FLAGS);
|
|
g_signal_connect(app.gtk_app, "startup", G_CALLBACK(+[](GtkApplication*, gpointer) {
|
|
install_css();
|
|
}), nullptr);
|
|
if (settings) g_signal_connect_swapped(app.gtk_app, "activate", G_CALLBACK(open_settings), &app);
|
|
else g_signal_connect(app.gtk_app, "activate", G_CALLBACK(activate), &app);
|
|
const int result = g_application_run(G_APPLICATION(app.gtk_app), argc, argv);
|
|
if (app.pipeline) close_recorder(&app);
|
|
remove_recording(&app);
|
|
g_object_unref(app.gtk_app);
|
|
curl_global_cleanup();
|
|
return result;
|
|
}
|