Skip to content

Commit 8b43e4a

Browse files
author
emezzzzz
committed
Harden audio shutdown across app surfaces
1 parent e1ccfaa commit 8b43e4a

25 files changed

Lines changed: 327 additions & 58 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ x clear selected layer (repeat to confirm)
6161
s save session
6262
e export mix
6363
l generate listening report
64+
k hard-silence audio (stop capture, mute layers, discard pending output)
6465
i toggle prompt/audio input mode
6566
tab cycle mode
6667
q quit

apps/macos/Sources/ORAMApp/App/ORAMApp.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,27 @@
1+
import AppKit
12
import SwiftUI
23

4+
@MainActor
5+
final class ORAMAppDelegate: NSObject, NSApplicationDelegate {
6+
weak var store: AppStore?
7+
private var isTerminating = false
8+
9+
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
10+
guard !isTerminating else {
11+
return .terminateNow
12+
}
13+
isTerminating = true
14+
Task {
15+
await store?.shutdown()
16+
sender.reply(toApplicationShouldTerminate: true)
17+
}
18+
return .terminateLater
19+
}
20+
}
21+
322
@main
423
struct ORAMApplication: App {
24+
@NSApplicationDelegateAdaptor(ORAMAppDelegate.self) private var appDelegate
525
@StateObject private var store = AppStore()
626

727
var body: some Scene {
@@ -10,8 +30,14 @@ struct ORAMApplication: App {
1030
.environmentObject(store)
1131
.frame(minWidth: 1040, minHeight: 680)
1232
.task {
33+
appDelegate.store = store
1334
await store.bootstrap()
1435
}
36+
.onDisappear {
37+
Task {
38+
await store.shutdown()
39+
}
40+
}
1541
}
1642
.commands {
1743
CommandGroup(after: .appInfo) {

apps/macos/Sources/ORAMApp/Stores/AppStore.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,18 @@ final class AppStore: ObservableObject {
2020
private var wsRetryTask: Task<Void, Never>?
2121
private var waveformCacheKeys: [Int: String] = [:]
2222
private var waveformFetches: Set<String> = []
23+
private var isShuttingDown = false
2324

2425
var selectedSound: SoundRecord? {
2526
sounds.first { $0.id == selectedSoundID }
2627
}
2728

2829
func bootstrap() async {
30+
isShuttingDown = false
2931
connectionStatus = await daemonManager.launchIfNeeded(client: client)
32+
if connectionStatus == "connected", client.isConfigured {
33+
try? await client.killAll()
34+
}
3035
await refreshAll()
3136
connectWebSocket()
3237
}
@@ -162,6 +167,26 @@ final class AppStore: ObservableObject {
162167
}
163168
}
164169

170+
func shutdown() async {
171+
guard !isShuttingDown else { return }
172+
isShuttingDown = true
173+
retryTask?.cancel()
174+
retryTask = nil
175+
wsRetryTask?.cancel()
176+
wsRetryTask = nil
177+
wsTask?.cancel(with: .goingAway, reason: nil)
178+
wsTask = nil
179+
do {
180+
if client.isConfigured {
181+
try await client.killAll()
182+
}
183+
} catch {
184+
errorMessage = error.localizedDescription
185+
}
186+
daemonManager.stop()
187+
connectionStatus = "stopped"
188+
}
189+
165190
func cycleInputMode() async {
166191
let current = modeKey
167192
let next = current == "prompt" ? "audio" : (current == "audio" ? "listen" : "prompt")

apps/macos/Sources/ORAMApp/Views/ContentView.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ struct ContentView: View {
200200
HeaderGlyph("", role: .danger, theme: lightTheme) {
201201
Task { await store.killAll() }
202202
}
203-
.onHoverHint("kill all sound — mute every layer and stop recording", $hint)
203+
.onHoverHint("kill all sound — stop capture, mute layers, discard pending output", $hint)
204204

205205
HeaderSeparator()
206206

@@ -1392,19 +1392,24 @@ private struct AboutOverlay: View {
13921392
.foregroundStyle(DashboardTheme.secondary(theme))
13931393
}
13941394

1395-
Text("recorder · looper · summoner · auto-generator · archive")
1395+
Text("recorder · looper · sampler · engine router · local archive")
13961396
.font(.system(size: 12, weight: .semibold, design: .monospaced))
13971397
.foregroundStyle(DashboardTheme.secondary(theme))
13981398

1399-
Text("Local-first BYOK sound material system. The app launches a localhost daemon, stores provider keys in macOS Keychain, keeps mock mode available, and writes generated sounds into the local ORAM Library.")
1399+
Text("Local-first BYOK sound workstation. The macOS app controls the Python engine through a localhost daemon, stores provider keys in macOS Keychain, keeps Local Mock available, and writes generated sounds into the local ORAM Library.")
14001400
.font(.system(size: 12, design: .monospaced))
14011401
.foregroundStyle(DashboardTheme.dim(theme))
14021402
.fixedSize(horizontal: false, vertical: true)
14031403

1404+
Text("macOS: quit sends kill-all audio first, then stops any daemon process launched by the app. The kill control stops recording and command capture, mutes every layer, and discards pending generation output.")
1405+
.font(.system(size: 11, design: .monospaced))
1406+
.foregroundStyle(DashboardTheme.dim(theme))
1407+
.fixedSize(horizontal: false, vertical: true)
1408+
14041409
HStack(spacing: 8) {
14051410
MiniFact("open source", theme: theme)
14061411
MiniFact("no telemetry", theme: theme)
1407-
MiniFact("no Momoto server", theme: theme)
1412+
MiniFact("localhost daemon", theme: theme)
14081413
MiniFact("local archive", theme: theme)
14091414
}
14101415

@@ -1463,6 +1468,7 @@ private struct CommandPaletteOverlay: View {
14631468
private let commands = [
14641469
"record",
14651470
"stop recording",
1471+
"kill audio",
14661472
"overdub",
14671473
"listen to the texture",
14681474
"export mix",

apps/macos/script/build_and_run.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
55
REPO_ROOT="$(cd "$ROOT_DIR/../.." && pwd)"
66
APP_NAME="ORAM"
77
BUNDLE_ID="wtf.momoto.oram"
8+
APP_VERSION="$(awk -F'"' '/^version = / {print $2; exit}' "$REPO_ROOT/pyproject.toml")"
9+
APP_VERSION="${APP_VERSION:-0.0.0}"
810
BUILD_CONFIGURATION="${ORAM_BUILD_CONFIGURATION:-release}"
911
DIST_DIR="$ROOT_DIR/dist"
1012
APP_DIR="$DIST_DIR/$APP_NAME.app"
@@ -84,7 +86,7 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
8486
<key>CFBundlePackageType</key>
8587
<string>APPL</string>
8688
<key>CFBundleShortVersionString</key>
87-
<string>0.1.0</string>
89+
<string>$APP_VERSION</string>
8890
<key>CFBundleVersion</key>
8991
<string>1</string>
9092
<key>LSMinimumSystemVersion</key>

commands.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ record eight seconds -> record(duration=8.0)
2121
record 8 seconds -> record(duration=8.0)
2222
record four bars -> record(bars=4) (requires BPM)
2323
stop recording -> stop_recording()
24+
kill audio -> kill_audio()
2425
loop this -> (set mode to loop)
2526
overdub -> overdub(target=selected)
2627
mute layer two -> mute_layer(target=2)
@@ -95,10 +96,11 @@ analyze the loop -> analyze_mix()
9596

9697
## action types
9798

98-
all 17 MVP action types:
99+
core action types:
99100

100101
- `record`
101102
- `stop_recording`
103+
- `kill_audio`
102104
- `overdub`
103105
- `select_layer`
104106
- `mute_layer`

docs/app/macos.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ The app:
2828
the repository DMG
2929
- stores provider keys directly in Keychain
3030
- calls the daemon over localhost
31+
- sends a hard-silence request before quitting, then stops any daemon process
32+
launched by the app
3133
- never displays a stored provider key by default
3234
- bundles the ORAM logo as an app resource and dashboard header image
3335

src/oram/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
"""oram — a speech-operated terminal looper for synthetic sound studies."""
22

3-
__version__ = "0.1.0"
3+
from importlib.metadata import PackageNotFoundError, version
4+
5+
try:
6+
__version__ = version("oram")
7+
except PackageNotFoundError:
8+
__version__ = "2.0.0"

src/oram/agent/llm_adapter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
Allowed actions and their schemas:
4343
- {"action":"record","target":"selected","duration":8.0,"overdub":false}
4444
- {"action":"stop_recording"}
45+
- {"action":"kill_audio"}
4546
- {"action":"overdub","target":"selected","duration":null}
4647
- {"action":"select_layer","target":1} (target: 1-4)
4748
- {"action":"mute_layer","target":1}

src/oram/app.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from rich.console import Console
1313
from rich.live import Live
1414

15+
from oram import __version__
1516
from oram.agent.controller import AgentController
1617
from oram.agent.llm_adapter import LLMCliAdapter
1718
from oram.audio.engine import MockAudioEngine
@@ -211,7 +212,7 @@ def on_status(msg: str):
211212
engine.start()
212213

213214
console.print("")
214-
console.print("oram v2.0.0 — recursive listening instrument", style="oram.title")
215+
console.print(f"oram {__version__}local recursive audio workstation", style="oram.title")
215216
stt_label = "off" if config.no_stt else config.stt_backend
216217
gw_label = "elevenlabs" if gateway else "mock"
217218
console.print(
@@ -224,7 +225,7 @@ def on_status(msg: str):
224225
style="oram.status",
225226
)
226227
console.print(
227-
" l=listen g=generate f=fork tab=mode s=save e=export q=quit",
228+
" l=listen g=generate f=fork k=kill tab=mode s=save e=export q=quit",
228229
style="oram.status",
229230
)
230231
console.print(
@@ -241,6 +242,7 @@ def on_status(msg: str):
241242
except KeyboardInterrupt:
242243
pass
243244
finally:
245+
router.kill_all_audio()
244246
engine.stop()
245247
console.print("\noram stopped.", style="oram.title")
246248

0 commit comments

Comments
 (0)