commit 30df2ba4a9b40bb606bf506a6d9c90736f9ef726 Author: intelligence Date: Fri Aug 14 12:49:58 2026 +0200 init diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..676da97 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,43 @@ +name: Build + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + build-amd64: + runs-on: debian-latest + env: + CMAKE_BUILD_PARALLEL_LEVEL: 2 + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \ + libgstreamer1.0-dev gstreamer1.0-plugins-good libsecret-1-dev \ + libjson-glib-dev wtype xdotool + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build build --parallel 2 + + build-arm64: + runs-on: debian-latest-arm64 + env: + CMAKE_BUILD_PARALLEL_LEVEL: 2 + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \ + libgstreamer1.0-dev gstreamer1.0-plugins-good libsecret-1-dev \ + libjson-glib-dev wtype xdotool + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build build --parallel 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..f3b6b46 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.16) +project(transui VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK3 REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(CURL REQUIRED IMPORTED_TARGET libcurl) +pkg_check_modules(GSTREAMER REQUIRED IMPORTED_TARGET gstreamer-1.0) +pkg_check_modules(SECRET REQUIRED IMPORTED_TARGET libsecret-1) +pkg_check_modules(JSON_GLIB REQUIRED IMPORTED_TARGET json-glib-1.0) + +add_executable(transui src/main.cpp) +target_link_libraries(transui PRIVATE + PkgConfig::GTK3 + PkgConfig::CURL + PkgConfig::GSTREAMER + PkgConfig::SECRET + PkgConfig::JSON_GLIB +) + +install(TARGETS transui RUNTIME DESTINATION bin) +install(FILES data/io.github.transui.Transui.desktop DESTINATION share/applications) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9182135 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Transui + +A compact GTK3 desktop transcription tool for OpenAI-compatible and llama.cpp +`/v1/audio/transcriptions` endpoints. + +## Dependencies + +On Debian/Ubuntu install the following packages yourself before compiling: + +```sh +sudo apt install cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \ + libgstreamer1.0-dev gstreamer1.0-plugins-good libsecret-1-dev \ + libjson-glib-dev wtype xdotool +``` + +`wtype` is used on Wayland and `xdotool` on X11. They are runtime tools, so +you only need the one matching your session. API keys are saved in the +freedesktop Secret Service (GNOME Keyring, KWallet Secret Service, and so on), +not in the settings file. + +## Build + +```sh +cmake -S . -B build +cmake --build build +sudo cmake --install build +``` + +## Commands + +```sh +transui settings # configure endpoints, prompt, and destination +transui record # open the record window +transui # same as record +``` + +Bind `transui record` to a shortcut in your desktop environment. Transui does +not register a global shortcut itself. + +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 only. + +Both a normal response (`{"text":"..."}`) and llama.cpp event response +(`{"type":"transcript.text.done","text":"..."}`) are accepted. diff --git a/data/io.github.transui.Transui.desktop b/data/io.github.transui.Transui.desktop new file mode 100644 index 0000000..00255ad --- /dev/null +++ b/data/io.github.transui.Transui.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Transui +Comment=Record and transcribe speech +Exec=transui settings +Icon=audio-input-microphone +Terminal=false +Type=Application +Categories=AudioVideo;Audio; +Keywords=transcription;dictation;speech; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..1fdb260 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,645 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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(data)->settings = nullptr; +} + +void save_clicked(GtkButton*, gpointer data) { + auto* fields = static_cast(data); + App* app = static_cast(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(p); + }), fields, nullptr, G_CONNECT_AFTER); + gtk_widget_show_all(window); +} + +gboolean tick_elapsed(gpointer data) { + auto* app = static_cast(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(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 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(static_cast(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(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(data); + app->processing = nullptr; +} + +void cancel_processing(GtkButton* button, gpointer data) { + auto* app = static_cast(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(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(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(data); + close_recorder(app); + remove_recording(app); +} + +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; + } + 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(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; +}