From 19c5be91fab53611184e7d187c1578932215d897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E7=BF=94=E5=AE=87?= Date: Wed, 20 May 2026 09:25:48 +0800 Subject: [PATCH] feat(sit): 15s Morse alert tone on sit threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UN notification ding is one short beep — easy to miss in a meeting. For sit alerts, repeat the system Morse tone (0.7s staccato "S O S") at 1Hz for 15 ticks, layered on top of the notification + dock bounce. - new: SoundPlayer.swift — NSSound-based repeating tone with stop() - WorkerStore.tickFire: trigger SoundPlayer.playMorseSitAlert(count: 15) alongside the existing notify() call - WorkerStore.sitStop: silence in-flight alert when user stops monitoring Uses /System/Library/Sounds/Morse.aiff — shipped on every macOS, no resource bundling needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/engine/SoundPlayer.swift | 80 ++++++++++++++++++++++++++++++++ Sources/engine/WorkerStore.swift | 9 +++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 Sources/engine/SoundPlayer.swift diff --git a/Sources/engine/SoundPlayer.swift b/Sources/engine/SoundPlayer.swift new file mode 100644 index 0000000..2cdf18c --- /dev/null +++ b/Sources/engine/SoundPlayer.swift @@ -0,0 +1,80 @@ +// +// SoundPlayer.swift +// 摸鱼侠 plugin v0.2.1 +// +// Sit-reminder alert tone. The bundled UN notification sound is a +// ~1s ding that's easy to miss in a meeting — for sit alerts we want +// a 15s nag that makes you actually stand up. +// +// Implementation: repeat the macOS system "Morse" sound (0.7s) once +// per second for 15 ticks. Morse is the most distinctive notification +// sound shipped with macOS — staccato 3-tone "S O S" pattern that +// pierces background noise better than Glass/Hero/Ping/Funk/etc. +// +// Plays via NSSound (not UNNotificationContent.sound) because UN +// sounds are limited to ~30s but get truncated to ~5s on macOS, and +// attach a single tone to a single notification — no way to fire a +// repeated sequence from UN. +// + +import AppKit +import Foundation + +@MainActor +final class SoundPlayer { + static let shared = SoundPlayer() + private init() {} + + /// System Morse sound — 0.7s staccato 3-tone "S". + /// Shipped on every macOS since at least 10.0; safe to hard-code path. + private let morseURL = URL(fileURLWithPath: "/System/Library/Sounds/Morse.aiff") + + private var timer: Timer? + private var remaining: Int = 0 + private var currentSound: NSSound? + + /// Repeated Morse alert: play `count` times at 1Hz. Each play is a + /// fresh NSSound so previous-in-flight playback doesn't get stomped + /// (NSSound.play() is non-blocking and the 0.7s tone overlaps with + /// the next-second silence — acceptable acoustic gap). + /// + /// Calling while a previous alert is still running cancels the old + /// one and restarts from `count`. This keeps "user sat back down + + /// trigger fired again 45min later" clean. + func playMorseSitAlert(count: Int = 15) { + stop() + remaining = count + playOnce() // fire immediately, then 1Hz repeat + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in self?.playOnce() } + } + } + + /// Cancel any in-flight alert. Called by WorkerStore.sitStop() so + /// "user stopped sit monitoring" silences the nag immediately. + func stop() { + timer?.invalidate() + timer = nil + remaining = 0 + currentSound?.stop() + currentSound = nil + } + + private func playOnce() { + guard remaining > 0 else { + stop() + return + } + remaining -= 1 + // byReference: true — load lazily from the system path each call. + // The cost is negligible (< 1ms) and avoids keeping a 200KB AIFF + // resident across the 45min idle stretch between alerts. + if let sound = NSSound(contentsOf: morseURL, byReference: true) { + sound.play() + currentSound = sound + } else { + WorkerDebugLog.write("SoundPlayer: failed to load \(morseURL.path)") + stop() + } + } +} diff --git a/Sources/engine/WorkerStore.swift b/Sources/engine/WorkerStore.swift index c7da2ed..178a2a1 100644 --- a/Sources/engine/WorkerStore.swift +++ b/Sources/engine/WorkerStore.swift @@ -256,7 +256,11 @@ final class WorkerStore: ObservableObject { title: "该起来动一下了", body: "你已连续坐了 \(sitTriggerMin) 分钟,起身喝口水吧。" ) - WorkerDebugLog.write("sit threshold notification fired (\(sitElapsedSec)s)") + // 1Hz × 15 ticks of system Morse tone — the UN + // notification ding is too easy to miss in a meeting, + // so the sit alert gets a louder, longer signal. + SoundPlayer.shared.playMorseSitAlert(count: 15) + WorkerDebugLog.write("sit threshold notification + morse alert fired (\(sitElapsedSec)s)") } } @@ -463,6 +467,9 @@ final class WorkerStore: ObservableObject { defaults.set(false, forKey: K.sitEnabled) defaults.set(0, forKey: K.sitAccumActive) defaults.set(0.0, forKey: K.sitLastNotified) + // Silence any in-flight Morse alert from a prior trigger so the + // user stopping monitoring also stops the nag immediately. + SoundPlayer.shared.stop() } func sitSetTrigger(_ min: Int) {