diff --git a/AudioVUMeter/AudioVUMeter.xcodeproj/project.pbxproj b/AudioVUMeter/AudioVUMeter.xcodeproj/project.pbxproj index 3de0cca..7fa74ba 100644 --- a/AudioVUMeter/AudioVUMeter.xcodeproj/project.pbxproj +++ b/AudioVUMeter/AudioVUMeter.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ A1000009229E3D0000000005 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000A229E3D0000000005 /* SystemMonitor.swift */; }; A100000B229E3D0000000006 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000C229E3D0000000006 /* SettingsView.swift */; }; A100000D229E3D0000000007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A100000E229E3D0000000007 /* Assets.xcassets */; }; + A1000020229E3D0000000019 /* SerialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000021229E3D000000001A /* SerialManager.swift */; }; + A1000022229E3D000000001B /* HardwareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000023229E3D000000001C /* HardwareView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -27,6 +29,8 @@ A100000F229E3D0000000008 /* AudioVUMeter.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AudioVUMeter.entitlements; sourceTree = ""; }; A1000010229E3D0000000009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A1000011229E3D000000000A /* AudioVUMeter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioVUMeter.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1000021229E3D000000001A /* SerialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialManager.swift; sourceTree = ""; }; + A1000023229E3D000000001C /* HardwareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -57,6 +61,8 @@ A1000008229E3D0000000004 /* AudioEngine.swift */, A100000A229E3D0000000005 /* SystemMonitor.swift */, A100000C229E3D0000000006 /* SettingsView.swift */, + A1000021229E3D000000001A /* SerialManager.swift */, + A1000023229E3D000000001C /* HardwareView.swift */, A100000E229E3D0000000007 /* Assets.xcassets */, A100000F229E3D0000000008 /* AudioVUMeter.entitlements */, A1000010229E3D0000000009 /* Info.plist */, @@ -147,6 +153,8 @@ A1000007229E3D0000000004 /* AudioEngine.swift in Sources */, A1000009229E3D0000000005 /* SystemMonitor.swift in Sources */, A100000B229E3D0000000006 /* SettingsView.swift in Sources */, + A1000020229E3D0000000019 /* SerialManager.swift in Sources */, + A1000022229E3D000000001B /* HardwareView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -277,7 +285,7 @@ CODE_SIGN_ENTITLEMENTS = AudioVUMeter/AudioVUMeter.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -290,7 +298,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.audiotools.AudioVUMeter; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -306,7 +314,7 @@ CODE_SIGN_ENTITLEMENTS = AudioVUMeter/AudioVUMeter.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -319,7 +327,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.audiotools.AudioVUMeter; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/AudioVUMeter/AudioVUMeter/AudioVUMeterApp.swift b/AudioVUMeter/AudioVUMeter/AudioVUMeterApp.swift index 7516d5a..8db5168 100644 --- a/AudioVUMeter/AudioVUMeter/AudioVUMeterApp.swift +++ b/AudioVUMeter/AudioVUMeter/AudioVUMeterApp.swift @@ -4,6 +4,7 @@ // // macOS Audio VU Meter with System Monitoring // Captures audio from BlackHole virtual audio device +// Outputs to physical VU meter hardware via Serial/USB // import SwiftUI @@ -12,12 +13,23 @@ import SwiftUI struct AudioVUMeterApp: App { @StateObject private var audioEngine = AudioEngine() @StateObject private var systemMonitor = SystemMonitor() + @StateObject private var serialManager = SerialManager() + + // Timer for updating hardware values + @State private var updateTimer: Timer? var body: some Scene { WindowGroup { ContentView() .environmentObject(audioEngine) .environmentObject(systemMonitor) + .environmentObject(serialManager) + .onAppear { + startHardwareUpdateTimer() + } + .onDisappear { + stopHardwareUpdateTimer() + } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -25,6 +37,18 @@ struct AudioVUMeterApp: App { Settings { SettingsView() .environmentObject(audioEngine) + .environmentObject(serialManager) } } + + private func startHardwareUpdateTimer() { + updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { _ in + serialManager.updateValues(audioEngine: audioEngine, systemMonitor: systemMonitor) + } + } + + private func stopHardwareUpdateTimer() { + updateTimer?.invalidate() + updateTimer = nil + } } diff --git a/AudioVUMeter/AudioVUMeter/ContentView.swift b/AudioVUMeter/AudioVUMeter/ContentView.swift index c091e4f..f580589 100644 --- a/AudioVUMeter/AudioVUMeter/ContentView.swift +++ b/AudioVUMeter/AudioVUMeter/ContentView.swift @@ -2,7 +2,7 @@ // ContentView.swift // AudioVUMeter // -// Main view containing all VU meters +// Main view containing all VU meters and hardware output // import SwiftUI @@ -10,8 +10,10 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var audioEngine: AudioEngine @EnvironmentObject var systemMonitor: SystemMonitor + @EnvironmentObject var serialManager: SerialManager @State private var showSettings = false + @State private var showHardwareSettings = false var body: some View { ZStack { @@ -26,185 +28,207 @@ struct ContentView: View { ) .ignoresSafeArea() - VStack(spacing: 20) { - // Header - HStack { - Text("Audio VU Meter") - .font(.system(size: 24, weight: .bold, design: .rounded)) - .foregroundColor(.white) + ScrollView { + VStack(spacing: 16) { + // Header + HStack { + Text("Audio VU Meter") + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(.white) - Spacer() + Spacer() - // Settings button - Button(action: { showSettings.toggle() }) { - Image(systemName: "gear") - .font(.system(size: 18)) + // Hardware settings button + Button(action: { showHardwareSettings.toggle() }) { + Image(systemName: "cable.connector") + .font(.system(size: 16)) + .foregroundColor(serialManager.isConnected ? .green : .gray) + } + .buttonStyle(.plain) + .help("Hardware Settings") + + // Settings button + Button(action: { showSettings.toggle() }) { + Image(systemName: "gear") + .font(.system(size: 18)) + .foregroundColor(.gray) + } + .buttonStyle(.plain) + .popover(isPresented: $showSettings) { + QuickSettingsView() + .environmentObject(audioEngine) + } + } + .padding(.horizontal) + .padding(.top, 10) + + // Audio device info + HStack { + Circle() + .fill(audioEngine.isRunning ? Color.green : Color.red) + .frame(width: 8, height: 8) + + Text(audioEngine.selectedDeviceName) + .font(.system(size: 12, design: .monospaced)) .foregroundColor(.gray) + + Spacer() + + Text(audioEngine.isRunning ? "ACTIVE" : "STOPPED") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundColor(audioEngine.isRunning ? .green : .red) } - .buttonStyle(.plain) - .popover(isPresented: $showSettings) { - QuickSettingsView() - .environmentObject(audioEngine) - } - } - .padding(.horizontal) - .padding(.top, 10) + .padding(.horizontal) - // Audio device info - HStack { - Circle() - .fill(audioEngine.isRunning ? Color.green : Color.red) - .frame(width: 8, height: 8) + Divider() + .background(Color.gray.opacity(0.3)) - Text(audioEngine.selectedDeviceName) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(.gray) + // Audio VU Meters + VStack(spacing: 15) { + Text("AUDIO LEVELS") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.gray) - Spacer() + HStack(spacing: 30) { + // Left Channel + VUMeterView( + level: audioEngine.leftLevel, + peakLevel: audioEngine.leftPeak, + label: "L", + colorScheme: .audio + ) - Text(audioEngine.isRunning ? "ACTIVE" : "STOPPED") - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .foregroundColor(audioEngine.isRunning ? .green : .red) - } - .padding(.horizontal) - - Divider() - .background(Color.gray.opacity(0.3)) - - // Audio VU Meters - VStack(spacing: 15) { - Text("AUDIO LEVELS") - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundColor(.gray) - - HStack(spacing: 30) { - // Left Channel - VUMeterView( - level: audioEngine.leftLevel, - peakLevel: audioEngine.leftPeak, - label: "L", - colorScheme: .audio - ) - - // Right Channel - VUMeterView( - level: audioEngine.rightLevel, - peakLevel: audioEngine.rightPeak, - label: "R", - colorScheme: .audio - ) - } - - // dB Display - HStack(spacing: 40) { - VStack { - Text(String(format: "%.1f dB", audioEngine.leftLevelDB)) - .font(.system(size: 14, weight: .bold, design: .monospaced)) - .foregroundColor(dbColor(for: audioEngine.leftLevelDB)) - Text("LEFT") - .font(.system(size: 9, design: .monospaced)) - .foregroundColor(.gray) + // Right Channel + VUMeterView( + level: audioEngine.rightLevel, + peakLevel: audioEngine.rightPeak, + label: "R", + colorScheme: .audio + ) } - VStack { - Text(String(format: "%.1f dB", audioEngine.rightLevelDB)) - .font(.system(size: 14, weight: .bold, design: .monospaced)) - .foregroundColor(dbColor(for: audioEngine.rightLevelDB)) - Text("RIGHT") - .font(.system(size: 9, design: .monospaced)) - .foregroundColor(.gray) + // dB Display + HStack(spacing: 40) { + VStack { + Text(String(format: "%.1f dB", audioEngine.leftLevelDB)) + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundColor(dbColor(for: audioEngine.leftLevelDB)) + Text("LEFT") + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.gray) + } + + VStack { + Text(String(format: "%.1f dB", audioEngine.rightLevelDB)) + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundColor(dbColor(for: audioEngine.rightLevelDB)) + Text("RIGHT") + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.gray) + } } } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.3)) + ) + .padding(.horizontal) + + Divider() + .background(Color.gray.opacity(0.3)) + + // System Monitors + VStack(spacing: 15) { + Text("SYSTEM MONITOR") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.gray) + + HStack(spacing: 25) { + // CPU Meter + SystemMeterView( + value: systemMonitor.cpuUsage, + label: "CPU", + unit: "%", + colorScheme: .cpu + ) + + // RAM Meter + SystemMeterView( + value: systemMonitor.memoryUsage, + label: "RAM", + unit: "%", + colorScheme: .ram + ) + + // Disk I/O Meter + SystemMeterView( + value: systemMonitor.diskActivity, + label: "DISK", + unit: "%", + colorScheme: .disk + ) + + // Network Meter + SystemMeterView( + value: systemMonitor.networkActivity, + label: "NET", + unit: "%", + colorScheme: .network + ) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.3)) + ) + .padding(.horizontal) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Hardware Output Panel + HardwarePanelView() + .environmentObject(serialManager) + + // Control buttons + HStack(spacing: 15) { + Button(action: { + if audioEngine.isRunning { + audioEngine.stop() + } else { + audioEngine.start() + } + }) { + HStack { + Image(systemName: audioEngine.isRunning ? "stop.fill" : "play.fill") + Text(audioEngine.isRunning ? "Stop" : "Start") + } + .frame(width: 80) + } + .buttonStyle(ControlButtonStyle(color: audioEngine.isRunning ? .red : .green)) + + Button(action: { + audioEngine.resetPeaks() + }) { + HStack { + Image(systemName: "arrow.counterclockwise") + Text("Reset") + } + .frame(width: 80) + } + .buttonStyle(ControlButtonStyle(color: .orange)) + } + .padding(.bottom, 15) } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.black.opacity(0.3)) - ) - .padding(.horizontal) - - Divider() - .background(Color.gray.opacity(0.3)) - - // System Monitors - VStack(spacing: 15) { - Text("SYSTEM MONITOR") - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundColor(.gray) - - HStack(spacing: 25) { - // CPU Meter - SystemMeterView( - value: systemMonitor.cpuUsage, - label: "CPU", - unit: "%", - colorScheme: .cpu - ) - - // RAM Meter - SystemMeterView( - value: systemMonitor.memoryUsage, - label: "RAM", - unit: "%", - colorScheme: .ram - ) - - // Disk I/O Meter - SystemMeterView( - value: systemMonitor.diskActivity, - label: "DISK", - unit: "%", - colorScheme: .disk - ) - - // Network Meter - SystemMeterView( - value: systemMonitor.networkActivity, - label: "NET", - unit: "%", - colorScheme: .network - ) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.black.opacity(0.3)) - ) - .padding(.horizontal) - - // Control buttons - HStack(spacing: 15) { - Button(action: { - if audioEngine.isRunning { - audioEngine.stop() - } else { - audioEngine.start() - } - }) { - HStack { - Image(systemName: audioEngine.isRunning ? "stop.fill" : "play.fill") - Text(audioEngine.isRunning ? "Stop" : "Start") - } - .frame(width: 80) - } - .buttonStyle(ControlButtonStyle(color: audioEngine.isRunning ? .red : .green)) - - Button(action: { - audioEngine.resetPeaks() - }) { - HStack { - Image(systemName: "arrow.counterclockwise") - Text("Reset") - } - .frame(width: 80) - } - .buttonStyle(ControlButtonStyle(color: .orange)) - } - .padding(.bottom, 15) } } - .frame(width: 400, height: 580) + .frame(width: 400, height: 750) + .sheet(isPresented: $showHardwareSettings) { + HardwareSettingsSheet() + .environmentObject(serialManager) + } .onAppear { audioEngine.start() systemMonitor.startMonitoring() @@ -212,6 +236,7 @@ struct ContentView: View { .onDisappear { audioEngine.stop() systemMonitor.stopMonitoring() + serialManager.disconnect() } } @@ -223,6 +248,33 @@ struct ContentView: View { } } +// MARK: - Hardware Settings Sheet +struct HardwareSettingsSheet: View { + @EnvironmentObject var serialManager: SerialManager + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Hardware Configuration") + .font(.headline) + Spacer() + Button("Done") { dismiss() } + } + .padding() + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + // Settings content + HardwareSettingsView() + .environmentObject(serialManager) + } + .frame(width: 500, height: 600) + } +} + // MARK: - Quick Settings Popover struct QuickSettingsView: View { @EnvironmentObject var audioEngine: AudioEngine @@ -287,4 +339,5 @@ struct ControlButtonStyle: ButtonStyle { ContentView() .environmentObject(AudioEngine()) .environmentObject(SystemMonitor()) + .environmentObject(SerialManager()) } diff --git a/AudioVUMeter/AudioVUMeter/HardwareView.swift b/AudioVUMeter/AudioVUMeter/HardwareView.swift new file mode 100644 index 0000000..6986d6d --- /dev/null +++ b/AudioVUMeter/AudioVUMeter/HardwareView.swift @@ -0,0 +1,338 @@ +// +// HardwareView.swift +// AudioVUMeter +// +// Hardware configuration and monitoring view for physical VU meters +// + +import SwiftUI + +// MARK: - Hardware Panel in Main View +struct HardwarePanelView: View { + @EnvironmentObject var serialManager: SerialManager + + var body: some View { + VStack(spacing: 15) { + // Header + HStack { + Text("HARDWARE OUTPUT") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.gray) + + Spacer() + + // Connection status + HStack(spacing: 6) { + Circle() + .fill(serialManager.isConnected ? Color.green : Color.red) + .frame(width: 8, height: 8) + + Text(serialManager.isConnected ? "CONNECTED" : "DISCONNECTED") + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundColor(serialManager.isConnected ? .green : .red) + } + } + + // 4 Physical Dial Indicators + HStack(spacing: 15) { + ForEach(0..<4) { index in + DialIndicatorView( + dialNumber: index + 1, + value: serialManager.dialValues[index], + channelName: shortChannelName(serialManager.dialConfigs[index].dialChannel), + isConnected: serialManager.isConnected + ) + } + } + + // Quick connect button + Button(action: { + serialManager.toggleConnection() + }) { + HStack { + Image(systemName: serialManager.isConnected ? "antenna.radiowaves.left.and.right.slash" : "antenna.radiowaves.left.and.right") + Text(serialManager.isConnected ? "Disconnect" : "Connect") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(HardwareButtonStyle(isConnected: serialManager.isConnected)) + + // Stats + if serialManager.isConnected { + HStack { + Text("TX: \(formatBytes(serialManager.bytesSent))") + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.gray) + + Spacer() + + Text(serialManager.selectedPortPath.components(separatedBy: "/").last ?? "") + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.gray) + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(serialManager.isConnected ? Color.green.opacity(0.3) : Color.clear, lineWidth: 1) + ) + ) + .padding(.horizontal) + } + + private func shortChannelName(_ channel: DialChannel) -> String { + switch channel { + case .audioLeft: return "L" + case .audioRight: return "R" + case .audioPeak: return "PK" + case .audioMono: return "M" + case .cpu: return "CPU" + case .ram: return "RAM" + case .disk: return "DSK" + case .network: return "NET" + } + } + + private func formatBytes(_ bytes: UInt64) -> String { + if bytes < 1024 { return "\(bytes) B" } + if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) } + return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) + } +} + +// MARK: - Single Dial Indicator +struct DialIndicatorView: View { + let dialNumber: Int + let value: Int + let channelName: String + let isConnected: Bool + + var body: some View { + VStack(spacing: 4) { + // Dial number + Text("D\(dialNumber)") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + + // Value arc + ZStack { + // Background arc + Circle() + .trim(from: 0.25, to: 0.75) + .stroke(Color.gray.opacity(0.2), lineWidth: 4) + .frame(width: 50, height: 50) + .rotationEffect(.degrees(180)) + + // Value arc + Circle() + .trim(from: 0.25, to: 0.25 + (Double(value) / 255.0) * 0.5) + .stroke( + isConnected ? dialColor(for: value) : Color.gray, + style: StrokeStyle(lineWidth: 4, lineCap: .round) + ) + .frame(width: 50, height: 50) + .rotationEffect(.degrees(180)) + + // Value text + VStack(spacing: 0) { + Text("\(value)") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundColor(isConnected ? .white : .gray) + } + } + + // Channel name + Text(channelName) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundColor(channelColor(channelName)) + } + } + + private func dialColor(for value: Int) -> Color { + let ratio = Double(value) / 255.0 + if ratio > 0.9 { return .red } + if ratio > 0.75 { return .orange } + if ratio > 0.5 { return .yellow } + return .green + } + + private func channelColor(_ name: String) -> Color { + switch name { + case "L", "R", "PK", "M": return .green + case "CPU": return .blue + case "RAM": return .purple + case "DSK": return .teal + case "NET": return .indigo + default: return .gray + } + } +} + +// MARK: - Hardware Settings View +struct HardwareSettingsView: View { + @EnvironmentObject var serialManager: SerialManager + + var body: some View { + Form { + // Connection Section + Section("Serial Connection") { + // Port selection + HStack { + Picker("Port", selection: $serialManager.selectedPortPath) { + Text("Select Port...").tag("") + ForEach(serialManager.availablePorts) { port in + Text(port.name).tag(port.path) + } + } + + Button(action: { serialManager.refreshPorts() }) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + } + + // Baud rate + Picker("Baud Rate", selection: $serialManager.baudRate) { + ForEach(SerialManager.availableBaudRates, id: \.self) { rate in + Text("\(rate)").tag(rate) + } + } + + // Protocol + Picker("Protocol", selection: $serialManager.selectedProtocol) { + ForEach(SerialProtocol.allCases) { proto in + Text(proto.rawValue).tag(proto) + } + } + + // Connect button + Button(action: { serialManager.toggleConnection() }) { + HStack { + Image(systemName: serialManager.isConnected ? "bolt.slash.fill" : "bolt.fill") + Text(serialManager.isConnected ? "Disconnect" : "Connect") + } + } + .foregroundColor(serialManager.isConnected ? .red : .green) + } + + // Dial Configuration Section + Section("Dial Assignments") { + ForEach(0..<4) { index in + DialConfigRow( + dialNumber: index + 1, + config: $serialManager.dialConfigs[index] + ) + } + } + + // Advanced Settings + Section("Advanced") { + ForEach(0..<4) { index in + DisclosureGroup("Dial \(index + 1) Settings") { + HStack { + Text("Min Value") + Spacer() + TextField("0", value: $serialManager.dialConfigs[index].minValue, format: .number) + .frame(width: 60) + .textFieldStyle(.roundedBorder) + } + + HStack { + Text("Max Value") + Spacer() + TextField("255", value: $serialManager.dialConfigs[index].maxValue, format: .number) + .frame(width: 60) + .textFieldStyle(.roundedBorder) + } + + Toggle("Invert", isOn: $serialManager.dialConfigs[index].inverted) + + HStack { + Text("Smoothing") + Slider(value: $serialManager.dialConfigs[index].smoothing, in: 0...0.9) + Text("\(Int(serialManager.dialConfigs[index].smoothing * 100))%") + .frame(width: 40) + } + } + } + } + + // Protocol Info + Section("Protocol Reference") { + VStack(alignment: .leading, spacing: 8) { + protocolInfo + } + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .formStyle(.grouped) + } + + @ViewBuilder + private var protocolInfo: some View { + switch serialManager.selectedProtocol { + case .rawBytes: + Text("Format: [0xAA] [D1] [D2] [D3] [D4] [0x55]") + Text("Values: 0-255 per dial") + case .textCommand: + Text("Format: CH1:val;CH2:val;CH3:val;CH4:val\\n") + Text("Values: 0-255 per channel") + case .json: + Text("Format: {\"dials\":[d1,d2,d3,d4]}\\n") + Text("Values: 0-255 array") + case .vuServer: + Text("Format: #0:val\\n#1:val\\n#2:val\\n#3:val\\n") + Text("Values: 0-100 percentage per dial") + } + } +} + +// MARK: - Dial Config Row +struct DialConfigRow: View { + let dialNumber: Int + @Binding var config: DialConfig + + var body: some View { + HStack { + Text("Dial \(dialNumber)") + .font(.system(.body, design: .monospaced)) + .frame(width: 60, alignment: .leading) + + Picker("", selection: $config.dialChannel) { + ForEach(DialChannel.allCases) { channel in + Text(channel.rawValue).tag(channel) + } + } + .labelsHidden() + } + } +} + +// MARK: - Hardware Button Style +struct HardwareButtonStyle: ButtonStyle { + let isConnected: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isConnected ? Color.red.opacity(0.7) : Color.green.opacity(0.7)) + .opacity(configuration.isPressed ? 0.6 : 1.0) + ) + } +} + +// MARK: - Preview +#Preview { + HardwareSettingsView() + .environmentObject(SerialManager()) + .frame(width: 450, height: 600) +} diff --git a/AudioVUMeter/AudioVUMeter/SerialManager.swift b/AudioVUMeter/AudioVUMeter/SerialManager.swift new file mode 100644 index 0000000..7305f65 --- /dev/null +++ b/AudioVUMeter/AudioVUMeter/SerialManager.swift @@ -0,0 +1,420 @@ +// +// SerialManager.swift +// AudioVUMeter +// +// Serial communication manager for physical VU meter hardware +// Supports multiple protocols: Raw bytes, Text commands, JSON, VU-Server compatible +// + +import Foundation +import IOKit +import IOKit.serial + +/// Protocol format for serial communication +enum SerialProtocol: String, CaseIterable, Identifiable { + case rawBytes = "Raw Bytes (0-255)" + case textCommand = "Text Commands" + case json = "JSON Format" + case vuServer = "VU-Server Compatible" + + var id: String { rawValue } +} + +/// Represents a serial port device +struct SerialPort: Identifiable, Hashable { + let id: String + let path: String + let name: String +} + +/// Channel assignment for physical VU meters +enum DialChannel: String, CaseIterable, Identifiable { + case audioLeft = "Audio Left" + case audioRight = "Audio Right" + case cpu = "CPU Usage" + case ram = "RAM Usage" + case disk = "Disk Activity" + case network = "Network Activity" + case audioPeak = "Audio Peak" + case audioMono = "Audio Mono (L+R)" + + var id: String { rawValue } +} + +/// Configuration for a single dial +struct DialConfig: Identifiable, Codable { + let id: Int + var channel: String + var minValue: Int + var maxValue: Int + var inverted: Bool + var smoothing: Double + + init(id: Int, channel: DialChannel = .audioLeft) { + self.id = id + self.channel = channel.rawValue + self.minValue = 0 + self.maxValue = 255 + self.inverted = false + self.smoothing = 0.3 + } +} + +/// Serial communication manager +class SerialManager: ObservableObject { + // MARK: - Published Properties + + @Published var isConnected = false + @Published var availablePorts: [SerialPort] = [] + @Published var selectedPortPath: String = "" + @Published var selectedProtocol: SerialProtocol = .vuServer + @Published var baudRate: Int = 115200 + @Published var dialConfigs: [DialConfig] = [] + @Published var lastError: String? + @Published var bytesSent: UInt64 = 0 + + // Current dial values (0-255) + @Published var dialValues: [Int] = [0, 0, 0, 0] + + // MARK: - Private Properties + + private var fileDescriptor: Int32 = -1 + private var writeQueue = DispatchQueue(label: "serial.write", qos: .userInteractive) + private var updateTimer: Timer? + private let updateInterval: TimeInterval = 1.0 / 30.0 // 30 Hz update rate + + // Smoothed values for each dial + private var smoothedValues: [Double] = [0, 0, 0, 0] + + // MARK: - Initialization + + init() { + // Initialize 4 dial configurations with default assignments + dialConfigs = [ + DialConfig(id: 0, channel: .audioLeft), + DialConfig(id: 1, channel: .audioRight), + DialConfig(id: 2, channel: .cpu), + DialConfig(id: 3, channel: .ram) + ] + + refreshPorts() + } + + deinit { + disconnect() + } + + // MARK: - Port Management + + /// Refresh list of available serial ports + func refreshPorts() { + availablePorts = getSerialPorts() + + // Auto-select first port if none selected + if selectedPortPath.isEmpty, let firstPort = availablePorts.first { + selectedPortPath = firstPort.path + } + } + + /// Get all available serial ports + private func getSerialPorts() -> [SerialPort] { + var ports: [SerialPort] = [] + + var iterator: io_iterator_t = 0 + let matchingDict = IOServiceMatching(kIOSerialBSDServiceValue) + + let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) + guard result == KERN_SUCCESS else { return ports } + + var service: io_object_t = IOIteratorNext(iterator) + while service != 0 { + defer { + IOObjectRelease(service) + service = IOIteratorNext(iterator) + } + + // Get device path + guard let pathKey = IORegistryEntryCreateCFProperty( + service, + kIOCalloutDeviceKey as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() as? String else { continue } + + // Get device name + var name = pathKey.components(separatedBy: "/").last ?? "Unknown" + + // Try to get a better name from USB info + if let usbName = IORegistryEntryCreateCFProperty( + service, + "USB Product Name" as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() as? String { + name = usbName + } + + // Filter for common serial devices + if pathKey.contains("cu.") { + ports.append(SerialPort( + id: pathKey, + path: pathKey, + name: name + )) + } + } + + IOObjectRelease(iterator) + return ports + } + + // MARK: - Connection Management + + /// Connect to selected serial port + func connect() { + guard !selectedPortPath.isEmpty else { + lastError = "No port selected" + return + } + + // Open serial port + fileDescriptor = open(selectedPortPath, O_RDWR | O_NOCTTY | O_NONBLOCK) + + guard fileDescriptor != -1 else { + lastError = "Failed to open port: \(String(cString: strerror(errno)))" + return + } + + // Configure serial port + var options = termios() + tcgetattr(fileDescriptor, &options) + + // Set baud rate + let speed = getBaudRateConstant(baudRate) + cfsetispeed(&options, speed) + cfsetospeed(&options, speed) + + // Configure 8N1 + options.c_cflag &= ~UInt(PARENB) // No parity + options.c_cflag &= ~UInt(CSTOPB) // 1 stop bit + options.c_cflag &= ~UInt(CSIZE) + options.c_cflag |= UInt(CS8) // 8 data bits + + // Enable receiver, ignore modem control lines + options.c_cflag |= UInt(CREAD | CLOCAL) + + // Raw input + options.c_lflag &= ~UInt(ICANON | ECHO | ECHOE | ISIG) + + // Raw output + options.c_oflag &= ~UInt(OPOST) + + // Apply settings + tcsetattr(fileDescriptor, TCSANOW, &options) + + // Clear any pending data + tcflush(fileDescriptor, TCIOFLUSH) + + isConnected = true + lastError = nil + + print("Connected to \(selectedPortPath) at \(baudRate) baud") + + // Start update timer + startUpdateTimer() + } + + /// Disconnect from serial port + func disconnect() { + stopUpdateTimer() + + if fileDescriptor != -1 { + close(fileDescriptor) + fileDescriptor = -1 + } + + isConnected = false + print("Disconnected from serial port") + } + + /// Toggle connection state + func toggleConnection() { + if isConnected { + disconnect() + } else { + connect() + } + } + + // MARK: - Data Transmission + + /// Update dial values from audio and system monitors + func updateValues(audioEngine: AudioEngine, systemMonitor: SystemMonitor) { + for (index, config) in dialConfigs.enumerated() { + guard index < 4 else { break } + + var rawValue: Double = 0 + + // Get value based on channel assignment + switch DialChannel(rawValue: config.channel) ?? .audioLeft { + case .audioLeft: + rawValue = audioEngine.leftLevel * 100 + case .audioRight: + rawValue = audioEngine.rightLevel * 100 + case .audioPeak: + rawValue = max(audioEngine.leftPeak, audioEngine.rightPeak) * 100 + case .audioMono: + rawValue = ((audioEngine.leftLevel + audioEngine.rightLevel) / 2) * 100 + case .cpu: + rawValue = systemMonitor.cpuUsage + case .ram: + rawValue = systemMonitor.memoryUsage + case .disk: + rawValue = systemMonitor.diskActivity + case .network: + rawValue = systemMonitor.networkActivity + } + + // Apply smoothing + let smoothing = config.smoothing + smoothedValues[index] = smoothedValues[index] * smoothing + rawValue * (1 - smoothing) + + // Map to dial range + var mappedValue = Int((smoothedValues[index] / 100.0) * Double(config.maxValue - config.minValue)) + config.minValue + + // Apply inversion if needed + if config.inverted { + mappedValue = config.maxValue - mappedValue + config.minValue + } + + // Clamp to valid range + dialValues[index] = max(config.minValue, min(config.maxValue, mappedValue)) + } + } + + /// Send current values to hardware + func sendValues() { + guard isConnected, fileDescriptor != -1 else { return } + + writeQueue.async { [weak self] in + guard let self = self else { return } + + let data: Data + + switch self.selectedProtocol { + case .rawBytes: + data = self.formatRawBytes() + case .textCommand: + data = self.formatTextCommand() + case .json: + data = self.formatJSON() + case .vuServer: + data = self.formatVUServer() + } + + self.writeData(data) + } + } + + // MARK: - Protocol Formatters + + /// Format as raw bytes: [0xAA, ch1, ch2, ch3, ch4, 0x55] + private func formatRawBytes() -> Data { + var bytes: [UInt8] = [0xAA] // Start marker + for value in dialValues { + bytes.append(UInt8(clamping: value)) + } + bytes.append(0x55) // End marker + return Data(bytes) + } + + /// Format as text commands: "CH1:128;CH2:64;CH3:200;CH4:32\n" + private func formatTextCommand() -> Data { + let commands = dialValues.enumerated().map { "CH\($0 + 1):\($1)" } + let message = commands.joined(separator: ";") + "\n" + return message.data(using: .utf8) ?? Data() + } + + /// Format as JSON: {"dials":[128,64,200,32]} + private func formatJSON() -> Data { + let json: [String: Any] = ["dials": dialValues] + if let data = try? JSONSerialization.data(withJSONObject: json, options: []) { + return data + "\n".data(using: .utf8)! + } + return Data() + } + + /// Format for VU-Server compatible hardware + /// Protocol: #:\n + private func formatVUServer() -> Data { + var message = "" + for (index, value) in dialValues.enumerated() { + // VU-Server uses percentage values 0-100 + let percentage = (value * 100) / 255 + message += "#\(index):\(percentage)\n" + } + return message.data(using: .utf8) ?? Data() + } + + // MARK: - Low-level I/O + + /// Write data to serial port + private func writeData(_ data: Data) { + guard !data.isEmpty else { return } + + data.withUnsafeBytes { buffer in + guard let baseAddress = buffer.baseAddress else { return } + let written = write(fileDescriptor, baseAddress, data.count) + + if written > 0 { + DispatchQueue.main.async { + self.bytesSent += UInt64(written) + } + } else if written < 0 { + let error = String(cString: strerror(errno)) + DispatchQueue.main.async { + self.lastError = "Write error: \(error)" + } + } + } + } + + // MARK: - Timer Management + + private func startUpdateTimer() { + stopUpdateTimer() + updateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] _ in + self?.sendValues() + } + } + + private func stopUpdateTimer() { + updateTimer?.invalidate() + updateTimer = nil + } + + // MARK: - Helpers + + private func getBaudRateConstant(_ rate: Int) -> speed_t { + switch rate { + case 9600: return speed_t(B9600) + case 19200: return speed_t(B19200) + case 38400: return speed_t(B38400) + case 57600: return speed_t(B57600) + case 115200: return speed_t(B115200) + case 230400: return speed_t(B230400) + default: return speed_t(B115200) + } + } + + /// Available baud rates + static let availableBaudRates = [9600, 19200, 38400, 57600, 115200, 230400] +} + +// MARK: - Dial Config Channel Extension +extension DialConfig { + var dialChannel: DialChannel { + get { DialChannel(rawValue: channel) ?? .audioLeft } + set { channel = newValue.rawValue } + } +} diff --git a/AudioVUMeter/AudioVUMeter/SettingsView.swift b/AudioVUMeter/AudioVUMeter/SettingsView.swift index e306dfd..b9ea68a 100644 --- a/AudioVUMeter/AudioVUMeter/SettingsView.swift +++ b/AudioVUMeter/AudioVUMeter/SettingsView.swift @@ -2,13 +2,14 @@ // SettingsView.swift // AudioVUMeter // -// Settings window for configuring audio device and preferences +// Settings window for configuring audio device, hardware output, and preferences // import SwiftUI struct SettingsView: View { @EnvironmentObject var audioEngine: AudioEngine + @EnvironmentObject var serialManager: SerialManager @AppStorage("showPeakIndicator") private var showPeakIndicator = true @AppStorage("meterStyle") private var meterStyle = "classic" @@ -62,6 +63,13 @@ struct SettingsView: View { Label("Audio", systemImage: "waveform") } + // Hardware Settings + HardwareSettingsView() + .environmentObject(serialManager) + .tabItem { + Label("Hardware", systemImage: "cable.connector") + } + // Display Settings Form { Section("Meter Display") { @@ -98,33 +106,40 @@ struct SettingsView: View { .font(.title) .fontWeight(.bold) - Text("Version 1.0.0") + Text("Version 1.1.0") .foregroundColor(.secondary) Divider() .frame(width: 200) - Text("A macOS audio level meter with system monitoring capabilities.") - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - .frame(width: 300) + VStack(spacing: 8) { + Text("A macOS audio level meter with system monitoring") + Text("and physical VU meter hardware support.") + } + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .frame(width: 300) Spacer() - Text("For use with BlackHole virtual audio device") - .font(.caption) - .foregroundColor(.secondary) + VStack(spacing: 4) { + Text("Supports BlackHole virtual audio device") + Text("and USB/Serial VU meter hardware") + } + .font(.caption) + .foregroundColor(.secondary) } .padding() .tabItem { Label("About", systemImage: "info.circle") } } - .frame(width: 450, height: 300) + .frame(width: 500, height: 400) } } #Preview { SettingsView() .environmentObject(AudioEngine()) + .environmentObject(SerialManager()) } diff --git a/AudioVUMeter/README.md b/AudioVUMeter/README.md index 51ea139..9efd1de 100644 --- a/AudioVUMeter/README.md +++ b/AudioVUMeter/README.md @@ -1,6 +1,6 @@ # Audio VU Meter for macOS -A native macOS SwiftUI application that displays real-time audio levels from BlackHole (or any audio input device) as a classic VU meter, along with system resource monitoring. +A native macOS SwiftUI application that displays real-time audio levels from BlackHole (or any audio input device) as a classic VU meter, along with system resource monitoring. **Now with physical VU meter hardware support!** ![macOS](https://img.shields.io/badge/macOS-13.0+-blue.svg) ![Swift](https://img.shields.io/badge/Swift-5.0-orange.svg) @@ -21,11 +21,26 @@ A native macOS SwiftUI application that displays real-time audio levels from Bla - **Disk Activity** - Disk I/O activity indicator - **Network Activity** - Network throughput monitoring +### Physical VU Meter Hardware Support +- **4 Physical Dials** - Support for up to 4 physical VU meter dials +- **Flexible Channel Mapping** - Assign any metric to any dial: + - Audio Left/Right channels + - Audio Peak or Mono (L+R) + - CPU, RAM, Disk, Network usage +- **Multiple Serial Protocols**: + - Raw Bytes: `[0xAA] [D1] [D2] [D3] [D4] [0x55]` + - Text Commands: `CH1:val;CH2:val;CH3:val;CH4:val\n` + - JSON: `{"dials":[d1,d2,d3,d4]}` + - VU-Server Compatible: `#0:val\n#1:val\n...` +- **Configurable per dial**: Min/max values, inversion, smoothing +- **Auto-detection** of USB serial devices + ## Requirements - macOS 13.0 (Ventura) or later - Xcode 15.0 or later (for building) - [BlackHole](https://existential.audio/blackhole/) virtual audio driver (recommended) +- USB/Serial VU meter hardware (optional) ## Installation @@ -40,7 +55,7 @@ A native macOS SwiftUI application that displays real-time audio levels from Bla 1. Clone the repository 2. Open `AudioVUMeter.xcodeproj` in Xcode -3. Build and run (⌘R) +3. Build and run (Cmd+R) ```bash git clone @@ -54,16 +69,29 @@ open AudioVUMeter.xcodeproj - **Audio Levels**: The vertical VU meters show Left (L) and Right (R) channel audio levels - **dB Readings**: Numeric display of current audio levels in decibels - **System Meters**: Circular gauges showing CPU, RAM, Disk, and Network usage +- **Hardware Output**: Shows status of connected physical VU meters ### Controls - **Start/Stop**: Toggle audio capture on/off - **Reset**: Clear peak hold indicators -- **Settings**: Access device selection and preferences +- **Settings** (gear icon): Access device selection and preferences +- **Hardware** (cable icon): Configure physical VU meter connection + +### Hardware Setup + +1. Connect your USB/Serial VU meter hardware +2. Click the cable icon or go to Settings -> Hardware +3. Select your serial port from the dropdown +4. Choose the appropriate baud rate (default: 115200) +5. Select the communication protocol your hardware uses +6. Assign channels to each dial (Audio L, R, CPU, RAM, etc.) +7. Click "Connect" ### Settings - **Input Device**: Select audio input source (BlackHole, microphone, etc.) - **Reference Level**: Adjust the 0 dB reference point - **Peak Hold Time**: Configure how long peak indicators remain visible +- **Hardware**: Serial port, protocol, and dial assignments ## Architecture @@ -74,6 +102,8 @@ AudioVUMeter/ ├── VUMeterView.swift # VU meter components ├── AudioEngine.swift # Core Audio capture engine ├── SystemMonitor.swift # System resource monitoring +├── SerialManager.swift # USB/Serial communication +├── HardwareView.swift # Hardware configuration UI ├── SettingsView.swift # Settings window └── Assets.xcassets/ # App icons and colors ``` @@ -82,16 +112,45 @@ AudioVUMeter/ - **AudioEngine**: Uses AVAudioEngine to capture audio from the selected input device, calculates RMS levels, and converts to dB - **SystemMonitor**: Uses Mach kernel APIs to retrieve CPU, memory, disk, and network statistics +- **SerialManager**: Handles USB/Serial communication with physical VU meter hardware - **VUMeterView**: SwiftUI views for classic vertical VU meters with segment-based display - **SystemMeterView**: Circular gauge components for system metrics +## Hardware Protocol Reference + +### Raw Bytes Protocol +``` +Start: 0xAA +Data: [Dial1] [Dial2] [Dial3] [Dial4] (0-255 each) +End: 0x55 +``` + +### Text Command Protocol +``` +CH1:128;CH2:64;CH3:200;CH4:32\n +``` + +### JSON Protocol +```json +{"dials":[128,64,200,32]} +``` + +### VU-Server Compatible Protocol +``` +#0:50 +#1:75 +#2:30 +#3:90 +``` +Values are percentages (0-100) + ## BlackHole Setup Guide 1. **Install BlackHole**: Download and install from [existential.audio](https://existential.audio/blackhole/) 2. **Create Multi-Output Device**: - - Open Audio MIDI Setup (Applications → Utilities) - - Click the `+` button → Create Multi-Output Device + - Open Audio MIDI Setup (Applications -> Utilities) + - Click the `+` button -> Create Multi-Output Device - Check both your speakers and BlackHole - Set as default output @@ -99,6 +158,13 @@ AudioVUMeter/ - System audio will now go to both speakers and BlackHole - Audio VU Meter captures from BlackHole input +## Compatible Hardware + +This app is designed to work with: +- [VU Dials by Sasa Karanovic](https://github.com/SasaKaranovic/VU-Server) +- Arduino-based VU meters with serial interface +- Any USB/Serial device accepting the supported protocols + ## API Reference ### AudioEngine @@ -136,6 +202,25 @@ systemMonitor.diskActivity systemMonitor.networkActivity ``` +### SerialManager + +```swift +// Connection +serialManager.connect() +serialManager.disconnect() + +// Configuration +serialManager.selectedPortPath = "/dev/cu.usbserial-XXX" +serialManager.baudRate = 115200 +serialManager.selectedProtocol = .vuServer + +// Dial assignment +serialManager.dialConfigs[0].dialChannel = .audioLeft +serialManager.dialConfigs[1].dialChannel = .audioRight +serialManager.dialConfigs[2].dialChannel = .cpu +serialManager.dialConfigs[3].dialChannel = .ram +``` + ## License MIT License - See LICENSE file for details.