Browse Source

feat: release v1.0.0 stable of Soniox Mobile to Mac dictation system

main
Ali Alavi 2 days ago
commit
53fd5469fc
  1. 29
      .gitignore
  2. 77
      README.md
  3. 54
      android/app/build.gradle
  4. 64
      android/app/src/main/AndroidManifest.xml
  5. 32
      android/app/src/main/java/com/soniox/remotemic/AppLogger.kt
  6. 112
      android/app/src/main/java/com/soniox/remotemic/AudioRecorderManager.kt
  7. 104
      android/app/src/main/java/com/soniox/remotemic/MacPasteClient.kt
  8. 357
      android/app/src/main/java/com/soniox/remotemic/MainActivity.kt
  9. 29
      android/app/src/main/java/com/soniox/remotemic/PreferencesManager.kt
  10. 348
      android/app/src/main/java/com/soniox/remotemic/SonioxLiveStreamer.kt
  11. 309
      android/app/src/main/java/com/soniox/remotemic/SonioxRecognitionService.kt
  12. 233
      android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt
  13. 102
      android/app/src/main/java/com/soniox/remotemic/VoiceRecognitionActivity.kt
  14. 9
      android/app/src/main/res/drawable/bg_card.xml
  15. 9
      android/app/src/main/res/drawable/bg_dialog.xml
  16. 16
      android/app/src/main/res/drawable/bg_mic_button.xml
  17. 12
      android/app/src/main/res/drawable/bg_mic_button_active.xml
  18. 9
      android/app/src/main/res/drawable/ic_mic.xml
  19. 243
      android/app/src/main/res/layout/activity_main.xml
  20. 102
      android/app/src/main/res/layout/activity_voice_recognition.xml
  21. 72
      android/app/src/main/res/layout/dialog_logs_sheet.xml
  22. 15
      android/app/src/main/res/values/colors.xml
  23. 15
      android/app/src/main/res/values/strings.xml
  24. 16
      android/app/src/main/res/values/themes.xml
  25. 3
      android/app/src/main/res/xml/recognition_service.xml
  26. 78
      android/app/src/test/java/com/soniox/remotemic/SonioxWsTest.kt
  27. 8
      android/build.gradle
  28. 4
      android/gradle.properties
  29. BIN
      android/gradle/wrapper/gradle-wrapper.jar
  30. 5
      android/gradle/wrapper/gradle-wrapper.properties
  31. 160
      android/gradlew
  32. 90
      android/gradlew.bat
  33. 16
      android/settings.gradle
  34. 41
      mac/Info.plist
  35. 121
      mac/make_icon.py
  36. 55
      mac/package.sh
  37. BIN
      mac/resources/AppIcon.iconset/icon_128x128.png
  38. BIN
      mac/resources/AppIcon.iconset/icon_128x128@2x.png
  39. BIN
      mac/resources/AppIcon.iconset/icon_16x16.png
  40. BIN
      mac/resources/AppIcon.iconset/icon_16x16@2x.png
  41. BIN
      mac/resources/AppIcon.iconset/icon_256x256.png
  42. BIN
      mac/resources/AppIcon.iconset/icon_256x256@2x.png
  43. BIN
      mac/resources/AppIcon.iconset/icon_32x32.png
  44. BIN
      mac/resources/AppIcon.iconset/icon_32x32@2x.png
  45. BIN
      mac/resources/AppIcon.iconset/icon_512x512.png
  46. BIN
      mac/resources/AppIcon.iconset/icon_512x512@2x.png
  47. BIN
      mac/resources/AppIcon.png
  48. 246
      mac/src/AppDelegate.swift
  49. 165
      mac/src/AudioRecorder.swift
  50. 180
      mac/src/HUDOverlay.swift
  51. 261
      mac/src/HotkeyManager.swift
  52. 138
      mac/src/RelayClient.swift
  53. 321
      mac/src/SonioxClient.swift
  54. 172
      mac/src/StatusBarController.swift
  55. 7
      mac/src/main.swift
  56. 428
      server/relay_server.py
  57. 13
      server/soniox-relay.service

29
.gitignore

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

77
README.md

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

54
android/app/build.gradle

@ -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'
}

64
android/app/src/main/AndroidManifest.xml

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

32
android/app/src/main/java/com/soniox/remotemic/AppLogger.kt

@ -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("")
}
}

112
android/app/src/main/java/com/soniox/remotemic/AudioRecorderManager.kt

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

104
android/app/src/main/java/com/soniox/remotemic/MacPasteClient.kt

@ -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)
}
}
}

357
android/app/src/main/java/com/soniox/remotemic/MainActivity.kt

@ -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()
}
}

29
android/app/src/main/java/com/soniox/remotemic/PreferencesManager.kt

@ -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()
}
}

348
android/app/src/main/java/com/soniox/remotemic/SonioxLiveStreamer.kt

@ -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()
}
}

309
android/app/src/main/java/com/soniox/remotemic/SonioxRecognitionService.kt

@ -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) {}
}
}

233
android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt

@ -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) {}
}
}

102
android/app/src/main/java/com/soniox/remotemic/VoiceRecognitionActivity.kt

@ -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()
}
}

9
android/app/src/main/res/drawable/bg_card.xml

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

9
android/app/src/main/res/drawable/bg_dialog.xml

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

16
android/app/src/main/res/drawable/bg_mic_button.xml

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

12
android/app/src/main/res/drawable/bg_mic_button_active.xml

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

9
android/app/src/main/res/drawable/ic_mic.xml

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

243
android/app/src/main/res/layout/activity_main.xml

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

102
android/app/src/main/res/layout/activity_voice_recognition.xml

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

72
android/app/src/main/res/layout/dialog_logs_sheet.xml

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

15
android/app/src/main/res/values/colors.xml

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

15
android/app/src/main/res/values/strings.xml

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

16
android/app/src/main/res/values/themes.xml

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

3
android/app/src/main/res/xml/recognition_service.xml

@ -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" />

78
android/app/src/test/java/com/soniox/remotemic/SonioxWsTest.kt

@ -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.")
}
}

8
android/build.gradle

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

4
android/gradle.properties

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

BIN
android/gradle/wrapper/gradle-wrapper.jar

5
android/gradle/wrapper/gradle-wrapper.properties

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

160
android/gradlew

@ -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 "$@"

90
android/gradlew.bat

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

16
android/settings.gradle

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

41
mac/Info.plist

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

121
mac/make_icon.py

@ -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, "icon_16x16@2x.png"),
(32, "icon_32x32.png"),
(64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"),
(256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"),
(512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"),
(1024, "icon_512x512@2x.png"),
]
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")

55
mac/package.sh

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

BIN
mac/resources/AppIcon.iconset/icon_128x128.png

After

Width: 128  |  Height: 128  |  Size: 10 KiB

BIN
mac/resources/AppIcon.iconset/icon_128x128@2x.png

After

Width: 256  |  Height: 256  |  Size: 23 KiB

BIN
mac/resources/AppIcon.iconset/icon_16x16.png

After

Width: 16  |  Height: 16  |  Size: 686 B

BIN
mac/resources/AppIcon.iconset/icon_16x16@2x.png

After

Width: 32  |  Height: 32  |  Size: 1.9 KiB

BIN
mac/resources/AppIcon.iconset/icon_256x256.png

After

Width: 256  |  Height: 256  |  Size: 23 KiB

BIN
mac/resources/AppIcon.iconset/icon_256x256@2x.png

After

Width: 512  |  Height: 512  |  Size: 47 KiB

BIN
mac/resources/AppIcon.iconset/icon_32x32.png

After

Width: 32  |  Height: 32  |  Size: 1.9 KiB

BIN
mac/resources/AppIcon.iconset/icon_32x32@2x.png

After

Width: 64  |  Height: 64  |  Size: 4.5 KiB

BIN
mac/resources/AppIcon.iconset/icon_512x512.png

After

Width: 512  |  Height: 512  |  Size: 47 KiB

BIN
mac/resources/AppIcon.iconset/icon_512x512@2x.png

After

Width: 1024  |  Height: 1024  |  Size: 31 KiB

BIN
mac/resources/AppIcon.png

After

Width: 1024  |  Height: 1024  |  Size: 31 KiB

246
mac/src/AppDelegate.swift

@ -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()
}
}

165
mac/src/AudioRecorder.swift

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

180
mac/src/HUDOverlay.swift

@ -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)
}
}
}

261
mac/src/HotkeyManager.swift

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

138
mac/src/RelayClient.swift

@ -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()
}
}

321
mac/src/SonioxClient.swift

@ -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()
}
}

172
mac/src/StatusBarController.swift

@ -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)
}
}

7
mac/src/main.swift

@ -0,0 +1,7 @@
import Cocoa
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

428
server/relay_server.py

@ -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", "alig@127.0.0.1",
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)

13
server/soniox-relay.service

@ -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
Loading…
Cancel
Save