macOS: centralize sound effect catalog/player

This commit is contained in:
Peter Steinberger
2025-12-09 03:37:16 +01:00
parent 76d559efc1
commit dbcb97949f
2 changed files with 114 additions and 84 deletions

View File

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

View File

@@ -28,74 +28,15 @@ enum VoiceWakeChime: Codable, Equatable, Sendable {
struct VoiceWakeChimeCatalog { struct VoiceWakeChimeCatalog {
/// Options shown in the picker. /// Options shown in the picker.
static let systemOptions: [String] = { static var systemOptions: [String] { SoundEffectCatalog.systemOptions }
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 func displayName(for raw: String) -> String { static func displayName(for raw: String) -> String {
return raw SoundEffectCatalog.displayName(for: raw)
} }
static func url(for name: String) -> URL? { static func url(for name: String) -> URL? {
return self.discoveredSoundMap[name] SoundEffectCatalog.url(for: name)
} }
private static let allowedExtensions: Set<String> = [
"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 @MainActor
@@ -103,13 +44,10 @@ enum VoiceWakeChimePlayer {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.chime") private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.chime")
private static var lastSound: NSSound? private static var lastSound: NSSound?
@MainActor
static func play(_ chime: VoiceWakeChime) { static func play(_ chime: VoiceWakeChime) {
guard let sound = self.sound(for: chime) else { return } 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.logger.log(level: .info, "chime play")
self.lastSound = sound SoundEffectPlayer.play(sound)
sound.stop()
sound.play()
} }
private static func sound(for chime: VoiceWakeChime) -> NSSound? { private static func sound(for chime: VoiceWakeChime) -> NSSound? {
@@ -117,25 +55,10 @@ enum VoiceWakeChimePlayer {
case .none: case .none:
return nil return nil
case let .system(name): case let .system(name):
if let named = NSSound(named: NSSound.Name(name)) { return SoundEffectPlayer.sound(named: name)
return named
}
if let url = VoiceWakeChimeCatalog.url(for: name) {
return NSSound(contentsOf: url, byReference: false)
}
return nil
case let .custom(_, bookmark): case let .custom(_, bookmark):
var stale = false return SoundEffectPlayer.sound(from: bookmark)
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)
} }
} }
} }