diff --git a/CMakeLists.txt b/CMakeLists.txt index 9712532..10eb15f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,6 @@ set(CMAKE_AUTORCC ON) set(QT_DEFAULT_MAJOR_VERSION 6 CACHE STRING "" FORCE) -# Require Qt 6.5+ # Require Qt 6.5+ find_package(Qt6 6.5 REQUIRED COMPONENTS Core Quick Qml Multimedia QuickControls2 Svg Network Concurrent) qt_standard_project_setup(REQUIRES 6.5) @@ -97,6 +96,8 @@ qt_add_executable(appTalkLess src/services/storageRepository.cpp src/services/soundboardService.h src/services/soundboardService.cpp + src/services/TranscriptionService.h + src/services/TranscriptionService.cpp ) # QML singletons @@ -247,6 +248,7 @@ target_link_libraries(appTalkLess PRIVATE Qt6::Svg Qt6::Network Qt6::Concurrent + ixwebsocket QHotkey::QHotkey ) diff --git a/qml/components/TeleprompterPopup.qml b/qml/components/TeleprompterPopup.qml index fa66756..bace55e 100644 --- a/qml/components/TeleprompterPopup.qml +++ b/qml/components/TeleprompterPopup.qml @@ -47,8 +47,49 @@ Popup { } } + // Transcription service connections + Connections { + target: transcriptionService + + function onTranscriptDelta(itemId, delta) { + // Append delta text to the text edit in real-time + console.log("[QML] Received transcript delta:", delta); + teleprompterTextEdit.insert(teleprompterTextEdit.length, delta); + } + + function onTranscriptFinal(itemId, text) { + // Add a space or newline after final segment + console.log("[QML] Received transcript final:", text); + teleprompterTextEdit.insert(teleprompterTextEdit.length, " "); + } + + function onSttError(message) { + console.log("[QML] Received STT error:", message); + errorLabel.text = message; + errorLabel.visible = true; + errorTimer.restart(); + } + } + + // Timer to hide error message + Timer { + id: errorTimer + interval: 5000 + onTriggered: errorLabel.visible = false + } + onOpened: { teleprompterTextEdit.text = root.teleprompterText; + errorLabel.visible = false; + } + + onClosed: { + // Auto-save the current content when dialog is closed + root.saved(root.clipId, teleprompterTextEdit.text); + // Stop listening if still active + if (transcriptionService.isListening) { + transcriptionService.stopListening(); + } } contentItem: ColumnLayout { @@ -123,7 +164,7 @@ Popup { // Upload Script button Rectangle { Layout.fillWidth: true - Layout.preferredHeight: 50 + Layout.preferredHeight: 44 color: Colors.surfaceDark radius: 8 border.color: Colors.border @@ -170,13 +211,16 @@ Popup { // Speak to Transcribe button Rectangle { + id: transcribeButton Layout.fillWidth: true - Layout.preferredHeight: 50 - color: Colors.surfaceDark + Layout.preferredHeight: 44 + color: transcriptionService.isListening ? Colors.accent : Colors.surfaceDark radius: 8 - border.color: Colors.border + border.color: transcriptionService.isListening ? Colors.accent : Colors.border border.width: 1 + Behavior on color { ColorAnimation { duration: 200 } } + RowLayout { anchors.centerIn: parent spacing: 10 @@ -195,27 +239,54 @@ Popup { height: 20 source: micIcon colorization: 1.0 - colorizationColor: Colors.textSecondary + colorizationColor: transcriptionService.isListening ? Colors.textOnPrimary : Colors.textSecondary } Text { - text: "Speak to Transcribe" + text: transcriptionService.isListening ? "Stop Listening" : "Speak to Transcribe" font.pixelSize: 13 - color: Colors.textPrimary + color: transcriptionService.isListening ? Colors.textOnPrimary : Colors.textPrimary } } + // Pulsing animation when listening + SequentialAnimation on opacity { + running: transcriptionService.isListening + loops: Animation.Infinite + NumberAnimation { to: 0.7; duration: 600 } + NumberAnimation { to: 1.0; duration: 600 } + } + MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - console.log("Speak to Transcribe clicked - placeholder"); + if (transcriptionService.isListening) { + transcriptionService.stopListening(); + } else if (!transcriptionService.hasApiKey) { + // Show API key input dialog + apiKeyDialog.open(); + } else { + transcriptionService.startListening(); + } } } } } + // Error message label + Text { + id: errorLabel + Layout.fillWidth: true + visible: false + text: "" + color: "#FF6B6B" + font.pixelSize: 12 + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + // Text area Rectangle { Layout.fillWidth: true @@ -360,4 +431,88 @@ Popup { } } } + + // API Key input dialog + Dialog { + id: apiKeyDialog + title: "OpenAI API Key Required" + modal: true + parent: Overlay.overlay + x: Math.round((parent ? parent.width : 800) / 2 - width / 2) + y: Math.round((parent ? parent.height : 600) / 2 - height / 2) + width: 420 + standardButtons: Dialog.Cancel + + background: Rectangle { + color: Colors.panelBg + radius: 12 + border.width: 1 + border.color: Colors.border + } + + ColumnLayout { + anchors.fill: parent + spacing: 16 + + Text { + Layout.fillWidth: true + text: "Enter your OpenAI API key to enable speech-to-text transcription." + font.pixelSize: 13 + color: Colors.textSecondary + wrapMode: Text.Wrap + } + + TextField { + id: apiKeyInput + Layout.fillWidth: true + placeholderText: "sk-proj-..." + echoMode: TextInput.Password + color: Colors.textPrimary + font.pixelSize: 14 + + background: Rectangle { + color: Colors.surfaceDark + radius: 8 + border.color: apiKeyInput.focus ? Colors.accent : Colors.border + border.width: 1 + } + } + + Text { + Layout.fillWidth: true + text: "Your key is stored locally and never sent anywhere except OpenAI." + font.pixelSize: 11 + color: Colors.textSecondary + opacity: 0.7 + } + + Button { + Layout.alignment: Qt.AlignRight + text: "Save & Start Listening" + enabled: apiKeyInput.text.length > 10 + + background: Rectangle { + color: parent.enabled ? Colors.accent : Colors.surfaceDark + radius: 8 + } + + contentItem: Text { + text: parent.text + font.pixelSize: 13 + font.weight: Font.DemiBold + color: parent.enabled ? Colors.textOnPrimary : Colors.textSecondary + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + onClicked: { + transcriptionService.setApiToken(apiKeyInput.text); + apiKeyInput.text = ""; + apiKeyDialog.close(); + transcriptionService.startListening(); + } + } + } + } } + diff --git a/qml/pages/ApplicationSettingsView.qml b/qml/pages/ApplicationSettingsView.qml index 55e5763..92b9ac7 100644 --- a/qml/pages/ApplicationSettingsView.qml +++ b/qml/pages/ApplicationSettingsView.qml @@ -238,7 +238,7 @@ Rectangle { ColumnLayout { anchors.fill: parent anchors.margins: 24 - spacing: 20 + spacing: 12 // Title Text { @@ -348,115 +348,87 @@ Rectangle { } } - // Test Mic Button - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 50 - color: Colors.surface - radius: 12 - border.width: 1 - border.color: Colors.border - - RowLayout { - anchors.left: parent.left - anchors.leftMargin: 16 - anchors.verticalCenter: parent.verticalCenter - spacing: 10 - - Text { - text: "Test Mic" - color: Colors.textPrimary - font.family: interFont.status === FontLoader.Ready ? interFont.name : "Arial" - font.pixelSize: Typography.fontSizeMedium - font.weight: Font.Medium - } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: console.log("Test mic clicked") - } - } - // Noise Cancellation Section ColumnLayout { Layout.fillWidth: true spacing: 8 - Layout.topMargin: 8 - Text { - text: "Noise Cancellation" - color: Colors.textPrimary - font.family: interFont.status === FontLoader.Ready ? interFont.name : "Arial" - font.pixelSize: Typography.fontSizeMedium - font.weight: Font.Medium - } - - Text { - text: "Reduce background noise from your microphone" - color: Colors.textSecondary - font.pixelSize: Typography.fontSizeSmall - } - - // Noise Cancellation Level Selector + // Noise Cancellation Toggle Row RowLayout { - id: noiseLevelSelector Layout.fillWidth: true - spacing: 8 - - property var levelNames: soundboardService?.getNoiseSuppressionLevelNames() ?? ["Off", "Low", "Moderate", "High", "Very High"] - property int currentLevel: soundboardService?.noiseSuppressionLevel ?? 2 + spacing: 12 - // Update when settings change - Connections { - target: soundboardService - function onSettingsChanged() { - noiseLevelSelector.currentLevel = soundboardService?.noiseSuppressionLevel ?? 2; + // Toggle Switch + Rectangle { + id: noiseCancellationToggle + width: 52 + height: 28 + radius: 14 + color: noiseCancellationToggle.isOn ? Colors.success : Colors.surfaceLight + + // ON = level 4 (VeryHigh), OFF = level 0 (Off) + property bool isOn: (soundboardService?.noiseSuppressionLevel ?? 0) > 0 + onIsOnChanged: { + if (soundboardService) { + var newLevel = isOn ? 4 : 0; + if (newLevel !== soundboardService.noiseSuppressionLevel) { + console.log("Noise cancellation toggled:", isOn ? "ON (VeryHigh)" : "OFF"); + soundboardService.setNoiseSuppressionLevel(newLevel); + } + } } - } - - Repeater { - model: noiseLevelSelector.levelNames - - Rectangle { - Layout.fillWidth: true - height: 36 - radius: 8 - - property bool isActive: index === noiseLevelSelector.currentLevel - - color: isActive ? Colors.accent : Colors.surface - border.width: isActive ? 2 : 1 - border.color: isActive ? Colors.accentLight : Colors.border - Text { - anchors.centerIn: parent - text: modelData - color: parent.isActive ? Colors.textOnAccent : Colors.textPrimary - font.family: interFont.status === FontLoader.Ready ? interFont.name : "Arial" - font.pixelSize: Typography.fontSizeSmall - font.weight: parent.isActive ? Font.DemiBold : Font.Normal + // Listen for backend changes to update the toggle + Connections { + target: soundboardService + function onSettingsChanged() { + if (soundboardService) { + noiseCancellationToggle.isOn = soundboardService.noiseSuppressionLevel > 0; + } } + } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - console.log("Noise cancellation level set to:", index, modelData); - soundboardService.setNoiseSuppressionLevel(index); + Rectangle { + width: 22 + height: 22 + radius: 11 + color: noiseCancellationToggle.isOn ? Colors.textPrimary : Colors.border + x: noiseCancellationToggle.isOn ? parent.width - width - 3 : 3 + anchors.verticalCenter: parent.verticalCenter + + Behavior on x { + NumberAnimation { + duration: 150 } } + } - Behavior on color { - ColorAnimation { duration: 150 } - } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: noiseCancellationToggle.isOn = !noiseCancellationToggle.isOn + } - Behavior on border.width { - NumberAnimation { duration: 150 } + Behavior on color { + ColorAnimation { + duration: 150 } } } + + Text { + text: "Noise Cancellation" + color: Colors.textPrimary + font.family: interFont.status === FontLoader.Ready ? interFont.name : "Arial" + font.pixelSize: Typography.fontSizeMedium + font.weight: Font.Medium + } + } + + Text { + text: "Reduce background noise from your microphone" + color: Colors.textSecondary + font.pixelSize: Typography.fontSizeSmall } } } diff --git a/src/main.cpp b/src/main.cpp index b66b8d9..8fb5d47 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include "qmlmodels/soundboardsListModel.h" #include "services/ApiClient.h" #include "services/soundboardService.h" +#include "services/TranscriptionService.h" #include #include @@ -31,6 +32,9 @@ int main(int argc, char* argv[]) // API Client for authentication ApiClient apiClient; + // Transcription Service for speech-to-text + TranscriptionService transcriptionService; + // Model for QML SoundboardsListModel soundboardsModel; soundboardsModel.setService(&soundboardService); @@ -59,6 +63,7 @@ int main(int argc, char* argv[]) // Expose to QML engine.rootContext()->setContextProperty("soundboardService", &soundboardService); engine.rootContext()->setContextProperty("apiClient", &apiClient); + engine.rootContext()->setContextProperty("transcriptionService", &transcriptionService); engine.rootContext()->setContextProperty("soundboardsModel", &soundboardsModel); engine.rootContext()->setContextProperty("clipsModel", &clipsModel); engine.rootContext()->setContextProperty("hotkeyManager", &hotkeyManager); diff --git a/src/services/TranscriptionService.cpp b/src/services/TranscriptionService.cpp new file mode 100644 index 0000000..9353bc6 --- /dev/null +++ b/src/services/TranscriptionService.cpp @@ -0,0 +1,356 @@ +#include "TranscriptionService.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +TranscriptionService::TranscriptionService(QObject* parent) + : QObject(parent) + , m_webSocket(std::make_unique()) +{ + // Load API token from settings on startup + QSettings settings; + m_apiToken = settings.value("openai/apiKey").toString(); + + // Setup inactivity timer + m_inactivityTimer.setSingleShot(true); + m_inactivityTimer.setInterval(INACTIVITY_TIMEOUT_MS); + connect(&m_inactivityTimer, &QTimer::timeout, this, &TranscriptionService::onInactivityTimeout); + + // Audio send timer (100ms intervals for real-time streaming) + m_audioSendTimer.setInterval(AUDIO_SEND_INTERVAL_MS); + connect(&m_audioSendTimer, &QTimer::timeout, this, &TranscriptionService::sendAudioChunk); +} + +TranscriptionService::~TranscriptionService() +{ + stopListening(); +} + +void TranscriptionService::setApiToken(const QString& token) +{ + m_apiToken = token; + QSettings settings; + settings.setValue("openai/apiKey", token); + emit hasApiKeyChanged(); +} + +void TranscriptionService::setLanguage(const QString& language) +{ + if (m_language != language) { + m_language = language; + emit languageChanged(); + qDebug() << "[TranscriptionService] Language set to:" << language; + } +} + +QString TranscriptionService::getLanguageCode() const +{ + if (m_language.contains("Sinhala") || m_language.contains("සිංහල")) { + return "si"; + } + return "en"; +} + +QString TranscriptionService::getApiToken() +{ + if (!m_apiToken.isEmpty()) { + return m_apiToken; + } + return QProcessEnvironment::systemEnvironment().value("OPENAI_API_KEY"); +} + +void TranscriptionService::startListening() +{ + if (m_isListening) { + return; + } + + QString token = getApiToken(); + if (token.isEmpty()) { + m_errorMessage = "No API key configured"; + emit sttError(m_errorMessage); + return; + } + + qDebug() << "[TranscriptionService] Starting real-time transcription..."; + + m_audioBuffer.clear(); + m_isListening = true; + emit isListeningChanged(); + + // Configure WebSocket with transcription intent + std::string url = "wss://api.openai.com/v1/realtime?intent=transcription"; + + ix::WebSocketHttpHeaders headers; + headers["Authorization"] = "Bearer " + token.toStdString(); + headers["OpenAI-Beta"] = "realtime=v1"; + + m_webSocket->setUrl(url); + m_webSocket->setExtraHeaders(headers); + + // Set up message callback + m_webSocket->setOnMessageCallback([this](const ix::WebSocketMessagePtr& msg) { + onWebSocketMessage(msg); + }); + + m_webSocket->start(); +} + +void TranscriptionService::stopListening() +{ + if (!m_isListening) { + return; + } + + qDebug() << "[TranscriptionService] Stopping..."; + + m_audioSendTimer.stop(); + m_inactivityTimer.stop(); + stopAudioCapture(); + + m_webSocket->stop(); + m_isConnected = false; + m_isListening = false; + emit isListeningChanged(); +} + +void TranscriptionService::onWebSocketMessage(const ix::WebSocketMessagePtr& msg) +{ + switch (msg->type) { + case ix::WebSocketMessageType::Open: + qDebug() << "[TranscriptionService] WebSocket connected"; + m_isConnected = true; + + // Send transcription session configuration + QMetaObject::invokeMethod(this, [this]() { + sendTranscriptionSessionUpdate(); + startAudioCapture(); + m_audioSendTimer.start(); + m_inactivityTimer.start(); + }, Qt::QueuedConnection); + break; + + case ix::WebSocketMessageType::Close: + qDebug() << "[TranscriptionService] WebSocket closed"; + m_isConnected = false; + QMetaObject::invokeMethod(this, [this]() { + if (m_isListening) { + stopListening(); + } + }, Qt::QueuedConnection); + break; + + case ix::WebSocketMessageType::Error: + qDebug() << "[TranscriptionService] WebSocket error:" << QString::fromStdString(msg->errorInfo.reason); + QMetaObject::invokeMethod(this, [this, error = QString::fromStdString(msg->errorInfo.reason)]() { + m_errorMessage = error; + emit sttError(m_errorMessage); + stopListening(); + }, Qt::QueuedConnection); + break; + + case ix::WebSocketMessageType::Message: + QMetaObject::invokeMethod(this, [this, text = QString::fromStdString(msg->str)]() { + processMessage(text); + }, Qt::QueuedConnection); + break; + + default: + break; + } +} + +void TranscriptionService::processMessage(const QString& message) +{ + QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8()); + if (!doc.isObject()) { + return; + } + + QJsonObject obj = doc.object(); + QString type = obj["type"].toString(); + + qDebug() << "[TranscriptionService] Event:" << type; + + // Handle transcription events + if (type == "conversation.item.input_audio_transcription.delta") { + QString delta = obj["delta"].toString(); + if (!delta.isEmpty()) { + qDebug() << "[TranscriptionService] Delta:" << delta; + emit transcriptDelta("", delta); + } + + } else if (type == "conversation.item.input_audio_transcription.completed") { + QString transcript = obj["transcript"].toString(); + qDebug() << "[TranscriptionService] Completed:" << transcript; + emit transcriptFinal("", transcript); + + } else if (type == "input_audio_buffer.speech_started") { + qDebug() << "[TranscriptionService] Speech started"; + m_inactivityTimer.stop(); + + } else if (type == "input_audio_buffer.speech_stopped") { + qDebug() << "[TranscriptionService] Speech stopped"; + m_inactivityTimer.start(); + + } else if (type == "error") { + QJsonObject errorObj = obj["error"].toObject(); + QString errorMsg = errorObj["message"].toString(); + qDebug() << "[TranscriptionService] Error:" << errorMsg; + m_errorMessage = errorMsg; + emit sttError(m_errorMessage); + + } else if (type == "transcription_session.created" || type == "transcription_session.updated") { + qDebug() << "[TranscriptionService] Session ready"; + } +} + +void TranscriptionService::startAudioCapture() +{ + QAudioDevice audioDevice = QMediaDevices::defaultAudioInput(); + if (audioDevice.isNull()) { + m_errorMessage = "No microphone available"; + emit sttError(m_errorMessage); + stopListening(); + return; + } + + qDebug() << "[TranscriptionService] Using audio device:" << audioDevice.description(); + + // Setup audio format: PCM16 mono at 48kHz (native Mac rate) + QAudioFormat format; + format.setSampleRate(CAPTURE_SAMPLE_RATE); + format.setChannelCount(1); + format.setSampleFormat(QAudioFormat::Int16); + + if (!audioDevice.isFormatSupported(format)) { + format = audioDevice.preferredFormat(); + format.setChannelCount(1); + format.setSampleFormat(QAudioFormat::Int16); + } + + qDebug() << "[TranscriptionService] Audio format:" << format.sampleRate() << "Hz"; + + m_audioSource = std::make_unique(audioDevice, format); + m_audioDevice = m_audioSource->start(); + + if (!m_audioDevice) { + m_errorMessage = "Failed to start microphone capture"; + emit sttError(m_errorMessage); + stopListening(); + return; + } + + qDebug() << "[TranscriptionService] Audio capture started"; +} + +void TranscriptionService::stopAudioCapture() +{ + m_audioSendTimer.stop(); + if (m_audioSource) { + m_audioSource->stop(); + m_audioSource.reset(); + } + m_audioDevice = nullptr; + m_audioBuffer.clear(); +} + +void TranscriptionService::sendAudioChunk() +{ + if (!m_isConnected || !m_audioDevice) { + return; + } + + QByteArray audioData = m_audioDevice->readAll(); + + if (audioData.isEmpty()) { + return; + } + + // Downsample from 48kHz to 24kHz (2:1 decimation) + const int16_t* samples = reinterpret_cast(audioData.constData()); + int sampleCount = audioData.size() / 2; + + QByteArray downsampled; + downsampled.reserve(audioData.size() / 2); + + for (int i = 0; i < sampleCount - 1; i += 2) { + int32_t avg = (static_cast(samples[i]) + static_cast(samples[i + 1])) / 2; + int16_t sample = static_cast(avg); + downsampled.append(reinterpret_cast(&sample), 2); + } + + if (downsampled.isEmpty()) { + return; + } + + // Base64 encode and send + QString base64Audio = downsampled.toBase64(); + + QJsonObject message; + message["type"] = "input_audio_buffer.append"; + message["audio"] = base64Audio; + + std::string jsonStr = QJsonDocument(message).toJson(QJsonDocument::Compact).toStdString(); + m_webSocket->send(jsonStr); +} + +void TranscriptionService::onInactivityTimeout() +{ + qDebug() << "[TranscriptionService] Inactivity timeout - stopping"; + stopListening(); +} + +void TranscriptionService::sendTranscriptionSessionUpdate() +{ + /* + * Transcription session configuration per OpenAI docs: + * { + * "type": "transcription_session.update", + * "session": { + * "input_audio_format": "pcm16", + * "input_audio_transcription": { + * "model": "gpt-4o-mini-transcribe", + * "language": "en" + * }, + * "turn_detection": { + * "type": "server_vad", + * "threshold": 0.5, + * "prefix_padding_ms": 300, + * "silence_duration_ms": 500 + * } + * } + * } + */ + + QJsonObject transcription; + transcription["model"] = "gpt-4o-transcribe"; + transcription["language"] = getLanguageCode(); + + QJsonObject turnDetection; + turnDetection["type"] = "server_vad"; + turnDetection["threshold"] = 0.5; + turnDetection["prefix_padding_ms"] = 300; + turnDetection["silence_duration_ms"] = 500; + + // Session config object + QJsonObject sessionConfig; + sessionConfig["input_audio_format"] = "pcm16"; + sessionConfig["input_audio_transcription"] = transcription; + sessionConfig["turn_detection"] = turnDetection; + + // Wrap in message with type + QJsonObject message; + message["type"] = "transcription_session.update"; + message["session"] = sessionConfig; + + std::string jsonStr = QJsonDocument(message).toJson(QJsonDocument::Compact).toStdString(); + qDebug() << "[TranscriptionService] Sending session config:" << QString::fromStdString(jsonStr); + m_webSocket->send(jsonStr); +} diff --git a/src/services/TranscriptionService.h b/src/services/TranscriptionService.h new file mode 100644 index 0000000..2d3ec74 --- /dev/null +++ b/src/services/TranscriptionService.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief OpenAI Realtime Transcription service + * + * Uses the Realtime API with transcription intent for real-time speech-to-text. + * Endpoint: wss://api.openai.com/v1/realtime?intent=transcription + * API key is stored persistently via QSettings. + */ +class TranscriptionService : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool isListening READ isListening NOTIFY isListeningChanged) + Q_PROPERTY(bool hasApiKey READ hasApiKey NOTIFY hasApiKeyChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY sttError) + Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged) + Q_PROPERTY(QStringList supportedLanguages READ supportedLanguages CONSTANT) + +public: + explicit TranscriptionService(QObject* parent = nullptr); + ~TranscriptionService() override; + + [[nodiscard]] bool isListening() const { return m_isListening; } + [[nodiscard]] bool hasApiKey() const { return !m_apiToken.isEmpty(); } + [[nodiscard]] QString errorMessage() const { return m_errorMessage; } + [[nodiscard]] QString language() const { return m_language; } + + /// Get list of supported languages (display names) + [[nodiscard]] QStringList supportedLanguages() const { + return {"English", "සිංහල (Sinhala)"}; + } + + /// Set the transcription language + void setLanguage(const QString& language); + + /// Set API key programmatically (saves to persistent settings) + Q_INVOKABLE void setApiToken(const QString& token); + +public slots: + /// Start listening - connects WebSocket and begins streaming audio + void startListening(); + + /// Stop listening - closes WebSocket connection + void stopListening(); + +signals: + /// Emitted with partial transcription delta + void transcriptDelta(const QString& itemId, const QString& delta); + + /// Emitted when transcription for an item is complete + void transcriptFinal(const QString& itemId, const QString& text); + + /// Emitted on any error + void sttError(const QString& message); + + /// Listening state changed + void isListeningChanged(); + + /// API key status changed + void hasApiKeyChanged(); + + /// Language changed + void languageChanged(); + +private slots: + void onInactivityTimeout(); + void sendAudioChunk(); + +private: + void onWebSocketMessage(const ix::WebSocketMessagePtr& msg); + void sendTranscriptionSessionUpdate(); + void startAudioCapture(); + void stopAudioCapture(); + QString getApiToken(); + QString getLanguageCode() const; + void processMessage(const QString& message); + + std::unique_ptr m_webSocket; + std::unique_ptr m_audioSource; + QIODevice* m_audioDevice = nullptr; + QTimer m_inactivityTimer; + QTimer m_audioSendTimer; + + QString m_apiToken; + QString m_language = "English"; + bool m_isListening = false; + bool m_isConnected = false; + QString m_errorMessage; + QByteArray m_audioBuffer; + std::mutex m_audioMutex; + + static constexpr int INACTIVITY_TIMEOUT_MS = 10000; // 10 seconds + static constexpr int CAPTURE_SAMPLE_RATE = 48000; // Native capture rate + static constexpr int API_SAMPLE_RATE = 24000; // API requires 24kHz + static constexpr int AUDIO_SEND_INTERVAL_MS = 100; // Send every 100ms for real-time +};