This commit is contained in:
intelligence committed 2026-08-14 12:49:58 +02:00
commit 30df2ba4a9
6 files changed
+767

No files matched your search

+43
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/build/
+24
View File
@@ -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)
+45
View File
@@ -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.
+9
View File
@@ -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;
+645
View File
@@ -0,0 +1,645 @@
#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;
}