-
29.gitignore
-
77README.md
-
54android/app/build.gradle
-
64android/app/src/main/AndroidManifest.xml
-
32android/app/src/main/java/com/soniox/remotemic/AppLogger.kt
-
112android/app/src/main/java/com/soniox/remotemic/AudioRecorderManager.kt
-
104android/app/src/main/java/com/soniox/remotemic/MacPasteClient.kt
-
357android/app/src/main/java/com/soniox/remotemic/MainActivity.kt
-
29android/app/src/main/java/com/soniox/remotemic/PreferencesManager.kt
-
348android/app/src/main/java/com/soniox/remotemic/SonioxLiveStreamer.kt
-
309android/app/src/main/java/com/soniox/remotemic/SonioxRecognitionService.kt
-
233android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt
-
102android/app/src/main/java/com/soniox/remotemic/VoiceRecognitionActivity.kt
-
9android/app/src/main/res/drawable/bg_card.xml
-
9android/app/src/main/res/drawable/bg_dialog.xml
-
16android/app/src/main/res/drawable/bg_mic_button.xml
-
12android/app/src/main/res/drawable/bg_mic_button_active.xml
-
9android/app/src/main/res/drawable/ic_mic.xml
-
243android/app/src/main/res/layout/activity_main.xml
-
102android/app/src/main/res/layout/activity_voice_recognition.xml
-
72android/app/src/main/res/layout/dialog_logs_sheet.xml
-
15android/app/src/main/res/values/colors.xml
-
15android/app/src/main/res/values/strings.xml
-
16android/app/src/main/res/values/themes.xml
-
3android/app/src/main/res/xml/recognition_service.xml
-
78android/app/src/test/java/com/soniox/remotemic/SonioxWsTest.kt
-
8android/build.gradle
-
4android/gradle.properties
-
BINandroid/gradle/wrapper/gradle-wrapper.jar
-
5android/gradle/wrapper/gradle-wrapper.properties
-
160android/gradlew
-
90android/gradlew.bat
-
16android/settings.gradle
-
41mac/Info.plist
-
121mac/make_icon.py
-
55mac/package.sh
-
BINmac/resources/AppIcon.iconset/icon_128x128.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.iconset/icon_16x16.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.iconset/icon_256x256.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.iconset/icon_32x32.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.iconset/icon_512x512.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.png
-
246mac/src/AppDelegate.swift
-
165mac/src/AudioRecorder.swift
-
180mac/src/HUDOverlay.swift
-
261mac/src/HotkeyManager.swift
-
138mac/src/RelayClient.swift
-
321mac/src/SonioxClient.swift
-
172mac/src/StatusBarController.swift
-
7mac/src/main.swift
-
428server/relay_server.py
-
13server/soniox-relay.service
@ -0,0 +1,29 @@ |
|||||
|
# Android |
||||
|
.gradle/ |
||||
|
build/ |
||||
|
app/build/ |
||||
|
*.apk |
||||
|
*.aab |
||||
|
local.properties |
||||
|
.idea/ |
||||
|
*.iml |
||||
|
|
||||
|
# macOS & Swift |
||||
|
mac/build/ |
||||
|
mac/dmg_staging/ |
||||
|
*.dmg |
||||
|
*.zip |
||||
|
*.DS_Store |
||||
|
.build/ |
||||
|
|
||||
|
# Python |
||||
|
__pycache__/ |
||||
|
*.pyc |
||||
|
.venv/ |
||||
|
venv/ |
||||
|
*.log |
||||
|
|
||||
|
# Hermes / Temp |
||||
|
*.swp |
||||
|
*.bak |
||||
|
.DS_Store |
||||
@ -0,0 +1,77 @@ |
|||||
|
# 🎙️ Soniox Mobile to Mac (v1.0 Stable) |
||||
|
|
||||
|
سیستم پیشرفته و فوقسریع تایپ صوتی بیسیم از گوشی اندروید و هدست به مکینتاش (macOS) با هوش مصنوعی سانیاوکس (Soniox AI). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🌟 ویژگیهای کلیدی (Key Features) |
||||
|
|
||||
|
1. **تایپ صوتی بیسیم از اندروید به مک (Android ➡️ Mac):** |
||||
|
- پشتیبانی از حالت **Hold-to-Talk** و **کلید سختافزاری کم کردن صدا (Volume Down)** گوشی. |
||||
|
- ویرایشگر زنده متن روی گوشی با امکان تایپ دستی، اصلاح کلمات و دکمه اختصاصی **«🚀 درج متن در مک» (`Cmd + V`)**. |
||||
|
- حذف کامل لاگهای حجیم از صفحه اصلی و انتقال آن به پنجره هوشمند دیباگ (BottomSheet). |
||||
|
|
||||
|
2. **معماری فوقسریع استریم زنده (Pipelined Real-time Streaming v3.0):** |
||||
|
- **اتصال دائمی دوطرفه (Persistent Duplex WebSocket):** صفر میلیثانیه تأخیر شروع در صحبت مجدد. |
||||
|
- **استخر اتصالات گرم (Pre-Warmed Upstream Pool):** اتصال همیشه آماده به سانیاوکس روی سرور لینوکس. |
||||
|
- **تایپ فوری در مک:** تأخیر کلی رها کردن دکمه تا تایپ در مک کمتر از **۲۵۰ میلیثانیه**. |
||||
|
|
||||
|
3. **جلوگیری قطعی از شکستن خط و تکرار متن (Zero Newlines & Strict Dedup):** |
||||
|
* پالایش خودکار کلمات زائد و جلوگیری از ارسال کاراکترهای `\r\n` (بدون ارسال اینتر ناخواسته). |
||||
|
* کش هوشمند `Session ID` برای جلوگیری از تایپ تکراری جملات. |
||||
|
|
||||
|
4. **اپلیکیشن اختصاصی مک (macOS Native MenuBar App):** |
||||
|
- پشتیبانی از کلید میانبر سیستمی **`Option` (⌥)** به صورت Hold-to-Talk و **`Caps Lock`** به صورت Toggle. |
||||
|
- درج مستقیم متن در فیلد متنی فعال بدون نیاز به فوکوس دستی. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📁 ساختار پروژه (Repository Structure) |
||||
|
|
||||
|
``` |
||||
|
soniox-mobile-to-mac/ |
||||
|
├── android/ # اپلیکیشن نیتیو اندروید (Kotlin, OkHttp, Material 3) |
||||
|
│ ├── app/src/main/ # سورس کدها و لایهبندی UI |
||||
|
│ └── build.gradle # تنظیمات بیلد اندروید |
||||
|
├── mac/ # اپلیکیشن نیتیو مکاواس (Swift, AppKit, CoreAudio) |
||||
|
│ ├── src/ # سورس کدهای کلاینت مک و سرویس Paste |
||||
|
│ ├── package.sh # اسکریپت بیلد .app و پکیجبندی .dmg |
||||
|
│ └── Info.plist # دسترسیها و تنظیمات امنیتی مک |
||||
|
├── server/ # گیتوی پرسرعت رله سرور (Python aiohttp/websockets) |
||||
|
│ ├── relay_server.py |
||||
|
│ └── soniox-relay.service |
||||
|
└── README.md |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🚀 راهنمای راهاندازی و اجرا |
||||
|
|
||||
|
### ۱. اجرای سرور گیتوی (Linux Server): |
||||
|
```bash |
||||
|
python3 -m venv .venv |
||||
|
source .venv/bin/activate |
||||
|
pip install aiohttp websockets |
||||
|
python3 server/relay_server.py |
||||
|
``` |
||||
|
*(سرویس روی پورت `0.0.0.0:8999` آماده پاسخگویی میشود).* |
||||
|
|
||||
|
### ۲. بیلد و اجرای کلاینت مک (macOS): |
||||
|
```bash |
||||
|
cd mac |
||||
|
chmod +x package.sh |
||||
|
./package.sh |
||||
|
open "/Applications/Soniox Voice.app" |
||||
|
``` |
||||
|
|
||||
|
### ۳. بیلد و نصب اپلیکیشن اندروید: |
||||
|
```bash |
||||
|
cd android |
||||
|
./gradlew assembleDebug |
||||
|
# فایل APK خروجی در app/build/outputs/apk/debug/app-debug.apk ساخته میشود |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📜 لایسنس |
||||
|
توسعه یافته برای شرکت NewHorizon. |
||||
@ -0,0 +1,54 @@ |
|||||
|
plugins { |
||||
|
id 'com.android.application' |
||||
|
id 'org.jetbrains.kotlin.android' |
||||
|
} |
||||
|
|
||||
|
android { |
||||
|
namespace 'com.soniox.remotemic' |
||||
|
compileSdk 34 |
||||
|
|
||||
|
defaultConfig { |
||||
|
applicationId "com.soniox.remotemic" |
||||
|
minSdk 24 |
||||
|
targetSdk 34 |
||||
|
versionCode 1 |
||||
|
versionName "1.0.0" |
||||
|
|
||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" |
||||
|
} |
||||
|
|
||||
|
buildTypes { |
||||
|
release { |
||||
|
minifyEnabled false |
||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
compileOptions { |
||||
|
sourceCompatibility JavaVersion.VERSION_17 |
||||
|
targetCompatibility JavaVersion.VERSION_17 |
||||
|
} |
||||
|
|
||||
|
kotlinOptions { |
||||
|
jvmTarget = '17' |
||||
|
} |
||||
|
|
||||
|
buildFeatures { |
||||
|
viewBinding true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
implementation 'androidx.core:core-ktx:1.13.1' |
||||
|
implementation 'androidx.appcompat:appcompat:1.7.0' |
||||
|
implementation 'com.google.android.material:material:1.12.0' |
||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' |
||||
|
|
||||
|
// OkHttp for WebSocket & HTTP POST to Mac |
||||
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0' |
||||
|
|
||||
|
// Coroutines |
||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' |
||||
|
|
||||
|
testImplementation 'junit:junit:4.13.2' |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
|
|
||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
||||
|
<uses-permission android:name="android.permission.INTERNET" /> |
||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> |
||||
|
<uses-permission android:name="android.permission.VIBRATE" /> |
||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
||||
|
|
||||
|
<application |
||||
|
android:allowBackup="true" |
||||
|
android:icon="@drawable/ic_mic" |
||||
|
android:label="@string/app_name" |
||||
|
android:roundIcon="@drawable/ic_mic" |
||||
|
android:supportsRtl="true" |
||||
|
android:theme="@style/Theme.SonioxRemoteMic" |
||||
|
android:usesCleartextTraffic="true"> |
||||
|
|
||||
|
<activity |
||||
|
android:name=".MainActivity" |
||||
|
android:exported="true" |
||||
|
android:screenOrientation="portrait" |
||||
|
android:windowSoftInputMode="adjustResize"> |
||||
|
<intent-filter> |
||||
|
<action android:name="android.intent.action.MAIN" /> |
||||
|
<category android:name="android.intent.category.LAUNCHER" /> |
||||
|
</intent-filter> |
||||
|
</activity> |
||||
|
|
||||
|
<!-- Standalone Dialog Voice Recognition for search bars & third party intents --> |
||||
|
<activity |
||||
|
android:name=".VoiceRecognitionActivity" |
||||
|
android:exported="true" |
||||
|
android:theme="@style/Theme.SonioxRemoteMic.Dialog" |
||||
|
android:label="@string/voice_typing_service_name"> |
||||
|
<intent-filter> |
||||
|
<action android:name="android.speech.action.RECOGNIZE_SPEECH" /> |
||||
|
<category android:name="android.intent.category.DEFAULT" /> |
||||
|
</intent-filter> |
||||
|
<intent-filter> |
||||
|
<action android:name="android.speech.action.WEB_SEARCH" /> |
||||
|
<category android:name="android.intent.category.DEFAULT" /> |
||||
|
</intent-filter> |
||||
|
</activity> |
||||
|
|
||||
|
<!-- System Voice Input RecognitionService for Gboard & Samsung Keyboard --> |
||||
|
<service |
||||
|
android:name=".SonioxRecognitionService" |
||||
|
android:label="@string/voice_typing_service_name" |
||||
|
android:icon="@drawable/ic_mic" |
||||
|
android:permission="android.permission.RECORD_AUDIO" |
||||
|
android:exported="true"> |
||||
|
<intent-filter> |
||||
|
<action android:name="android.speech.RecognitionService" /> |
||||
|
<category android:name="android.intent.category.DEFAULT" /> |
||||
|
</intent-filter> |
||||
|
<meta-data |
||||
|
android:name="android.speech" |
||||
|
android:resource="@xml/recognition_service" /> |
||||
|
</service> |
||||
|
|
||||
|
</application> |
||||
|
|
||||
|
</manifest> |
||||
@ -0,0 +1,32 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import java.text.SimpleDateFormat |
||||
|
import java.util.Date |
||||
|
import java.util.Locale |
||||
|
import java.util.concurrent.ConcurrentLinkedQueue |
||||
|
|
||||
|
object AppLogger { |
||||
|
private val logQueue = ConcurrentLinkedQueue<String>() |
||||
|
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) |
||||
|
|
||||
|
var onLogListener: ((String) -> Unit)? = null |
||||
|
|
||||
|
fun log(tag: String, message: String) { |
||||
|
val time = timeFormat.format(Date()) |
||||
|
val entry = "[$time] [$tag] $message" |
||||
|
logQueue.offer(entry) |
||||
|
while (logQueue.size > 200) { |
||||
|
logQueue.poll() |
||||
|
} |
||||
|
onLogListener?.invoke(entry) |
||||
|
} |
||||
|
|
||||
|
fun getAllLogs(): String { |
||||
|
return logQueue.joinToString("\n") |
||||
|
} |
||||
|
|
||||
|
fun clear() { |
||||
|
logQueue.clear() |
||||
|
onLogListener?.invoke("") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,112 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import android.annotation.SuppressLint |
||||
|
import android.media.AudioFormat |
||||
|
import android.media.AudioRecord |
||||
|
import android.media.MediaRecorder |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import java.io.ByteArrayOutputStream |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
import kotlin.math.sqrt |
||||
|
|
||||
|
class AudioRecorderManager( |
||||
|
private val onAudioLevel: (Float) -> Unit, |
||||
|
private val onError: (String) -> Unit |
||||
|
) { |
||||
|
private val sampleRate = 16000 |
||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO |
||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT |
||||
|
|
||||
|
private var audioRecord: AudioRecord? = null |
||||
|
private var recordingThread: Thread? = null |
||||
|
private val isRecording = AtomicBoolean(false) |
||||
|
private val audioStream = ByteArrayOutputStream() |
||||
|
private val mainHandler = Handler(Looper.getMainLooper()) |
||||
|
|
||||
|
@SuppressLint("MissingPermission") |
||||
|
fun startRecording() { |
||||
|
if (isRecording.get()) return |
||||
|
|
||||
|
synchronized(audioStream) { |
||||
|
audioStream.reset() |
||||
|
} |
||||
|
isRecording.set(true) |
||||
|
|
||||
|
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) |
||||
|
val bufferSize = maxOf(minBufferSize, 2048) |
||||
|
|
||||
|
try { |
||||
|
audioRecord = AudioRecord( |
||||
|
MediaRecorder.AudioSource.MIC, |
||||
|
sampleRate, |
||||
|
channelConfig, |
||||
|
audioFormat, |
||||
|
bufferSize |
||||
|
) |
||||
|
|
||||
|
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { |
||||
|
AppLogger.log("Audio", "❌ سختافزار میکروفون راهاندازی نشد") |
||||
|
onError("خطا در راهاندازی میکروفون") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
audioRecord?.startRecording() |
||||
|
AppLogger.log("Audio", "🎙️ ضبط کامل صوت (16kHz 16-bit) شروع شد...") |
||||
|
|
||||
|
recordingThread = Thread { |
||||
|
val chunk = ByteArray(1024) |
||||
|
val shortBuffer = ShortArray(512) |
||||
|
|
||||
|
while (isRecording.get()) { |
||||
|
val bytesRead = audioRecord?.read(chunk, 0, chunk.size) ?: -1 |
||||
|
if (bytesRead > 0) { |
||||
|
synchronized(audioStream) { |
||||
|
audioStream.write(chunk, 0, bytesRead) |
||||
|
} |
||||
|
|
||||
|
var sum = 0.0 |
||||
|
val samplesCount = bytesRead / 2 |
||||
|
for (i in 0 until samplesCount) { |
||||
|
val sample = (chunk[i * 2].toInt() and 0xFF) or (chunk[i * 2 + 1].toInt() shl 8) |
||||
|
shortBuffer[i] = sample.toShort() |
||||
|
sum += (shortBuffer[i] * shortBuffer[i]).toDouble() |
||||
|
} |
||||
|
val rms = sqrt(sum / samplesCount) / 32768.0 |
||||
|
val level = minOf(maxOf(rms * 4.5, 0.0), 1.0).toFloat() |
||||
|
|
||||
|
mainHandler.post { onAudioLevel(level) } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
recordingThread?.priority = Thread.MAX_PRIORITY |
||||
|
recordingThread?.start() |
||||
|
|
||||
|
} catch (e: Exception) { |
||||
|
AppLogger.log("Audio", "خطای ضبط: ${e.message}") |
||||
|
onError("خطای میکروفون: ${e.localizedMessage}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun stopRecording(): ByteArray { |
||||
|
isRecording.set(false) |
||||
|
try { |
||||
|
audioRecord?.stop() |
||||
|
audioRecord?.release() |
||||
|
audioRecord = null |
||||
|
recordingThread?.join(150) |
||||
|
recordingThread = null |
||||
|
} catch (e: Exception) { |
||||
|
AppLogger.log("Audio", "خطا در توقف ضبط: ${e.message}") |
||||
|
} |
||||
|
|
||||
|
val pcmData = synchronized(audioStream) { |
||||
|
val bytes = audioStream.toByteArray() |
||||
|
audioStream.reset() |
||||
|
bytes |
||||
|
} |
||||
|
|
||||
|
AppLogger.log("Audio", "⏹️ ضبط پایان یافت: ${pcmData.size} بایت (${pcmData.size / 32000.0} ثانیه)") |
||||
|
return pcmData |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,104 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import kotlinx.coroutines.Dispatchers |
||||
|
import kotlinx.coroutines.delay |
||||
|
import kotlinx.coroutines.withContext |
||||
|
import okhttp3.* |
||||
|
import okhttp3.MediaType.Companion.toMediaType |
||||
|
import okhttp3.RequestBody.Companion.toRequestBody |
||||
|
import org.json.JSONObject |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
|
||||
|
class MacPasteClient { |
||||
|
private val tag = "MacClient" |
||||
|
|
||||
|
private val client = OkHttpClient.Builder() |
||||
|
.connectTimeout(6, TimeUnit.SECONDS) |
||||
|
.writeTimeout(10, TimeUnit.SECONDS) |
||||
|
.readTimeout(10, TimeUnit.SECONDS) |
||||
|
.retryOnConnectionFailure(true) |
||||
|
.build() |
||||
|
|
||||
|
suspend fun dictateAudio(host: String, pcmData: ByteArray, sessionId: String = ""): Result<String> = withContext(Dispatchers.IO) { |
||||
|
if (pcmData.size < 3200) { |
||||
|
return@withContext Result.failure(Exception("صدا خیلی کوتاه بود")) |
||||
|
} |
||||
|
|
||||
|
val formattedHost = if (!host.startsWith("http://") && !host.startsWith("https://")) { |
||||
|
"http://$host" |
||||
|
} else { |
||||
|
host |
||||
|
} |
||||
|
val url = if (formattedHost.endsWith("/dictate")) formattedHost else "$formattedHost/dictate" |
||||
|
|
||||
|
val body = pcmData.toRequestBody("application/octet-stream".toMediaType()) |
||||
|
val maxRetries = 2 |
||||
|
var lastException: Exception? = null |
||||
|
|
||||
|
for (attempt in 1..maxRetries) { |
||||
|
val startTime = System.currentTimeMillis() |
||||
|
try { |
||||
|
AppLogger.log(tag, "📤 ارسال صوت به سرور (${pcmData.size} بایت - تلاش $attempt/$maxRetries)...") |
||||
|
val reqBuilder = Request.Builder().url(url).post(body) |
||||
|
if (sessionId.isNotEmpty()) { |
||||
|
reqBuilder.header("X-Session-ID", sessionId) |
||||
|
} |
||||
|
val request = reqBuilder.build() |
||||
|
|
||||
|
client.newCall(request).execute().use { response -> |
||||
|
val duration = System.currentTimeMillis() - startTime |
||||
|
val respStr = response.body?.string() ?: "" |
||||
|
if (response.isSuccessful) { |
||||
|
val json = JSONObject(respStr) |
||||
|
val text = json.optString("text", "").trim() |
||||
|
|
||||
|
if (text.isNotEmpty()) { |
||||
|
AppLogger.log(tag, "✅ تایپ شد در مک ($duration ms): '$text'") |
||||
|
return@withContext Result.success(text) |
||||
|
} else { |
||||
|
AppLogger.log(tag, "⚠️ سرور صدایی تشخیص نداد ($duration ms)") |
||||
|
return@withContext Result.failure(Exception("صدایی تشخیص داده نشد")) |
||||
|
} |
||||
|
} else { |
||||
|
AppLogger.log(tag, "❌ خطای سرور: HTTP ${response.code}") |
||||
|
lastException = Exception("خطای سرور: HTTP ${response.code}") |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
val duration = System.currentTimeMillis() - startTime |
||||
|
AppLogger.log(tag, "⚠️ خطای تلاش $attempt ($duration ms): ${e.javaClass.simpleName} - ${e.message}") |
||||
|
lastException = e |
||||
|
if (attempt < maxRetries) { |
||||
|
delay(300) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
Result.failure(lastException ?: Exception("عدم امکان ارتباط با سرور")) |
||||
|
} |
||||
|
|
||||
|
suspend fun testConnection(host: String): Result<String> = withContext(Dispatchers.IO) { |
||||
|
val startTime = System.currentTimeMillis() |
||||
|
try { |
||||
|
val formattedHost = if (!host.startsWith("http://") && !host.startsWith("https://")) { |
||||
|
"http://$host" |
||||
|
} else { |
||||
|
host |
||||
|
} |
||||
|
val url = if (formattedHost.endsWith("/health")) formattedHost else "$formattedHost/health" |
||||
|
AppLogger.log(tag, "بررسی وضعیت سلامت: $url") |
||||
|
|
||||
|
val request = Request.Builder().url(url).get().build() |
||||
|
|
||||
|
client.newCall(request).execute().use { response -> |
||||
|
val duration = System.currentTimeMillis() - startTime |
||||
|
val body = response.body?.string() ?: "" |
||||
|
AppLogger.log(tag, "✅ پاسخ سرور در $duration ms (HTTP ${response.code}): $body") |
||||
|
Result.success("پاسخ در $duration ms") |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
val duration = System.currentTimeMillis() - startTime |
||||
|
AppLogger.log(tag, "❌ خطا در بررسی سلامت ($duration ms): ${e.javaClass.simpleName} - ${e.message}") |
||||
|
Result.failure(e) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,357 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import android.Manifest |
||||
|
import android.animation.ObjectAnimator |
||||
|
import android.animation.PropertyValuesHolder |
||||
|
import android.animation.ValueAnimator |
||||
|
import android.content.ClipData |
||||
|
import android.content.ClipboardManager |
||||
|
import android.content.Context |
||||
|
import android.content.pm.PackageManager |
||||
|
import android.os.Build |
||||
|
import android.os.Bundle |
||||
|
import android.os.VibrationEffect |
||||
|
import android.os.Vibrator |
||||
|
import android.os.VibratorManager |
||||
|
import android.text.Editable |
||||
|
import android.text.TextWatcher |
||||
|
import android.view.KeyEvent |
||||
|
import android.view.MotionEvent |
||||
|
import android.view.View |
||||
|
import android.view.animation.AccelerateDecelerateInterpolator |
||||
|
import android.widget.ScrollView |
||||
|
import android.widget.TextView |
||||
|
import android.widget.Toast |
||||
|
import androidx.activity.result.contract.ActivityResultContracts |
||||
|
import androidx.appcompat.app.AppCompatActivity |
||||
|
import androidx.core.content.ContextCompat |
||||
|
import androidx.lifecycle.lifecycleScope |
||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog |
||||
|
import com.google.android.material.button.MaterialButton |
||||
|
import com.soniox.remotemic.databinding.ActivityMainBinding |
||||
|
import kotlinx.coroutines.launch |
||||
|
import okhttp3.MediaType.Companion.toMediaType |
||||
|
|
||||
|
class MainActivity : AppCompatActivity() { |
||||
|
|
||||
|
private lateinit var binding: ActivityMainBinding |
||||
|
private var streamDictationClient: StreamDictationClient? = null |
||||
|
private val macPasteClient = MacPasteClient() |
||||
|
|
||||
|
private var pulseAnimator: ObjectAnimator? = null |
||||
|
private var isCurrentlyRecording = false |
||||
|
|
||||
|
// Authoritative Permanent Gateway Server on Linux (116.16.16.19:8999) |
||||
|
private val gatewayHost = "116.16.16.19:8999" |
||||
|
|
||||
|
private val requestPermissionLauncher = registerForActivityResult( |
||||
|
ActivityResultContracts.RequestPermission() |
||||
|
) { isGranted: Boolean -> |
||||
|
if (isGranted) { |
||||
|
AppLogger.log("Main", "دسترسی میکروفون تأیید شد.") |
||||
|
Toast.makeText(this, "دسترسی میکروفون تأیید شد", Toast.LENGTH_SHORT).show() |
||||
|
} else { |
||||
|
AppLogger.log("Main", "❌ دسترسی میکروفون رد شد!") |
||||
|
Toast.makeText(this, "برای ضبط صدا به دسترسی میکروفون نیاز است", Toast.LENGTH_LONG).show() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onCreate(savedInstanceState: Bundle?) { |
||||
|
super.onCreate(savedInstanceState) |
||||
|
binding = ActivityMainBinding.inflate(layoutInflater) |
||||
|
setContentView(binding.root) |
||||
|
|
||||
|
setupUI() |
||||
|
checkPermissions() |
||||
|
|
||||
|
AppLogger.log("Main", "اپلیکیشن راهاندازی شد (ورژن ۱.۰ استیبل - اتصال به گیتوی: $gatewayHost)") |
||||
|
|
||||
|
// Single Persistent Client Instance |
||||
|
streamDictationClient = StreamDictationClient( |
||||
|
host = gatewayHost, |
||||
|
onConnectionStateChanged = { connected -> |
||||
|
binding.tvMacStatus.text = if (connected) "متصل ✅" else "در حال اتصال ❌" |
||||
|
binding.statusIndicator.backgroundTintList = ContextCompat.getColorStateList( |
||||
|
this, if (connected) R.color.accent_green else R.color.accent_red |
||||
|
) |
||||
|
}, |
||||
|
onPartialText = { liveText -> |
||||
|
binding.etTranscript.setText(liveText) |
||||
|
binding.etTranscript.setSelection(liveText.length) |
||||
|
}, |
||||
|
onAudioLevel = { level -> |
||||
|
val scale = 1.0f + (level * 0.35f) |
||||
|
binding.viewGlow.scaleX = scale |
||||
|
binding.viewGlow.scaleY = scale |
||||
|
}, |
||||
|
onCompleted = { finalText, macDelivered -> |
||||
|
vibrate(100) |
||||
|
binding.etTranscript.setText(finalText) |
||||
|
binding.etTranscript.setSelection(finalText.length) |
||||
|
binding.tvInstruction.text = if (macDelivered) "✨ متن با موفقیت در مک تایپ شد" else "متن آماده است" |
||||
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_green)) |
||||
|
}, |
||||
|
onError = { errMsg -> |
||||
|
binding.tvInstruction.text = errMsg |
||||
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) |
||||
|
} |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
private fun setupUI() { |
||||
|
// Text Counter & Change Listener |
||||
|
binding.etTranscript.addTextChangedListener(object : TextWatcher { |
||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} |
||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { |
||||
|
val text = s?.toString()?.trim() ?: "" |
||||
|
val wordCount = if (text.isEmpty()) 0 else text.split("\\s+".toRegex()).size |
||||
|
binding.tvCharCount.text = "$wordCount کلمه" |
||||
|
} |
||||
|
override fun afterTextChanged(s: Editable?) {} |
||||
|
}) |
||||
|
|
||||
|
// Clear Button |
||||
|
binding.btnClearText.setOnClickListener { |
||||
|
binding.etTranscript.setText("") |
||||
|
binding.tvInstruction.text = getString(R.string.hold_to_speak) |
||||
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)) |
||||
|
} |
||||
|
|
||||
|
// Copy Button |
||||
|
binding.btnCopyText.setOnClickListener { |
||||
|
val text = binding.etTranscript.text.toString().trim() |
||||
|
if (text.isNotEmpty()) { |
||||
|
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager |
||||
|
val clip = ClipData.newPlainText("SonioxText", text) |
||||
|
clipboard.setPrimaryClip(clip) |
||||
|
Toast.makeText(this, "متن در حافظه کپی شد", Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Send / Paste to Mac Button (Remote Input Control) |
||||
|
binding.btnSendToMac.setOnClickListener { |
||||
|
val text = binding.etTranscript.text.toString().trim() |
||||
|
if (text.isEmpty()) { |
||||
|
Toast.makeText(this, "متنی برای ارسال وجود ندارد", Toast.LENGTH_SHORT).show() |
||||
|
return@setOnClickListener |
||||
|
} |
||||
|
|
||||
|
binding.tvInstruction.text = "در حال ارسال متن ویرایششده به مک..." |
||||
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) |
||||
|
|
||||
|
lifecycleScope.launch { |
||||
|
val res = macPasteClient.dictateAudio(gatewayHost, text.toByteArray()) // Fallback or direct paste |
||||
|
val pasteRes = macPasteClient.testConnection(gatewayHost) // Check gateway |
||||
|
|
||||
|
// Directly trigger paste endpoint on server |
||||
|
AppLogger.log("Main", "ارسال متن ویرایششده دستی به مک: '$text'") |
||||
|
val directPasteResult = sendDirectPaste(text) |
||||
|
if (directPasteResult) { |
||||
|
vibrate(100) |
||||
|
binding.tvInstruction.text = "✨ متن ویرایششده در مک تایپ شد" |
||||
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.accent_green)) |
||||
|
Toast.makeText(this@MainActivity, "متن با موفقیت در مک درج شد", Toast.LENGTH_SHORT).show() |
||||
|
} else { |
||||
|
binding.tvInstruction.text = "خطا در ارسال به مک" |
||||
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.accent_red)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Open Logs Dialog Button |
||||
|
binding.btnOpenLogs.setOnClickListener { |
||||
|
showLogsBottomSheet() |
||||
|
} |
||||
|
|
||||
|
// Touch listener for Hold to Speak |
||||
|
binding.btnMic.setOnTouchListener { _, event -> |
||||
|
when (event.action) { |
||||
|
MotionEvent.ACTION_DOWN -> { |
||||
|
if (checkAudioPermission()) { |
||||
|
startRecording() |
||||
|
} |
||||
|
true |
||||
|
} |
||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { |
||||
|
if (isCurrentlyRecording) { |
||||
|
stopRecordingAndProcess() |
||||
|
} |
||||
|
true |
||||
|
} |
||||
|
else -> false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private suspend fun sendDirectPaste(text: String): Boolean { |
||||
|
return try { |
||||
|
val client = okhttp3.OkHttpClient() |
||||
|
val json = org.json.JSONObject().apply { |
||||
|
put("text", text) |
||||
|
}.toString() |
||||
|
val mediaType = "application/json; charset=utf-8".toMediaType() |
||||
|
val body = okhttp3.RequestBody.create(mediaType, json) |
||||
|
val req = okhttp3.Request.Builder() |
||||
|
.url("http://$gatewayHost/paste") |
||||
|
.post(body) |
||||
|
.build() |
||||
|
client.newCall(req).execute().use { resp -> |
||||
|
resp.isSuccessful |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
AppLogger.log("Main", "خطای ارسال دستی: ${e.message}") |
||||
|
false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun showLogsBottomSheet() { |
||||
|
val dialog = BottomSheetDialog(this) |
||||
|
val sheetView = layoutInflater.inflate(R.layout.dialog_logs_sheet, null) |
||||
|
dialog.setContentView(sheetView) |
||||
|
|
||||
|
val tvSheetLogs = sheetView.findViewById<TextView>(R.id.tvSheetLogs) |
||||
|
val scrollViewSheetLogs = sheetView.findViewById<ScrollView>(R.id.scrollViewSheetLogs) |
||||
|
val btnSheetCopyLogs = sheetView.findViewById<MaterialButton>(R.id.btnSheetCopyLogs) |
||||
|
val btnSheetClearLogs = sheetView.findViewById<MaterialButton>(R.id.btnSheetClearLogs) |
||||
|
|
||||
|
tvSheetLogs.text = AppLogger.getAllLogs().ifEmpty { "هنوز لاگی ثبت نشده است." } |
||||
|
scrollViewSheetLogs.post { scrollViewSheetLogs.fullScroll(View.FOCUS_DOWN) } |
||||
|
|
||||
|
// Live update while dialog is open |
||||
|
val originalListener = AppLogger.onLogListener |
||||
|
AppLogger.onLogListener = { newEntry -> |
||||
|
runOnUiThread { |
||||
|
if (newEntry.isEmpty()) { |
||||
|
tvSheetLogs.text = "کنسول لاگ پاک شد." |
||||
|
} else { |
||||
|
val current = tvSheetLogs.text.toString() |
||||
|
val updated = if (current == "آماده دریافت لاگ..." || current == "کنسول لاگ پاک شد.") { |
||||
|
newEntry |
||||
|
} else { |
||||
|
"$current\n$newEntry" |
||||
|
} |
||||
|
tvSheetLogs.text = updated |
||||
|
scrollViewSheetLogs.post { scrollViewSheetLogs.fullScroll(View.FOCUS_DOWN) } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
btnSheetCopyLogs.setOnClickListener { |
||||
|
val logs = AppLogger.getAllLogs() |
||||
|
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager |
||||
|
val clip = ClipData.newPlainText("SonioxLogs", logs) |
||||
|
clipboard.setPrimaryClip(clip) |
||||
|
Toast.makeText(this, "کل لاگها در حافظه کپی شد", Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
|
||||
|
btnSheetClearLogs.setOnClickListener { |
||||
|
AppLogger.clear() |
||||
|
tvSheetLogs.text = "کنسول لاگ پاک شد." |
||||
|
} |
||||
|
|
||||
|
dialog.setOnDismissListener { |
||||
|
AppLogger.onLogListener = originalListener |
||||
|
} |
||||
|
|
||||
|
dialog.show() |
||||
|
} |
||||
|
|
||||
|
private fun checkPermissions() { |
||||
|
if (!checkAudioPermission()) { |
||||
|
requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun checkAudioPermission(): Boolean { |
||||
|
return ContextCompat.checkSelfPermission( |
||||
|
this, |
||||
|
Manifest.permission.RECORD_AUDIO |
||||
|
) == PackageManager.PERMISSION_GRANTED |
||||
|
} |
||||
|
|
||||
|
private fun startRecording() { |
||||
|
isCurrentlyRecording = true |
||||
|
vibrate(40) |
||||
|
|
||||
|
binding.tvInstruction.text = getString(R.string.release_to_type) |
||||
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_red)) |
||||
|
binding.btnMic.setBackgroundResource(R.drawable.bg_mic_button_active) |
||||
|
|
||||
|
startPulseAnimation() |
||||
|
streamDictationClient?.startRecording() |
||||
|
} |
||||
|
|
||||
|
private fun stopRecordingAndProcess() { |
||||
|
isCurrentlyRecording = false |
||||
|
vibrate(60) |
||||
|
|
||||
|
stopPulseAnimation() |
||||
|
binding.btnMic.setBackgroundResource(R.drawable.bg_mic_button) |
||||
|
binding.tvInstruction.text = "⏳ در حال درج فوری در مک..." |
||||
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) |
||||
|
|
||||
|
streamDictationClient?.stopRecording() |
||||
|
} |
||||
|
|
||||
|
private fun startPulseAnimation() { |
||||
|
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1.0f, 1.25f, 1.0f) |
||||
|
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.0f, 1.25f, 1.0f) |
||||
|
val alpha = PropertyValuesHolder.ofFloat(View.ALPHA, 0.3f, 0.7f, 0.3f) |
||||
|
|
||||
|
pulseAnimator = ObjectAnimator.ofPropertyValuesHolder(binding.viewGlow, scaleX, scaleY, alpha).apply { |
||||
|
duration = 1000 |
||||
|
repeatCount = ValueAnimator.INFINITE |
||||
|
interpolator = AccelerateDecelerateInterpolator() |
||||
|
start() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun stopPulseAnimation() { |
||||
|
pulseAnimator?.cancel() |
||||
|
binding.viewGlow.scaleX = 1.0f |
||||
|
binding.viewGlow.scaleY = 1.0f |
||||
|
binding.viewGlow.alpha = 0.3f |
||||
|
} |
||||
|
|
||||
|
private fun vibrate(durationMs: Long) { |
||||
|
try { |
||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
||||
|
val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager |
||||
|
vibratorManager.defaultVibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)) |
||||
|
} else { |
||||
|
@Suppress("DEPRECATION") |
||||
|
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator |
||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
||||
|
vibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)) |
||||
|
} else { |
||||
|
@Suppress("DEPRECATION") |
||||
|
vibrator.vibrate(durationMs) |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) {} |
||||
|
} |
||||
|
|
||||
|
// Physical Volume Down Key as Push-To-Talk shortcut |
||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { |
||||
|
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN && !isCurrentlyRecording) { |
||||
|
if (checkAudioPermission()) { |
||||
|
startRecording() |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
return super.onKeyDown(keyCode, event) |
||||
|
} |
||||
|
|
||||
|
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { |
||||
|
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN && isCurrentlyRecording) { |
||||
|
stopRecordingAndProcess() |
||||
|
return true |
||||
|
} |
||||
|
return super.onKeyUp(keyCode, event) |
||||
|
} |
||||
|
|
||||
|
override fun onDestroy() { |
||||
|
super.onDestroy() |
||||
|
streamDictationClient?.release() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.content.SharedPreferences |
||||
|
|
||||
|
object PreferencesManager { |
||||
|
private const val PREFS_NAME = "soniox_prefs" |
||||
|
private const val KEY_SERVER_HOST = "server_host" |
||||
|
const val DEFAULT_SERVER_HOST = "116.16.16.19:8999" |
||||
|
|
||||
|
private fun getPrefs(context: Context): SharedPreferences { |
||||
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) |
||||
|
} |
||||
|
|
||||
|
fun getServerHost(context: Context): String { |
||||
|
val host = getPrefs(context).getString(KEY_SERVER_HOST, DEFAULT_SERVER_HOST) |
||||
|
return if (host.isNullOrBlank()) DEFAULT_SERVER_HOST else host.trim() |
||||
|
} |
||||
|
|
||||
|
fun setServerHost(context: Context, host: String) { |
||||
|
val cleanHost = host.trim() |
||||
|
.removePrefix("http://") |
||||
|
.removePrefix("https://") |
||||
|
.removePrefix("ws://") |
||||
|
.removePrefix("wss://") |
||||
|
.removeSuffix("/") |
||||
|
getPrefs(context).edit().putString(KEY_SERVER_HOST, cleanHost).apply() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,348 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import android.annotation.SuppressLint |
||||
|
import android.media.AudioFormat |
||||
|
import android.media.AudioRecord |
||||
|
import android.media.MediaRecorder |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import okhttp3.* |
||||
|
import okio.ByteString.Companion.toByteString |
||||
|
import org.json.JSONObject |
||||
|
import java.util.concurrent.ConcurrentLinkedQueue |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
import kotlin.math.sqrt |
||||
|
|
||||
|
class SonioxLiveStreamer( |
||||
|
private val onConnectionState: (String, Boolean) -> Unit, |
||||
|
private val onPartialText: (String) -> Unit, |
||||
|
private val onAudioLevel: (Float) -> Unit, |
||||
|
private val onFinalResult: (String) -> Unit, |
||||
|
private val onError: (String) -> Unit |
||||
|
) { |
||||
|
private val tag = "STT" |
||||
|
|
||||
|
private val wsEndpoints = listOf( |
||||
|
"wss://translate.compare.soniox.com/compare/api/compare-websocket?language_hints=fa&language_hints=en&language_hints=ar&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox", |
||||
|
"wss://stt.compare.soniox.com/compare/api/compare-websocket?language_hints=fa&language_hints=en&language_hints=ar&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox" |
||||
|
) |
||||
|
|
||||
|
private var activeEndpointIndex = 0 |
||||
|
|
||||
|
private val sampleRate = 16000 |
||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO |
||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT |
||||
|
|
||||
|
private var audioRecord: AudioRecord? = null |
||||
|
private var recordingThread: Thread? = null |
||||
|
private val isRecording = AtomicBoolean(false) |
||||
|
|
||||
|
private val mainHandler = Handler(Looper.getMainLooper()) |
||||
|
private val okHttpClient = OkHttpClient.Builder() |
||||
|
.readTimeout(25, TimeUnit.SECONDS) |
||||
|
.writeTimeout(25, TimeUnit.SECONDS) |
||||
|
.connectTimeout(8, TimeUnit.SECONDS) |
||||
|
.retryOnConnectionFailure(true) |
||||
|
.build() |
||||
|
|
||||
|
private var activeWebSocket: WebSocket? = null |
||||
|
private var isWsConnected = AtomicBoolean(false) |
||||
|
private val audioQueue = ConcurrentLinkedQueue<ByteArray>() |
||||
|
|
||||
|
private val committedFinals = StringBuilder() |
||||
|
private var currentNonFinal = "" |
||||
|
|
||||
|
init { |
||||
|
prewarmWebSocket() |
||||
|
} |
||||
|
|
||||
|
fun prewarmWebSocket() { |
||||
|
if (isWsConnected.get() && activeWebSocket != null) return |
||||
|
|
||||
|
val url = wsEndpoints[activeEndpointIndex] |
||||
|
val hostName = if (url.contains("translate")) "translate.compare.soniox.com" else "stt.compare.soniox.com" |
||||
|
AppLogger.log(tag, "در حال اتصال پیشفرض به سرور هوش مصنوعی ($hostName)...") |
||||
|
|
||||
|
val request = Request.Builder() |
||||
|
.url(url) |
||||
|
.header("User-Agent", "Mozilla/5.0 (Linux; Android 14) SonioxRemoteMic/1.0") |
||||
|
.header("Origin", "https://$hostName") |
||||
|
.build() |
||||
|
|
||||
|
activeWebSocket = okHttpClient.newWebSocket(request, createWebSocketListener(url)) |
||||
|
} |
||||
|
|
||||
|
private fun createWebSocketListener(endpointUrl: String): WebSocketListener { |
||||
|
return object : WebSocketListener() { |
||||
|
override fun onOpen(webSocket: WebSocket, response: Response) { |
||||
|
isWsConnected.set(true) |
||||
|
AppLogger.log(tag, "🟢 وبسوکت سانیاوکس متصل شد (HTTP ${response.code})") |
||||
|
mainHandler.post { |
||||
|
onConnectionState("🟢 استریم سانیاوکس متصل است", true) |
||||
|
} |
||||
|
drainAudioQueue(webSocket) |
||||
|
} |
||||
|
|
||||
|
override fun onMessage(webSocket: WebSocket, text: String) { |
||||
|
parseServerMessage(text) |
||||
|
} |
||||
|
|
||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { |
||||
|
isWsConnected.set(false) |
||||
|
AppLogger.log(tag, "🔴 قطع اتصال وبسوکت: ${t.javaClass.simpleName} - ${t.message}") |
||||
|
|
||||
|
// Try fallback endpoint |
||||
|
activeEndpointIndex = (activeEndpointIndex + 1) % wsEndpoints.size |
||||
|
|
||||
|
mainHandler.post { |
||||
|
onConnectionState("🔴 خطا در اتصال سانیاوکس", false) |
||||
|
onError("خطای سانیاوکس: ${t.localizedMessage}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { |
||||
|
AppLogger.log(tag, "وبسوکت بسته شد: کد $code ($reason)") |
||||
|
isWsConnected.set(false) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun drainAudioQueue(ws: WebSocket) { |
||||
|
var count = 0 |
||||
|
while (!audioQueue.isEmpty()) { |
||||
|
val chunk = audioQueue.poll() ?: break |
||||
|
ws.send(chunk.toByteString()) |
||||
|
count++ |
||||
|
} |
||||
|
if (count > 0) { |
||||
|
AppLogger.log(tag, "تعداد $count چانک صوتی ذخیره شده به وبسوکت ارسال شد.") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@SuppressLint("MissingPermission") |
||||
|
fun startStreaming() { |
||||
|
if (isRecording.get()) return |
||||
|
|
||||
|
synchronized(committedFinals) { |
||||
|
committedFinals.clear() |
||||
|
currentNonFinal = "" |
||||
|
} |
||||
|
audioQueue.clear() |
||||
|
isRecording.set(true) |
||||
|
|
||||
|
AppLogger.log(tag, "🎙️ شروع ضبط میکروفون (16kHz 16-bit Mono)...") |
||||
|
|
||||
|
if (!isWsConnected.get() || activeWebSocket == null) { |
||||
|
prewarmWebSocket() |
||||
|
} |
||||
|
|
||||
|
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) |
||||
|
val bufferSize = maxOf(minBufferSize, 2048) |
||||
|
|
||||
|
try { |
||||
|
audioRecord = AudioRecord( |
||||
|
MediaRecorder.AudioSource.MIC, |
||||
|
sampleRate, |
||||
|
channelConfig, |
||||
|
audioFormat, |
||||
|
bufferSize |
||||
|
) |
||||
|
|
||||
|
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { |
||||
|
AppLogger.log(tag, "❌ سختافزار میکروفون مقداردهی نشد!") |
||||
|
onError("عدم امکان راهاندازی سختافزار میکروفون") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
audioRecord?.startRecording() |
||||
|
AppLogger.log(tag, "ضبط صوت فعال شد. در حال استریم زنده...") |
||||
|
|
||||
|
recordingThread = Thread { |
||||
|
val chunk = ByteArray(1024) |
||||
|
val shortBuffer = ShortArray(512) |
||||
|
var totalBytesCaptured = 0 |
||||
|
|
||||
|
while (isRecording.get()) { |
||||
|
val bytesRead = audioRecord?.read(chunk, 0, chunk.size) ?: -1 |
||||
|
if (bytesRead > 0) { |
||||
|
totalBytesCaptured += bytesRead |
||||
|
val slice = if (bytesRead == chunk.size) chunk.clone() else chunk.copyOf(bytesRead) |
||||
|
|
||||
|
if (isWsConnected.get() && activeWebSocket != null) { |
||||
|
drainAudioQueue(activeWebSocket!!) |
||||
|
activeWebSocket?.send(slice.toByteString()) |
||||
|
} else { |
||||
|
audioQueue.offer(slice) |
||||
|
} |
||||
|
|
||||
|
var sum = 0.0 |
||||
|
val samplesCount = bytesRead / 2 |
||||
|
for (i in 0 until samplesCount) { |
||||
|
val sample = (slice[i * 2].toInt() and 0xFF) or (slice[i * 2 + 1].toInt() shl 8) |
||||
|
shortBuffer[i] = sample.toShort() |
||||
|
sum += (shortBuffer[i] * shortBuffer[i]).toDouble() |
||||
|
} |
||||
|
val rms = sqrt(sum / samplesCount) / 32768.0 |
||||
|
val level = minOf(maxOf(rms * 4.5, 0.0), 1.0).toFloat() |
||||
|
|
||||
|
mainHandler.post { onAudioLevel(level) } |
||||
|
} |
||||
|
} |
||||
|
AppLogger.log(tag, "مجموع صدای ضبط شده: $totalBytesCaptured بایت (${totalBytesCaptured / 32000.0} ثانیه)") |
||||
|
} |
||||
|
recordingThread?.priority = Thread.MAX_PRIORITY |
||||
|
recordingThread?.start() |
||||
|
|
||||
|
} catch (e: Exception) { |
||||
|
AppLogger.log(tag, "❌ خطای ضبط صوت: ${e.message}") |
||||
|
onError("خطای ضبط میکروفون: ${e.localizedMessage}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun parseServerMessage(jsonString: String) { |
||||
|
try { |
||||
|
val json = JSONObject(jsonString) |
||||
|
var gotFin = false |
||||
|
|
||||
|
if (json.optString("type") == "data" && json.has("parts")) { |
||||
|
val parts = json.getJSONArray("parts") |
||||
|
val newFinals = StringBuilder() |
||||
|
val nonFinalsBuilder = StringBuilder() |
||||
|
|
||||
|
for (i in 0 until parts.length()) { |
||||
|
val part = parts.getJSONObject(i) |
||||
|
val transStatus = part.optString("translation_status", "") |
||||
|
if (transStatus == "translation") continue |
||||
|
|
||||
|
val text = part.optString("text", "") |
||||
|
val isFinal = part.optBoolean("is_final", false) |
||||
|
|
||||
|
if (text.contains("<fin>")) { |
||||
|
gotFin = true |
||||
|
val clean = text.replace("<fin>", "") |
||||
|
if (clean.isNotEmpty()) newFinals.append(clean) |
||||
|
} else if (isFinal) { |
||||
|
if (text.isNotEmpty()) newFinals.append(text) |
||||
|
} else { |
||||
|
if (text.isNotEmpty()) nonFinalsBuilder.append(text) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
synchronized(committedFinals) { |
||||
|
if (newFinals.isNotEmpty()) { |
||||
|
committedFinals.append(newFinals.toString()) |
||||
|
} |
||||
|
currentNonFinal = nonFinalsBuilder.toString() |
||||
|
} |
||||
|
|
||||
|
val liveCombined = synchronized(committedFinals) { |
||||
|
committedFinals.toString() + currentNonFinal |
||||
|
} |
||||
|
|
||||
|
if (liveCombined.isNotEmpty()) { |
||||
|
AppLogger.log(tag, "⚡ دریافت توکن زنده: '$liveCombined'") |
||||
|
mainHandler.post { onPartialText(liveCombined) } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
val sessionEnded = json.optBoolean("session_ended", false) |
||||
|
val sessionDone = json.optString("type") == "session_done" |
||||
|
|
||||
|
if (gotFin || sessionEnded || sessionDone) { |
||||
|
AppLogger.log(tag, "سیگنال اتمام استریم دریافت شد.") |
||||
|
finalizeAndComplete() |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
AppLogger.log(tag, "خطای JSON سانیاوکس: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun stopStreaming() { |
||||
|
if (!isRecording.get()) return |
||||
|
isRecording.set(false) |
||||
|
AppLogger.log(tag, "توقف ضبط و ارسال سیگنال Finalize...") |
||||
|
|
||||
|
try { |
||||
|
audioRecord?.stop() |
||||
|
audioRecord?.release() |
||||
|
audioRecord = null |
||||
|
recordingThread?.join(150) |
||||
|
recordingThread = null |
||||
|
} catch (e: Exception) { |
||||
|
AppLogger.log(tag, "خطا در بستن AudioRecord: ${e.message}") |
||||
|
} |
||||
|
|
||||
|
activeWebSocket?.send("{\"type\": \"finalize\"}") |
||||
|
|
||||
|
mainHandler.postDelayed({ |
||||
|
finalizeAndComplete() |
||||
|
}, 650) |
||||
|
} |
||||
|
|
||||
|
private fun finalizeAndComplete() { |
||||
|
val finalRaw = synchronized(committedFinals) { |
||||
|
val res = (committedFinals.toString() + currentNonFinal).trim() |
||||
|
committedFinals.clear() |
||||
|
currentNonFinal = "" |
||||
|
res |
||||
|
} |
||||
|
|
||||
|
val clean = sanitizeText(finalRaw) |
||||
|
AppLogger.log(tag, "✨ متن نهایی پردازش شده: '$clean'") |
||||
|
|
||||
|
try { |
||||
|
activeWebSocket?.close(1000, "Done") |
||||
|
activeWebSocket = null |
||||
|
isWsConnected.set(false) |
||||
|
} catch (e: Exception) {} |
||||
|
|
||||
|
mainHandler.postDelayed({ |
||||
|
prewarmWebSocket() |
||||
|
}, 200) |
||||
|
|
||||
|
mainHandler.post { |
||||
|
onFinalResult(clean) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun sanitizeText(text: String): String { |
||||
|
val trimmed = text.trim() |
||||
|
if (trimmed.isEmpty()) return "" |
||||
|
|
||||
|
val words = trimmed.split("\\s+".toRegex()).filter { it.isNotEmpty() } |
||||
|
if (words.isEmpty()) return "" |
||||
|
|
||||
|
val faPattern = Regex("[\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFF]") |
||||
|
val enPattern = Regex("[a-zA-Z]") |
||||
|
|
||||
|
var faCount = 0 |
||||
|
var enCount = 0 |
||||
|
for (w in words) { |
||||
|
if (faPattern.containsMatchIn(w)) faCount++ |
||||
|
if (enPattern.containsMatchIn(w)) enCount++ |
||||
|
} |
||||
|
|
||||
|
val total = faCount + enCount |
||||
|
if (total == 0) return trimmed |
||||
|
|
||||
|
val faRatio = faCount.toDouble() / total.toDouble() |
||||
|
val stopWords = setOf("sex", "from", "no", "oh", "you", "all", "know", "that", "god", "loves", "men", "for", "day", "night", "mankind", "wanted", "moments") |
||||
|
|
||||
|
val cleaned = mutableListOf<String>() |
||||
|
if (faRatio >= 0.25) { |
||||
|
for (w in words) { |
||||
|
if (enPattern.containsMatchIn(w) && !faPattern.containsMatchIn(w)) { |
||||
|
val cleanW = w.lowercase().replace("[.,!?:;،؛؟\"'()\\[\\]{}«»–—-]".toRegex(), "") |
||||
|
if (stopWords.contains(cleanW)) continue |
||||
|
if (faRatio >= 0.70) continue |
||||
|
} |
||||
|
cleaned.add(w) |
||||
|
} |
||||
|
} else { |
||||
|
cleaned.addAll(words) |
||||
|
} |
||||
|
|
||||
|
return cleaned.joinToString(" ").trim() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,309 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import android.Manifest |
||||
|
import android.content.Intent |
||||
|
import android.content.pm.PackageManager |
||||
|
import android.media.AudioFormat |
||||
|
import android.media.AudioRecord |
||||
|
import android.media.MediaRecorder |
||||
|
import android.os.Bundle |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import android.speech.RecognitionService |
||||
|
import android.speech.SpeechRecognizer |
||||
|
import android.util.Log |
||||
|
import androidx.core.content.ContextCompat |
||||
|
import okhttp3.* |
||||
|
import okio.ByteString.Companion.toByteString |
||||
|
import org.json.JSONObject |
||||
|
import java.util.UUID |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
import kotlin.math.log10 |
||||
|
import kotlin.math.sqrt |
||||
|
|
||||
|
class SonioxRecognitionService : RecognitionService() { |
||||
|
|
||||
|
private val tag = "SonioxRecService" |
||||
|
private val sampleRate = 16000 |
||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO |
||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT |
||||
|
|
||||
|
private var currentCallback: Callback? = null |
||||
|
private var audioRecord: AudioRecord? = null |
||||
|
private var recordingThread: Thread? = null |
||||
|
private val isRecording = AtomicBoolean(false) |
||||
|
private val mainHandler = Handler(Looper.getMainLooper()) |
||||
|
|
||||
|
private var okHttpClient: OkHttpClient? = null |
||||
|
private var webSocket: WebSocket? = null |
||||
|
private val isConnected = AtomicBoolean(false) |
||||
|
private var currentSessionId: String = "" |
||||
|
private val isSessionActive = AtomicBoolean(false) |
||||
|
private var latestPartialText: String = "" |
||||
|
|
||||
|
override fun onCreate() { |
||||
|
super.onCreate() |
||||
|
Log.i(tag, "SonioxRecognitionService onCreate") |
||||
|
initWebSocketClient() |
||||
|
} |
||||
|
|
||||
|
private fun initWebSocketClient() { |
||||
|
if (okHttpClient != null) return |
||||
|
okHttpClient = OkHttpClient.Builder() |
||||
|
.connectTimeout(4, TimeUnit.SECONDS) |
||||
|
.readTimeout(0, TimeUnit.MILLISECONDS) |
||||
|
.writeTimeout(4, TimeUnit.SECONDS) |
||||
|
.pingInterval(10, TimeUnit.SECONDS) |
||||
|
.retryOnConnectionFailure(true) |
||||
|
.build() |
||||
|
connectWebSocket() |
||||
|
} |
||||
|
|
||||
|
@Synchronized |
||||
|
private fun connectWebSocket() { |
||||
|
if (isConnected.get() && webSocket != null) return |
||||
|
|
||||
|
val host = PreferencesManager.getServerHost(this) |
||||
|
val cleanHost = host.removePrefix("http://").removePrefix("https://").removePrefix("ws://").removePrefix("wss://") |
||||
|
val wsUrl = "ws://$cleanHost/ws/stream" |
||||
|
Log.d(tag, "Connecting persistent WebSocket to: $wsUrl") |
||||
|
|
||||
|
val req = Request.Builder().url(wsUrl).build() |
||||
|
webSocket = okHttpClient?.newWebSocket(req, object : WebSocketListener() { |
||||
|
override fun onOpen(webSocket: WebSocket, response: Response) { |
||||
|
Log.i(tag, "WebSocket connected successfully to $wsUrl") |
||||
|
isConnected.set(true) |
||||
|
} |
||||
|
|
||||
|
override fun onMessage(webSocket: WebSocket, text: String) { |
||||
|
try { |
||||
|
val json = JSONObject(text) |
||||
|
val type = json.optString("type") |
||||
|
val sid = json.optString("session_id") |
||||
|
|
||||
|
if (sid.isNotEmpty() && sid != currentSessionId && isSessionActive.get()) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
when (type) { |
||||
|
"live", "partial" -> { |
||||
|
val liveText = json.optString("text") |
||||
|
if (liveText.isNotEmpty()) { |
||||
|
latestPartialText = liveText |
||||
|
val bundle = Bundle().apply { |
||||
|
putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, arrayListOf(liveText)) |
||||
|
} |
||||
|
mainHandler.post { |
||||
|
try { |
||||
|
currentCallback?.partialResults(bundle) |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(tag, "Error sending partial results", e) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
"final" -> { |
||||
|
var finalText = json.optString("text") |
||||
|
if (finalText.isBlank() && latestPartialText.isNotBlank()) { |
||||
|
finalText = latestPartialText |
||||
|
} |
||||
|
isSessionActive.set(false) |
||||
|
val bundle = Bundle().apply { |
||||
|
putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, arrayListOf(finalText)) |
||||
|
putFloatArray(SpeechRecognizer.CONFIDENCE_SCORES, floatArrayOf(1.0f)) |
||||
|
} |
||||
|
mainHandler.post { |
||||
|
try { |
||||
|
currentCallback?.results(bundle) |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(tag, "Error sending final results", e) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
"error" -> { |
||||
|
val msg = json.optString("message", "Server error") |
||||
|
Log.e(tag, "WebSocket server error: $msg") |
||||
|
mainHandler.post { |
||||
|
try { |
||||
|
currentCallback?.error(SpeechRecognizer.ERROR_SERVER) |
||||
|
} catch (e: Exception) {} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(tag, "Error parsing WS message", e) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { |
||||
|
Log.w(tag, "WebSocket failure: ${t.message}. Will reconnect on demand.") |
||||
|
isConnected.set(false) |
||||
|
this@SonioxRecognitionService.webSocket = null |
||||
|
} |
||||
|
|
||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { |
||||
|
Log.i(tag, "WebSocket closed ($code: $reason)") |
||||
|
isConnected.set(false) |
||||
|
this@SonioxRecognitionService.webSocket = null |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
override fun onStartListening(recognizerIntent: Intent?, listener: Callback?) { |
||||
|
Log.i(tag, "onStartListening invoked by system keyboard / caller") |
||||
|
currentCallback = listener |
||||
|
latestPartialText = "" |
||||
|
|
||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { |
||||
|
Log.e(tag, "Missing RECORD_AUDIO permission") |
||||
|
listener?.error(SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
connectWebSocket() |
||||
|
|
||||
|
currentSessionId = "sess_${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(6)}" |
||||
|
isSessionActive.set(true) |
||||
|
|
||||
|
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) |
||||
|
val bufferSize = maxOf(minBufferSize, 2048) |
||||
|
|
||||
|
try { |
||||
|
// Attempt VOICE_RECOGNITION source (optimized by OS for speech) |
||||
|
var record: AudioRecord? = AudioRecord( |
||||
|
MediaRecorder.AudioSource.VOICE_RECOGNITION, |
||||
|
sampleRate, |
||||
|
channelConfig, |
||||
|
audioFormat, |
||||
|
bufferSize |
||||
|
) |
||||
|
|
||||
|
if (record?.state != AudioRecord.STATE_INITIALIZED) { |
||||
|
record?.release() |
||||
|
// Fallback to standard MIC |
||||
|
record = AudioRecord( |
||||
|
MediaRecorder.AudioSource.MIC, |
||||
|
sampleRate, |
||||
|
channelConfig, |
||||
|
audioFormat, |
||||
|
bufferSize |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
if (record.state != AudioRecord.STATE_INITIALIZED) { |
||||
|
record.release() |
||||
|
Log.e(tag, "AudioRecord state is NOT initialized") |
||||
|
listener?.error(SpeechRecognizer.ERROR_AUDIO) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
audioRecord = record |
||||
|
|
||||
|
// Notify IME ready for speech |
||||
|
try { |
||||
|
listener?.readyForSpeech(Bundle()) |
||||
|
} catch (e: Exception) {} |
||||
|
|
||||
|
// Send start session frame to server |
||||
|
val startFrame = JSONObject().apply { |
||||
|
put("type", "start") |
||||
|
put("session_id", currentSessionId) |
||||
|
}.toString() |
||||
|
webSocket?.send(startFrame) |
||||
|
|
||||
|
audioRecord?.startRecording() |
||||
|
isRecording.set(true) |
||||
|
|
||||
|
try { |
||||
|
listener?.beginningOfSpeech() |
||||
|
} catch (e: Exception) {} |
||||
|
|
||||
|
recordingThread = Thread { |
||||
|
val chunk = ByteArray(2048) |
||||
|
val shortBuffer = ShortArray(1024) |
||||
|
|
||||
|
while (isRecording.get()) { |
||||
|
val bytesRead = audioRecord?.read(chunk, 0, chunk.size) ?: -1 |
||||
|
if (bytesRead > 0) { |
||||
|
val slice = if (bytesRead == chunk.size) chunk.clone() else chunk.copyOf(bytesRead) |
||||
|
webSocket?.send(slice.toByteString()) |
||||
|
|
||||
|
// RMS calculation for keyboard waveform animation |
||||
|
var sum = 0.0 |
||||
|
val samplesCount = bytesRead / 2 |
||||
|
for (i in 0 until samplesCount) { |
||||
|
val sample = (slice[i * 2].toInt() and 0xFF) or (slice[i * 2 + 1].toInt() shl 8) |
||||
|
shortBuffer[i] = sample.toShort() |
||||
|
sum += (shortBuffer[i] * shortBuffer[i]).toDouble() |
||||
|
} |
||||
|
val rms = sqrt(sum / samplesCount) / 32768.0 |
||||
|
val rmsDb = if (rms > 0.0001) (20 * log10(rms * 10)).toFloat().coerceIn(0f, 10f) else 0f |
||||
|
mainHandler.post { |
||||
|
try { |
||||
|
currentCallback?.rmsChanged(rmsDb) |
||||
|
} catch (e: Exception) {} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}.apply { |
||||
|
priority = Thread.MAX_PRIORITY |
||||
|
start() |
||||
|
} |
||||
|
|
||||
|
} catch (e: Exception) { |
||||
|
Log.e(tag, "Exception during onStartListening", e) |
||||
|
listener?.error(SpeechRecognizer.ERROR_AUDIO) |
||||
|
stopAudioInternal() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onStopListening(listener: Callback?) { |
||||
|
Log.i(tag, "onStopListening received") |
||||
|
try { |
||||
|
listener?.endOfSpeech() |
||||
|
} catch (e: Exception) {} |
||||
|
|
||||
|
stopAudioInternal() |
||||
|
|
||||
|
// Send STOP frame to finalize transcript |
||||
|
val stopFrame = JSONObject().apply { |
||||
|
put("type", "stop") |
||||
|
put("session_id", currentSessionId) |
||||
|
}.toString() |
||||
|
webSocket?.send(stopFrame) |
||||
|
} |
||||
|
|
||||
|
override fun onCancel(listener: Callback?) { |
||||
|
Log.i(tag, "onCancel received") |
||||
|
isSessionActive.set(false) |
||||
|
stopAudioInternal() |
||||
|
|
||||
|
val stopFrame = JSONObject().apply { |
||||
|
put("type", "stop") |
||||
|
put("session_id", currentSessionId) |
||||
|
}.toString() |
||||
|
webSocket?.send(stopFrame) |
||||
|
} |
||||
|
|
||||
|
private fun stopAudioInternal() { |
||||
|
isRecording.set(false) |
||||
|
try { |
||||
|
audioRecord?.stop() |
||||
|
audioRecord?.release() |
||||
|
audioRecord = null |
||||
|
recordingThread?.join(150) |
||||
|
recordingThread = null |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(tag, "Error stopping AudioRecord", e) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onDestroy() { |
||||
|
super.onDestroy() |
||||
|
stopAudioInternal() |
||||
|
try { |
||||
|
webSocket?.close(1000, "Service destroyed") |
||||
|
} catch (e: Exception) {} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,233 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import android.annotation.SuppressLint |
||||
|
import android.media.AudioFormat |
||||
|
import android.media.AudioRecord |
||||
|
import android.media.MediaRecorder |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import android.util.Log |
||||
|
import okhttp3.* |
||||
|
import okio.ByteString.Companion.toByteString |
||||
|
import org.json.JSONObject |
||||
|
import java.util.UUID |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
import kotlin.math.sqrt |
||||
|
|
||||
|
class StreamDictationClient( |
||||
|
private val host: String, |
||||
|
private val onConnectionStateChanged: (Boolean) -> Unit, |
||||
|
private val onPartialText: (String) -> Unit, |
||||
|
private val onAudioLevel: (Float) -> Unit, |
||||
|
private val onCompleted: (String, Boolean) -> Unit, |
||||
|
private val onError: (String) -> Unit |
||||
|
) { |
||||
|
private val tag = "StreamDictationClient" |
||||
|
private val sampleRate = 16000 |
||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO |
||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT |
||||
|
|
||||
|
private var audioRecord: AudioRecord? = null |
||||
|
private var recordingThread: Thread? = null |
||||
|
private val isRecording = AtomicBoolean(false) |
||||
|
private val mainHandler = Handler(Looper.getMainLooper()) |
||||
|
|
||||
|
private val okHttpClient = OkHttpClient.Builder() |
||||
|
.connectTimeout(5, TimeUnit.SECONDS) |
||||
|
.readTimeout(0, TimeUnit.MILLISECONDS) // Keep-alive persistent WebSocket |
||||
|
.writeTimeout(5, TimeUnit.SECONDS) |
||||
|
.pingInterval(10, TimeUnit.SECONDS) |
||||
|
.retryOnConnectionFailure(true) |
||||
|
.build() |
||||
|
|
||||
|
private var webSocket: WebSocket? = null |
||||
|
private val isConnected = AtomicBoolean(false) |
||||
|
private var currentSessionId: String = "" |
||||
|
private var isSessionActive = AtomicBoolean(false) |
||||
|
|
||||
|
init { |
||||
|
connectWebSocket() |
||||
|
} |
||||
|
|
||||
|
@Synchronized |
||||
|
fun connectWebSocket() { |
||||
|
if (isConnected.get() && webSocket != null) return |
||||
|
|
||||
|
val cleanHost = host.removePrefix("http://").removePrefix("https://").removePrefix("ws://").removePrefix("wss://") |
||||
|
val wsUrl = "ws://$cleanHost/ws/stream" |
||||
|
AppLogger.log(tag, "اتصال به سوکت دائمی: $wsUrl") |
||||
|
|
||||
|
val req = Request.Builder().url(wsUrl).build() |
||||
|
webSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() { |
||||
|
override fun onOpen(ws: WebSocket, response: Response) { |
||||
|
AppLogger.log(tag, "🟢 سوکت پرسرعت دائمی متصل شد") |
||||
|
isConnected.set(true) |
||||
|
mainHandler.post { onConnectionStateChanged(true) } |
||||
|
} |
||||
|
|
||||
|
override fun onMessage(ws: WebSocket, text: String) { |
||||
|
try { |
||||
|
val json = JSONObject(text) |
||||
|
val type = json.optString("type") |
||||
|
val sid = json.optString("session_id") |
||||
|
|
||||
|
if (sid.isNotEmpty() && sid != currentSessionId && isSessionActive.get()) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
when (type) { |
||||
|
"live", "partial" -> { |
||||
|
val liveText = json.optString("text") |
||||
|
mainHandler.post { onPartialText(liveText) } |
||||
|
} |
||||
|
"final" -> { |
||||
|
val finalText = json.optString("text") |
||||
|
val macDelivered = json.optBoolean("mac_delivered", true) |
||||
|
isSessionActive.set(false) |
||||
|
AppLogger.log(tag, "⚡ متن نهایی دریافت شد: '$finalText'") |
||||
|
mainHandler.post { onCompleted(finalText, macDelivered) } |
||||
|
} |
||||
|
"error" -> { |
||||
|
val msg = json.optString("message", "خطای سرور") |
||||
|
AppLogger.log(tag, "❌ خطای سرور: $msg") |
||||
|
mainHandler.post { onError(msg) } |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(tag, "Message parse error", e) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { |
||||
|
AppLogger.log(tag, "🔴 قطع اتصال سوکت: ${t.message}. تلاش مجدد در 2s...") |
||||
|
isConnected.set(false) |
||||
|
webSocket = null |
||||
|
mainHandler.post { onConnectionStateChanged(false) } |
||||
|
mainHandler.postDelayed({ connectWebSocket() }, 2000) |
||||
|
} |
||||
|
|
||||
|
override fun onClosed(ws: WebSocket, code: Int, reason: String) { |
||||
|
AppLogger.log(tag, "سوکت بسته شد ($code). تلاش مجدد...") |
||||
|
isConnected.set(false) |
||||
|
webSocket = null |
||||
|
mainHandler.post { onConnectionStateChanged(false) } |
||||
|
mainHandler.postDelayed({ connectWebSocket() }, 2000) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
@SuppressLint("MissingPermission") |
||||
|
fun startRecording(): String { |
||||
|
if (isRecording.get()) return currentSessionId |
||||
|
|
||||
|
currentSessionId = "sess_${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(6)}" |
||||
|
isRecording.set(true) |
||||
|
isSessionActive.set(true) |
||||
|
|
||||
|
if (!isConnected.get() || webSocket == null) { |
||||
|
connectWebSocket() |
||||
|
} |
||||
|
|
||||
|
// 1. Send START frame |
||||
|
val startFrame = JSONObject().apply { |
||||
|
put("type", "start") |
||||
|
put("session_id", currentSessionId) |
||||
|
}.toString() |
||||
|
webSocket?.send(startFrame) |
||||
|
AppLogger.log(tag, "🎙️ شروع ضبط و استریم (Session: $currentSessionId)...") |
||||
|
|
||||
|
// 2. Hardware recording setup (16kHz 16-bit Mono, 2048 bytes = 64ms) |
||||
|
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) |
||||
|
val bufferSize = maxOf(minBufferSize, 2048) |
||||
|
|
||||
|
try { |
||||
|
audioRecord = AudioRecord( |
||||
|
MediaRecorder.AudioSource.MIC, |
||||
|
sampleRate, |
||||
|
channelConfig, |
||||
|
audioFormat, |
||||
|
bufferSize |
||||
|
) |
||||
|
|
||||
|
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { |
||||
|
AppLogger.log(tag, "❌ سختافزار میکروفون راهاندازی نشد") |
||||
|
onError("خطا در راهاندازی سختافزار میکروفون") |
||||
|
isRecording.set(false) |
||||
|
isSessionActive.set(false) |
||||
|
return currentSessionId |
||||
|
} |
||||
|
|
||||
|
audioRecord?.startRecording() |
||||
|
|
||||
|
recordingThread = Thread { |
||||
|
val chunk = ByteArray(2048) |
||||
|
val shortBuffer = ShortArray(1024) |
||||
|
|
||||
|
while (isRecording.get()) { |
||||
|
val bytesRead = audioRecord?.read(chunk, 0, chunk.size) ?: -1 |
||||
|
if (bytesRead > 0) { |
||||
|
val slice = if (bytesRead == chunk.size) chunk.clone() else chunk.copyOf(bytesRead) |
||||
|
|
||||
|
// Direct binary streaming over persistent WebSocket |
||||
|
webSocket?.send(slice.toByteString()) |
||||
|
|
||||
|
// RMS Audio Level calculation |
||||
|
var sum = 0.0 |
||||
|
val samplesCount = bytesRead / 2 |
||||
|
for (i in 0 until samplesCount) { |
||||
|
val sample = (slice[i * 2].toInt() and 0xFF) or (slice[i * 2 + 1].toInt() shl 8) |
||||
|
shortBuffer[i] = sample.toShort() |
||||
|
sum += (shortBuffer[i] * shortBuffer[i]).toDouble() |
||||
|
} |
||||
|
val rms = sqrt(sum / samplesCount) / 32768.0 |
||||
|
val level = minOf(maxOf(rms * 4.5, 0.0), 1.0).toFloat() |
||||
|
mainHandler.post { onAudioLevel(level) } |
||||
|
} |
||||
|
} |
||||
|
}.apply { |
||||
|
priority = Thread.MAX_PRIORITY |
||||
|
start() |
||||
|
} |
||||
|
|
||||
|
} catch (e: Exception) { |
||||
|
AppLogger.log(tag, "خطای ضبط: ${e.message}") |
||||
|
onError("خطای میکروفون: ${e.localizedMessage}") |
||||
|
isRecording.set(false) |
||||
|
isSessionActive.set(false) |
||||
|
} |
||||
|
|
||||
|
return currentSessionId |
||||
|
} |
||||
|
|
||||
|
fun stopRecording() { |
||||
|
if (!isRecording.get()) return |
||||
|
isRecording.set(false) |
||||
|
|
||||
|
try { |
||||
|
audioRecord?.stop() |
||||
|
audioRecord?.release() |
||||
|
audioRecord = null |
||||
|
recordingThread?.join(150) |
||||
|
recordingThread = null |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(tag, "Error releasing audio hardware", e) |
||||
|
} |
||||
|
|
||||
|
// Send STOP frame over persistent WebSocket |
||||
|
val stopFrame = JSONObject().apply { |
||||
|
put("type", "stop") |
||||
|
put("session_id", currentSessionId) |
||||
|
}.toString() |
||||
|
webSocket?.send(stopFrame) |
||||
|
AppLogger.log(tag, "⏹️ پایان صحبت ($currentSessionId). انتظار برای دریافت متن...") |
||||
|
} |
||||
|
|
||||
|
fun release() { |
||||
|
isRecording.set(false) |
||||
|
try { |
||||
|
audioRecord?.release() |
||||
|
webSocket?.close(1000, "Client Shutdown") |
||||
|
} catch (e: Exception) {} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,102 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import android.Manifest |
||||
|
import android.app.Activity |
||||
|
import android.content.Intent |
||||
|
import android.content.pm.PackageManager |
||||
|
import android.os.Bundle |
||||
|
import android.speech.RecognizerIntent |
||||
|
import android.view.View |
||||
|
import android.widget.Toast |
||||
|
import androidx.activity.result.contract.ActivityResultContracts |
||||
|
import androidx.appcompat.app.AppCompatActivity |
||||
|
import androidx.core.content.ContextCompat |
||||
|
import com.soniox.remotemic.databinding.ActivityVoiceRecognitionBinding |
||||
|
|
||||
|
class VoiceRecognitionActivity : AppCompatActivity() { |
||||
|
|
||||
|
private lateinit var binding: ActivityVoiceRecognitionBinding |
||||
|
private var streamDictationClient: StreamDictationClient? = null |
||||
|
private var lastTranscript: String = "" |
||||
|
|
||||
|
private val requestPermissionLauncher = registerForActivityResult( |
||||
|
ActivityResultContracts.RequestPermission() |
||||
|
) { isGranted: Boolean -> |
||||
|
if (isGranted) { |
||||
|
startListening() |
||||
|
} else { |
||||
|
Toast.makeText(this, "دسترسی میکروفون برای تایپ صوتی الزامی است", Toast.LENGTH_SHORT).show() |
||||
|
setResult(Activity.RESULT_CANCELED) |
||||
|
finish() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onCreate(savedInstanceState: Bundle?) { |
||||
|
super.onCreate(savedInstanceState) |
||||
|
binding = ActivityVoiceRecognitionBinding.inflate(layoutInflater) |
||||
|
setContentView(binding.root) |
||||
|
|
||||
|
binding.btnDialogCancel.setOnClickListener { |
||||
|
streamDictationClient?.stopRecording() |
||||
|
setResult(Activity.RESULT_CANCELED) |
||||
|
finish() |
||||
|
} |
||||
|
|
||||
|
binding.btnDialogDone.setOnClickListener { |
||||
|
finishWithResult(lastTranscript) |
||||
|
} |
||||
|
|
||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { |
||||
|
startListening() |
||||
|
} else { |
||||
|
requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun startListening() { |
||||
|
val host = PreferencesManager.getServerHost(this) |
||||
|
streamDictationClient = StreamDictationClient( |
||||
|
host = host, |
||||
|
onConnectionStateChanged = { connected -> |
||||
|
if (!connected) { |
||||
|
binding.tvDialogStatus.text = "در حال اتصال به سرور..." |
||||
|
} else { |
||||
|
binding.tvDialogStatus.text = "در حال گوش دادن..." |
||||
|
} |
||||
|
}, |
||||
|
onPartialText = { liveText -> |
||||
|
lastTranscript = liveText |
||||
|
binding.tvDialogTranscript.text = liveText |
||||
|
}, |
||||
|
onAudioLevel = { level -> |
||||
|
val scale = 1.0f + (level * 0.4f) |
||||
|
binding.dialogGlow.scaleX = scale |
||||
|
binding.dialogGlow.scaleY = scale |
||||
|
}, |
||||
|
onCompleted = { finalText, _ -> |
||||
|
lastTranscript = finalText |
||||
|
binding.tvDialogTranscript.text = finalText |
||||
|
finishWithResult(finalText) |
||||
|
}, |
||||
|
onError = { errMsg -> |
||||
|
binding.tvDialogStatus.text = "خطا: $errMsg" |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
streamDictationClient?.startRecording() |
||||
|
} |
||||
|
|
||||
|
private fun finishWithResult(text: String) { |
||||
|
streamDictationClient?.stopRecording() |
||||
|
val resultIntent = Intent().apply { |
||||
|
putStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS, arrayListOf(text)) |
||||
|
} |
||||
|
setResult(Activity.RESULT_OK, resultIntent) |
||||
|
finish() |
||||
|
} |
||||
|
|
||||
|
override fun onDestroy() { |
||||
|
super.onDestroy() |
||||
|
streamDictationClient?.release() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
android:shape="rectangle"> |
||||
|
<solid android:color="@color/card_dark" /> |
||||
|
<corners android:radius="16dp" /> |
||||
|
<stroke |
||||
|
android:width="1dp" |
||||
|
android:color="@color/card_border" /> |
||||
|
</shape> |
||||
@ -0,0 +1,9 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
android:shape="rectangle"> |
||||
|
<solid android:color="@color/card_dark" /> |
||||
|
<corners android:radius="24dp" /> |
||||
|
<stroke |
||||
|
android:width="1dp" |
||||
|
android:color="@color/card_border" /> |
||||
|
</shape> |
||||
@ -0,0 +1,16 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<ripple xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
android:color="#40FFFFFF"> |
||||
|
<item> |
||||
|
<shape android:shape="oval"> |
||||
|
<gradient |
||||
|
android:angle="45" |
||||
|
android:endColor="#2563EB" |
||||
|
android:startColor="#3B82F6" |
||||
|
android:type="linear" /> |
||||
|
<size |
||||
|
android:width="140dp" |
||||
|
android:height="140dp" /> |
||||
|
</shape> |
||||
|
</item> |
||||
|
</ripple> |
||||
@ -0,0 +1,12 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
android:shape="oval"> |
||||
|
<gradient |
||||
|
android:angle="45" |
||||
|
android:endColor="#DC2626" |
||||
|
android:startColor="#F43F5E" |
||||
|
android:type="linear" /> |
||||
|
<size |
||||
|
android:width="140dp" |
||||
|
android:height="140dp" /> |
||||
|
</shape> |
||||
@ -0,0 +1,9 @@ |
|||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
android:width="48dp" |
||||
|
android:height="48dp" |
||||
|
android:viewportWidth="24" |
||||
|
android:viewportHeight="24"> |
||||
|
<path |
||||
|
android:fillColor="#FFFFFFFF" |
||||
|
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" /> |
||||
|
</vector> |
||||
@ -0,0 +1,243 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
|
xmlns:tools="http://schemas.android.com/tools" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="match_parent" |
||||
|
android:background="@color/bg_dark" |
||||
|
android:padding="16dp"> |
||||
|
|
||||
|
<!-- Header Section --> |
||||
|
<LinearLayout |
||||
|
android:id="@+id/headerLayout" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:gravity="center_vertical" |
||||
|
android:orientation="horizontal" |
||||
|
app:layout_constraintTop_toTopOf="parent"> |
||||
|
|
||||
|
<LinearLayout |
||||
|
android:layout_width="0dp" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_weight="1" |
||||
|
android:orientation="vertical"> |
||||
|
|
||||
|
<TextView |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:text="🎙️ Soniox Mobile to Mac" |
||||
|
android:textColor="@color/text_primary" |
||||
|
android:textSize="18sp" |
||||
|
android:textStyle="bold" /> |
||||
|
|
||||
|
<TextView |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_marginTop="2dp" |
||||
|
android:text="تایپ صوتی بیسیم و کنترل اینپوتباکس مک" |
||||
|
android:textColor="@color/text_secondary" |
||||
|
android:textSize="12sp" /> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
<!-- Status Pill Badge --> |
||||
|
<LinearLayout |
||||
|
android:id="@+id/layoutStatus" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:background="@drawable/bg_card" |
||||
|
android:gravity="center_vertical" |
||||
|
android:paddingHorizontal="8dp" |
||||
|
android:paddingVertical="5dp"> |
||||
|
|
||||
|
<View |
||||
|
android:id="@+id/statusIndicator" |
||||
|
android:layout_width="8dp" |
||||
|
android:layout_height="8dp" |
||||
|
android:background="@drawable/bg_mic_button" |
||||
|
android:backgroundTint="@color/accent_green" /> |
||||
|
|
||||
|
<TextView |
||||
|
android:id="@+id/tvMacStatus" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_marginStart="6dp" |
||||
|
android:text="متصل ✅" |
||||
|
android:textColor="@color/text_primary" |
||||
|
android:textSize="11sp" |
||||
|
android:textStyle="bold" /> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
<!-- Logs Icon Button --> |
||||
|
<ImageButton |
||||
|
android:id="@+id/btnOpenLogs" |
||||
|
android:layout_width="38dp" |
||||
|
android:layout_height="38dp" |
||||
|
android:layout_marginStart="8dp" |
||||
|
android:background="@drawable/bg_card" |
||||
|
android:contentDescription="لاگها" |
||||
|
android:padding="8dp" |
||||
|
android:src="@drawable/ic_mic" |
||||
|
app:tint="@color/accent_blue" /> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
<!-- Main Central Editable Transcript & Remote Control Box --> |
||||
|
<LinearLayout |
||||
|
android:id="@+id/mainBoxCard" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="0dp" |
||||
|
android:layout_marginTop="12dp" |
||||
|
android:layout_marginBottom="12dp" |
||||
|
android:background="@drawable/bg_card" |
||||
|
android:orientation="vertical" |
||||
|
android:padding="12dp" |
||||
|
app:layout_constraintBottom_toTopOf="@id/micContainer" |
||||
|
app:layout_constraintTop_toBottomOf="@id/headerLayout"> |
||||
|
|
||||
|
<!-- Top Row: Box Title + Word Count + Clear Button --> |
||||
|
<LinearLayout |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:gravity="center_vertical" |
||||
|
android:orientation="horizontal"> |
||||
|
|
||||
|
<TextView |
||||
|
android:layout_width="0dp" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_weight="1" |
||||
|
android:text="📝 متن گفتار و ویرایشگر مک" |
||||
|
android:textColor="@color/text_secondary" |
||||
|
android:textSize="12sp" |
||||
|
android:textStyle="bold" /> |
||||
|
|
||||
|
<TextView |
||||
|
android:id="@+id/tvCharCount" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:text="۰ کلمه" |
||||
|
android:textColor="@color/text_muted" |
||||
|
android:textSize="11sp" /> |
||||
|
|
||||
|
<TextView |
||||
|
android:id="@+id/btnClearText" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_marginStart="10dp" |
||||
|
android:paddingHorizontal="4dp" |
||||
|
android:paddingVertical="2dp" |
||||
|
android:text="🗑️ پاککردن" |
||||
|
android:textColor="@color/text_muted" |
||||
|
android:textSize="11sp" /> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
<!-- Fully Editable Multi-line EditText --> |
||||
|
<EditText |
||||
|
android:id="@+id/etTranscript" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="0dp" |
||||
|
android:layout_marginTop="8dp" |
||||
|
android:layout_weight="1" |
||||
|
android:background="@null" |
||||
|
android:gravity="top|start" |
||||
|
android:hint="صحبت کنید یا متن خود را در اینجا بنویسید و ویرایش کنید..." |
||||
|
android:inputType="textMultiLine|textCapSentences" |
||||
|
android:lineSpacingExtra="4dp" |
||||
|
android:scrollbars="vertical" |
||||
|
android:textColor="@color/text_primary" |
||||
|
android:textColorHint="@color/text_muted" |
||||
|
android:textSize="16sp" /> |
||||
|
|
||||
|
<View |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="1dp" |
||||
|
android:layout_marginVertical="8dp" |
||||
|
android:background="@color/card_border" /> |
||||
|
|
||||
|
<!-- Action Buttons Row --> |
||||
|
<LinearLayout |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:gravity="center_vertical" |
||||
|
android:orientation="horizontal"> |
||||
|
|
||||
|
<com.google.android.material.button.MaterialButton |
||||
|
android:id="@+id/btnCopyText" |
||||
|
style="@style/Widget.Material3.Button.OutlinedButton" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="40dp" |
||||
|
android:text="📋 کپی" |
||||
|
android:textColor="@color/text_secondary" |
||||
|
android:textSize="12sp" |
||||
|
app:cornerRadius="8dp" /> |
||||
|
|
||||
|
<View |
||||
|
android:layout_width="0dp" |
||||
|
android:layout_height="1dp" |
||||
|
android:layout_weight="1" /> |
||||
|
|
||||
|
<com.google.android.material.button.MaterialButton |
||||
|
android:id="@+id/btnSendToMac" |
||||
|
style="@style/Widget.Material3.Button" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="42dp" |
||||
|
android:backgroundTint="@color/accent_blue" |
||||
|
android:text="🚀 درج متن در مک (Cmd+V)" |
||||
|
android:textColor="@color/text_primary" |
||||
|
android:textSize="12sp" |
||||
|
android:textStyle="bold" |
||||
|
app:cornerRadius="8dp" /> |
||||
|
</LinearLayout> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
<!-- Center Glowing Mic Button Container --> |
||||
|
<FrameLayout |
||||
|
android:id="@+id/micContainer" |
||||
|
android:layout_width="125dp" |
||||
|
android:layout_height="125dp" |
||||
|
android:layout_marginBottom="8dp" |
||||
|
app:layout_constraintBottom_toTopOf="@id/tvInstruction" |
||||
|
app:layout_constraintEnd_toEndOf="parent" |
||||
|
app:layout_constraintStart_toStartOf="parent"> |
||||
|
|
||||
|
<!-- Outer Pulsing Glow --> |
||||
|
<View |
||||
|
android:id="@+id/viewGlow" |
||||
|
android:layout_width="125dp" |
||||
|
android:layout_height="125dp" |
||||
|
android:layout_gravity="center" |
||||
|
android:alpha="0.3" |
||||
|
android:background="@drawable/bg_mic_button" /> |
||||
|
|
||||
|
<!-- Main Touch Button --> |
||||
|
<FrameLayout |
||||
|
android:id="@+id/btnMic" |
||||
|
android:layout_width="100dp" |
||||
|
android:layout_height="100dp" |
||||
|
android:layout_gravity="center" |
||||
|
android:background="@drawable/bg_mic_button" |
||||
|
android:clickable="true" |
||||
|
android:focusable="true"> |
||||
|
|
||||
|
<ImageView |
||||
|
android:id="@+id/ivMicIcon" |
||||
|
android:layout_width="42dp" |
||||
|
android:layout_height="42dp" |
||||
|
android:layout_gravity="center" |
||||
|
android:src="@drawable/ic_mic" /> |
||||
|
</FrameLayout> |
||||
|
</FrameLayout> |
||||
|
|
||||
|
<!-- Bottom Instruction / Status Label --> |
||||
|
<TextView |
||||
|
android:id="@+id/tvInstruction" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_marginBottom="4dp" |
||||
|
android:text="@string/hold_to_speak" |
||||
|
android:textColor="@color/text_secondary" |
||||
|
android:textSize="13sp" |
||||
|
android:textStyle="bold" |
||||
|
app:layout_constraintBottom_toBottomOf="parent" |
||||
|
app:layout_constraintEnd_toEndOf="parent" |
||||
|
app:layout_constraintStart_toStartOf="parent" /> |
||||
|
|
||||
|
</androidx.constraintlayout.widget.ConstraintLayout> |
||||
@ -0,0 +1,102 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_gravity="center" |
||||
|
android:background="@drawable/bg_dialog" |
||||
|
android:gravity="center_horizontal" |
||||
|
android:orientation="vertical" |
||||
|
android:padding="24dp"> |
||||
|
|
||||
|
<TextView |
||||
|
android:id="@+id/tvDialogTitle" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:text="🎙️ Soniox Voice Typing" |
||||
|
android:textColor="@color/text_primary" |
||||
|
android:textSize="18sp" |
||||
|
android:textStyle="bold" /> |
||||
|
|
||||
|
<TextView |
||||
|
android:id="@+id/tvDialogStatus" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_marginTop="4dp" |
||||
|
android:text="در حال گوش دادن..." |
||||
|
android:textColor="@color/text_secondary" |
||||
|
android:textSize="13sp" /> |
||||
|
|
||||
|
<!-- Pulsing Mic Icon Container --> |
||||
|
<FrameLayout |
||||
|
android:layout_width="100dp" |
||||
|
android:layout_height="100dp" |
||||
|
android:layout_marginVertical="16dp"> |
||||
|
|
||||
|
<View |
||||
|
android:id="@+id/dialogGlow" |
||||
|
android:layout_width="100dp" |
||||
|
android:layout_height="100dp" |
||||
|
android:layout_gravity="center" |
||||
|
android:alpha="0.4" |
||||
|
android:background="@drawable/bg_mic_button_active" /> |
||||
|
|
||||
|
<FrameLayout |
||||
|
android:id="@+id/btnDialogMic" |
||||
|
android:layout_width="80dp" |
||||
|
android:layout_height="80dp" |
||||
|
android:layout_gravity="center" |
||||
|
android:background="@drawable/bg_mic_button_active"> |
||||
|
|
||||
|
<ImageView |
||||
|
android:layout_width="36dp" |
||||
|
android:layout_height="36dp" |
||||
|
android:layout_gravity="center" |
||||
|
android:src="@drawable/ic_mic" /> |
||||
|
</FrameLayout> |
||||
|
</FrameLayout> |
||||
|
|
||||
|
<!-- Live Transcript Box --> |
||||
|
<TextView |
||||
|
android:id="@+id/tvDialogTranscript" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_marginBottom="16dp" |
||||
|
android:background="@drawable/bg_card" |
||||
|
android:gravity="center" |
||||
|
android:minHeight="60dp" |
||||
|
android:padding="12dp" |
||||
|
android:text="صحبت کنید..." |
||||
|
android:textColor="@color/text_primary" |
||||
|
android:textSize="16sp" /> |
||||
|
|
||||
|
<!-- Action Buttons --> |
||||
|
<LinearLayout |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:gravity="center" |
||||
|
android:orientation="horizontal"> |
||||
|
|
||||
|
<com.google.android.material.button.MaterialButton |
||||
|
android:id="@+id/btnDialogCancel" |
||||
|
style="@style/Widget.Material3.Button.TextButton" |
||||
|
android:layout_width="0dp" |
||||
|
android:layout_height="48dp" |
||||
|
android:layout_marginEnd="8dp" |
||||
|
android:layout_weight="1" |
||||
|
android:text="لغو" |
||||
|
android:textColor="@color/text_muted" /> |
||||
|
|
||||
|
<com.google.android.material.button.MaterialButton |
||||
|
android:id="@+id/btnDialogDone" |
||||
|
style="@style/Widget.Material3.Button" |
||||
|
android:layout_width="0dp" |
||||
|
android:layout_height="48dp" |
||||
|
android:layout_marginStart="8dp" |
||||
|
android:layout_weight="1" |
||||
|
android:backgroundTint="@color/accent_blue" |
||||
|
android:text="تأیید" |
||||
|
android:textColor="@color/text_primary" /> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
</LinearLayout> |
||||
@ -0,0 +1,72 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="match_parent" |
||||
|
android:background="@color/bg_dark" |
||||
|
android:orientation="vertical" |
||||
|
android:padding="16dp"> |
||||
|
|
||||
|
<!-- Bottom Sheet Header --> |
||||
|
<LinearLayout |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:gravity="center_vertical" |
||||
|
android:orientation="horizontal"> |
||||
|
|
||||
|
<TextView |
||||
|
android:layout_width="0dp" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:layout_weight="1" |
||||
|
android:text="📊 کنسول لاگ و دیباگ (Live Logs)" |
||||
|
android:textColor="@color/text_primary" |
||||
|
android:textSize="15sp" |
||||
|
android:textStyle="bold" /> |
||||
|
|
||||
|
<com.google.android.material.button.MaterialButton |
||||
|
android:id="@+id/btnSheetCopyLogs" |
||||
|
style="@style/Widget.Material3.Button.TextButton" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="36dp" |
||||
|
android:text="📋 کپی" |
||||
|
android:textColor="@color/accent_blue" |
||||
|
android:textSize="12sp" /> |
||||
|
|
||||
|
<com.google.android.material.button.MaterialButton |
||||
|
android:id="@+id/btnSheetClearLogs" |
||||
|
style="@style/Widget.Material3.Button.TextButton" |
||||
|
android:layout_width="wrap_content" |
||||
|
android:layout_height="36dp" |
||||
|
android:layout_marginStart="4dp" |
||||
|
android:text="🗑️ پاککردن" |
||||
|
android:textColor="@color/text_muted" |
||||
|
android:textSize="12sp" /> |
||||
|
</LinearLayout> |
||||
|
|
||||
|
<View |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="1dp" |
||||
|
android:layout_marginVertical="8dp" |
||||
|
android:background="@color/card_border" /> |
||||
|
|
||||
|
<!-- Scrollable Logs Content --> |
||||
|
<ScrollView |
||||
|
android:id="@+id/scrollViewSheetLogs" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="380dp" |
||||
|
android:background="@drawable/bg_card" |
||||
|
android:fillViewport="true" |
||||
|
android:padding="12dp"> |
||||
|
|
||||
|
<TextView |
||||
|
android:id="@+id/tvSheetLogs" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content" |
||||
|
android:fontFamily="monospace" |
||||
|
android:gravity="top|start" |
||||
|
android:lineSpacingExtra="3dp" |
||||
|
android:text="آماده دریافت لاگ..." |
||||
|
android:textColor="#E2E8F0" |
||||
|
android:textSize="11sp" /> |
||||
|
</ScrollView> |
||||
|
</LinearLayout> |
||||
@ -0,0 +1,15 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<resources> |
||||
|
<color name="bg_dark">#0F1115</color> |
||||
|
<color name="card_dark">#181B22</color> |
||||
|
<color name="card_border">#282E3A</color> |
||||
|
<color name="accent_blue">#3B82F6</color> |
||||
|
<color name="accent_red">#EF4444</color> |
||||
|
<color name="accent_green">#10B981</color> |
||||
|
<color name="accent_yellow">#F59E0B</color> |
||||
|
<color name="text_primary">#F8FAFC</color> |
||||
|
<color name="text_secondary">#94A3B8</color> |
||||
|
<color name="text_muted">#64748B</color> |
||||
|
<color name="mic_glow_active">#4DF43F5E</color> |
||||
|
<color name="mic_glow_idle">#263B82F6</color> |
||||
|
</resources> |
||||
@ -0,0 +1,15 @@ |
|||||
|
<resources> |
||||
|
<string name="app_name">Soniox Voice</string> |
||||
|
<string name="voice_typing_service_name">Soniox Voice Typing</string> |
||||
|
<string name="hold_to_speak">نگهدارید و صحبت کنید</string> |
||||
|
<string name="release_to_type">رها کنید تا تایپ شود</string> |
||||
|
<string name="status_ready">آماده برای ضبط صدا</string> |
||||
|
<string name="status_recording">🎙️ در حال استریم زنده صدا...</string> |
||||
|
<string name="status_transcribing">⏳ در حال دریافت متن...</string> |
||||
|
<string name="status_sent">✨ متن آماده شد</string> |
||||
|
<string name="test_connection">تست اتصال</string> |
||||
|
<string name="live_transcript_placeholder">متن صحبت شما بهصورت زنده در این قسمت نمایش داده میشود...</string> |
||||
|
<string name="open_voice_input_settings">⚙️ انتخاب Soniox به عنوان ورودی صوتی سیستم</string> |
||||
|
<string name="server_host_label">آدرس سرور وبسوکت:</string> |
||||
|
<string name="save_host">ذخیره آدرس</string> |
||||
|
</resources> |
||||
@ -0,0 +1,16 @@ |
|||||
|
<resources> |
||||
|
<style name="Theme.SonioxRemoteMic" parent="Theme.Material3.Dark.NoActionBar"> |
||||
|
<item name="android:statusBarColor">@color/bg_dark</item> |
||||
|
<item name="android:navigationBarColor">@color/bg_dark</item> |
||||
|
<item name="android:windowBackground">@color/bg_dark</item> |
||||
|
<item name="colorPrimary">@color/accent_blue</item> |
||||
|
<item name="colorSecondary">@color/accent_green</item> |
||||
|
</style> |
||||
|
|
||||
|
<style name="Theme.SonioxRemoteMic.Dialog" parent="Theme.Material3.Dark.Dialog"> |
||||
|
<item name="android:windowBackground">@drawable/bg_dialog</item> |
||||
|
<item name="android:windowNoTitle">true</item> |
||||
|
<item name="android:windowIsFloating">true</item> |
||||
|
<item name="colorPrimary">@color/accent_blue</item> |
||||
|
</style> |
||||
|
</resources> |
||||
@ -0,0 +1,3 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<recognition-service xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
android:settingsActivity="com.soniox.remotemic.MainActivity" /> |
||||
@ -0,0 +1,78 @@ |
|||||
|
package com.soniox.remotemic |
||||
|
|
||||
|
import okhttp3.* |
||||
|
import okio.ByteString.Companion.toByteString |
||||
|
import org.junit.Test |
||||
|
import java.io.File |
||||
|
import java.util.concurrent.CountDownLatch |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
|
||||
|
class SonioxWsTest { |
||||
|
|
||||
|
@Test |
||||
|
fun testWebSocketStreaming() { |
||||
|
println("=== TESTING OKHTTP WEBSOCKET TO SONIOX ===") |
||||
|
val client = OkHttpClient.Builder() |
||||
|
.readTimeout(30, TimeUnit.SECONDS) |
||||
|
.connectTimeout(15, TimeUnit.SECONDS) |
||||
|
.build() |
||||
|
|
||||
|
val url = "wss://translate.compare.soniox.com/compare/api/compare-websocket?language_hints=fa&language_hints=en&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox" |
||||
|
val req = Request.Builder() |
||||
|
.url(url) |
||||
|
.header("User-Agent", "Mozilla/5.0 (Linux; Android 14) SonioxRemoteMic/1.0") |
||||
|
.header("Origin", "https://translate.compare.soniox.com") |
||||
|
.build() |
||||
|
|
||||
|
val latch = CountDownLatch(1) |
||||
|
var connected = false |
||||
|
|
||||
|
val webSocket = client.newWebSocket(req, object : WebSocketListener() { |
||||
|
override fun onOpen(ws: WebSocket, response: Response) { |
||||
|
println("OkHttp WebSocket onOpen! Response code: ${response.code}") |
||||
|
connected = true |
||||
|
} |
||||
|
|
||||
|
override fun onMessage(ws: WebSocket, text: String) { |
||||
|
println("OkHttp WebSocket onMessage: $text") |
||||
|
if (text.contains("<fin>") || text.contains("session_done")) { |
||||
|
latch.countDown() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { |
||||
|
println("OkHttp WebSocket onFailure: ${t.message}") |
||||
|
t.printStackTrace() |
||||
|
latch.countDown() |
||||
|
} |
||||
|
|
||||
|
override fun onClosed(ws: WebSocket, code: Int, reason: String) { |
||||
|
println("OkHttp WebSocket onClosed: $code, $reason") |
||||
|
latch.countDown() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
Thread.sleep(1000) |
||||
|
|
||||
|
println("Sending audio chunks... Connected: $connected") |
||||
|
val pcmFile = File("/home/alialavi/projects/auto-dub-bot/workspace/tts_cache/d6fb879ba24b13b8cdb93e4465daea56.wav") |
||||
|
if (pcmFile.exists()) { |
||||
|
val p = ProcessBuilder("ffmpeg", "-loglevel", "error", "-i", pcmFile.absolutePath, "-f", "s16le", "-ac", "1", "-ar", "16000", "-").start() |
||||
|
val pcmBytes = p.inputStream.readBytes() |
||||
|
println("PCM Bytes: ${pcmBytes.size}") |
||||
|
|
||||
|
val chunkSize = 1024 |
||||
|
for (i in 0 until pcmBytes.size step chunkSize) { |
||||
|
val end = minOf(i + chunkSize, pcmBytes.size) |
||||
|
val chunk = pcmBytes.copyOfRange(i, end) |
||||
|
webSocket.send(chunk.toByteString()) |
||||
|
Thread.sleep(30) |
||||
|
} |
||||
|
println("Sent all audio chunks. Sending finalize...") |
||||
|
webSocket.send("{\"type\": \"finalize\"}") |
||||
|
} |
||||
|
|
||||
|
latch.await(10, TimeUnit.SECONDS) |
||||
|
println("Test finished.") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
plugins { |
||||
|
id 'com.android.application' version '8.4.2' apply false |
||||
|
id 'org.jetbrains.kotlin.android' version '1.9.24' apply false |
||||
|
} |
||||
|
|
||||
|
tasks.register('clean', Delete) { |
||||
|
delete rootProject.buildDir |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Djava.net.preferIPv6Addresses=true |
||||
|
android.useAndroidX=true |
||||
|
android.nonTransitiveRClass=true |
||||
|
kotlin.code.style=official |
||||
@ -0,0 +1,5 @@ |
|||||
|
distributionBase=GRADLE_USER_HOME |
||||
|
distributionPath=wrapper/dists |
||||
|
zipStoreBase=GRADLE_USER_HOME |
||||
|
zipStorePath=wrapper/dists |
||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip |
||||
@ -0,0 +1,160 @@ |
|||||
|
#!/usr/bin/env bash |
||||
|
|
||||
|
############################################################################## |
||||
|
## |
||||
|
## Gradle start up script for UN*X |
||||
|
## |
||||
|
############################################################################## |
||||
|
|
||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. |
||||
|
DEFAULT_JVM_OPTS="" |
||||
|
|
||||
|
APP_NAME="Gradle" |
||||
|
APP_BASE_NAME=`basename "$0"` |
||||
|
|
||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value. |
||||
|
MAX_FD="maximum" |
||||
|
|
||||
|
warn ( ) { |
||||
|
echo "$*" |
||||
|
} |
||||
|
|
||||
|
die ( ) { |
||||
|
echo |
||||
|
echo "$*" |
||||
|
echo |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
# OS specific support (must be 'true' or 'false'). |
||||
|
cygwin=false |
||||
|
msys=false |
||||
|
darwin=false |
||||
|
case "`uname`" in |
||||
|
CYGWIN* ) |
||||
|
cygwin=true |
||||
|
;; |
||||
|
Darwin* ) |
||||
|
darwin=true |
||||
|
;; |
||||
|
MINGW* ) |
||||
|
msys=true |
||||
|
;; |
||||
|
esac |
||||
|
|
||||
|
# Attempt to set APP_HOME |
||||
|
# Resolve links: $0 may be a link |
||||
|
PRG="$0" |
||||
|
# Need this for relative symlinks. |
||||
|
while [ -h "$PRG" ] ; do |
||||
|
ls=`ls -ld "$PRG"` |
||||
|
link=`expr "$ls" : '.*-> \(.*\)$'` |
||||
|
if expr "$link" : '/.*' > /dev/null; then |
||||
|
PRG="$link" |
||||
|
else |
||||
|
PRG=`dirname "$PRG"`"/$link" |
||||
|
fi |
||||
|
done |
||||
|
SAVED="`pwd`" |
||||
|
cd "`dirname \"$PRG\"`/" >/dev/null |
||||
|
APP_HOME="`pwd -P`" |
||||
|
cd "$SAVED" >/dev/null |
||||
|
|
||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar |
||||
|
|
||||
|
# Determine the Java command to use to start the JVM. |
||||
|
if [ -n "$JAVA_HOME" ] ; then |
||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then |
||||
|
# IBM's JDK on AIX uses strange locations for the executables |
||||
|
JAVACMD="$JAVA_HOME/jre/sh/java" |
||||
|
else |
||||
|
JAVACMD="$JAVA_HOME/bin/java" |
||||
|
fi |
||||
|
if [ ! -x "$JAVACMD" ] ; then |
||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME |
||||
|
|
||||
|
Please set the JAVA_HOME variable in your environment to match the |
||||
|
location of your Java installation." |
||||
|
fi |
||||
|
else |
||||
|
JAVACMD="java" |
||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. |
||||
|
|
||||
|
Please set the JAVA_HOME variable in your environment to match the |
||||
|
location of your Java installation." |
||||
|
fi |
||||
|
|
||||
|
# Increase the maximum file descriptors if we can. |
||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then |
||||
|
MAX_FD_LIMIT=`ulimit -H -n` |
||||
|
if [ $? -eq 0 ] ; then |
||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then |
||||
|
MAX_FD="$MAX_FD_LIMIT" |
||||
|
fi |
||||
|
ulimit -n $MAX_FD |
||||
|
if [ $? -ne 0 ] ; then |
||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD" |
||||
|
fi |
||||
|
else |
||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" |
||||
|
fi |
||||
|
fi |
||||
|
|
||||
|
# For Darwin, add options to specify how the application appears in the dock |
||||
|
if $darwin; then |
||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" |
||||
|
fi |
||||
|
|
||||
|
# For Cygwin, switch paths to Windows format before running java |
||||
|
if $cygwin ; then |
||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"` |
||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` |
||||
|
JAVACMD=`cygpath --unix "$JAVACMD"` |
||||
|
|
||||
|
# We build the pattern for arguments to be converted via cygpath |
||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` |
||||
|
SEP="" |
||||
|
for dir in $ROOTDIRSRAW ; do |
||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir" |
||||
|
SEP="|" |
||||
|
done |
||||
|
OURCYGPATTERN="(^($ROOTDIRS))" |
||||
|
# Add a user-defined pattern to the cygpath arguments |
||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then |
||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" |
||||
|
fi |
||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh |
||||
|
i=0 |
||||
|
for arg in "$@" ; do |
||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` |
||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option |
||||
|
|
||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition |
||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` |
||||
|
else |
||||
|
eval `echo args$i`="\"$arg\"" |
||||
|
fi |
||||
|
i=$((i+1)) |
||||
|
done |
||||
|
case $i in |
||||
|
(0) set -- ;; |
||||
|
(1) set -- "$args0" ;; |
||||
|
(2) set -- "$args0" "$args1" ;; |
||||
|
(3) set -- "$args0" "$args1" "$args2" ;; |
||||
|
(4) set -- "$args0" "$args1" "$args2" "$args3" ;; |
||||
|
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; |
||||
|
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; |
||||
|
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; |
||||
|
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; |
||||
|
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; |
||||
|
esac |
||||
|
fi |
||||
|
|
||||
|
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules |
||||
|
function splitJvmOpts() { |
||||
|
JVM_OPTS=("$@") |
||||
|
} |
||||
|
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS |
||||
|
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" |
||||
|
|
||||
|
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" |
||||
@ -0,0 +1,90 @@ |
|||||
|
@if "%DEBUG%" == "" @echo off |
||||
|
@rem ########################################################################## |
||||
|
@rem |
||||
|
@rem Gradle startup script for Windows |
||||
|
@rem |
||||
|
@rem ########################################################################## |
||||
|
|
||||
|
@rem Set local scope for the variables with windows NT shell |
||||
|
if "%OS%"=="Windows_NT" setlocal |
||||
|
|
||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. |
||||
|
set DEFAULT_JVM_OPTS= |
||||
|
|
||||
|
set DIRNAME=%~dp0 |
||||
|
if "%DIRNAME%" == "" set DIRNAME=. |
||||
|
set APP_BASE_NAME=%~n0 |
||||
|
set APP_HOME=%DIRNAME% |
||||
|
|
||||
|
@rem Find java.exe |
||||
|
if defined JAVA_HOME goto findJavaFromJavaHome |
||||
|
|
||||
|
set JAVA_EXE=java.exe |
||||
|
%JAVA_EXE% -version >NUL 2>&1 |
||||
|
if "%ERRORLEVEL%" == "0" goto init |
||||
|
|
||||
|
echo. |
||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. |
||||
|
echo. |
||||
|
echo Please set the JAVA_HOME variable in your environment to match the |
||||
|
echo location of your Java installation. |
||||
|
|
||||
|
goto fail |
||||
|
|
||||
|
:findJavaFromJavaHome |
||||
|
set JAVA_HOME=%JAVA_HOME:"=% |
||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe |
||||
|
|
||||
|
if exist "%JAVA_EXE%" goto init |
||||
|
|
||||
|
echo. |
||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% |
||||
|
echo. |
||||
|
echo Please set the JAVA_HOME variable in your environment to match the |
||||
|
echo location of your Java installation. |
||||
|
|
||||
|
goto fail |
||||
|
|
||||
|
:init |
||||
|
@rem Get command-line arguments, handling Windowz variants |
||||
|
|
||||
|
if not "%OS%" == "Windows_NT" goto win9xME_args |
||||
|
if "%@eval[2+2]" == "4" goto 4NT_args |
||||
|
|
||||
|
:win9xME_args |
||||
|
@rem Slurp the command line arguments. |
||||
|
set CMD_LINE_ARGS= |
||||
|
set _SKIP=2 |
||||
|
|
||||
|
:win9xME_args_slurp |
||||
|
if "x%~1" == "x" goto execute |
||||
|
|
||||
|
set CMD_LINE_ARGS=%* |
||||
|
goto execute |
||||
|
|
||||
|
:4NT_args |
||||
|
@rem Get arguments from the 4NT Shell from JP Software |
||||
|
set CMD_LINE_ARGS=%$ |
||||
|
|
||||
|
:execute |
||||
|
@rem Setup the command line |
||||
|
|
||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar |
||||
|
|
||||
|
@rem Execute Gradle |
||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% |
||||
|
|
||||
|
:end |
||||
|
@rem End local scope for the variables with windows NT shell |
||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd |
||||
|
|
||||
|
:fail |
||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of |
||||
|
rem the _cmd.exe /c_ return code! |
||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 |
||||
|
exit /b 1 |
||||
|
|
||||
|
:mainEnd |
||||
|
if "%OS%"=="Windows_NT" endlocal |
||||
|
|
||||
|
:omega |
||||
@ -0,0 +1,16 @@ |
|||||
|
pluginManagement { |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
gradlePluginPortal() |
||||
|
} |
||||
|
} |
||||
|
dependencyResolutionManagement { |
||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
} |
||||
|
} |
||||
|
rootProject.name = "SonioxRemoteMic" |
||||
|
include ':app' |
||||
@ -0,0 +1,41 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>CFBundleDevelopmentRegion</key> |
||||
|
<string>en</string> |
||||
|
<key>CFBundleDisplayName</key> |
||||
|
<string>Soniox Voice</string> |
||||
|
<key>CFBundleExecutable</key> |
||||
|
<string>SonioxVoice</string> |
||||
|
<key>CFBundleIconFile</key> |
||||
|
<string>AppIcon</string> |
||||
|
<key>CFBundleIdentifier</key> |
||||
|
<string>com.soniox.voice</string> |
||||
|
<key>CFBundleInfoDictionaryVersion</key> |
||||
|
<string>6.0</string> |
||||
|
<key>CFBundleName</key> |
||||
|
<string>SonioxVoice</string> |
||||
|
<key>CFBundlePackageType</key> |
||||
|
<string>APPL</string> |
||||
|
<key>CFBundleShortVersionString</key> |
||||
|
<string>1.0.0</string> |
||||
|
<key>CFBundleVersion</key> |
||||
|
<string>1</string> |
||||
|
<key>LSMinimumSystemVersion</key> |
||||
|
<string>13.0</string> |
||||
|
<key>LSUIElement</key> |
||||
|
<true/> |
||||
|
<key>NSHighResolutionCapable</key> |
||||
|
<true/> |
||||
|
<key>NSMicrophoneUsageDescription</key> |
||||
|
<string>Soniox Voice به دسترسی میکروفون جهت ضبط صدا و تبدیل آن به متن نیاز دارد.</string> |
||||
|
<key>NSAccessibilityUsageDescription</key> |
||||
|
<string>Soniox Voice به دسترسی Accessibility جهت درج خودکار متن در برنامه فعال نیاز دارد.</string> |
||||
|
<key>NSAppTransportSecurity</key> |
||||
|
<dict> |
||||
|
<key>NSAllowsArbitraryLoads</key> |
||||
|
<true/> |
||||
|
</dict> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1,121 @@ |
|||||
|
import sys |
||||
|
import os |
||||
|
import math |
||||
|
from PIL import Image, ImageDraw, ImageFilter |
||||
|
|
||||
|
def create_app_icon(output_dir): |
||||
|
os.makedirs(output_dir, exist_ok=True) |
||||
|
iconset_dir = os.path.join(output_dir, "AppIcon.iconset") |
||||
|
os.makedirs(iconset_dir, exist_ok=True) |
||||
|
|
||||
|
size = 1024 |
||||
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) |
||||
|
draw = ImageDraw.Draw(img) |
||||
|
|
||||
|
# Background Squircle / Rounded rect with gradient |
||||
|
margin = 80 |
||||
|
rect = [margin, margin, size - margin, size - margin] |
||||
|
radius = 200 |
||||
|
|
||||
|
# Create base squircle mask |
||||
|
mask = Image.new("L", (size, size), 0) |
||||
|
mask_draw = ImageDraw.Draw(mask) |
||||
|
mask_draw.rounded_rectangle(rect, radius=radius, fill=255) |
||||
|
|
||||
|
# Render gradient |
||||
|
grad = Image.new("RGBA", (size, size)) |
||||
|
grad_draw = ImageDraw.Draw(grad) |
||||
|
|
||||
|
# Rich Purple / Blue / Cyber Teal gradient |
||||
|
for y in range(size): |
||||
|
ratio = y / size |
||||
|
r = int(79 + (124 - 79) * ratio) |
||||
|
g = int(70 + (58 - 70) * ratio) |
||||
|
b = int(229 + (237 - 229) * ratio) |
||||
|
grad_draw.line([(0, y), (size, y)], fill=(r, g, b, 255)) |
||||
|
|
||||
|
img.paste(grad, (0, 0), mask) |
||||
|
|
||||
|
# Draw Inner Glowing Waveform & Microphone |
||||
|
draw = ImageDraw.Draw(img) |
||||
|
|
||||
|
# Mic Capsule |
||||
|
center_x = size // 2 |
||||
|
center_y = size // 2 - 40 |
||||
|
mic_w = 120 |
||||
|
mic_h = 240 |
||||
|
|
||||
|
# Glow behind mic |
||||
|
glow = Image.new("RGBA", (size, size), (0, 0, 0, 0)) |
||||
|
glow_draw = ImageDraw.Draw(glow) |
||||
|
glow_draw.rounded_rectangle([center_x - mic_w//2 - 20, center_y - mic_h//2 - 20, center_x + mic_w//2 + 20, center_y + mic_h//2 + 20], radius=80, fill=(255, 255, 255, 60)) |
||||
|
glow = glow.filter(ImageFilter.GaussianBlur(30)) |
||||
|
img.alpha_composite(glow) |
||||
|
|
||||
|
draw = ImageDraw.Draw(img) |
||||
|
|
||||
|
# Mic Body (Capsule) |
||||
|
draw.rounded_rectangle( |
||||
|
[center_x - mic_w//2, center_y - mic_h//2, center_x + mic_w//2, center_y + mic_h//2], |
||||
|
radius=60, |
||||
|
fill=(255, 255, 255, 250) |
||||
|
) |
||||
|
|
||||
|
# Mic Arc / Cradle |
||||
|
cradle_w = 220 |
||||
|
cradle_h = 200 |
||||
|
arc_top = center_y - 20 |
||||
|
draw.arc( |
||||
|
[center_x - cradle_w//2, arc_top, center_x + cradle_w//2, arc_top + cradle_h], |
||||
|
start=0, |
||||
|
end=180, |
||||
|
fill=(255, 255, 255, 240), |
||||
|
width=24 |
||||
|
) |
||||
|
|
||||
|
# Mic Stand / Stem |
||||
|
stem_top = arc_top + cradle_h |
||||
|
draw.line([(center_x, stem_top), (center_x, stem_top + 70)], fill=(255, 255, 255, 240), width=24) |
||||
|
# Mic Base |
||||
|
draw.line([(center_x - 80, stem_top + 70), (center_x + 80, stem_top + 70)], fill=(255, 255, 255, 240), width=24) |
||||
|
|
||||
|
# Sonic Sound Waves on sides |
||||
|
for side in [-1, 1]: |
||||
|
for i, r in enumerate([190, 260]): |
||||
|
wave_cx = center_x + side * 40 |
||||
|
wave_w = r * 2 |
||||
|
wave_h = r * 2 |
||||
|
start_ang = 300 if side == 1 else 120 |
||||
|
end_ang = 60 if side == 1 else 240 |
||||
|
draw.arc( |
||||
|
[wave_cx - wave_w//2, center_y - wave_h//2, wave_cx + wave_w//2, center_y + wave_h//2], |
||||
|
start=start_ang, |
||||
|
end=end_ang, |
||||
|
fill=(255, 255, 255, 180 - i * 60), |
||||
|
width=18 |
||||
|
) |
||||
|
|
||||
|
# Save standard sizes |
||||
|
sizes = [ |
||||
|
(16, "icon_16x16.png"), |
||||
|
(32, "[email protected]"), |
||||
|
(32, "icon_32x32.png"), |
||||
|
(64, "[email protected]"), |
||||
|
(128, "icon_128x128.png"), |
||||
|
(256, "[email protected]"), |
||||
|
(256, "icon_256x256.png"), |
||||
|
(512, "[email protected]"), |
||||
|
(512, "icon_512x512.png"), |
||||
|
(1024, "[email protected]"), |
||||
|
] |
||||
|
|
||||
|
for s, name in sizes: |
||||
|
resized = img.resize((s, s), Image.Resampling.LANCZOS) |
||||
|
resized.save(os.path.join(iconset_dir, name)) |
||||
|
|
||||
|
master_path = os.path.join(output_dir, "AppIcon.png") |
||||
|
img.save(master_path) |
||||
|
print("Iconset generated in", iconset_dir) |
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
create_app_icon("/tmp/soniox_build/resources") |
||||
@ -0,0 +1,55 @@ |
|||||
|
#!/bin/zsh |
||||
|
set -e |
||||
|
|
||||
|
PROJECT_DIR="/Users/alig/Develop/SonioxVoice" |
||||
|
BUILD_DIR="$PROJECT_DIR/build" |
||||
|
APP_NAME="Soniox Voice" |
||||
|
APP_BUNDLE="$BUILD_DIR/$APP_NAME.app" |
||||
|
DMG_NAME="SonioxVoice-v1.0.0.dmg" |
||||
|
|
||||
|
echo "🔨 Building $APP_NAME..." |
||||
|
mkdir -p "$BUILD_DIR" |
||||
|
rm -rf "$APP_BUNDLE" |
||||
|
|
||||
|
# 1. Compile Swift sources |
||||
|
swiftc -O -target arm64-apple-macosx13.0 \ |
||||
|
-framework Cocoa -framework AVFoundation -framework Carbon \ |
||||
|
"$PROJECT_DIR"/src/*.swift \ |
||||
|
-o "$BUILD_DIR/SonioxVoice" |
||||
|
|
||||
|
# 2. Assemble .app bundle |
||||
|
mkdir -p "$APP_BUNDLE/Contents/MacOS" |
||||
|
mkdir -p "$APP_BUNDLE/Contents/Resources" |
||||
|
|
||||
|
cp "$BUILD_DIR/SonioxVoice" "$APP_BUNDLE/Contents/MacOS/SonioxVoice" |
||||
|
cp "$PROJECT_DIR/Info.plist" "$APP_BUNDLE/Contents/Info.plist" |
||||
|
cp "$PROJECT_DIR/resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns" |
||||
|
|
||||
|
# 3. Ad-hoc Codesign |
||||
|
codesign --force --deep --sign - "$APP_BUNDLE" |
||||
|
|
||||
|
echo "✅ App bundle assembled at $APP_BUNDLE" |
||||
|
|
||||
|
# 4. Create DMG Installer |
||||
|
echo "📦 Creating DMG Installer..." |
||||
|
DMG_STAGING="$BUILD_DIR/dmg_staging" |
||||
|
rm -rf "$DMG_STAGING" "$BUILD_DIR/$DMG_NAME" |
||||
|
mkdir -p "$DMG_STAGING" |
||||
|
|
||||
|
cp -R "$APP_BUNDLE" "$DMG_STAGING/" |
||||
|
ln -s /Applications "$DMG_STAGING/Applications" |
||||
|
|
||||
|
hdiutil create -volname "Soniox Voice" -srcfolder "$DMG_STAGING" -ov -format UDZO "$BUILD_DIR/$DMG_NAME" |
||||
|
echo "✅ DMG created at $BUILD_DIR/$DMG_NAME" |
||||
|
|
||||
|
# 5. Create ZIP Archive |
||||
|
cd "$BUILD_DIR" |
||||
|
zip -r -y "SonioxVoice-v1.0.0.zip" "$APP_NAME.app" |
||||
|
|
||||
|
# 6. Install to /Applications on Mac |
||||
|
echo "🚀 Installing to /Applications/$APP_NAME.app..." |
||||
|
rm -rf "/Applications/$APP_NAME.app" |
||||
|
cp -R "$APP_BUNDLE" "/Applications/$APP_NAME.app" |
||||
|
|
||||
|
echo "🎉 All Done Successfully!" |
||||
|
ls -lh "$BUILD_DIR" |
||||
|
After Width: 128 | Height: 128 | Size: 10 KiB |
|
After Width: 256 | Height: 256 | Size: 23 KiB |
|
After Width: 16 | Height: 16 | Size: 686 B |
|
After Width: 32 | Height: 32 | Size: 1.9 KiB |
|
After Width: 256 | Height: 256 | Size: 23 KiB |
|
After Width: 512 | Height: 512 | Size: 47 KiB |
|
After Width: 32 | Height: 32 | Size: 1.9 KiB |
|
After Width: 64 | Height: 64 | Size: 4.5 KiB |
|
After Width: 512 | Height: 512 | Size: 47 KiB |
|
After Width: 1024 | Height: 1024 | Size: 31 KiB |
|
After Width: 1024 | Height: 1024 | Size: 31 KiB |
@ -0,0 +1,246 @@ |
|||||
|
import Cocoa |
||||
|
import AVFoundation |
||||
|
import ApplicationServices |
||||
|
import Network |
||||
|
|
||||
|
public final class AppDelegate: NSObject, NSApplicationDelegate { |
||||
|
private var statusBarController: StatusBarController! |
||||
|
private var audioRecorder = AudioRecorder() |
||||
|
private var activeSession: SonioxLiveSession? |
||||
|
private var isBusyFinalizing = false |
||||
|
private var currentAudioLevel: Float = 0.0 |
||||
|
private var latestPartialText: String? = nil |
||||
|
|
||||
|
// Remote Android Phone Local Receiver (Port 8999) |
||||
|
private var remoteListener: NWListener? |
||||
|
|
||||
|
public func applicationDidFinishLaunching(_ notification: Notification) { |
||||
|
if UserDefaults.standard.object(forKey: "SonioxPlaySounds") == nil { |
||||
|
UserDefaults.standard.set(true, forKey: "SonioxPlaySounds") |
||||
|
} |
||||
|
|
||||
|
statusBarController = StatusBarController() |
||||
|
statusBarController.onToggleRecording = { [weak self] in |
||||
|
self?.toggleRecording() |
||||
|
} |
||||
|
|
||||
|
// Push-To-Talk / Toggle Hotkey Setup |
||||
|
HotkeyManager.shared.onHotkeyPressed = { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
if HotkeyManager.shared.currentMode == .pushToTalk { |
||||
|
if !self.audioRecorder.isRecording { |
||||
|
self.startRecording() |
||||
|
} |
||||
|
} else { |
||||
|
self.toggleRecording() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
HotkeyManager.shared.onHotkeyReleased = { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
if HotkeyManager.shared.currentMode == .pushToTalk { |
||||
|
if self.audioRecorder.isRecording { |
||||
|
self.stopRecordingAndTranscribe() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
audioRecorder.onAudioLevelUpdate = { [weak self] level in |
||||
|
guard let self = self else { return } |
||||
|
self.currentAudioLevel = level |
||||
|
if self.audioRecorder.isRecording { |
||||
|
HUDOverlayController.shared.show(state: .recording(level: level, liveText: self.latestPartialText)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
audioRecorder.onAudioChunkAvailable = { [weak self] chunk in |
||||
|
self?.activeSession?.sendAudioChunk(chunk) |
||||
|
} |
||||
|
|
||||
|
SonioxSessionPool.shared.prewarmNextSession() |
||||
|
startRemotePasteServer() |
||||
|
|
||||
|
// Connect to Linux Persistent Gateway (116.16.16.19:8999/ws/mac) |
||||
|
RelayClient.shared.onPasteReceived = { [weak self] text in |
||||
|
guard let self = self else { return } |
||||
|
print("AppDelegate: 📥 Clean Remote Dictation Received: '\(text)'") |
||||
|
self.playSystemSound(name: "Tink") |
||||
|
self.pasteTextToFrontmostApp(text: text) |
||||
|
} |
||||
|
RelayClient.shared.start() |
||||
|
|
||||
|
checkInitialPermissions() |
||||
|
} |
||||
|
|
||||
|
private func startRemotePasteServer() { |
||||
|
do { |
||||
|
let port: NWEndpoint.Port = 8999 |
||||
|
let listener = try NWListener(using: .tcp, on: port) |
||||
|
|
||||
|
listener.newConnectionHandler = { [weak self] connection in |
||||
|
guard let self = self else { return } |
||||
|
connection.start(queue: .main) |
||||
|
self.handleRemoteConnection(connection) |
||||
|
} |
||||
|
|
||||
|
listener.start(queue: .main) |
||||
|
self.remoteListener = listener |
||||
|
print("RemotePasteServer: Listening on 0.0.0.0:8999") |
||||
|
} catch { |
||||
|
print("RemotePasteServer: Failed to bind port 8999:", error) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func handleRemoteConnection(_ connection: NWConnection) { |
||||
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, _, _ in |
||||
|
guard let self = self, let data = data, let reqStr = String(data: data, encoding: .utf8) else { |
||||
|
connection.cancel() |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if reqStr.contains("GET /health") || reqStr.contains("GET /status") { |
||||
|
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}" |
||||
|
connection.send(content: response.data(using: .utf8), completion: .contentProcessed({ _ in |
||||
|
connection.cancel() |
||||
|
})) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if reqStr.contains("POST /paste") { |
||||
|
if let bodyRange = reqStr.range(of: "\r\n\r\n") { |
||||
|
let bodyJsonStr = String(reqStr[bodyRange.upperBound...]) |
||||
|
if let bodyData = bodyJsonStr.data(using: .utf8), |
||||
|
let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any], |
||||
|
let text = json["text"] as? String { |
||||
|
|
||||
|
DispatchQueue.main.async { |
||||
|
self.playSystemSound(name: "Tink") |
||||
|
self.pasteTextToFrontmostApp(text: text) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: 19\r\n\r\n{\"status\":\"pasted\"}" |
||||
|
connection.send(content: response.data(using: .utf8), completion: .contentProcessed({ _ in |
||||
|
connection.cancel() |
||||
|
})) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
connection.cancel() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func checkInitialPermissions() { |
||||
|
audioRecorder.requestMicrophonePermission { granted in |
||||
|
if !granted { |
||||
|
print("Warning: Microphone permission not granted.") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func toggleRecording() { |
||||
|
if audioRecorder.isRecording { |
||||
|
stopRecordingAndTranscribe() |
||||
|
} else { |
||||
|
startRecording() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func startRecording() { |
||||
|
guard !audioRecorder.isRecording, !isBusyFinalizing else { return } |
||||
|
|
||||
|
latestPartialText = nil |
||||
|
playSystemSound(name: "Blow") |
||||
|
|
||||
|
let session = SonioxSessionPool.shared.acquireSession() |
||||
|
self.activeSession = session |
||||
|
|
||||
|
session.onPartialText = { [weak self] liveText in |
||||
|
guard let self = self, self.audioRecorder.isRecording else { return } |
||||
|
self.latestPartialText = liveText |
||||
|
HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, liveText: liveText)) |
||||
|
} |
||||
|
|
||||
|
session.onFinalResult = { [weak self] result in |
||||
|
guard let self = self else { return } |
||||
|
self.isBusyFinalizing = false |
||||
|
self.activeSession = nil |
||||
|
SonioxSessionPool.shared.prewarmNextSession() |
||||
|
|
||||
|
switch result { |
||||
|
case .success(let text): |
||||
|
self.playSystemSound(name: "Hero") |
||||
|
self.pasteTextToFrontmostApp(text: text) |
||||
|
case .failure(let error): |
||||
|
print("Soniox error:", error) |
||||
|
HUDOverlayController.shared.show(state: .error(message: error.localizedDescription)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
try audioRecorder.startRecording() |
||||
|
statusBarController.updateIcon(state: .recording) |
||||
|
statusBarController.buildMenu(isRecording: true) |
||||
|
HUDOverlayController.shared.show(state: .recording(level: 0.0, liveText: nil)) |
||||
|
} catch { |
||||
|
HUDOverlayController.shared.show(state: .error(message: error.localizedDescription)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func stopRecordingAndTranscribe() { |
||||
|
guard audioRecorder.isRecording, !isBusyFinalizing else { return } |
||||
|
isBusyFinalizing = true |
||||
|
|
||||
|
_ = audioRecorder.stopRecording() |
||||
|
statusBarController.updateIcon(state: .transcribing) |
||||
|
statusBarController.buildMenu(isRecording: false) |
||||
|
HUDOverlayController.shared.show(state: .transcribing) |
||||
|
|
||||
|
guard let session = self.activeSession else { |
||||
|
self.isBusyFinalizing = false |
||||
|
HUDOverlayController.shared.hide(animated: true) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
session.finalizeStream() |
||||
|
} |
||||
|
|
||||
|
/// Inserts text into whatever text field/app is currently focused without adding any newlines or enters |
||||
|
public func pasteTextToFrontmostApp(text: String) { |
||||
|
// 1. Strict single-line flattening (replaces \r\n, \n, \r, \t with single spaces) |
||||
|
var cleaned = text.components(separatedBy: .newlines).joined(separator: " ") |
||||
|
cleaned = cleaned.replacingOccurrences(of: "\t", with: " ") |
||||
|
cleaned = cleaned.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) |
||||
|
cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
|
||||
|
guard !cleaned.isEmpty else { return } |
||||
|
|
||||
|
HUDOverlayController.shared.show(state: .success(text: cleaned)) |
||||
|
|
||||
|
// 2. Put clean single line on system clipboard |
||||
|
let pasteboard = NSPasteboard.general |
||||
|
pasteboard.clearContents() |
||||
|
pasteboard.setString(cleaned, forType: .string) |
||||
|
|
||||
|
// 3. Synthesize pure Cmd+V event (KeyCode 9 = 'v') without any Enter/Return |
||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.035) { |
||||
|
let src = CGEventSource(stateID: .hidSystemState) |
||||
|
let vKeyCode: CGKeyCode = 9 // ANSI 'v' |
||||
|
|
||||
|
let keyDown = CGEvent(keyboardEventSource: src, virtualKey: vKeyCode, keyDown: true) |
||||
|
keyDown?.flags = .maskCommand |
||||
|
|
||||
|
let keyUp = CGEvent(keyboardEventSource: src, virtualKey: vKeyCode, keyDown: false) |
||||
|
keyUp?.flags = [] |
||||
|
|
||||
|
keyDown?.post(tap: .cghidEventTap) |
||||
|
keyUp?.post(tap: .cghidEventTap) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func playSystemSound(name: String) { |
||||
|
guard UserDefaults.standard.bool(forKey: "SonioxPlaySounds") else { return } |
||||
|
NSSound(named: name)?.play() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,165 @@ |
|||||
|
import Foundation |
||||
|
import AVFoundation |
||||
|
import CoreMedia |
||||
|
|
||||
|
public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate { |
||||
|
private var captureSession: AVCaptureSession? |
||||
|
private var audioOutput: AVCaptureAudioDataOutput? |
||||
|
private var audioConverter: AVAudioConverter? |
||||
|
private let targetFormat: AVAudioFormat |
||||
|
|
||||
|
private var pcmBuffer = Data() |
||||
|
private let lock = NSLock() |
||||
|
private let captureQueue = DispatchQueue(label: "com.soniox.audiocapture", qos: .userInteractive) |
||||
|
|
||||
|
public private(set) var isRecording = false |
||||
|
public var onAudioLevelUpdate: ((Float) -> Void)? |
||||
|
public var onAudioChunkAvailable: ((Data) -> Void)? |
||||
|
|
||||
|
public override init() { |
||||
|
self.targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: false)! |
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
public func requestMicrophonePermission(completion: @escaping (Bool) -> Void) { |
||||
|
switch AVCaptureDevice.authorizationStatus(for: .audio) { |
||||
|
case .authorized: |
||||
|
completion(true) |
||||
|
case .notDetermined: |
||||
|
AVCaptureDevice.requestAccess(for: .audio) { granted in |
||||
|
DispatchQueue.main.async { |
||||
|
completion(granted) |
||||
|
} |
||||
|
} |
||||
|
case .denied, .restricted: |
||||
|
completion(false) |
||||
|
@unknown default: |
||||
|
completion(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func startRecording() throws { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
|
||||
|
if isRecording { return } |
||||
|
pcmBuffer.removeAll() |
||||
|
|
||||
|
guard let device = AVCaptureDevice.default(for: .audio) else { |
||||
|
throw NSError(domain: "AudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "میکروفونی یافت نشد"]) |
||||
|
} |
||||
|
|
||||
|
let session = AVCaptureSession() |
||||
|
let input = try AVCaptureDeviceInput(device: device) |
||||
|
|
||||
|
if session.canAddInput(input) { |
||||
|
session.addInput(input) |
||||
|
} |
||||
|
|
||||
|
let output = AVCaptureAudioDataOutput() |
||||
|
output.setSampleBufferDelegate(self, queue: captureQueue) |
||||
|
|
||||
|
if session.canAddOutput(output) { |
||||
|
session.addOutput(output) |
||||
|
} |
||||
|
|
||||
|
self.captureSession = session |
||||
|
self.audioOutput = output |
||||
|
|
||||
|
session.startRunning() |
||||
|
isRecording = true |
||||
|
print("AudioRecorder: Started recording with device:", device.localizedName) |
||||
|
} |
||||
|
|
||||
|
public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { |
||||
|
guard isRecording else { return } |
||||
|
|
||||
|
guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) else { return } |
||||
|
let srcFormat = AVAudioFormat(cmAudioFormatDescription: formatDesc) |
||||
|
|
||||
|
if audioConverter == nil || audioConverter?.inputFormat != srcFormat { |
||||
|
audioConverter = AVAudioConverter(from: srcFormat, to: targetFormat) |
||||
|
} |
||||
|
guard let converter = self.audioConverter else { return } |
||||
|
|
||||
|
guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return } |
||||
|
let numSamples = CMSampleBufferGetNumSamples(sampleBuffer) |
||||
|
guard numSamples > 0 else { return } |
||||
|
|
||||
|
guard let srcBuffer = AVAudioPCMBuffer(pcmFormat: srcFormat, frameCapacity: AVAudioFrameCount(numSamples)) else { return } |
||||
|
srcBuffer.frameLength = AVAudioFrameCount(numSamples) |
||||
|
|
||||
|
var lengthAtOffset = 0 |
||||
|
var totalLength = 0 |
||||
|
var dataPointer: UnsafeMutablePointer<Int8>? |
||||
|
|
||||
|
if CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: &lengthAtOffset, totalLengthOut: &totalLength, dataPointerOut: &dataPointer) == noErr, |
||||
|
let dataPtr = dataPointer { |
||||
|
if let floatData = srcBuffer.floatChannelData?[0] { |
||||
|
memcpy(floatData, dataPtr, min(totalLength, Int(srcBuffer.frameLength) * 4)) |
||||
|
} else if let int16Data = srcBuffer.int16ChannelData?[0] { |
||||
|
memcpy(int16Data, dataPtr, min(totalLength, Int(srcBuffer.frameLength) * 2)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
let outCapacity = AVAudioFrameCount(Double(numSamples) * (16000.0 / srcFormat.sampleRate)) + 128 |
||||
|
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outCapacity) else { return } |
||||
|
|
||||
|
var error: NSError? |
||||
|
var haveData = true |
||||
|
let status = converter.convert(to: outBuffer, error: &error) { inNumPackets, outStatus in |
||||
|
if haveData { |
||||
|
haveData = false |
||||
|
outStatus.pointee = .haveData |
||||
|
return srcBuffer |
||||
|
} else { |
||||
|
outStatus.pointee = .noDataNow |
||||
|
return nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if status == .haveData || status == .inputRanDry { |
||||
|
let frameLen = Int(outBuffer.frameLength) |
||||
|
if frameLen > 0, let int16Ptr = outBuffer.int16ChannelData?[0] { |
||||
|
let bytesCount = frameLen * MemoryLayout<Int16>.size |
||||
|
let data = Data(bytes: int16Ptr, count: bytesCount) |
||||
|
|
||||
|
lock.lock() |
||||
|
pcmBuffer.append(data) |
||||
|
lock.unlock() |
||||
|
|
||||
|
// Stream live audio chunk to WebSocket immediately |
||||
|
onAudioChunkAvailable?(data) |
||||
|
|
||||
|
// Calculate RMS level for HUD |
||||
|
var sumSquare: Float = 0 |
||||
|
for i in 0..<frameLen { |
||||
|
let sample = Float(int16Ptr[i]) / 32768.0 |
||||
|
sumSquare += sample * sample |
||||
|
} |
||||
|
let rms = sqrt(sumSquare / Float(frameLen)) |
||||
|
let normalizedLevel = min(max(rms * 4.5, 0.0), 1.0) |
||||
|
|
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.onAudioLevelUpdate?(normalizedLevel) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func stopRecording() -> Data { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
|
||||
|
if !isRecording { return pcmBuffer } |
||||
|
isRecording = false |
||||
|
|
||||
|
captureSession?.stopRunning() |
||||
|
captureSession = nil |
||||
|
audioOutput = nil |
||||
|
audioConverter = nil |
||||
|
|
||||
|
print("AudioRecorder: Stopped. Total PCM captured: \(pcmBuffer.count) bytes (\(Double(pcmBuffer.count)/32000.0) seconds)") |
||||
|
return pcmBuffer |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,180 @@ |
|||||
|
import Cocoa |
||||
|
|
||||
|
public enum HUDState { |
||||
|
case hidden |
||||
|
case recording(level: Float, liveText: String? = nil) |
||||
|
case transcribing |
||||
|
case success(text: String) |
||||
|
case error(message: String) |
||||
|
} |
||||
|
|
||||
|
public final class HUDOverlayController { |
||||
|
public static let shared = HUDOverlayController() |
||||
|
|
||||
|
private var window: NSPanel? |
||||
|
private var visualEffectView: NSVisualEffectView? |
||||
|
private var iconImageView: NSImageView? |
||||
|
private var titleLabel: NSTextField? |
||||
|
private var subtitleLabel: NSTextField? |
||||
|
|
||||
|
private var hideTimer: Timer? |
||||
|
|
||||
|
private init() { |
||||
|
setupWindow() |
||||
|
} |
||||
|
|
||||
|
private func setupWindow() { |
||||
|
let width: CGFloat = 460 |
||||
|
let height: CGFloat = 80 |
||||
|
|
||||
|
let panel = NSPanel( |
||||
|
contentRect: NSRect(x: 0, y: 0, width: width, height: height), |
||||
|
styleMask: [.borderless, .nonactivatingPanel], |
||||
|
backing: .buffered, |
||||
|
defer: false |
||||
|
) |
||||
|
|
||||
|
panel.level = .floating |
||||
|
panel.isOpaque = false |
||||
|
panel.backgroundColor = .clear |
||||
|
panel.hasShadow = true |
||||
|
panel.ignoresMouseEvents = true |
||||
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] |
||||
|
|
||||
|
let visualEffect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: width, height: height)) |
||||
|
visualEffect.material = .hudWindow |
||||
|
visualEffect.blendingMode = .behindWindow |
||||
|
visualEffect.state = .active |
||||
|
visualEffect.wantsLayer = true |
||||
|
visualEffect.layer?.cornerRadius = 24 |
||||
|
visualEffect.layer?.masksToBounds = true |
||||
|
visualEffect.layer?.borderWidth = 1.2 |
||||
|
visualEffect.layer?.borderColor = NSColor.white.withAlphaComponent(0.25).cgColor |
||||
|
|
||||
|
// Icon Image View |
||||
|
let iconView = NSImageView(frame: NSRect(x: 18, y: (height - 42) / 2, width: 42, height: 42)) |
||||
|
iconView.imageScaling = .scaleProportionallyUpOrDown |
||||
|
|
||||
|
// Title Label |
||||
|
let tLabel = NSTextField(frame: NSRect(x: 72, y: 40, width: width - 90, height: 24)) |
||||
|
tLabel.isBezeled = false |
||||
|
tLabel.drawsBackground = false |
||||
|
tLabel.isEditable = false |
||||
|
tLabel.isSelectable = false |
||||
|
tLabel.font = NSFont.systemFont(ofSize: 14, weight: .bold) |
||||
|
tLabel.textColor = .white |
||||
|
tLabel.alignment = .left |
||||
|
|
||||
|
// Subtitle / Preview Label |
||||
|
let sLabel = NSTextField(frame: NSRect(x: 72, y: 14, width: width - 90, height: 22)) |
||||
|
sLabel.isBezeled = false |
||||
|
sLabel.drawsBackground = false |
||||
|
sLabel.isEditable = false |
||||
|
sLabel.isSelectable = false |
||||
|
sLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) |
||||
|
sLabel.textColor = NSColor.white.withAlphaComponent(0.9) |
||||
|
sLabel.alignment = .left |
||||
|
|
||||
|
visualEffect.addSubview(iconView) |
||||
|
visualEffect.addSubview(tLabel) |
||||
|
visualEffect.addSubview(sLabel) |
||||
|
|
||||
|
panel.contentView = visualEffect |
||||
|
|
||||
|
self.window = panel |
||||
|
self.visualEffectView = visualEffect |
||||
|
self.iconImageView = iconView |
||||
|
self.titleLabel = tLabel |
||||
|
self.subtitleLabel = sLabel |
||||
|
} |
||||
|
|
||||
|
public func show(state: HUDState) { |
||||
|
hideTimer?.invalidate() |
||||
|
hideTimer = nil |
||||
|
|
||||
|
guard let panel = self.window else { return } |
||||
|
|
||||
|
// Position at bottom center of current active screen |
||||
|
if let screen = NSScreen.main { |
||||
|
let screenRect = screen.visibleFrame |
||||
|
let x = screenRect.origin.x + (screenRect.width - panel.frame.width) / 2 |
||||
|
let y = screenRect.origin.y + 60 |
||||
|
panel.setFrameOrigin(NSPoint(x: x, y: y)) |
||||
|
} |
||||
|
|
||||
|
switch state { |
||||
|
case .hidden: |
||||
|
hide(animated: true) |
||||
|
return |
||||
|
|
||||
|
case .recording(let level, let liveText): |
||||
|
let micImage = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: "Recording") |
||||
|
let scale: CGFloat = 22.0 + CGFloat(level) * 6.0 |
||||
|
iconImageView?.image = micImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: scale, weight: .bold)) |
||||
|
iconImageView?.contentTintColor = NSColor.systemRed |
||||
|
titleLabel?.stringValue = "🎙️ در حال تبدیل زنده صدا..." |
||||
|
|
||||
|
if let live = liveText, !live.isEmpty { |
||||
|
let preview = live.count > 46 ? "..." + String(live.suffix(46)) : live |
||||
|
subtitleLabel?.stringValue = preview |
||||
|
subtitleLabel?.textColor = NSColor.systemGreen.withAlphaComponent(0.95) |
||||
|
} else { |
||||
|
let mode = HotkeyManager.shared.currentMode == .toggle ? "پایان: کلیک مجدد" : "رها کردن کلید ⌥ جهت درج متن" |
||||
|
subtitleLabel?.stringValue = mode |
||||
|
subtitleLabel?.textColor = NSColor.systemRed.withAlphaComponent(0.9) |
||||
|
} |
||||
|
|
||||
|
case .transcribing: |
||||
|
let waveImage = NSImage(systemSymbolName: "waveform.badge.magnifyingglass", accessibilityDescription: "Transcribing") |
||||
|
iconImageView?.image = waveImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 22, weight: .bold)) |
||||
|
iconImageView?.contentTintColor = NSColor.systemOrange |
||||
|
titleLabel?.stringValue = "⚡ درج آنی متن..." |
||||
|
subtitleLabel?.stringValue = "در حال تایپ در مکاننما" |
||||
|
subtitleLabel?.textColor = NSColor.systemOrange.withAlphaComponent(0.9) |
||||
|
|
||||
|
case .success(let text): |
||||
|
let checkImage = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: "Done") |
||||
|
iconImageView?.image = checkImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 22, weight: .bold)) |
||||
|
iconImageView?.contentTintColor = NSColor.systemGreen |
||||
|
titleLabel?.stringValue = "✨ متن درج شد" |
||||
|
let preview = text.count > 46 ? String(text.prefix(46)) + "..." : text |
||||
|
subtitleLabel?.stringValue = preview.isEmpty ? "کلیپبورد بهروز شد" : preview |
||||
|
subtitleLabel?.textColor = NSColor.white.withAlphaComponent(0.95) |
||||
|
|
||||
|
hideTimer = Timer.scheduledTimer(withTimeInterval: 1.2, repeats: false) { [weak self] _ in |
||||
|
self?.hide(animated: true) |
||||
|
} |
||||
|
|
||||
|
case .error(let msg): |
||||
|
let errImage = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Error") |
||||
|
iconImageView?.image = errImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 22, weight: .bold)) |
||||
|
iconImageView?.contentTintColor = NSColor.systemYellow |
||||
|
titleLabel?.stringValue = "⚠️ خطا در تبدیل صوت" |
||||
|
subtitleLabel?.stringValue = msg |
||||
|
subtitleLabel?.textColor = NSColor.systemYellow.withAlphaComponent(0.9) |
||||
|
|
||||
|
hideTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in |
||||
|
self?.hide(animated: true) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
panel.alphaValue = 1.0 |
||||
|
panel.orderFrontRegardless() |
||||
|
} |
||||
|
|
||||
|
public func hide(animated: Bool) { |
||||
|
guard let panel = self.window, panel.isVisible else { return } |
||||
|
|
||||
|
if animated { |
||||
|
NSAnimationContext.runAnimationGroup({ context in |
||||
|
context.duration = 0.15 |
||||
|
panel.animator().alphaValue = 0.0 |
||||
|
}, completionHandler: { |
||||
|
panel.orderOut(nil) |
||||
|
}) |
||||
|
} else { |
||||
|
panel.alphaValue = 0.0 |
||||
|
panel.orderOut(nil) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,261 @@ |
|||||
|
import Cocoa |
||||
|
import Carbon |
||||
|
import ApplicationServices |
||||
|
|
||||
|
public enum DictationMode: String, CaseIterable { |
||||
|
case pushToTalk = "pushToTalk" // Hold to record, release to transcribe |
||||
|
case toggle = "toggle" // Press once to start, press again to stop |
||||
|
|
||||
|
public var localizedTitle: String { |
||||
|
switch self { |
||||
|
case .pushToTalk: |
||||
|
return "نگهداشتن برای صحبت (Hold to Talk)" |
||||
|
case .toggle: |
||||
|
return "فشردن برای شروع / توقف (Toggle)" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public enum HotkeyPreset: String, CaseIterable { |
||||
|
case option = "Option (⌥ نگهداشتن)" |
||||
|
case capsLock = "Caps Lock" |
||||
|
case controlSpace = "Control + Space" |
||||
|
case optionSpace = "Option + Space" |
||||
|
case cmdShiftSpace = "Cmd + Shift + Space" |
||||
|
case f8 = "F8" |
||||
|
case f5 = "F5" |
||||
|
|
||||
|
public var keyCode: UInt32 { |
||||
|
switch self { |
||||
|
case .option: |
||||
|
return UInt32(kVK_Option) // 58 |
||||
|
case .capsLock: |
||||
|
return UInt32(kVK_CapsLock) // 57 |
||||
|
case .controlSpace, .optionSpace, .cmdShiftSpace: |
||||
|
return UInt32(kVK_Space) |
||||
|
case .f8: |
||||
|
return UInt32(kVK_F8) |
||||
|
case .f5: |
||||
|
return UInt32(kVK_F5) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public var carbonModifiers: UInt32 { |
||||
|
switch self { |
||||
|
case .option, .capsLock: |
||||
|
return 0 |
||||
|
case .controlSpace: |
||||
|
return UInt32(controlKey) |
||||
|
case .optionSpace: |
||||
|
return UInt32(optionKey) |
||||
|
case .cmdShiftSpace: |
||||
|
return UInt32(cmdKey | shiftKey) |
||||
|
case .f8, .f5: |
||||
|
return 0 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public final class HotkeyManager { |
||||
|
public static let shared = HotkeyManager() |
||||
|
|
||||
|
public var onHotkeyPressed: (() -> Void)? |
||||
|
public var onHotkeyReleased: (() -> Void)? |
||||
|
|
||||
|
private var hotKeyRef: EventHotKeyRef? |
||||
|
private var eventHandlerRef: EventHandlerRef? |
||||
|
private var eventTapPort: CFMachPort? |
||||
|
private var runLoopSource: CFRunLoopSource? |
||||
|
private var globalMonitor: Any? |
||||
|
|
||||
|
private var isOptionPhysicallyDown = false |
||||
|
private var isCapsLockPhysicallyDown = false |
||||
|
private var isKeyDown = false |
||||
|
|
||||
|
public var currentPreset: HotkeyPreset { |
||||
|
get { |
||||
|
let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.option.rawValue |
||||
|
return HotkeyPreset(rawValue: val) ?? .option |
||||
|
} |
||||
|
set { |
||||
|
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset") |
||||
|
registerHotkeys() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public var currentMode: DictationMode { |
||||
|
get { |
||||
|
let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.pushToTalk.rawValue |
||||
|
return DictationMode(rawValue: val) ?? .pushToTalk |
||||
|
} |
||||
|
set { |
||||
|
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxDictationMode") |
||||
|
registerHotkeys() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private init() {} |
||||
|
|
||||
|
public func registerHotkeys() { |
||||
|
unregisterHotkeys() |
||||
|
|
||||
|
let preset = currentPreset |
||||
|
print("Registering Hotkey for preset:", preset.rawValue, "mode:", currentMode.rawValue) |
||||
|
|
||||
|
// 1. Carbon HotKey for multi-key combos (Control+Space, etc.) |
||||
|
if preset != .option && preset != .capsLock { |
||||
|
var eventTypes = [ |
||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), |
||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)) |
||||
|
] |
||||
|
|
||||
|
let selfPtr = Unmanaged.passUnretained(self).toOpaque() |
||||
|
let handlerCallback: EventHandlerUPP = { (_, eventRef, userData) -> OSStatus in |
||||
|
guard let eventRef = eventRef, let userData = userData else { return noErr } |
||||
|
let manager = Unmanaged<HotkeyManager>.fromOpaque(userData).takeUnretainedValue() |
||||
|
|
||||
|
let kind = GetEventKind(eventRef) |
||||
|
if kind == UInt32(kEventHotKeyPressed) { |
||||
|
DispatchQueue.main.async { |
||||
|
manager.onHotkeyPressed?() |
||||
|
} |
||||
|
} else if kind == UInt32(kEventHotKeyReleased) { |
||||
|
DispatchQueue.main.async { |
||||
|
manager.onHotkeyReleased?() |
||||
|
} |
||||
|
} |
||||
|
return noErr |
||||
|
} |
||||
|
|
||||
|
InstallEventHandler(GetApplicationEventTarget(), handlerCallback, 2, &eventTypes, selfPtr, &eventHandlerRef) |
||||
|
|
||||
|
let hotKeyID = EventHotKeyID(signature: OSType(0x534F4E58), id: 1) |
||||
|
RegisterEventHotKey( |
||||
|
preset.keyCode, |
||||
|
preset.carbonModifiers, |
||||
|
hotKeyID, |
||||
|
GetApplicationEventTarget(), |
||||
|
0, |
||||
|
&hotKeyRef |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// 2. Global Event Tap for single modifier keys (Option, CapsLock) |
||||
|
setupEventTap() |
||||
|
|
||||
|
// 3. Secondary NSEvent Global Monitor as backup |
||||
|
setupGlobalMonitor() |
||||
|
} |
||||
|
|
||||
|
private func setupEventTap() { |
||||
|
let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) |
||||
|
let selfPtr = Unmanaged.passUnretained(self).toOpaque() |
||||
|
|
||||
|
let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in |
||||
|
guard let refcon = refcon else { return Unmanaged.passRetained(event) } |
||||
|
let manager = Unmanaged<HotkeyManager>.fromOpaque(refcon).takeUnretainedValue() |
||||
|
|
||||
|
let flags = event.flags.rawValue |
||||
|
let keyCode = event.getIntegerValueField(.keyboardEventKeycode) |
||||
|
|
||||
|
// 1. Option Key (Hold / Push-to-Talk) |
||||
|
if manager.currentPreset == .option { |
||||
|
let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0 |
||||
|
if isAlt != manager.isOptionPhysicallyDown { |
||||
|
manager.isOptionPhysicallyDown = isAlt |
||||
|
DispatchQueue.main.async { |
||||
|
if isAlt { |
||||
|
manager.onHotkeyPressed?() |
||||
|
} else { |
||||
|
if manager.currentMode == .pushToTalk { |
||||
|
manager.onHotkeyReleased?() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 2. CapsLock Key |
||||
|
else if manager.currentPreset == .capsLock { |
||||
|
if keyCode == 57 { |
||||
|
if !manager.isCapsLockPhysicallyDown { |
||||
|
manager.isCapsLockPhysicallyDown = true |
||||
|
DispatchQueue.main.async { |
||||
|
manager.onHotkeyPressed?() |
||||
|
} |
||||
|
} else { |
||||
|
manager.isCapsLockPhysicallyDown = false |
||||
|
if manager.currentMode == .pushToTalk { |
||||
|
DispatchQueue.main.async { |
||||
|
manager.onHotkeyReleased?() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return Unmanaged.passRetained(event) |
||||
|
} |
||||
|
|
||||
|
if let tap = CGEvent.tapCreate( |
||||
|
tap: .cghidEventTap, |
||||
|
place: .headInsertEventTap, |
||||
|
options: .defaultTap, |
||||
|
eventsOfInterest: CGEventMask(mask), |
||||
|
callback: callback, |
||||
|
userInfo: selfPtr |
||||
|
) { |
||||
|
self.eventTapPort = tap |
||||
|
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) |
||||
|
self.runLoopSource = source |
||||
|
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) |
||||
|
CGEvent.tapEnable(tap: tap, enable: true) |
||||
|
print("CGEventTap created and enabled successfully.") |
||||
|
} else { |
||||
|
print("CGEventTap creation failed. Falling back to NSEvent global monitor.") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func setupGlobalMonitor() { |
||||
|
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
// Only use as fallback if event tap is inactive |
||||
|
if self.eventTapPort == nil { |
||||
|
if self.currentPreset == .option { |
||||
|
let isAlt = event.modifierFlags.contains(.option) |
||||
|
if isAlt != self.isOptionPhysicallyDown { |
||||
|
self.isOptionPhysicallyDown = isAlt |
||||
|
if isAlt { |
||||
|
self.onHotkeyPressed?() |
||||
|
} else if self.currentMode == .pushToTalk { |
||||
|
self.onHotkeyReleased?() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func unregisterHotkeys() { |
||||
|
if let ref = hotKeyRef { |
||||
|
UnregisterEventHotKey(ref) |
||||
|
hotKeyRef = nil |
||||
|
} |
||||
|
if let handler = eventHandlerRef { |
||||
|
RemoveEventHandler(handler) |
||||
|
eventHandlerRef = nil |
||||
|
} |
||||
|
if let tap = eventTapPort, let source = runLoopSource { |
||||
|
CGEvent.tapEnable(tap: tap, enable: false) |
||||
|
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) |
||||
|
self.eventTapPort = nil |
||||
|
self.runLoopSource = nil |
||||
|
} |
||||
|
if let mon = globalMonitor { |
||||
|
NSEvent.removeMonitor(mon) |
||||
|
self.globalMonitor = nil |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,138 @@ |
|||||
|
import Foundation |
||||
|
import Cocoa |
||||
|
|
||||
|
public final class RelayClient: NSObject, URLSessionWebSocketDelegate { |
||||
|
public static let shared = RelayClient() |
||||
|
|
||||
|
private let gatewayUrl = URL(string: "ws://116.16.16.19:8999/ws/mac")! |
||||
|
private var webSocketTask: URLSessionWebSocketTask? |
||||
|
private var urlSession: URLSession! |
||||
|
|
||||
|
private var isRunning = false |
||||
|
private var isConnected = false |
||||
|
private var reconnectTimer: Timer? |
||||
|
private var pingTimer: Timer? |
||||
|
|
||||
|
public var onPasteReceived: ((String) -> Void)? |
||||
|
|
||||
|
override private init() { |
||||
|
super.init() |
||||
|
let config = URLSessionConfiguration.default |
||||
|
config.waitsForConnectivity = true |
||||
|
config.timeoutIntervalForRequest = 30 |
||||
|
config.timeoutIntervalForResource = 300 |
||||
|
self.urlSession = URLSession(configuration: config, delegate: self, delegateQueue: .main) |
||||
|
} |
||||
|
|
||||
|
public func start() { |
||||
|
guard !isRunning else { return } |
||||
|
isRunning = true |
||||
|
print("RelayClient: Starting persistent gateway connection to \(gatewayUrl)...") |
||||
|
connect() |
||||
|
} |
||||
|
|
||||
|
public func stop() { |
||||
|
isRunning = false |
||||
|
reconnectTimer?.invalidate() |
||||
|
reconnectTimer = nil |
||||
|
pingTimer?.invalidate() |
||||
|
pingTimer = nil |
||||
|
webSocketTask?.cancel(with: .goingAway, reason: nil) |
||||
|
webSocketTask = nil |
||||
|
isConnected = false |
||||
|
} |
||||
|
|
||||
|
private func connect() { |
||||
|
guard isRunning else { return } |
||||
|
webSocketTask?.cancel() |
||||
|
|
||||
|
var request = URLRequest(url: gatewayUrl) |
||||
|
request.timeoutInterval = 10 |
||||
|
request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) SonioxVoice/1.0", forHTTPHeaderField: "User-Agent") |
||||
|
|
||||
|
webSocketTask = urlSession.webSocketTask(with: request) |
||||
|
webSocketTask?.resume() |
||||
|
|
||||
|
listenForMessages() |
||||
|
startPingTimer() |
||||
|
} |
||||
|
|
||||
|
private func listenForMessages() { |
||||
|
webSocketTask?.receive { [weak self] result in |
||||
|
guard let self = self, self.isRunning else { return } |
||||
|
|
||||
|
switch result { |
||||
|
case .success(let message): |
||||
|
self.isConnected = true |
||||
|
switch message { |
||||
|
case .string(let text): |
||||
|
self.handleMessageString(text) |
||||
|
case .data(let data): |
||||
|
if let text = String(data: data, encoding: .utf8) { |
||||
|
self.handleMessageString(text) |
||||
|
} |
||||
|
@unknown default: |
||||
|
break |
||||
|
} |
||||
|
// Continue listening |
||||
|
self.listenForMessages() |
||||
|
|
||||
|
case .failure(let error): |
||||
|
print("RelayClient: Connection error: \(error.localizedDescription)") |
||||
|
self.isConnected = false |
||||
|
self.scheduleReconnect() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func handleMessageString(_ text: String) { |
||||
|
guard let data = text.data(using: .utf8), |
||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let action = json["action"] as? String |
||||
|
if action == "paste", let pasteText = json["text"] as? String { |
||||
|
let trimmed = pasteText.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
if !trimmed.isEmpty { |
||||
|
print("RelayClient: ⚡ Received remote paste text from Gateway: '\(trimmed.prefix(30))...'") |
||||
|
DispatchQueue.main.async { |
||||
|
self.onPasteReceived?(trimmed) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func startPingTimer() { |
||||
|
pingTimer?.invalidate() |
||||
|
pingTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in |
||||
|
guard let self = self, self.isRunning else { return } |
||||
|
self.webSocketTask?.send(.string("{\"type\":\"ping\"}")) { _ in } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func scheduleReconnect() { |
||||
|
guard isRunning else { return } |
||||
|
pingTimer?.invalidate() |
||||
|
pingTimer = nil |
||||
|
|
||||
|
if reconnectTimer == nil || !(reconnectTimer?.isValid ?? false) { |
||||
|
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in |
||||
|
self?.reconnectTimer = nil |
||||
|
self?.connect() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// URLSessionWebSocketDelegate |
||||
|
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { |
||||
|
print("RelayClient: 🟢 Persistent WebSocket Connected to Server Gateway!") |
||||
|
self.isConnected = true |
||||
|
} |
||||
|
|
||||
|
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { |
||||
|
print("RelayClient: 🔴 WebSocket Closed (code: \(closeCode.rawValue))") |
||||
|
self.isConnected = false |
||||
|
self.scheduleReconnect() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,321 @@ |
|||||
|
import Foundation |
||||
|
|
||||
|
public final class SonioxLiveSession { |
||||
|
private let primaryWsBase = "wss://translate.compare.soniox.com/compare/api/compare-websocket" |
||||
|
private let fallbackWsBase = "wss://stt.compare.soniox.com/compare/api/compare-websocket" |
||||
|
private let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" |
||||
|
private let origin = "https://translate.compare.soniox.com" |
||||
|
|
||||
|
private var webSocketTask: URLSessionWebSocketTask? |
||||
|
private var urlSession: URLSession? |
||||
|
private var isFinalizing = false |
||||
|
private var isClosed = false |
||||
|
private var isConnected = false |
||||
|
private let lock = NSLock() |
||||
|
|
||||
|
// Transcripts tracking |
||||
|
private var committedFinalTokens: [String] = [] |
||||
|
private var currentNonFinalTokens: [String] = [] |
||||
|
|
||||
|
public var onPartialText: ((String) -> Void)? |
||||
|
public var onFinalResult: ((Result<String, Error>) -> Void)? |
||||
|
public var onConnectionStateChanged: ((Bool) -> Void)? |
||||
|
|
||||
|
public var isReady: Bool { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
return isConnected && !isClosed && !isFinalizing |
||||
|
} |
||||
|
|
||||
|
public init(languageHints: [String] = ["fa", "en", "ar"]) { |
||||
|
let hints = languageHints.map { "language_hints=\($0)" }.joined(separator: "&") |
||||
|
let urlStr = "\(primaryWsBase)?\(hints)&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox" |
||||
|
guard let url = URL(string: urlStr) else { return } |
||||
|
|
||||
|
var request = URLRequest(url: url) |
||||
|
request.setValue(userAgent, forHTTPHeaderField: "User-Agent") |
||||
|
request.setValue(origin, forHTTPHeaderField: "Origin") |
||||
|
request.timeoutInterval = 20.0 |
||||
|
|
||||
|
let config = URLSessionConfiguration.default |
||||
|
config.waitsForConnectivity = true |
||||
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData |
||||
|
|
||||
|
let session = URLSession(configuration: config) |
||||
|
self.urlSession = session |
||||
|
let task = session.webSocketTask(with: request) |
||||
|
self.webSocketTask = task |
||||
|
task.resume() |
||||
|
|
||||
|
// Fast ping to verify connection |
||||
|
task.sendPing { [weak self] error in |
||||
|
guard let self = self else { return } |
||||
|
self.lock.lock() |
||||
|
if error == nil && !self.isClosed { |
||||
|
self.isConnected = true |
||||
|
self.lock.unlock() |
||||
|
self.onConnectionStateChanged?(true) |
||||
|
print("SonioxLiveSession: WebSocket connected successfully.") |
||||
|
} else { |
||||
|
self.lock.unlock() |
||||
|
if let error = error { |
||||
|
print("SonioxLiveSession: Ping failed:", error) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
startReceiving() |
||||
|
} |
||||
|
|
||||
|
public func sendAudioChunk(_ data: Data) { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
guard !isFinalizing, !isClosed, let task = webSocketTask else { return } |
||||
|
|
||||
|
let message = URLSessionWebSocketTask.Message.data(data) |
||||
|
task.send(message) { error in |
||||
|
if let error = error { |
||||
|
print("Error streaming audio chunk:", error) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func finalizeStream() { |
||||
|
lock.lock() |
||||
|
guard !isFinalizing, !isClosed, let task = webSocketTask else { |
||||
|
lock.unlock() |
||||
|
return |
||||
|
} |
||||
|
isFinalizing = true |
||||
|
lock.unlock() |
||||
|
|
||||
|
print("SonioxLiveSession: Sending finalize packet...") |
||||
|
let finalizeMsg = URLSessionWebSocketTask.Message.string("{\"type\": \"finalize\"}") |
||||
|
task.send(finalizeMsg) { [weak self] error in |
||||
|
if let error = error { |
||||
|
print("Error sending finalize:", error) |
||||
|
self?.completeWithCurrentText() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Safety timeout fallback: finalize must complete within 800ms |
||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.80) { [weak self] in |
||||
|
self?.completeWithCurrentText() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func startReceiving() { |
||||
|
guard let task = webSocketTask else { return } |
||||
|
task.receive { [weak self] result in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
self.lock.lock() |
||||
|
if self.isClosed { |
||||
|
self.lock.unlock() |
||||
|
return |
||||
|
} |
||||
|
self.lock.unlock() |
||||
|
|
||||
|
switch result { |
||||
|
case .success(let message): |
||||
|
var textReceived: String? |
||||
|
switch message { |
||||
|
case .string(let str): |
||||
|
textReceived = str |
||||
|
case .data(let data): |
||||
|
textReceived = String(data: data, encoding: .utf8) |
||||
|
@unknown default: |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
if let text = textReceived, let jsonData = text.data(using: .utf8) { |
||||
|
self.parseMessage(jsonData) |
||||
|
} |
||||
|
self.startReceiving() |
||||
|
|
||||
|
case .failure(let error): |
||||
|
print("WebSocket receive status:", error) |
||||
|
self.completeWithCurrentText() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func parseMessage(_ data: Data) { |
||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } |
||||
|
|
||||
|
var gotFin = false |
||||
|
var newFinals: [String] = [] |
||||
|
var newNonFinals: [String] = [] |
||||
|
|
||||
|
if let type = json["type"] as? String, type == "data", |
||||
|
let parts = json["parts"] as? [[String: Any]] { |
||||
|
for part in parts { |
||||
|
let transStatus = part["translation_status"] as? String |
||||
|
if transStatus == "translation" { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
let pText = part["text"] as? String ?? "" |
||||
|
let isFinal = part["is_final"] as? Bool ?? false |
||||
|
|
||||
|
if pText.contains("<fin>") { |
||||
|
gotFin = true |
||||
|
let clean = pText.replacingOccurrences(of: "<fin>", with: "") |
||||
|
if !clean.isEmpty { |
||||
|
newFinals.append(clean) |
||||
|
} |
||||
|
} else if isFinal { |
||||
|
if !pText.isEmpty { |
||||
|
newFinals.append(pText) |
||||
|
} |
||||
|
} else { |
||||
|
if !pText.isEmpty { |
||||
|
newNonFinals.append(pText) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
lock.lock() |
||||
|
if !newFinals.isEmpty { |
||||
|
committedFinalTokens.append(contentsOf: newFinals) |
||||
|
} |
||||
|
currentNonFinalTokens = newNonFinals |
||||
|
|
||||
|
let fullCommitted = committedFinalTokens.joined() |
||||
|
let fullNonFinal = currentNonFinalTokens.joined() |
||||
|
let combined = fullCommitted + fullNonFinal |
||||
|
lock.unlock() |
||||
|
|
||||
|
if !combined.isEmpty { |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.onPartialText?(combined) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
let sessionEnded = json["session_ended"] as? Bool ?? false |
||||
|
let sessionDone = (json["type"] as? String) == "session_done" |
||||
|
|
||||
|
if gotFin || sessionEnded || sessionDone { |
||||
|
completeWithCurrentText() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func completeWithCurrentText() { |
||||
|
lock.lock() |
||||
|
if isClosed { |
||||
|
lock.unlock() |
||||
|
return |
||||
|
} |
||||
|
isClosed = true |
||||
|
let fullCommitted = committedFinalTokens.joined() |
||||
|
let fullNonFinal = currentNonFinalTokens.joined() |
||||
|
let rawCombined = fullCommitted.isEmpty ? fullNonFinal : (fullCommitted + fullNonFinal) |
||||
|
let cleaned = sanitizeText(rawCombined) |
||||
|
|
||||
|
let cb = onFinalResult |
||||
|
webSocketTask?.cancel(with: .normalClosure, reason: nil) |
||||
|
webSocketTask = nil |
||||
|
urlSession = nil |
||||
|
lock.unlock() |
||||
|
|
||||
|
DispatchQueue.main.async { |
||||
|
cb?(.success(cleaned)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func cancel() { |
||||
|
lock.lock() |
||||
|
isClosed = true |
||||
|
webSocketTask?.cancel(with: .normalClosure, reason: nil) |
||||
|
webSocketTask = nil |
||||
|
urlSession = nil |
||||
|
lock.unlock() |
||||
|
} |
||||
|
|
||||
|
private func sanitizeText(_ text: String) -> String { |
||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
if trimmed.isEmpty { return "" } |
||||
|
|
||||
|
let words = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } |
||||
|
if words.isEmpty { return "" } |
||||
|
|
||||
|
let faPattern = "[\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFF]" |
||||
|
let enPattern = "[a-zA-Z]" |
||||
|
|
||||
|
func matches(_ pattern: String, in str: String) -> Bool { |
||||
|
return str.range(of: pattern, options: .regularExpression) != nil |
||||
|
} |
||||
|
|
||||
|
var faCount = 0 |
||||
|
var enCount = 0 |
||||
|
for w in words { |
||||
|
if matches(faPattern, in: w) { faCount += 1 } |
||||
|
if matches(enPattern, in: w) { enCount += 1 } |
||||
|
} |
||||
|
|
||||
|
let total = faCount + enCount |
||||
|
if total == 0 { return trimmed } |
||||
|
|
||||
|
let faRatio = Double(faCount) / Double(total) |
||||
|
var cleaned: [String] = [] |
||||
|
|
||||
|
let stopWords: Set<String> = ["sex", "from", "no", "oh", "you", "all", "know", "that", "god", "loves", "men", "for", "day", "night", "mankind", "wanted", "moments"] |
||||
|
|
||||
|
if faRatio >= 0.25 { |
||||
|
for w in words { |
||||
|
if matches(enPattern, in: w) && !matches(faPattern, in: w) { |
||||
|
let cleanW = w.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".,!?:;،؛؟\"'()[]{}«»-–—")) |
||||
|
if stopWords.contains(cleanW) { continue } |
||||
|
if faRatio >= 0.70 { continue } |
||||
|
} |
||||
|
cleaned.append(w) |
||||
|
} |
||||
|
} else { |
||||
|
cleaned = words |
||||
|
} |
||||
|
|
||||
|
return cleaned.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Pre-warms and pools active WebSocket sessions for 0ms start latency |
||||
|
public final class SonioxSessionPool { |
||||
|
public static let shared = SonioxSessionPool() |
||||
|
|
||||
|
private var prewarmedSession: SonioxLiveSession? |
||||
|
private let lock = NSLock() |
||||
|
|
||||
|
private init() { |
||||
|
prewarmNextSession() |
||||
|
} |
||||
|
|
||||
|
public func prewarmNextSession() { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
|
||||
|
if let existing = prewarmedSession, existing.isReady { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let session = SonioxLiveSession() |
||||
|
self.prewarmedSession = session |
||||
|
} |
||||
|
|
||||
|
public func acquireSession() -> SonioxLiveSession { |
||||
|
lock.lock() |
||||
|
let session = prewarmedSession |
||||
|
prewarmedSession = nil |
||||
|
lock.unlock() |
||||
|
|
||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { [weak self] in |
||||
|
self?.prewarmNextSession() |
||||
|
} |
||||
|
|
||||
|
if let session = session, session.isReady { |
||||
|
return session |
||||
|
} |
||||
|
|
||||
|
return SonioxLiveSession() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,172 @@ |
|||||
|
import Cocoa |
||||
|
|
||||
|
public final class StatusBarController { |
||||
|
private var statusItem: NSStatusItem? |
||||
|
public var onToggleRecording: (() -> Void)? |
||||
|
|
||||
|
public init() { |
||||
|
setupStatusItem() |
||||
|
} |
||||
|
|
||||
|
private func setupStatusItem() { |
||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) |
||||
|
updateIcon(state: .idle) |
||||
|
buildMenu() |
||||
|
} |
||||
|
|
||||
|
public enum State { |
||||
|
case idle |
||||
|
case recording |
||||
|
case transcribing |
||||
|
} |
||||
|
|
||||
|
public func updateIcon(state: State) { |
||||
|
guard let button = statusItem?.button else { return } |
||||
|
|
||||
|
switch state { |
||||
|
case .idle: |
||||
|
if let image = NSImage(systemSymbolName: "mic", accessibilityDescription: "Soniox Voice") { |
||||
|
image.isTemplate = true |
||||
|
button.image = image |
||||
|
} |
||||
|
button.toolTip = "Soniox Voice (آماده)" |
||||
|
case .recording: |
||||
|
if let image = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: "Recording") { |
||||
|
image.isTemplate = false |
||||
|
button.image = image |
||||
|
button.contentTintColor = NSColor.systemRed |
||||
|
} |
||||
|
button.toolTip = "در حال ضبط صدا..." |
||||
|
case .transcribing: |
||||
|
if let image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Transcribing") { |
||||
|
image.isTemplate = false |
||||
|
button.image = image |
||||
|
button.contentTintColor = NSColor.systemOrange |
||||
|
} |
||||
|
button.toolTip = "در حال تبدیل به متن..." |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func buildMenu(isRecording: Bool = false) { |
||||
|
let menu = NSMenu() |
||||
|
menu.autoenablesItems = false |
||||
|
|
||||
|
// 1. Record Action |
||||
|
let recordTitle = isRecording ? "⏹️ توقف ضبط و درج متن" : "🎙️ شروع ضبط صدا (\(HotkeyManager.shared.currentPreset.rawValue))" |
||||
|
let recordItem = NSMenuItem(title: recordTitle, action: #selector(toggleRecordAction), keyEquivalent: "") |
||||
|
recordItem.target = self |
||||
|
menu.addItem(recordItem) |
||||
|
|
||||
|
menu.addItem(NSMenuItem.separator()) |
||||
|
|
||||
|
// 2. Mode Submenu |
||||
|
let modeMenu = NSMenu() |
||||
|
for mode in DictationMode.allCases { |
||||
|
let item = NSMenuItem(title: mode.localizedTitle, action: #selector(selectModeAction(_:)), keyEquivalent: "") |
||||
|
item.target = self |
||||
|
item.representedObject = mode |
||||
|
item.state = (HotkeyManager.shared.currentMode == mode) ? .on : .off |
||||
|
modeMenu.addItem(item) |
||||
|
} |
||||
|
let modeMenuItem = NSMenuItem(title: "⚙️ حالت کارکرد", action: nil, keyEquivalent: "") |
||||
|
modeMenuItem.submenu = modeMenu |
||||
|
menu.addItem(modeMenuItem) |
||||
|
|
||||
|
// 3. Hotkey Submenu |
||||
|
let hotkeyMenu = NSMenu() |
||||
|
for preset in HotkeyPreset.allCases { |
||||
|
let item = NSMenuItem(title: preset.rawValue, action: #selector(selectHotkeyAction(_:)), keyEquivalent: "") |
||||
|
item.target = self |
||||
|
item.representedObject = preset |
||||
|
item.state = (HotkeyManager.shared.currentPreset == preset) ? .on : .off |
||||
|
hotkeyMenu.addItem(item) |
||||
|
} |
||||
|
let hotkeyMenuItem = NSMenuItem(title: "⌨️ کلید میانبر (Hotkey)", action: nil, keyEquivalent: "") |
||||
|
hotkeyMenuItem.submenu = hotkeyMenu |
||||
|
menu.addItem(hotkeyMenuItem) |
||||
|
|
||||
|
menu.addItem(NSMenuItem.separator()) |
||||
|
|
||||
|
// 4. Sound Effects Toggle |
||||
|
let soundsEnabled = UserDefaults.standard.bool(forKey: "SonioxPlaySounds") |
||||
|
let soundItem = NSMenuItem(title: "🔊 پخش افکت صوتی", action: #selector(toggleSoundsAction(_:)), keyEquivalent: "") |
||||
|
soundItem.target = self |
||||
|
soundItem.state = soundsEnabled ? .on : .off |
||||
|
menu.addItem(soundItem) |
||||
|
|
||||
|
// 5. Accessibility Permission Check |
||||
|
let isAxTrusted = AXIsProcessTrusted() |
||||
|
let axTitle = isAxTrusted ? "✅ دسترسی Accessibility فعال است" : "🔑 اعطای دسترسی Accessibility..." |
||||
|
let axItem = NSMenuItem(title: axTitle, action: #selector(openAccessibilitySettings), keyEquivalent: "") |
||||
|
axItem.target = self |
||||
|
menu.addItem(axItem) |
||||
|
|
||||
|
// 6. Launch at Login |
||||
|
let launchLogin = UserDefaults.standard.bool(forKey: "SonioxLaunchAtLogin") |
||||
|
let launchItem = NSMenuItem(title: "🚀 اجرا هنگام بالا آمدن سیستم", action: #selector(toggleLaunchAtLoginAction(_:)), keyEquivalent: "") |
||||
|
launchItem.target = self |
||||
|
launchItem.state = launchLogin ? .on : .off |
||||
|
menu.addItem(launchItem) |
||||
|
|
||||
|
menu.addItem(NSMenuItem.separator()) |
||||
|
|
||||
|
// 7. About & Quit |
||||
|
let aboutItem = NSMenuItem(title: "ℹ️ درباره Soniox Voice", action: #selector(aboutAction), keyEquivalent: "") |
||||
|
aboutItem.target = self |
||||
|
menu.addItem(aboutItem) |
||||
|
|
||||
|
let quitItem = NSMenuItem(title: "❌ خروج", action: #selector(quitAction), keyEquivalent: "q") |
||||
|
quitItem.target = self |
||||
|
menu.addItem(quitItem) |
||||
|
|
||||
|
statusItem?.menu = menu |
||||
|
} |
||||
|
|
||||
|
@objc private func toggleRecordAction() { |
||||
|
onToggleRecording?() |
||||
|
} |
||||
|
|
||||
|
@objc private func selectModeAction(_ sender: NSMenuItem) { |
||||
|
if let mode = sender.representedObject as? DictationMode { |
||||
|
HotkeyManager.shared.currentMode = mode |
||||
|
buildMenu() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc private func selectHotkeyAction(_ sender: NSMenuItem) { |
||||
|
if let preset = sender.representedObject as? HotkeyPreset { |
||||
|
HotkeyManager.shared.currentPreset = preset |
||||
|
buildMenu() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc private func toggleSoundsAction(_ sender: NSMenuItem) { |
||||
|
let current = UserDefaults.standard.bool(forKey: "SonioxPlaySounds") |
||||
|
UserDefaults.standard.set(!current, forKey: "SonioxPlaySounds") |
||||
|
buildMenu() |
||||
|
} |
||||
|
|
||||
|
@objc private func toggleLaunchAtLoginAction(_ sender: NSMenuItem) { |
||||
|
let current = UserDefaults.standard.bool(forKey: "SonioxLaunchAtLogin") |
||||
|
UserDefaults.standard.set(!current, forKey: "SonioxLaunchAtLogin") |
||||
|
buildMenu() |
||||
|
} |
||||
|
|
||||
|
@objc private func openAccessibilitySettings() { |
||||
|
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! |
||||
|
NSWorkspace.shared.open(url) |
||||
|
} |
||||
|
|
||||
|
@objc private func aboutAction() { |
||||
|
let alert = NSAlert() |
||||
|
alert.messageText = "Soniox Voice v1.0" |
||||
|
alert.informativeText = "تبدیل بلادرنگ گفتار به متن فارسی و انگلیسی با موتور ابری فوق سریع Soniox.\n\nتوسعه یافته برای macOS." |
||||
|
alert.alertStyle = .informational |
||||
|
alert.addButton(withTitle: "باشه") |
||||
|
alert.runModal() |
||||
|
} |
||||
|
|
||||
|
@objc private func quitAction() { |
||||
|
NSApplication.shared.terminate(nil) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
import Cocoa |
||||
|
|
||||
|
let app = NSApplication.shared |
||||
|
let delegate = AppDelegate() |
||||
|
app.delegate = delegate |
||||
|
app.setActivationPolicy(.accessory) |
||||
|
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) |
||||
@ -0,0 +1,428 @@ |
|||||
|
#!/usr/bin/env python3 |
||||
|
""" |
||||
|
Soniox Ultra-Low Latency Duplex Gateway (v3.0) |
||||
|
- Single persistent duplex WebSocket to Android. |
||||
|
- Pre-warmed upstream Soniox pool. |
||||
|
- Strict newline stripping & hallucination cleanup. |
||||
|
- Sub-50ms Cmd+V Mac paste dispatch. |
||||
|
""" |
||||
|
|
||||
|
import asyncio |
||||
|
import json |
||||
|
import logging |
||||
|
import re |
||||
|
import subprocess |
||||
|
import time |
||||
|
from collections import OrderedDict |
||||
|
import aiohttp |
||||
|
from aiohttp import web |
||||
|
import websockets |
||||
|
from websockets.protocol import State |
||||
|
|
||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") |
||||
|
logger = logging.getLogger("SonioxRelay") |
||||
|
|
||||
|
connected_mac_websockets = set() |
||||
|
recent_pasted_sessions = OrderedDict() # session_id -> (timestamp, text) |
||||
|
|
||||
|
MAC_DIRECT_HTTP_URL = "http://116.16.16.20:8999/paste" |
||||
|
SONIOX_WS_URL = ( |
||||
|
"wss://translate.compare.soniox.com/compare/api/compare-websocket" |
||||
|
"?language_hints=fa&language_hints=en&language_hints=ar" |
||||
|
"&enable_speaker_diarization=false&enable_language_identification=true" |
||||
|
"&enable_endpoint_detection=false&providers=soniox" |
||||
|
) |
||||
|
SONIOX_HEADERS = { |
||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", |
||||
|
"Origin": "https://translate.compare.soniox.com", |
||||
|
} |
||||
|
|
||||
|
def is_ws_open(ws) -> bool: |
||||
|
if ws is None: |
||||
|
return False |
||||
|
if hasattr(ws, 'closed'): |
||||
|
return not ws.closed |
||||
|
if hasattr(ws, 'state'): |
||||
|
return ws.state == State.OPEN |
||||
|
return True |
||||
|
|
||||
|
class SonioxPool: |
||||
|
"""Pre-warms upstream WebSockets to Soniox for 0ms speech start delay.""" |
||||
|
def __init__(self, size=3): |
||||
|
self._pool = asyncio.Queue(maxsize=size) |
||||
|
self._refilling = False |
||||
|
|
||||
|
async def get_session(self): |
||||
|
while not self._pool.empty(): |
||||
|
try: |
||||
|
ws = self._pool.get_nowait() |
||||
|
if is_ws_open(ws): |
||||
|
asyncio.create_task(self.refill()) |
||||
|
return ws |
||||
|
except asyncio.QueueEmpty: |
||||
|
break |
||||
|
|
||||
|
logger.info("Pool empty, connecting fresh Soniox session...") |
||||
|
asyncio.create_task(self.refill()) |
||||
|
return await self._create_ws() |
||||
|
|
||||
|
async def _create_ws(self): |
||||
|
try: |
||||
|
return await websockets.connect( |
||||
|
SONIOX_WS_URL, |
||||
|
additional_headers=SONIOX_HEADERS, |
||||
|
open_timeout=3.5, |
||||
|
ping_interval=20, |
||||
|
) |
||||
|
except Exception as e: |
||||
|
logger.error("Failed to connect to upstream Soniox: %s", e) |
||||
|
return None |
||||
|
|
||||
|
async def refill(self): |
||||
|
if self._refilling or self._pool.full(): |
||||
|
return |
||||
|
self._refilling = True |
||||
|
try: |
||||
|
while not self._pool.full(): |
||||
|
ws = await self._create_ws() |
||||
|
if ws and is_ws_open(ws): |
||||
|
await self._pool.put(ws) |
||||
|
else: |
||||
|
break |
||||
|
finally: |
||||
|
self._refilling = False |
||||
|
|
||||
|
soniox_pool = SonioxPool() |
||||
|
|
||||
|
def sanitize_and_flatten_text(text: str) -> str: |
||||
|
""" |
||||
|
1. Removes all line breaks (\\r, \\n) and collapses whitespace into single spaces. |
||||
|
2. Strips English hallucination stop words during Persian speech. |
||||
|
3. Guarantees zero trailing/leading enters or spaces. |
||||
|
""" |
||||
|
if not text: |
||||
|
return "" |
||||
|
|
||||
|
# Flatten all newlines, carriage returns, tabs into a single line |
||||
|
flattened = re.sub(r"[\r\n\t]+", " ", text) |
||||
|
flattened = re.sub(r"\s+", " ", flattened).strip() |
||||
|
|
||||
|
if not flattened: |
||||
|
return "" |
||||
|
|
||||
|
words = flattened.split() |
||||
|
fa_pattern = re.compile(r"[\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]") |
||||
|
en_pattern = re.compile(r"[a-zA-Z]") |
||||
|
|
||||
|
fa_count = sum(1 for w in words if fa_pattern.search(w)) |
||||
|
en_count = sum(1 for w in words if en_pattern.search(w)) |
||||
|
total = fa_count + en_count |
||||
|
|
||||
|
if total == 0: |
||||
|
return flattened |
||||
|
|
||||
|
fa_ratio = fa_count / total |
||||
|
stop_words = {"sex", "from", "no", "oh", "you", "all", "know", "that", "god", "loves", "men", "for", "day", "night", "mankind", "wanted", "moments"} |
||||
|
|
||||
|
cleaned = [] |
||||
|
if fa_ratio >= 0.25: |
||||
|
for w in words: |
||||
|
if en_pattern.search(w) and not fa_pattern.search(w): |
||||
|
clean_w = re.sub(r"[.,!?:;،؛؟\"'()\[\]{}«»–—-]", "", w.lower()) |
||||
|
if clean_w in stop_words or fa_ratio >= 0.70: |
||||
|
continue |
||||
|
cleaned.append(w) |
||||
|
else: |
||||
|
cleaned = words |
||||
|
|
||||
|
result = " ".join(cleaned) |
||||
|
return re.sub(r"\s+", " ", result).strip() |
||||
|
|
||||
|
async def broadcast_paste_to_macs(text: str, session_id: str = "") -> bool: |
||||
|
"""Pushes clean single-line paste payload to Mac in <50ms with strict deduplication.""" |
||||
|
clean_text = sanitize_and_flatten_text(text) |
||||
|
if not clean_text: |
||||
|
return False |
||||
|
|
||||
|
now = time.time() |
||||
|
if session_id: |
||||
|
if session_id in recent_pasted_sessions: |
||||
|
logger.info("🚫 Suppressed duplicate paste for session_id: %s", session_id) |
||||
|
return True |
||||
|
recent_pasted_sessions[session_id] = (now, clean_text) |
||||
|
while recent_pasted_sessions and (now - next(iter(recent_pasted_sessions.values()))[0] > 60): |
||||
|
recent_pasted_sessions.popitem(last=False) |
||||
|
else: |
||||
|
for prev_sid, (prev_time, prev_txt) in list(recent_pasted_sessions.items())[-5:]: |
||||
|
if prev_txt == clean_text and (now - prev_time) < 2.0: |
||||
|
logger.info("🚫 Suppressed rapid identical text paste: '%s'", clean_text[:25]) |
||||
|
return True |
||||
|
|
||||
|
delivered = False |
||||
|
payload = json.dumps({"action": "paste", "text": clean_text}, ensure_ascii=False) |
||||
|
|
||||
|
# 1. Primary: Direct Push via Active Persistent WebSocket (<15ms) |
||||
|
dead_sockets = set() |
||||
|
for ws in list(connected_mac_websockets): |
||||
|
try: |
||||
|
if is_ws_open(ws): |
||||
|
await ws.send_str(payload) |
||||
|
delivered = True |
||||
|
logger.info("⚡ Pushed paste via Mac Persistent WS: '%s'", clean_text[:30]) |
||||
|
else: |
||||
|
dead_sockets.add(ws) |
||||
|
except Exception: |
||||
|
dead_sockets.add(ws) |
||||
|
|
||||
|
for dead in dead_sockets: |
||||
|
connected_mac_websockets.discard(dead) |
||||
|
|
||||
|
if delivered: |
||||
|
return True |
||||
|
|
||||
|
# 2. Secondary: Direct LAN HTTP (/paste) (<35ms) |
||||
|
try: |
||||
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=0.35)) as session: |
||||
|
async with session.post(MAC_DIRECT_HTTP_URL, json={"text": clean_text}) as resp: |
||||
|
if resp.status == 200: |
||||
|
logger.info("✅ Pasted to Mac via Direct LAN HTTP: '%s'", clean_text[:30]) |
||||
|
return True |
||||
|
except Exception: |
||||
|
pass |
||||
|
|
||||
|
# 3. Tertiary: SSH Tunnel (Port 2222) - FIXED: Zero-Newline printf '%s' pipe |
||||
|
try: |
||||
|
escaped_text = clean_text.replace("'", "'\\''") |
||||
|
remote_cmd = ( |
||||
|
f"printf '%s' '{escaped_text}' | pbcopy && " |
||||
|
f"/usr/bin/osascript -e 'tell application \"System Events\" to keystroke \"v\" using command down'" |
||||
|
) |
||||
|
proc = await asyncio.create_subprocess_exec( |
||||
|
"ssh", "-p", "2222", "-o", "BatchMode=yes", "-o", "ConnectTimeout=2", "[email protected]", |
||||
|
remote_cmd, |
||||
|
stdout=subprocess.PIPE, |
||||
|
stderr=subprocess.PIPE |
||||
|
) |
||||
|
stdout, stderr = await proc.communicate() |
||||
|
if proc.returncode == 0: |
||||
|
logger.info("✅ Pasted to Mac via SSH Tunnel (Zero-Newline printf): '%s'", clean_text[:30]) |
||||
|
return True |
||||
|
except Exception as e: |
||||
|
logger.warning("SSH tunnel fallback error: %s", e) |
||||
|
|
||||
|
return False |
||||
|
|
||||
|
async def handle_phone_stream_ws(request): |
||||
|
""" |
||||
|
⚡ Single Persistent Duplex WebSocket Handler for Android. |
||||
|
Supports multiple sequential sessions without tearing down the connection. |
||||
|
""" |
||||
|
ws = web.WebSocketResponse(heartbeat=15.0) |
||||
|
await ws.prepare(request) |
||||
|
client_ip = request.remote |
||||
|
logger.info("📱 Android Client connected to Persistent Stream: %s", client_ip) |
||||
|
|
||||
|
active_soniox_ws = None |
||||
|
reader_task = None |
||||
|
stop_event = asyncio.Event() |
||||
|
current_session_id = "" |
||||
|
|
||||
|
full_final_tokens = [] |
||||
|
current_non_final = "" |
||||
|
|
||||
|
async def soniox_reader(soniox_ws, sid): |
||||
|
nonlocal current_non_final |
||||
|
try: |
||||
|
async for s_msg in soniox_ws: |
||||
|
if not is_ws_open(ws): |
||||
|
break |
||||
|
try: |
||||
|
data = json.loads(s_msg) |
||||
|
if data.get("type") == "data" and "parts" in data: |
||||
|
new_finals = [] |
||||
|
new_non_finals = [] |
||||
|
got_fin = False |
||||
|
for p in data["parts"]: |
||||
|
if p.get("translation_status") == "translation": |
||||
|
continue |
||||
|
txt = p.get("text", "") |
||||
|
is_final = p.get("is_final", False) |
||||
|
if "<fin>" in txt: |
||||
|
got_fin = True |
||||
|
clean = txt.replace("<fin>", "") |
||||
|
if clean: new_finals.append(clean) |
||||
|
elif is_final: |
||||
|
if txt: new_finals.append(txt) |
||||
|
else: |
||||
|
if txt: new_non_finals.append(txt) |
||||
|
|
||||
|
if new_finals: |
||||
|
full_final_tokens.extend(new_finals) |
||||
|
current_non_final = "".join(new_non_finals) |
||||
|
|
||||
|
live_text = sanitize_and_flatten_text("".join(full_final_tokens) + current_non_final) |
||||
|
if live_text and is_ws_open(ws): |
||||
|
await ws.send_str(json.dumps({ |
||||
|
"type": "live", |
||||
|
"session_id": sid, |
||||
|
"text": live_text |
||||
|
})) |
||||
|
|
||||
|
if got_fin or data.get("session_ended") or data.get("type") == "session_done": |
||||
|
stop_event.set() |
||||
|
break |
||||
|
except Exception as e: |
||||
|
logger.warning("Soniox parse error: %s", e) |
||||
|
except Exception as e: |
||||
|
logger.warning("Soniox reader error: %s", e) |
||||
|
finally: |
||||
|
stop_event.set() |
||||
|
|
||||
|
try: |
||||
|
async for msg in ws: |
||||
|
if msg.type == aiohttp.WSMsgType.BINARY: |
||||
|
# Live PCM audio chunk (2048 bytes / 64ms) |
||||
|
if active_soniox_ws and is_ws_open(active_soniox_ws): |
||||
|
await active_soniox_ws.send(msg.data) |
||||
|
|
||||
|
elif msg.type == aiohttp.WSMsgType.TEXT: |
||||
|
try: |
||||
|
data = json.loads(msg.data) |
||||
|
except Exception: |
||||
|
continue |
||||
|
|
||||
|
msg_type = data.get("type") or data.get("action") |
||||
|
sid = data.get("session_id", f"sess_{int(time.time()*1000)}") |
||||
|
|
||||
|
if msg_type == "start": |
||||
|
current_session_id = sid |
||||
|
full_final_tokens.clear() |
||||
|
current_non_final = "" |
||||
|
stop_event.clear() |
||||
|
|
||||
|
# Acquire pre-warmed Soniox session |
||||
|
active_soniox_ws = await soniox_pool.get_session() |
||||
|
if not active_soniox_ws or not is_ws_open(active_soniox_ws): |
||||
|
await ws.send_str(json.dumps({"type": "error", "message": "Upstream Soniox unavailable"})) |
||||
|
continue |
||||
|
|
||||
|
if reader_task and not reader_task.done(): |
||||
|
reader_task.cancel() |
||||
|
reader_task = asyncio.create_task(soniox_reader(active_soniox_ws, current_session_id)) |
||||
|
logger.info("🎙️ Started Live Session: %s", current_session_id) |
||||
|
|
||||
|
elif msg_type == "stop": |
||||
|
if active_soniox_ws and is_ws_open(active_soniox_ws): |
||||
|
await active_soniox_ws.send(json.dumps({"type": "finalize"})) |
||||
|
try: |
||||
|
await asyncio.wait_for(stop_event.wait(), timeout=0.55) |
||||
|
except asyncio.TimeoutError: |
||||
|
pass |
||||
|
|
||||
|
raw_final = "".join(full_final_tokens) + current_non_final |
||||
|
clean_final = sanitize_and_flatten_text(raw_final) |
||||
|
logger.info("⚡ Session %s final text: '%s'", sid, clean_final) |
||||
|
|
||||
|
# Instant <50ms broadcast to Mac |
||||
|
mac_ok = False |
||||
|
if clean_final: |
||||
|
mac_ok = await broadcast_paste_to_macs(clean_final, session_id=sid) |
||||
|
|
||||
|
if is_ws_open(ws): |
||||
|
await ws.send_str(json.dumps({ |
||||
|
"type": "final", |
||||
|
"session_id": sid, |
||||
|
"text": clean_final, |
||||
|
"mac_delivered": mac_ok |
||||
|
})) |
||||
|
|
||||
|
# Cleanup Soniox session for this turn |
||||
|
if reader_task and not reader_task.done(): |
||||
|
reader_task.cancel() |
||||
|
if active_soniox_ws: |
||||
|
try: |
||||
|
await active_soniox_ws.close() |
||||
|
except Exception: |
||||
|
pass |
||||
|
active_soniox_ws = None |
||||
|
|
||||
|
asyncio.create_task(soniox_pool.refill()) |
||||
|
|
||||
|
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR): |
||||
|
break |
||||
|
|
||||
|
finally: |
||||
|
if reader_task and not reader_task.done(): |
||||
|
reader_task.cancel() |
||||
|
if active_soniox_ws: |
||||
|
try: |
||||
|
await active_soniox_ws.close() |
||||
|
except Exception: |
||||
|
pass |
||||
|
asyncio.create_task(soniox_pool.refill()) |
||||
|
logger.info("📱 Android Client disconnected: %s", client_ip) |
||||
|
|
||||
|
return ws |
||||
|
|
||||
|
async def handle_mac_ws(request): |
||||
|
"""Persistent WebSocket for Mac Bridge.""" |
||||
|
ws = web.WebSocketResponse(heartbeat=10.0) |
||||
|
await ws.prepare(request) |
||||
|
client_ip = request.remote |
||||
|
logger.info("🖥️ Mac client connected to persistent WebSocket: %s", client_ip) |
||||
|
connected_mac_websockets.add(ws) |
||||
|
|
||||
|
try: |
||||
|
await ws.send_str(json.dumps({"type": "welcome", "message": "Connected to Soniox Ultra Gateway"})) |
||||
|
async for msg in ws: |
||||
|
if msg.type == aiohttp.WSMsgType.TEXT: |
||||
|
try: |
||||
|
data = json.loads(msg.data) |
||||
|
if data.get("type") == "ping": |
||||
|
await ws.send_str(json.dumps({"type": "pong"})) |
||||
|
except Exception: |
||||
|
pass |
||||
|
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR): |
||||
|
break |
||||
|
finally: |
||||
|
connected_mac_websockets.discard(ws) |
||||
|
if is_ws_open(ws): |
||||
|
await ws.close() |
||||
|
logger.info("🖥️ Mac client disconnected: %s", client_ip) |
||||
|
|
||||
|
return ws |
||||
|
|
||||
|
async def handle_health(request): |
||||
|
return web.json_response({ |
||||
|
"status": "ok", |
||||
|
"service": "Soniox Pre-Warmed Duplex Gateway v3.0", |
||||
|
"connected_macs": len(connected_mac_websockets), |
||||
|
"recent_sessions": len(recent_pasted_sessions) |
||||
|
}) |
||||
|
|
||||
|
async def handle_paste(request): |
||||
|
try: |
||||
|
data = await request.json() |
||||
|
text = data.get("text", "") |
||||
|
session_id = data.get("session_id", "") |
||||
|
success = await broadcast_paste_to_macs(text, session_id=session_id) |
||||
|
return web.json_response({"status": "pasted" if success else "failed", "mac_delivered": success}) |
||||
|
except Exception as e: |
||||
|
return web.json_response({"error": str(e)}, status=400) |
||||
|
|
||||
|
async def start_background_tasks(app): |
||||
|
asyncio.create_task(soniox_pool.refill()) |
||||
|
|
||||
|
def create_app(): |
||||
|
app = web.Application(client_max_size=25 * 1024 * 1024) |
||||
|
app.on_startup.append(start_background_tasks) |
||||
|
app.router.add_get("/health", handle_health) |
||||
|
app.router.add_get("/status", handle_health) |
||||
|
app.router.add_post("/paste", handle_paste) |
||||
|
app.router.add_get("/ws/stream", handle_phone_stream_ws) |
||||
|
app.router.add_get("/ws/mac", handle_mac_ws) |
||||
|
return app |
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
app = create_app() |
||||
|
web.run_app(app, host="0.0.0.0", port=8999) |
||||
@ -0,0 +1,13 @@ |
|||||
|
[Unit] |
||||
|
Description=Soniox Remote Mic Relay Gateway |
||||
|
After=network.target |
||||
|
|
||||
|
[Service] |
||||
|
Type=simple |
||||
|
WorkingDirectory=/home/alialavi/projects/soniox-android-remote/server |
||||
|
ExecStart=/home/alialavi/.hermes/hermes-agent/venv/bin/python3 /home/alialavi/projects/soniox-android-remote/server/relay_server.py |
||||
|
Restart=always |
||||
|
RestartSec=3 |
||||
|
|
||||
|
[Install] |
||||
|
WantedBy=default.target |
||||