Soniox Mobile to Mac - Real-time Voice Dictation & Remote Input Control
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

209 lines
8.3 KiB

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) {
// Completely silent operation by default
UserDefaults.standard.set(false, 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 (via local tunnel 18999 -> 8999)
RelayClient.shared.onRemoteUpdateReceived = { text, cursor, isFullReplace in
print("AppDelegate: 📥 Clean Remote Input Update: '\(text.prefix(30))...' (replace: \(isFullReplace))")
// 1. FIRST apply the remote update directly into the active input while window is in pristine focus!
FocusedInputSync.shared.applyRemoteUpdate(text: text, cursor: cursor, isFullReplace: isFullReplace)
// 2. THEN show the floating success HUD notification
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
HUDOverlayController.shared.show(state: .success(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) { data, _, _, _ in
guard 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 {
FocusedInputSync.shared.applyRemoteUpdate(text: text, cursor: nil, isFullReplace: true)
}
}
}
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
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):
FocusedInputSync.shared.applyRemoteUpdate(text: text, cursor: nil, isFullReplace: false)
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()
}
}