diff --git a/apps/HushApp/Shared/Features/Demo/HushDemoRootView.swift b/apps/HushApp/Shared/Features/Demo/HushDemoRootView.swift index 3c4efa0..ee23eca 100644 --- a/apps/HushApp/Shared/Features/Demo/HushDemoRootView.swift +++ b/apps/HushApp/Shared/Features/Demo/HushDemoRootView.swift @@ -2,6 +2,7 @@ import SwiftUI struct HushDemoRootView: View { @StateObject private var store: HushDemoStore + @StateObject private var ambientAudio = HushAmbientAudioModel() @ObservedObject private var sleepSchedule = HushSleepScheduleController.shared @State private var isShowingSettings = false @@ -55,6 +56,7 @@ struct HushDemoRootView: View { HushRestTaskGeneratingView() } else { HushDoorView( + ambientAudio: ambientAudio, taskText: agentTaskText, onOpenTask: openAgentTask, onSettings: { @@ -167,6 +169,23 @@ struct HushDemoRootView: View { openedSuggestionMessage = nil store.clearGeneratedRestSuggestion() } + .alert( + "声音暂不可用", + isPresented: Binding( + get: { ambientAudio.errorMessage != nil }, + set: { isPresented in + if !isPresented { + ambientAudio.clearError() + } + } + ) + ) { + Button("好", role: .cancel) { + ambientAudio.clearError() + } + } message: { + Text(ambientAudio.errorMessage ?? "") + } .sheet(isPresented: $isShowingSettings) { HushSettingsView( degraded: store.content.status.isFallback, diff --git a/apps/HushApp/Shared/Features/HushDoor/HushDoorView.swift b/apps/HushApp/Shared/Features/HushDoor/HushDoorView.swift index eae4a2f..a8c9ff1 100644 --- a/apps/HushApp/Shared/Features/HushDoor/HushDoorView.swift +++ b/apps/HushApp/Shared/Features/HushDoor/HushDoorView.swift @@ -1,6 +1,188 @@ +import AVFoundation import SwiftUI +enum HushAmbientSound: String, CaseIterable, Identifiable { + case whiteNoise + case pinkNoise + case brownNoise + case oceanWaves + case rain + + var id: String { rawValue } + + var title: String { + switch self { + case .whiteNoise: + "白噪音" + case .pinkNoise: + "粉红噪音" + case .brownNoise: + "棕色噪音" + case .oceanWaves: + "海浪" + case .rain: + "轻雨" + } + } + + fileprivate var resourceName: String { + switch self { + case .whiteNoise: + "white-noise" + case .pinkNoise: + "pink-noise" + case .brownNoise: + "brown-noise" + case .oceanWaves: + "ocean-waves" + case .rain: + "rain" + } + } +} + +enum HushAmbientVolume: String, CaseIterable, Identifiable { + case quiet + case balanced + case clear + + var id: String { rawValue } + + var title: String { + switch self { + case .quiet: + "轻声" + case .balanced: + "平稳" + case .clear: + "清晰" + } + } + + fileprivate var value: Float { + switch self { + case .quiet: + 0.12 + case .balanced: + 0.2 + case .clear: + 0.32 + } + } +} + +@MainActor +final class HushAmbientAudioModel: ObservableObject { + @Published var selectedSound: HushAmbientSound { + didSet { + defaults.set(selectedSound.rawValue, forKey: Self.soundKey) + guard isPlaying else { return } + startPlayback() + } + } + + @Published var volume: HushAmbientVolume { + didSet { + defaults.set(volume.rawValue, forKey: Self.volumeKey) + player?.volume = volume.value + } + } + + @Published private(set) var isPlaying = false + @Published private(set) var errorMessage: String? + + private static let soundKey = "hush.ambient.sound" + private static let volumeKey = "hush.ambient.volume" + + private let defaults = UserDefaults.standard + private var player: AVAudioPlayer? + + init() { + selectedSound = HushAmbientSound( + rawValue: defaults.string(forKey: Self.soundKey) ?? "" + ) ?? .oceanWaves + volume = HushAmbientVolume( + rawValue: defaults.string(forKey: Self.volumeKey) ?? "" + ) ?? .balanced + } + + func togglePlayback() { + isPlaying ? stopPlayback() : startPlayback() + } + + func stopPlayback() { + player?.stop() + player = nil + isPlaying = false + deactivateAudioSessionIfNeeded() + } + + func clearError() { + errorMessage = nil + } + + private func startPlayback() { + player?.stop() + player = nil + isPlaying = false + errorMessage = nil + + guard + let url = Bundle.main.url( + forResource: selectedSound.resourceName, + withExtension: "mp3" + ) + else { + errorMessage = "声音资源还没有加入当前 App Target。" + return + } + + do { + try activateAudioSessionIfNeeded() + let player = try AVAudioPlayer(contentsOf: url) + player.numberOfLoops = -1 + player.volume = volume.value + player.prepareToPlay() + guard player.play() else { + throw HushAmbientAudioError.playbackFailed + } + self.player = player + isPlaying = true + } catch { + errorMessage = "暂时无法播放这个声音。" + deactivateAudioSessionIfNeeded() + } + } + + private func activateAudioSessionIfNeeded() throws { + #if os(iOS) + let session = AVAudioSession.sharedInstance() + try session.setCategory( + .ambient, + mode: .default, + options: [.mixWithOthers] + ) + try session.setActive(true) + #endif + } + + private func deactivateAudioSessionIfNeeded() { + #if os(iOS) + try? AVAudioSession.sharedInstance().setActive( + false, + options: [.notifyOthersOnDeactivation] + ) + #endif + } +} + +private enum HushAmbientAudioError: Error { + case playbackFailed +} + struct HushDoorView: View { + @ObservedObject var ambientAudio: HushAmbientAudioModel + let taskText: String let onOpenTask: () -> Void let onSettings: () -> Void @@ -26,6 +208,65 @@ struct HushDoorView: View { ) HStack(spacing: HushSpacing.xs) { + Menu { + Button( + ambientAudio.isPlaying + ? "停止播放" + : "开始播放" + ) { + ambientAudio.togglePlayback() + } + + Divider() + + Picker( + "陪伴声音", + selection: $ambientAudio.selectedSound + ) { + ForEach(HushAmbientSound.allCases) { sound in + Text(sound.title).tag(sound) + } + } + + Picker( + "音量", + selection: $ambientAudio.volume + ) { + ForEach(HushAmbientVolume.allCases) { volume in + Text(volume.title).tag(volume) + } + } + } label: { + Image( + systemName: ambientAudio.isPlaying + ? "speaker.wave.2.fill" + : "speaker.slash" + ) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle( + Color.white.opacity( + ambientAudio.isPlaying ? 0.9 : 0.68 + ) + ) + .frame(width: 34, height: 34) + .background( + Circle().fill(Color.white.opacity(0.035)) + ) + .overlay( + Circle().stroke( + Color.white.opacity( + ambientAudio.isPlaying ? 0.2 : 0.1 + ), + lineWidth: 0.8 + ) + ) + } + .accessibilityLabel( + ambientAudio.isPlaying + ? "陪伴声音正在播放" + : "选择陪伴声音" + ) + if let onOpenCompanion { Button(action: onOpenCompanion) { Image(systemName: "sun.max.fill") diff --git a/content/ambient-sounds/README.md b/content/ambient-sounds/README.md new file mode 100644 index 0000000..24628ef --- /dev/null +++ b/content/ambient-sounds/README.md @@ -0,0 +1,30 @@ +# Hush Ambient Sound Assets + +These files are short, local loops for the Hush always-on companion. They are +bundled for offline playback and must never be fetched from the network at +runtime. + +All five source recordings are public-domain works hosted by Wikimedia +Commons. The checked-in MP3 files are Wikimedia's generated MP3 transcodes of +the original Ogg files. + +| Bundled file | Source | Source status | +|---|---|---| +| `white-noise.mp3` | [Whitenoisesound.ogg](https://commons.wikimedia.org/wiki/File:Whitenoisesound.ogg) | Public domain; ineligible for copyright | +| `pink-noise.mp3` | [Pink.Noise.ogg](https://commons.wikimedia.org/wiki/File:Pink.Noise.ogg) | Released into the public domain by the author | +| `brown-noise.mp3` | [Brownnoise.ogg](https://commons.wikimedia.org/wiki/File:Brownnoise.ogg) | Public domain; ineligible for copyright | +| `ocean-waves.mp3` | [Beach sounds South Carolina.ogg](https://commons.wikimedia.org/wiki/File:Beach_sounds_South_Carolina.ogg) | Released into the public domain by the author | +| `rain.mp3` | [Rain (1).ogg](https://commons.wikimedia.org/wiki/File:Rain_(1).ogg) | Released into the public domain by the author | + +Downloaded and verified on 2026-07-25. The app should start with sound off and +must only begin playback after an explicit user action. + +## Integrity + +```text +86744e9e365ce2d60f2b2c04a324082fba90bd4e5e20c0cba3ed994653e465d9 brown-noise.mp3 +d929ddcc476e7e25c5c0b3d59f37e50be7f557064c89fad4276122d9ba58ded2 ocean-waves.mp3 +0d617413a00d2b575055a6ec30ceb844a6d27f5937c595befe9a38f85d70827f pink-noise.mp3 +a5c8ad766740caaa22cabdadca034d81a6b8122833188dddc2338b84d7fdb403 rain.mp3 +0c1cff70a1066c0f9e75c5b3d64c245fe9845130f54d3e699906eab0c45e2ac3 white-noise.mp3 +``` diff --git a/content/ambient-sounds/brown-noise.mp3 b/content/ambient-sounds/brown-noise.mp3 new file mode 100644 index 0000000..b9595c1 Binary files /dev/null and b/content/ambient-sounds/brown-noise.mp3 differ diff --git a/content/ambient-sounds/ocean-waves.mp3 b/content/ambient-sounds/ocean-waves.mp3 new file mode 100644 index 0000000..e0301c5 Binary files /dev/null and b/content/ambient-sounds/ocean-waves.mp3 differ diff --git a/content/ambient-sounds/pink-noise.mp3 b/content/ambient-sounds/pink-noise.mp3 new file mode 100644 index 0000000..63f9154 Binary files /dev/null and b/content/ambient-sounds/pink-noise.mp3 differ diff --git a/content/ambient-sounds/rain.mp3 b/content/ambient-sounds/rain.mp3 new file mode 100644 index 0000000..676e84a Binary files /dev/null and b/content/ambient-sounds/rain.mp3 differ diff --git a/content/ambient-sounds/white-noise.mp3 b/content/ambient-sounds/white-noise.mp3 new file mode 100644 index 0000000..8e505e9 Binary files /dev/null and b/content/ambient-sounds/white-noise.mp3 differ diff --git a/docs/demo-ambient-sounds-handoff.md b/docs/demo-ambient-sounds-handoff.md new file mode 100644 index 0000000..99be02d --- /dev/null +++ b/docs/demo-ambient-sounds-handoff.md @@ -0,0 +1,80 @@ +# Ambient Sounds Demo Handoff + +Owner: M2 / P4 -> M1 / P1 + +## P4 delivery + +The Hush Door now exposes a small speaker menu with five local ambient loops: + +- white noise; +- pink noise; +- brown noise; +- ocean waves; +- light rain. + +Playback is off by default and begins only after a user action. The selected +sound is stored locally. On iOS, playback uses the ambient audio-session +category, mixes with existing audio, and does not request background-audio +capability. + +Source and public-domain records are in: + +```text +content/ambient-sounds/README.md +``` + +## P1 Xcode resource step + +M2 does not modify `project.pbxproj`. P1 should add these existing files to the +Hush Xcode project: + +```text +content/ambient-sounds/white-noise.mp3 +content/ambient-sounds/pink-noise.mp3 +content/ambient-sounds/brown-noise.mp3 +content/ambient-sounds/ocean-waves.mp3 +content/ambient-sounds/rain.mp3 +``` + +Target membership: + +```text +Hush +HushMac +``` + +Do not add them to: + +```text +HushDeviceActivityMonitor +HushRestLiveActivity +``` + +Confirm all five files appear in **Build Phases -> Copy Bundle Resources** for +both App targets. Keep the final bundled resource names unchanged: + +```text +white-noise.mp3 +pink-noise.mp3 +brown-noise.mp3 +ocean-waves.mp3 +rain.mp3 +``` + +No entitlement, Signing, Bundle ID, App Group, background mode, Swift Package, +Contract, networking, or backend change is required. + +## Verification + +1. Build the `HushMac` scheme and run it on My Mac. +2. Open the speaker menu on the Hush Door. +3. Confirm every sound starts, changes while playing, loops, and stops. +4. Confirm the app launches silently after a fresh start. +5. Build the `Hush` scheme and repeat on an iPhone. +6. Start other audio before Hush and confirm Hush mixes without taking over + device playback. +7. Confirm leaving the Hush Door for another in-app route does not restart the + sound. + +If the App target does not contain the resource, Hush shows a local error +instead of attempting a network download.