-
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 |
|||