Skip to content
Merged
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ cable so any app (Zoom, Meet, Discord, OBS, …) receives studio-clean audio.
- `VoicePreset` — preset → DSP params + voice-chain settings (single source of truth).
- `CLIArguments` + `AudioDenoiseOptions` — shared CLI parser (live device, `--action`, `--denoise`).
- `AudioFileDenoiser` — offline file decode → mono 48 kHz → `DeepFilterNetDSP` → optional `VoiceChain` → write WAV/CAF/etc. Waits on `DeepFilterNetDSP.waitUntilReady()` before processing; writes to a temp sibling then moves on success.
- `Sources/App` — SwiftUI menu-bar app: `NoNoiseMacApp`, `ContentView` (popover), `SettingsView`.
- `Sources/App` — SwiftUI menu-bar app: `NoNoiseMacApp`, `ContentView` (popover), `SettingsView`, and `LaunchAtLoginManager` (macOS Service Management adapter).
- `Sources/CLI` — `NoNoiseMacCLI`: live device pipeline, one-shot `--action` URL dispatch, and `--denoise` offline file mode (audio containers only — no MP4/video remux in v1).
- `Resources` — `DeepFilterNet3_Streaming.mlmodelc`, `AppIcon.icns`, `NoNoiseMacLogo.png`, `Info.plist`, `NoNoiseMac.entitlements`.
- `Tests/NoNoiseMacTests` — pure DSP / preset / voice-chain unit tests (run headless).

Launch at Startup uses macOS `SMAppService.mainApp` as the system-owned source of truth. It does
not duplicate state in `UserDefaults`, create a LaunchAgent/helper, or add an entitlement; the
setting works only when the installed app is running as a bundled application.

## Build, run, test
```bash
swift build # debug
Expand Down
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ let package = Package(
path: "Sources/App",
resources: [
.process("../../Resources")
],
linkerSettings: [
.linkedFramework("ServiceManagement")
]
),
.executableTarget(
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Everything happens **on your device**. Your audio never leaves your Mac.
- **🔒 100% private** — fully on-device on the Neural Engine; nothing is uploaded, ever.
- **🎛️ One-click modes** — Meeting, Podcast, Tutorial, or Custom, with strength + tone control.
- **↩️ Safe reset** — restore audio/device settings to defaults from Settings without deleting saved Voice Profiles or custom Hotkeys.
- **🚀 Launch at Startup** — open **Settings → General → Launch at Startup** to start the menu-bar app automatically when you log in. The setting is off by default and requires the bundled app; if macOS asks for approval, enable NoNoise Mac under **System Settings → General → Login Items**.
- **🎙️ Broadcast Voice** — a one-tap clarity lift (Off / Low / Medium / High) that adds studio presence and tames sibilance, so you sound clearer and more present while still sounding like *you*.
- **🎧 Clean Incoming / Guest** — de-noise the *other* side too. NoNoise Mac captures all system audio via a native macOS process tap (no loopback device or BlackHole required) and cleans **what you hear** in real time with the same on-device AI — no cloud, no subscription. Requires macOS 14.4+.
- **🫦 Mouth Noise** — an optional de-plosive (P-pop / B-thump suppressor) and de-click (lip-smack / mouth-click suppressor) stage (Off / Low / Medium / High). Both are identity at rest — only the artifact is removed, never the voice.
Expand Down
11 changes: 7 additions & 4 deletions Sources/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct ContentView: View {
@ObservedObject var dispatcher: ActionDispatcher
@ObservedObject var hotkeyManager: HotkeyManager
@ObservedObject var updaterController: UpdaterController
@ObservedObject var launchAtLoginManager: LaunchAtLoginManager

var body: some View {
VStack(spacing: 14) {
Expand Down Expand Up @@ -46,7 +47,7 @@ struct ContentView: View {
}
Spacer()
Button {
WindowManager.openSettings(model: audioModel, hotkeyManager: hotkeyManager, updaterController: updaterController)
WindowManager.openSettings(model: audioModel, hotkeyManager: hotkeyManager, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager)
} label: {
Image(systemName: "gearshape.fill")
.font(.system(size: 14))
Expand Down Expand Up @@ -274,7 +275,7 @@ struct ContentView: View {
private var footer: some View {
HStack(spacing: 8) {
Button {
WindowManager.openSettings(model: audioModel, hotkeyManager: hotkeyManager, updaterController: updaterController)
WindowManager.openSettings(model: audioModel, hotkeyManager: hotkeyManager, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager)
} label: {
Label("Settings", systemImage: "slider.horizontal.3")
}
Expand Down Expand Up @@ -440,12 +441,13 @@ extension View {

// MARK: - Settings window

@MainActor
class WindowManager {
static var settingsWindow: NSWindow?

static func openSettings(model: AudioModel, hotkeyManager: HotkeyManager, updaterController: UpdaterController) {
static func openSettings(model: AudioModel, hotkeyManager: HotkeyManager, updaterController: UpdaterController, launchAtLoginManager: LaunchAtLoginManager) {
if settingsWindow == nil {
let view = SettingsView(audioModel: model, hotkeyManager: hotkeyManager, updaterController: updaterController)
let view = SettingsView(audioModel: model, hotkeyManager: hotkeyManager, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager)
let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 520, height: 460),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
Expand All @@ -472,6 +474,7 @@ class WindowManager {
model?.endMeterObservation(.settings)
}
}
launchAtLoginManager.refresh()
settingsWindow?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
Expand Down
55 changes: 55 additions & 0 deletions Sources/App/LaunchAtLoginManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Combine
import Core
import Foundation
import ServiceManagement

@MainActor
final class LaunchAtLoginManager: ObservableObject {
@Published private(set) var state: LaunchAtLoginState = .notRegistered
@Published private(set) var errorMessage: String?

var isEnabled: Bool { state.isEnabled }

init() {
refresh()
}

func refresh() {
switch SMAppService.mainApp.status {
case .enabled:
state = .enabled
case .notRegistered:
state = .notRegistered
case .requiresApproval:
state = .requiresApproval
case .notFound:
state = .notFound
@unknown default:
state = .notFound
}
}

func setEnabled(_ enabled: Bool) {
errorMessage = nil

do {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
} catch let error as NSError where enabled && error.code == Int(kSMErrorAlreadyRegistered) {
// Registration is idempotent from the user's perspective.
errorMessage = nil
} catch {
errorMessage = "NoNoise Mac could not update its login item."
}

refresh()
}

func openLoginItems() {
guard #available(macOS 14.0, *) else { return }
SMAppService.openSystemSettingsLoginItems()
}
}
5 changes: 4 additions & 1 deletion Sources/App/NoNoiseMacApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct NoNoiseMacApp: App {
@StateObject private var dispatcher: ActionDispatcher
@StateObject private var hotkeyManager: HotkeyManager
@StateObject private var updaterController: UpdaterController
@StateObject private var launchAtLoginManager: LaunchAtLoginManager

init() {
// Init order matters: AudioModel → ActionDispatcher(model:) → HotkeyManager(dispatcher:).
Expand All @@ -29,6 +30,8 @@ struct NoNoiseMacApp: App {
// to the AppDelegate so it can fire one prompt background check in didFinishLaunching.
let updater = UpdaterController()
_updaterController = StateObject(wrappedValue: updater)
let launchAtLogin = LaunchAtLoginManager()
_launchAtLoginManager = StateObject(wrappedValue: launchAtLogin)

// Hand the dispatcher to the AppDelegate at LAUNCH (finding #3) — NOT in
// ContentView.onAppear. A MenuBarExtra's content view isn't instantiated until the
Expand All @@ -43,7 +46,7 @@ struct NoNoiseMacApp: App {

var body: some Scene {
MenuBarExtra {
ContentView(audioModel: audioModel, dispatcher: dispatcher, hotkeyManager: hotkeyManager, updaterController: updaterController)
ContentView(audioModel: audioModel, dispatcher: dispatcher, hotkeyManager: hotkeyManager, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager)
} label: {
Image(nsImage: NoNoiseLogoImage.menuBar(isActive: audioModel.isAIEnabled))
}
Expand Down
47 changes: 46 additions & 1 deletion Sources/App/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ struct SettingsView: View {
@ObservedObject var audioModel: AudioModel
@ObservedObject var hotkeyManager: HotkeyManager
@ObservedObject var updaterController: UpdaterController
@ObservedObject var launchAtLoginManager: LaunchAtLoginManager

var body: some View {
TabView {
GeneralSettingsView(audioModel: audioModel, meterModel: audioModel.meterModel, updaterController: updaterController)
GeneralSettingsView(audioModel: audioModel, meterModel: audioModel.meterModel, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager)
.tabItem {
Label("General", systemImage: "slider.horizontal.3")
}
Expand Down Expand Up @@ -37,6 +38,7 @@ struct GeneralSettingsView: View {
// MeterModel — observe it so the Settings readouts stay live while the popover is closed.
@ObservedObject var meterModel: MeterModel
@ObservedObject var updaterController: UpdaterController
@ObservedObject var launchAtLoginManager: LaunchAtLoginManager

@State private var isShowingSaveSheet = false
@State private var newProfileName: String = ""
Expand All @@ -48,6 +50,7 @@ struct GeneralSettingsView: View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
brandedHeader
launchAtStartupCard
suppressionCard
inputVolumeCard
profilesCard
Expand Down Expand Up @@ -90,6 +93,48 @@ struct GeneralSettingsView: View {
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
}

private var launchAtStartupCard: some View {
VStack(alignment: .leading, spacing: 10) {
Toggle(isOn: Binding(
get: { launchAtLoginManager.isEnabled },
set: { launchAtLoginManager.setEnabled($0) }
)) {
VStack(alignment: .leading, spacing: 2) {
Text("Launch at Startup")
.font(.subheadline)
Text("Start NoNoise Mac automatically when you log in to your Mac.")
.font(.caption)
.foregroundColor(.secondary)
}
}
.toggleStyle(.switch)

if launchAtLoginManager.state == .requiresApproval {
loginItemsGuidance("macOS requires approval in System Settings > General > Login Items.")
} else if launchAtLoginManager.state == .notFound {
loginItemsGuidance("Launch at Startup works when NoNoise Mac is running as a bundled app.")
}
if let errorMessage = launchAtLoginManager.errorMessage {
loginItemsGuidance(errorMessage)
}
}
.padding(14)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}

private func loginItemsGuidance(_ message: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(message)
.font(.caption)
.foregroundColor(.secondary)
Button("Open Login Items") {
launchAtLoginManager.openLoginItems()
}
.buttonStyle(.bordered)
}
}

// MARK: Suppression

private var suppressionCard: some View {
Expand Down
14 changes: 14 additions & 0 deletions Sources/Core/LaunchAtLoginState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
public enum LaunchAtLoginState: Equatable {
case enabled
case notRegistered
case requiresApproval
case notFound

public var isEnabled: Bool {
self == .enabled
}

public var needsSystemSettings: Bool {
self == .requiresApproval || self == .notFound
}
}
18 changes: 18 additions & 0 deletions Tests/NoNoiseMacTests/LaunchAtLoginStateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import XCTest
@testable import Core

final class LaunchAtLoginStateTests: XCTestCase {
func testOnlyEnabledStateTurnsToggleOn() {
XCTAssertTrue(LaunchAtLoginState.enabled.isEnabled)
XCTAssertFalse(LaunchAtLoginState.notRegistered.isEnabled)
XCTAssertFalse(LaunchAtLoginState.requiresApproval.isEnabled)
XCTAssertFalse(LaunchAtLoginState.notFound.isEnabled)
}

func testApprovalAndNotFoundStatesRequestSystemSettingsGuidance() {
XCTAssertTrue(LaunchAtLoginState.requiresApproval.needsSystemSettings)
XCTAssertTrue(LaunchAtLoginState.notFound.needsSystemSettings)
XCTAssertFalse(LaunchAtLoginState.enabled.needsSystemSettings)
XCTAssertFalse(LaunchAtLoginState.notRegistered.needsSystemSettings)
}
}
5 changes: 5 additions & 0 deletions docs/knowledge/timeline1.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

Chronological log of notable changes. Newest on top.

### 2026-07-12 — Launch at Startup setting
- **What:** added a General Settings toggle that starts the NoNoise Mac menu-bar app when the user logs in.
- **How:** uses `SMAppService.mainApp` as the system-owned source of truth, with approval and recoverable error guidance in the UI; no duplicate preference, helper, LaunchAgent, or entitlement was added.
- **Verification:** the Service Management path requires manual verification in the bundled app, including System Settings → General → Login Items when approval is requested.

### 2026-06-22 — Repin NoNoise Mic Engine after hardware churn (Codex)
- **What:** Added a tested `VirtualMicRouting.shouldRepinPlaybackAfterHardwareRefresh` predicate and
wired `AudioModel.fetchOutputDevices()` to explicitly rebuild the playback graph when a hardware
Expand Down
Loading
Loading