diff --git a/apps/macos/Sources/Clawdis/SoundEffects.swift b/apps/macos/Sources/Clawdis/SoundEffects.swift new file mode 100644 index 000000000..3470e766c --- /dev/null +++ b/apps/macos/Sources/Clawdis/SoundEffects.swift @@ -0,0 +1,107 @@ +import AppKit +import Foundation + +struct SoundEffectCatalog { + /// All discoverable system sound names, with "Glass" pinned first. + static var systemOptions: [String] { + var names = Set(Self.discoveredSoundMap.keys).union(Self.fallbackNames) + names.remove("Glass") + let sorted = names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + return ["Glass"] + sorted + } + + static func displayName(for raw: String) -> String { raw } + + static func url(for name: String) -> URL? { + Self.discoveredSoundMap[name] + } + + // MARK: - Internals + + private static let allowedExtensions: Set = [ + "aif", "aiff", "caf", "wav", "m4a", "mp3", + ] + + private static let fallbackNames: [String] = [ + "Glass", // default + "Ping", + "Pop", + "Frog", + "Submarine", + "Funk", + "Tink", + "Basso", + "Blow", + "Bottle", + "Hero", + "Morse", + "Purr", + "Sosumi", + "Mail Sent", + "New Mail", + "Mail Scheduled", + "Mail Fetch Error", + ] + + private static let searchRoots: [URL] = [ + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"), + URL(fileURLWithPath: "/Library/Sounds"), + URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh” + URL(fileURLWithPath: "/System/Library/Sounds"), + ] + + private static let discoveredSoundMap: [String: URL] = { + var map: [String: URL] = [:] + for root in Self.searchRoots { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + else { continue } + + for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) { + let name = url.deletingPathExtension().lastPathComponent + // Preserve the first match in priority order. + if map[name] == nil { + map[name] = url + } + } + } + return map + }() +} + +@MainActor +enum SoundEffectPlayer { + private static var lastSound: NSSound? + + static func sound(named name: String) -> NSSound? { + if let named = NSSound(named: NSSound.Name(name)) { + return named + } + if let url = SoundEffectCatalog.url(for: name) { + return NSSound(contentsOf: url, byReference: false) + } + return nil + } + + static func sound(from bookmark: Data) -> NSSound? { + var stale = false + guard let url = try? URL( + resolvingBookmarkData: bookmark, + options: [.withoutUI, .withSecurityScope], + bookmarkDataIsStale: &stale) + else { return nil } + + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + return NSSound(contentsOf: url, byReference: false) + } + + static func play(_ sound: NSSound?) { + guard let sound else { return } + self.lastSound = sound + sound.stop() + sound.play() + } +} diff --git a/apps/macos/Sources/Clawdis/VoiceWakeChime.swift b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift index fb0947024..426531e93 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeChime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift @@ -28,74 +28,15 @@ enum VoiceWakeChime: Codable, Equatable, Sendable { struct VoiceWakeChimeCatalog { /// Options shown in the picker. - static let systemOptions: [String] = { - let discovered = Self.discoveredSoundMap.keys - let fallback: [String] = [ - "Glass", // default - "Ping", - "Pop", - "Frog", - "Submarine", - "Funk", - "Tink", - "Basso", - "Blow", - "Bottle", - "Hero", - "Morse", - "Purr", - "Sosumi", - "Mail Sent", - "New Mail", - "Mail Scheduled", - "Mail Fetch Error", - ] - - // Keep Glass first, then present the rest alphabetically without duplicates. - var names = Set(discovered).union(fallback) - names.remove("Glass") - let sorted = names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - return ["Glass"] + sorted - }() + static var systemOptions: [String] { SoundEffectCatalog.systemOptions } static func displayName(for raw: String) -> String { - return raw + SoundEffectCatalog.displayName(for: raw) } static func url(for name: String) -> URL? { - return self.discoveredSoundMap[name] + SoundEffectCatalog.url(for: name) } - - private static let allowedExtensions: Set = [ - "aif", "aiff", "caf", "wav", "m4a", "mp3", - ] - - private static let searchRoots: [URL] = [ - FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"), - URL(fileURLWithPath: "/Library/Sounds"), - URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh” - URL(fileURLWithPath: "/System/Library/Sounds"), - ] - - private static let discoveredSoundMap: [String: URL] = { - var map: [String: URL] = [:] - for root in Self.searchRoots { - guard let contents = try? FileManager.default.contentsOfDirectory( - at: root, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles]) - else { continue } - - for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) { - let name = url.deletingPathExtension().lastPathComponent - // Preserve the first match in priority order. - if map[name] == nil { - map[name] = url - } - } - } - return map - }() } @MainActor @@ -103,13 +44,10 @@ enum VoiceWakeChimePlayer { private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.chime") private static var lastSound: NSSound? - @MainActor static func play(_ chime: VoiceWakeChime) { guard let sound = self.sound(for: chime) else { return } - self.logger.log(level: .info, "chime play type=\(String(describing: chime), privacy: .public) name=\(sound.name ?? "", privacy: .public)") - self.lastSound = sound - sound.stop() - sound.play() + self.logger.log(level: .info, "chime play") + SoundEffectPlayer.play(sound) } private static func sound(for chime: VoiceWakeChime) -> NSSound? { @@ -117,25 +55,10 @@ enum VoiceWakeChimePlayer { case .none: return nil case let .system(name): - if let named = NSSound(named: NSSound.Name(name)) { - return named - } - if let url = VoiceWakeChimeCatalog.url(for: name) { - return NSSound(contentsOf: url, byReference: false) - } - return nil + return SoundEffectPlayer.sound(named: name) case let .custom(_, bookmark): - var stale = false - guard let url = try? URL( - resolvingBookmarkData: bookmark, - options: [.withoutUI, .withSecurityScope], - bookmarkDataIsStale: &stale) - else { return nil } - - let scoped = url.startAccessingSecurityScopedResource() - defer { if scoped { url.stopAccessingSecurityScopedResource() } } - return NSSound(contentsOf: url, byReference: false) + return SoundEffectPlayer.sound(from: bookmark) } } }