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 {
/// 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<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
@@ -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)
}
}
}