Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/HushApp/Shared/Features/Demo/HushDemoRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,6 +56,7 @@ struct HushDemoRootView: View {
HushRestTaskGeneratingView()
} else {
HushDoorView(
ambientAudio: ambientAudio,
taskText: agentTaskText,
onOpenTask: openAgentTask,
onSettings: {
Expand Down Expand Up @@ -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,
Expand Down
241 changes: 241 additions & 0 deletions apps/HushApp/Shared/Features/HushDoor/HushDoorView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down
30 changes: 30 additions & 0 deletions content/ambient-sounds/README.md
Original file line number Diff line number Diff line change
@@ -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
```
Binary file added content/ambient-sounds/brown-noise.mp3
Binary file not shown.
Binary file added content/ambient-sounds/ocean-waves.mp3
Binary file not shown.
Binary file added content/ambient-sounds/pink-noise.mp3
Binary file not shown.
Binary file added content/ambient-sounds/rain.mp3
Binary file not shown.
Binary file added content/ambient-sounds/white-noise.mp3
Binary file not shown.
Loading
Loading