diff --git a/apps/desktop/src/main/managers/recording-manager.ts b/apps/desktop/src/main/managers/recording-manager.ts index e902096f..8508cdfc 100644 --- a/apps/desktop/src/main/managers/recording-manager.ts +++ b/apps/desktop/src/main/managers/recording-manager.ts @@ -301,6 +301,13 @@ export class RecordingManager extends EventEmitter { const preferences = await settingsService.getPreferences(); const shouldMute = preferences?.muteSystemAudio ?? true; + // Always play rec-start sound, await completion before muting (so chime isn't cut off) + try { + await nativeBridge.call("playSound", { sound: "rec-start" }); + } catch (error) { + logger.audio.warn("Failed to play rec-start sound", { error }); + } + if (shouldMute) { const result = await nativeBridge.call("muteSystemAudio", {}); this.systemAudioMuted = !!result?.success; @@ -371,6 +378,14 @@ export class RecordingManager extends EventEmitter { logger.main.warn("Failed to restore system audio", { error }); } + // Always play rec-stop sound (fire-and-forget) + this.serviceManager + .getService("nativeBridge") + .call("playSound", { sound: "rec-stop" }) + .catch((error) => { + logger.audio.warn("Failed to play rec-stop sound", { error }); + }); + // Cancel streaming for cancel codes (not null, not dismissed) if (code && code !== "dismissed" && sessionId) { try { diff --git a/apps/desktop/src/services/platform/native-bridge-service.ts b/apps/desktop/src/services/platform/native-bridge-service.ts index 4ab01b44..e58bda9d 100644 --- a/apps/desktop/src/services/platform/native-bridge-service.ts +++ b/apps/desktop/src/services/platform/native-bridge-service.ts @@ -38,6 +38,9 @@ import { RestoreSystemAudioParams, RestoreSystemAudioResult, RestoreSystemAudioResultSchema, + PlaySoundParams, + PlaySoundResult, + PlaySoundResultSchema, SetShortcutsParams, SetShortcutsResult, SetShortcutsResultSchema, @@ -77,6 +80,10 @@ interface RPCMethods { params: RestoreSystemAudioParams; result: RestoreSystemAudioResult; }; + playSound: { + params: PlaySoundParams; + result: PlaySoundResult; + }; setShortcuts: { params: SetShortcutsParams; result: SetShortcutsResult; @@ -106,6 +113,7 @@ const RPC_RESULT_SCHEMAS: Record = { pasteText: PasteTextResultSchema, muteSystemAudio: MuteSystemAudioResultSchema, restoreSystemAudio: RestoreSystemAudioResultSchema, + playSound: PlaySoundResultSchema, setShortcuts: SetShortcutsResultSchema, recheckPressedKeys: RecheckPressedKeysResultSchema, }; diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/AudioService.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/AudioService.swift index df86406d..448bbbbb 100644 --- a/packages/native-helpers/swift-helper/Sources/SwiftHelper/AudioService.swift +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/AudioService.swift @@ -3,7 +3,7 @@ import Foundation class AudioService: NSObject, AVAudioPlayerDelegate { private var audioPlayer: AVAudioPlayer? - private var audioCompletionHandler: (() -> Void)? + private var audioCompletionHandler: ((Bool) -> Void)? private var preloadedAudio: [String: Data] = [:] override init() { super.init() @@ -21,10 +21,10 @@ class AudioService: NSObject, AVAudioPlayerDelegate { logToStderr("[AudioService] Audio files preloaded at startup") } - func playSound(named soundName: String, completion: (() -> Void)? = nil) { + func playSound(named soundName: String, completion: ((Bool) -> Void)? = nil) { logToStderr("[AudioService] playSound called with soundName: \(soundName)") - // Stop any currently playing sound + // Stop any currently playing sound and complete the previous handler as interrupted if audioPlayer?.isPlaying == true { logToStderr( "[AudioService] Sound '\(audioPlayer?.url?.lastPathComponent ?? "previous")' is playing. Stopping it." @@ -33,7 +33,9 @@ class AudioService: NSObject, AVAudioPlayerDelegate { audioPlayer?.stop() } audioPlayer = nil + let previousHandler = audioCompletionHandler audioCompletionHandler = nil + previousHandler?(false) audioCompletionHandler = completion @@ -50,8 +52,10 @@ class AudioService: NSObject, AVAudioPlayerDelegate { case "rec-stop": soundData = Data(PackageResources.rec_stop_mp3) default: - logToStderr("[AudioService] Error: Unknown sound name '\(soundName)'. Completion will not be called.") + logToStderr("[AudioService] Error: Unknown sound name '\(soundName)'. Calling completion immediately.") + let handler = audioCompletionHandler audioCompletionHandler = nil + handler?(false) return } } @@ -64,15 +68,19 @@ class AudioService: NSObject, AVAudioPlayerDelegate { logToStderr("[AudioService] Playing sound: \(soundName).mp3. Delegate will handle completion.") } else { logToStderr( - "[AudioService] Failed to start playing sound: \(soundName).mp3. Completion will not be called." + "[AudioService] Failed to start playing sound: \(soundName).mp3. Calling completion immediately." ) + let handler = audioCompletionHandler audioCompletionHandler = nil + handler?(false) } } catch { logToStderr( - "[AudioService] Error initializing AVAudioPlayer for \(soundName).mp3: \(error.localizedDescription). Completion will not be called." + "[AudioService] Error initializing AVAudioPlayer for \(soundName).mp3: \(error.localizedDescription). Calling completion immediately." ) + let handler = audioCompletionHandler audioCompletionHandler = nil + handler?(false) } } @@ -88,10 +96,10 @@ class AudioService: NSObject, AVAudioPlayerDelegate { if flag { logToStderr("[AudioService] Sound finished successfully. Executing completion handler.") - handlerToCall?() } else { - logToStderr("[AudioService] Sound did not finish successfully. Not executing completion handler.") + logToStderr("[AudioService] Sound did not finish successfully. Executing completion handler anyway.") } + handlerToCall?(flag) } private func logToStderr(_ message: String) { diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift index 2f8cfb04..306e90d7 100644 --- a/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift @@ -95,50 +95,29 @@ class IOBridge: NSObject { case .muteSystemAudio: logToStderr("[IOBridge] Handling muteSystemAudio for ID: \(request.id)") - audioService.playSound(named: "rec-start") { [weak self] in - guard let self = self else { - HelperLogger.logToStderr( - "[IOBridge] self is nil in playSound completion for muteSystemAudio. ID: \(request.id)" - ) - return - } + let muteSuccess = accessibilityService.muteSystemAudio() + let muteResultPayload = MuteSystemAudioResultSchema( + message: muteSuccess ? "Mute command sent" : "Failed to send mute command", + success: muteSuccess) - self.logToStderr( - "[IOBridge] rec-start.mp3 finished playing successfully. Proceeding to mute system audio. ID: \(request.id)" + do { + let resultData = try jsonEncoder.encode(muteResultPayload) + let resultAsJsonAny = try jsonDecoder.decode(JSONAny.self, from: resultData) + rpcResponse = RPCResponseSchema(error: nil, id: request.id, result: resultAsJsonAny) + } catch { + logToStderr( + "[IOBridge] Error encoding muteSystemAudio result: \(error.localizedDescription) for ID: \(request.id)" ) - let success = self.accessibilityService.muteSystemAudio() - let resultPayload = MuteSystemAudioResultSchema( - message: success ? "Mute command sent" : "Failed to send mute command", - success: success) - - var responseToSend: RPCResponseSchema - do { - let resultData = try self.jsonEncoder.encode(resultPayload) - let resultAsJsonAny = try self.jsonDecoder.decode( - JSONAny.self, from: resultData) - responseToSend = RPCResponseSchema( - error: nil, id: request.id, result: resultAsJsonAny) - } catch { - self.logToStderr( - "[IOBridge] Error encoding muteSystemAudio result: \(error.localizedDescription) for ID: \(request.id)" - ) - let errPayload = Error( - code: -32603, data: nil, - message: "Error encoding result: \(error.localizedDescription)") - responseToSend = RPCResponseSchema( - error: errPayload, id: request.id, result: nil) - } - self.sendRpcResponse(responseToSend) + let errPayload = Error( + code: -32603, data: nil, + message: "Error encoding result: \(error.localizedDescription)") + rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil) } - return case .restoreSystemAudio: logToStderr("[IOBridge] Handling restoreSystemAudio for ID: \(request.id)") let success = accessibilityService.restoreSystemAudio() - if success { // Play sound only if restore was successful - audioService.playSound(named: "rec-stop") - } let resultPayload = RestoreSystemAudioResultSchema( message: success ? "Restore command sent" : "Failed to send restore command", success: success) @@ -230,6 +209,44 @@ class IOBridge: NSObject { rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil) } + case .playSound: + logToStderr("[IOBridge] Handling playSound for ID: \(request.id)") + guard let paramsAnyCodable = request.params else { + let errPayload = Error( + code: -32602, data: nil, message: "Missing params for playSound") + rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil) + sendRpcResponse(rpcResponse) + return + } + + do { + let paramsData = try jsonEncoder.encode(paramsAnyCodable) + let playSoundParams = try jsonDecoder.decode( + PlaySoundParamsSchema.self, from: paramsData) + + audioService.playSound(named: playSoundParams.sound) { [weak self] success in + guard let self = self else { + HelperLogger.logToStderr( + "[IOBridge] self is nil in playSound completion. ID: \(request.id)") + return + } + self.sendResult( + id: request.id, + result: PlaySoundResultSchema( + message: success ? "Sound playback completed" : "Sound playback failed", + success: success)) + } + return + } catch { + logToStderr( + "[IOBridge] Error processing playSound params: \(error.localizedDescription) for ID: \(request.id)" + ) + let errPayload = Error( + code: -32602, data: request.params, + message: "Invalid params: \(error.localizedDescription)") + rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil) + } + default: logToStderr("[IOBridge] Method not found: \(request.method) for ID: \(request.id)") let errPayload = Error( diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d42c7c76..3a0645e7 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,6 +8,7 @@ export * from "./schemas/methods/get-accessibility-context.js"; export * from "./schemas/methods/paste-text.js"; export * from "./schemas/methods/mute-system-audio.js"; export * from "./schemas/methods/restore-system-audio.js"; +export * from "./schemas/methods/play-sound.js"; export * from "./schemas/methods/set-shortcuts.js"; export * from "./schemas/methods/recheck-pressed-keys.js"; diff --git a/packages/types/src/schemas/methods/play-sound.ts b/packages/types/src/schemas/methods/play-sound.ts new file mode 100644 index 00000000..cc77587e --- /dev/null +++ b/packages/types/src/schemas/methods/play-sound.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +// Request params +export const PlaySoundParamsSchema = z.object({ + sound: z.enum(["rec-start", "rec-stop"]), +}); +export type PlaySoundParams = z.infer; + +// Response result +export const PlaySoundResultSchema = z.object({ + success: z.boolean(), + message: z.string().optional(), +}); +export type PlaySoundResult = z.infer; diff --git a/packages/types/src/schemas/rpc/request.ts b/packages/types/src/schemas/rpc/request.ts index 9037f535..2c49e8fc 100644 --- a/packages/types/src/schemas/rpc/request.ts +++ b/packages/types/src/schemas/rpc/request.ts @@ -13,6 +13,7 @@ const RPCMethodNameSchema = z.union([ z.literal("pasteText"), z.literal("muteSystemAudio"), z.literal("restoreSystemAudio"), + z.literal("playSound"), z.literal("setShortcuts"), z.literal("recheckPressedKeys"), ]);