feat(sound): swap sit/clockout tones for better character

- sit:      Morse → Submarine (1.5s sonar). Longer sustain + lower
            pitch pierces meeting audio and noise-cancelling
            headphones better than Morse's crisp staccato. 1Hz
            cadence over 15s still drives urgency.
- clockout: Glass → Hero (1.1s heroic fanfare). Glass was "
            achievement"; Hero is "🎺 victory" — better scale for
            the 下班 moment. Three plays with 0.6s gap.

Both still macOS-built-in (/System/Library/Sounds), zero external
deps. Single hard-coded path per sound — to swap again, edit the
URL line. Function name `playMorseSitAlert` kept for callsite
compat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-05-20 10:26:37 +08:00
parent 5e5540d9b1
commit 8a66043f6f

View File

@ -25,22 +25,29 @@ final class SoundPlayer {
static let shared = SoundPlayer() static let shared = SoundPlayer()
private init() {} private init() {}
/// System Morse sound 0.7s staccato 3-tone "S". /// Sit-alert sound. Picked Submarine (1.5s sonar ping) over the
/// Shipped on every macOS since at least 10.0; safe to hard-code path. /// original Morse the sonar's longer sustain + lower pitch
private let morseURL = URL(fileURLWithPath: "/System/Library/Sounds/Morse.aiff") /// pierces noise-cancelling headphones and meeting audio better,
/// while the 15-second 1Hz cadence still drives the urgency.
/// To switch back: change to "Morse" / "Tink" / "Ping" / etc.
/// All built-in: /System/Library/Sounds/*.aiff
private let sitAlertURL = URL(fileURLWithPath: "/System/Library/Sounds/Submarine.aiff")
private var timer: Timer? private var timer: Timer?
private var remaining: Int = 0 private var remaining: Int = 0
private var currentSound: NSSound? private var currentSound: NSSound?
/// Repeated Morse alert: play `count` times at 1Hz. Each play is a /// Repeated sit alert: play `count` times at 1Hz. Each play is a
/// fresh NSSound so previous-in-flight playback doesn't get stomped /// fresh NSSound so previous-in-flight playback doesn't get stomped
/// (NSSound.play() is non-blocking and the 0.7s tone overlaps with /// (NSSound.play() is non-blocking; Submarine's 1.5s overlap with
/// the next-second silence acceptable acoustic gap). /// the next-second trigger creates a slightly layered effect
/// acoustically more present than crisp single beeps).
/// ///
/// Calling while a previous alert is still running cancels the old /// Calling while a previous alert is still running cancels the old
/// one and restarts from `count`. This keeps "user sat back down + /// one and restarts from `count`. This keeps "user sat back down +
/// trigger fired again 45min later" clean. /// trigger fired again 45min later" clean.
///
/// Name retained for source compat (callsite is single, low cost).
func playMorseSitAlert(count: Int = 15) { func playMorseSitAlert(count: Int = 15) {
stop() stop()
remaining = count remaining = count
@ -50,19 +57,21 @@ final class SoundPlayer {
} }
} }
/// Clockout celebration tone Glass (1.0s sustained "" tone) /// Clockout celebration tone Hero (1.1s heroic fanfare),
/// played 3 times with 0.5s gap. Distinct from sit Morse so the /// played 3 times with 0.6s gap. Picked over the original Glass
/// user instantly knows "" not "". /// for more triumph energy on the "" moment. Glass was
/// " achievement"; Hero is "🎺 victory" appropriate scale.
/// To switch: change to "Funk" / "Sosumi" / "Glass" / etc.
func playClockoutCelebration() { func playClockoutCelebration() {
stop() stop()
let glassURL = URL(fileURLWithPath: "/System/Library/Sounds/Glass.aiff") let celebrationURL = URL(fileURLWithPath: "/System/Library/Sounds/Hero.aiff")
// 3 chimes at 0s, 0.6s, 1.2s shorter cadence than sit (full // 3 chimes at 0s, 0.6s, 1.2s full shot of joy, not a nag.
// shot of joy, not a nag). Schedule with DispatchQueue async // Schedule with DispatchQueue async since this is a fixed-shot
// since this is a fixed-shot pattern, no need for a Timer loop. // pattern, no need for a Timer loop.
for delay in [0.0, 0.6, 1.2] { for delay in [0.0, 0.6, 1.2] {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let _ = self else { return } guard let _ = self else { return }
if let s = NSSound(contentsOf: glassURL, byReference: true) { if let s = NSSound(contentsOf: celebrationURL, byReference: true) {
s.play() s.play()
} }
} }
@ -88,11 +97,11 @@ final class SoundPlayer {
// byReference: true load lazily from the system path each call. // byReference: true load lazily from the system path each call.
// The cost is negligible (< 1ms) and avoids keeping a 200KB AIFF // The cost is negligible (< 1ms) and avoids keeping a 200KB AIFF
// resident across the 45min idle stretch between alerts. // resident across the 45min idle stretch between alerts.
if let sound = NSSound(contentsOf: morseURL, byReference: true) { if let sound = NSSound(contentsOf: sitAlertURL, byReference: true) {
sound.play() sound.play()
currentSound = sound currentSound = sound
} else { } else {
WorkerDebugLog.write("SoundPlayer: failed to load \(morseURL.path)") WorkerDebugLog.write("SoundPlayer: failed to load \(sitAlertURL.path)")
stop() stop()
} }
} }