commit 53fd5469fc372cde52a0a84b3bf3a4ce696eb52e Author: Ali Alavi Date: Sun Aug 23 06:30:18 2026 +0000 feat: release v1.0.0 stable of Soniox Mobile to Mac dictation system diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..112503d --- /dev/null +++ b/.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..13e278f --- /dev/null +++ b/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. diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..aa26700 --- /dev/null +++ b/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' +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d593bd5 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/soniox/remotemic/AppLogger.kt b/android/app/src/main/java/com/soniox/remotemic/AppLogger.kt new file mode 100644 index 0000000..c543ffb --- /dev/null +++ b/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() + 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("") + } +} diff --git a/android/app/src/main/java/com/soniox/remotemic/AudioRecorderManager.kt b/android/app/src/main/java/com/soniox/remotemic/AudioRecorderManager.kt new file mode 100644 index 0000000..c8028e3 --- /dev/null +++ b/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 + } +} diff --git a/android/app/src/main/java/com/soniox/remotemic/MacPasteClient.kt b/android/app/src/main/java/com/soniox/remotemic/MacPasteClient.kt new file mode 100644 index 0000000..435e8fa --- /dev/null +++ b/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 = 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 = 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) + } + } +} diff --git a/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt b/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt new file mode 100644 index 0000000..8a4b7d2 --- /dev/null +++ b/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(R.id.tvSheetLogs) + val scrollViewSheetLogs = sheetView.findViewById(R.id.scrollViewSheetLogs) + val btnSheetCopyLogs = sheetView.findViewById(R.id.btnSheetCopyLogs) + val btnSheetClearLogs = sheetView.findViewById(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() + } +} diff --git a/android/app/src/main/java/com/soniox/remotemic/PreferencesManager.kt b/android/app/src/main/java/com/soniox/remotemic/PreferencesManager.kt new file mode 100644 index 0000000..dfd4f79 --- /dev/null +++ b/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() + } +} diff --git a/android/app/src/main/java/com/soniox/remotemic/SonioxLiveStreamer.kt b/android/app/src/main/java/com/soniox/remotemic/SonioxLiveStreamer.kt new file mode 100644 index 0000000..ad48044 --- /dev/null +++ b/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() + + 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("")) { + gotFin = true + val clean = text.replace("", "") + 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() + 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() + } +} diff --git a/android/app/src/main/java/com/soniox/remotemic/SonioxRecognitionService.kt b/android/app/src/main/java/com/soniox/remotemic/SonioxRecognitionService.kt new file mode 100644 index 0000000..617abd7 --- /dev/null +++ b/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) {} + } +} diff --git a/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt b/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt new file mode 100644 index 0000000..27d5791 --- /dev/null +++ b/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) {} + } +} diff --git a/android/app/src/main/java/com/soniox/remotemic/VoiceRecognitionActivity.kt b/android/app/src/main/java/com/soniox/remotemic/VoiceRecognitionActivity.kt new file mode 100644 index 0000000..cdaa198 --- /dev/null +++ b/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() + } +} diff --git a/android/app/src/main/res/drawable/bg_card.xml b/android/app/src/main/res/drawable/bg_card.xml new file mode 100644 index 0000000..4fa9446 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_card.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_dialog.xml b/android/app/src/main/res/drawable/bg_dialog.xml new file mode 100644 index 0000000..bef9566 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_dialog.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_mic_button.xml b/android/app/src/main/res/drawable/bg_mic_button.xml new file mode 100644 index 0000000..74e22f8 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_mic_button.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_mic_button_active.xml b/android/app/src/main/res/drawable/bg_mic_button_active.xml new file mode 100644 index 0000000..3c00ed3 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_mic_button_active.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_mic.xml b/android/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 0000000..44f6a80 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..7cf5955 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_voice_recognition.xml b/android/app/src/main/res/layout/activity_voice_recognition.xml new file mode 100644 index 0000000..b0d3cee --- /dev/null +++ b/android/app/src/main/res/layout/activity_voice_recognition.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_logs_sheet.xml b/android/app/src/main/res/layout/dialog_logs_sheet.xml new file mode 100644 index 0000000..951959b --- /dev/null +++ b/android/app/src/main/res/layout/dialog_logs_sheet.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..d34b89d --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,15 @@ + + + #0F1115 + #181B22 + #282E3A + #3B82F6 + #EF4444 + #10B981 + #F59E0B + #F8FAFC + #94A3B8 + #64748B + #4DF43F5E + #263B82F6 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..47231ea --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + Soniox Voice + Soniox Voice Typing + نگه‌دارید و صحبت کنید + رها کنید تا تایپ شود + آماده برای ضبط صدا + 🎙️ در حال استریم زنده صدا... + ⏳ در حال دریافت متن... + ✨ متن آماده شد + تست اتصال + متن صحبت شما به‌صورت زنده در این قسمت نمایش داده می‌شود... + ⚙️ انتخاب Soniox به عنوان ورودی صوتی سیستم + آدرس سرور وب‌سوکت: + ذخیره آدرس + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ce64efa --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/xml/recognition_service.xml b/android/app/src/main/res/xml/recognition_service.xml new file mode 100644 index 0000000..2021a3c --- /dev/null +++ b/android/app/src/main/res/xml/recognition_service.xml @@ -0,0 +1,3 @@ + + diff --git a/android/app/src/test/java/com/soniox/remotemic/SonioxWsTest.kt b/android/app/src/test/java/com/soniox/remotemic/SonioxWsTest.kt new file mode 100644 index 0000000..d708b21 --- /dev/null +++ b/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("") || 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.") + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..2f75d69 --- /dev/null +++ b/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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..97c802f --- /dev/null +++ b/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 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..efdcc4a --- /dev/null +++ b/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 diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/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 "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/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 diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..d57ca5c --- /dev/null +++ b/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' diff --git a/mac/Info.plist b/mac/Info.plist new file mode 100644 index 0000000..496a3f9 --- /dev/null +++ b/mac/Info.plist @@ -0,0 +1,41 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Soniox Voice + CFBundleExecutable + SonioxVoice + CFBundleIconFile + AppIcon + CFBundleIdentifier + com.soniox.voice + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + SonioxVoice + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHighResolutionCapable + + NSMicrophoneUsageDescription + Soniox Voice به دسترسی میکروفون جهت ضبط صدا و تبدیل آن به متن نیاز دارد. + NSAccessibilityUsageDescription + Soniox Voice به دسترسی Accessibility جهت درج خودکار متن در برنامه فعال نیاز دارد. + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/mac/make_icon.py b/mac/make_icon.py new file mode 100644 index 0000000..7b012d7 --- /dev/null +++ b/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") diff --git a/mac/package.sh b/mac/package.sh new file mode 100644 index 0000000..88c409c --- /dev/null +++ b/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" diff --git a/mac/resources/AppIcon.iconset/icon_128x128.png b/mac/resources/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000..27dbadb Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_128x128.png differ diff --git a/mac/resources/AppIcon.iconset/icon_128x128@2x.png b/mac/resources/AppIcon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..6d1fa4d Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/mac/resources/AppIcon.iconset/icon_16x16.png b/mac/resources/AppIcon.iconset/icon_16x16.png new file mode 100644 index 0000000..aca1b51 Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_16x16.png differ diff --git a/mac/resources/AppIcon.iconset/icon_16x16@2x.png b/mac/resources/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..a542351 Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/mac/resources/AppIcon.iconset/icon_256x256.png b/mac/resources/AppIcon.iconset/icon_256x256.png new file mode 100644 index 0000000..6d1fa4d Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_256x256.png differ diff --git a/mac/resources/AppIcon.iconset/icon_256x256@2x.png b/mac/resources/AppIcon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..2fa55f0 Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/mac/resources/AppIcon.iconset/icon_32x32.png b/mac/resources/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000..a542351 Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_32x32.png differ diff --git a/mac/resources/AppIcon.iconset/icon_32x32@2x.png b/mac/resources/AppIcon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..bee1a97 Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/mac/resources/AppIcon.iconset/icon_512x512.png b/mac/resources/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000..2fa55f0 Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_512x512.png differ diff --git a/mac/resources/AppIcon.iconset/icon_512x512@2x.png b/mac/resources/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..7c5030a Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/mac/resources/AppIcon.png b/mac/resources/AppIcon.png new file mode 100644 index 0000000..7c5030a Binary files /dev/null and b/mac/resources/AppIcon.png differ diff --git a/mac/src/AppDelegate.swift b/mac/src/AppDelegate.swift new file mode 100644 index 0000000..2cfa54d --- /dev/null +++ b/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() + } +} diff --git a/mac/src/AudioRecorder.swift b/mac/src/AudioRecorder.swift new file mode 100644 index 0000000..d712c65 --- /dev/null +++ b/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? + + 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.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.. 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 + } +} diff --git a/mac/src/HUDOverlay.swift b/mac/src/HUDOverlay.swift new file mode 100644 index 0000000..71e79d6 --- /dev/null +++ b/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) + } + } +} diff --git a/mac/src/HotkeyManager.swift b/mac/src/HotkeyManager.swift new file mode 100644 index 0000000..b5f68a7 --- /dev/null +++ b/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.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? in + guard let refcon = refcon else { return Unmanaged.passRetained(event) } + let manager = Unmanaged.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 + } + } +} diff --git a/mac/src/RelayClient.swift b/mac/src/RelayClient.swift new file mode 100644 index 0000000..2c749be --- /dev/null +++ b/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() + } +} diff --git a/mac/src/SonioxClient.swift b/mac/src/SonioxClient.swift new file mode 100644 index 0000000..f0451bf --- /dev/null +++ b/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) -> 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("") { + gotFin = true + let clean = pText.replacingOccurrences(of: "", 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 = ["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() + } +} diff --git a/mac/src/StatusBarController.swift b/mac/src/StatusBarController.swift new file mode 100644 index 0000000..29457fc --- /dev/null +++ b/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) + } +} diff --git a/mac/src/main.swift b/mac/src/main.swift new file mode 100644 index 0000000..467311d --- /dev/null +++ b/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) diff --git a/server/relay_server.py b/server/relay_server.py new file mode 100644 index 0000000..4bd25f3 --- /dev/null +++ b/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 "" in txt: + got_fin = True + clean = txt.replace("", "") + 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) diff --git a/server/soniox-relay.service b/server/soniox-relay.service new file mode 100644 index 0000000..f7bfab0 --- /dev/null +++ b/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