diff --git a/AudioVUMeter/AudioVUMeter.xcodeproj/project.pbxproj b/AudioVUMeter/AudioVUMeter.xcodeproj/project.pbxproj index 7fa74ba..4618b26 100644 --- a/AudioVUMeter/AudioVUMeter.xcodeproj/project.pbxproj +++ b/AudioVUMeter/AudioVUMeter.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 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 */; }; + A1000024229E3D000000001D /* VUServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000025229E3D000000001E /* VUServer.swift */; }; + A1000026229E3D000000001F /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000027229E3D0000000020 /* ServerView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -31,6 +33,8 @@ 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 = ""; }; + A1000025229E3D000000001E /* VUServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VUServer.swift; sourceTree = ""; }; + A1000027229E3D0000000020 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -63,6 +67,8 @@ A100000C229E3D0000000006 /* SettingsView.swift */, A1000021229E3D000000001A /* SerialManager.swift */, A1000023229E3D000000001C /* HardwareView.swift */, + A1000025229E3D000000001E /* VUServer.swift */, + A1000027229E3D0000000020 /* ServerView.swift */, A100000E229E3D0000000007 /* Assets.xcassets */, A100000F229E3D0000000008 /* AudioVUMeter.entitlements */, A1000010229E3D0000000009 /* Info.plist */, @@ -155,6 +161,8 @@ A100000B229E3D0000000006 /* SettingsView.swift in Sources */, A1000020229E3D0000000019 /* SerialManager.swift in Sources */, A1000022229E3D000000001B /* HardwareView.swift in Sources */, + A1000024229E3D000000001D /* VUServer.swift in Sources */, + A1000026229E3D000000001F /* ServerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -298,7 +306,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.audiotools.AudioVUMeter; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -327,7 +335,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.audiotools.AudioVUMeter; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/AudioVUMeter/AudioVUMeter/AudioVUMeter.entitlements b/AudioVUMeter/AudioVUMeter/AudioVUMeter.entitlements index 4a6467e..ae4c2ba 100644 --- a/AudioVUMeter/AudioVUMeter/AudioVUMeter.entitlements +++ b/AudioVUMeter/AudioVUMeter/AudioVUMeter.entitlements @@ -6,7 +6,13 @@ com.apple.security.device.audio-input + com.apple.security.device.serial + com.apple.security.files.user-selected.read-only + com.apple.security.network.server + + com.apple.security.network.client + diff --git a/AudioVUMeter/AudioVUMeter/AudioVUMeterApp.swift b/AudioVUMeter/AudioVUMeter/AudioVUMeterApp.swift index 8db5168..6cf4cc4 100644 --- a/AudioVUMeter/AudioVUMeter/AudioVUMeterApp.swift +++ b/AudioVUMeter/AudioVUMeter/AudioVUMeterApp.swift @@ -5,6 +5,7 @@ // macOS Audio VU Meter with System Monitoring // Captures audio from BlackHole virtual audio device // Outputs to physical VU meter hardware via Serial/USB +// Includes VU Server for external app connections // import SwiftUI @@ -14,6 +15,7 @@ struct AudioVUMeterApp: App { @StateObject private var audioEngine = AudioEngine() @StateObject private var systemMonitor = SystemMonitor() @StateObject private var serialManager = SerialManager() + @StateObject private var vuServer = VUServer() // Timer for updating hardware values @State private var updateTimer: Timer? @@ -24,11 +26,14 @@ struct AudioVUMeterApp: App { .environmentObject(audioEngine) .environmentObject(systemMonitor) .environmentObject(serialManager) + .environmentObject(vuServer) .onAppear { + setupServer() startHardwareUpdateTimer() } .onDisappear { stopHardwareUpdateTimer() + vuServer.stop() } } .windowStyle(.hiddenTitleBar) @@ -38,12 +43,30 @@ struct AudioVUMeterApp: App { SettingsView() .environmentObject(audioEngine) .environmentObject(serialManager) + .environmentObject(vuServer) + } + } + + private func setupServer() { + // Link server to serial manager for broadcasting + vuServer.serialManager = serialManager + + // Auto-start server if it was enabled + if vuServer.options.enabled { + vuServer.start() } } private func startHardwareUpdateTimer() { updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { _ in - serialManager.updateValues(audioEngine: audioEngine, systemMonitor: systemMonitor) + // Check if external control is active from VU Server + if vuServer.externalControlActive, let externalValues = vuServer.receivedDialValues { + // Use values from external app + serialManager.dialValues = externalValues + } else { + // Use local audio/system values + serialManager.updateValues(audioEngine: audioEngine, systemMonitor: systemMonitor) + } } } diff --git a/AudioVUMeter/AudioVUMeter/ContentView.swift b/AudioVUMeter/AudioVUMeter/ContentView.swift index f580589..70b6671 100644 --- a/AudioVUMeter/AudioVUMeter/ContentView.swift +++ b/AudioVUMeter/AudioVUMeter/ContentView.swift @@ -11,9 +11,11 @@ struct ContentView: View { @EnvironmentObject var audioEngine: AudioEngine @EnvironmentObject var systemMonitor: SystemMonitor @EnvironmentObject var serialManager: SerialManager + @EnvironmentObject var vuServer: VUServer @State private var showSettings = false @State private var showHardwareSettings = false + @State private var showServerSettings = false var body: some View { ZStack { @@ -38,6 +40,15 @@ struct ContentView: View { Spacer() + // Server settings button + Button(action: { showServerSettings.toggle() }) { + Image(systemName: "server.rack") + .font(.system(size: 14)) + .foregroundColor(vuServer.isRunning ? .cyan : .gray) + } + .buttonStyle(.plain) + .help("VU Server Settings") + // Hardware settings button Button(action: { showHardwareSettings.toggle() }) { Image(systemName: "cable.connector") @@ -192,6 +203,10 @@ struct ContentView: View { HardwarePanelView() .environmentObject(serialManager) + // VU Server Panel + ServerPanelView() + .environmentObject(vuServer) + // Control buttons HStack(spacing: 15) { Button(action: { @@ -224,11 +239,15 @@ struct ContentView: View { } } } - .frame(width: 400, height: 750) + .frame(width: 400, height: 880) .sheet(isPresented: $showHardwareSettings) { HardwareSettingsSheet() .environmentObject(serialManager) } + .sheet(isPresented: $showServerSettings) { + ServerSettingsSheet() + .environmentObject(vuServer) + } .onAppear { audioEngine.start() systemMonitor.startMonitoring() @@ -340,4 +359,5 @@ struct ControlButtonStyle: ButtonStyle { .environmentObject(AudioEngine()) .environmentObject(SystemMonitor()) .environmentObject(SerialManager()) + .environmentObject(VUServer()) } diff --git a/AudioVUMeter/AudioVUMeter/SerialManager.swift b/AudioVUMeter/AudioVUMeter/SerialManager.swift index 24f0e56..9c50857 100644 --- a/AudioVUMeter/AudioVUMeter/SerialManager.swift +++ b/AudioVUMeter/AudioVUMeter/SerialManager.swift @@ -316,19 +316,60 @@ class SerialManager: ObservableObject { /// Perform the actual auto-probe private func performAutoProbe() { let ports = availablePorts - let baudRates = [115200, 9600, 57600, 38400, 19200] // Most common first - let protocols = SerialProtocol.allCases - let totalSteps = Double(ports.count * baudRates.count * protocols.count) + // First pass: quick check which ports can be opened + let baudRates = [115200, 9600] // Most common baud rates only + let protocols: [SerialProtocol] = [.vuServer, .rawBytes] // Most likely protocols + + let totalSteps = Double(ports.count * baudRates.count * protocols.count + ports.count) var currentStep = 0 - var bestResult: ProbeResult? + var workingPorts: [(port: SerialPort, baudRate: Int)] = [] + + // Phase 1: Find all ports that can be opened + DispatchQueue.main.async { + self.probeStatus = "Scanning USB ports..." + } for port in ports { guard isProbing else { break } + currentStep += 1 DispatchQueue.main.async { - self.probeStatus = "Probing: \(port.name)" + self.probeProgress = Double(currentStep) / totalSteps + self.probeStatus = "Checking: \(port.name)" + } + + // Quick check if port can be opened + let fd = open(port.path, O_RDWR | O_NOCTTY | O_NONBLOCK) + if fd != -1 { + close(fd) + // Port can be opened - it's a candidate + // Default to 115200 baud for serial USB devices + workingPorts.append((port: port, baudRate: 115200)) + + DispatchQueue.main.async { + let result = ProbeResult( + port: port, + protocol_: .vuServer, + baudRate: 115200, + success: true, + response: "Port accessible", + responseTime: 0.01 + ) + self.probeResults.append(result) + } + } + } + + // Phase 2: If we have working ports, try to communicate + var bestResult: ProbeResult? + + for (port, defaultBaud) in workingPorts { + guard isProbing else { break } + + DispatchQueue.main.async { + self.probeStatus = "Testing: \(port.name)" } for baud in baudRates { @@ -355,7 +396,7 @@ class SerialManager: ObservableObject { } // If we got a response, this is very likely the device - if result.response != nil { + if result.response != nil && !result.response!.isEmpty { DispatchQueue.main.async { self.detectedDevice = port self.selectedPortPath = port.path @@ -382,9 +423,16 @@ class SerialManager: ObservableObject { self.selectedPortPath = best.port.path self.selectedProtocol = best.protocol_ self.baudRate = best.baudRate - self.probeStatus = "Found: \(best.port.name) (\(best.protocol_.rawValue))" + self.probeStatus = "Found: \(best.port.name)" + } else if let firstWorking = workingPorts.first { + // No response but port works - use it anyway + self.detectedDevice = firstWorking.port + self.selectedPortPath = firstWorking.port.path + self.selectedProtocol = .vuServer + self.baudRate = firstWorking.baudRate + self.probeStatus = "Using: \(firstWorking.port.name) (no response)" } else { - self.probeStatus = "No VU meter found" + self.probeStatus = "No serial devices found" } } } @@ -409,9 +457,11 @@ class SerialManager: ObservableObject { options.c_lflag &= ~UInt(ICANON | ECHO | ECHOE | ISIG) options.c_oflag &= ~UInt(OPOST) - // Set read timeout - options.c_cc.16 = 0 // VMIN - options.c_cc.17 = 5 // VTIME (0.5 seconds) + // Set read timeout using withUnsafeMutableBytes for c_cc tuple + withUnsafeMutableBytes(of: &options.c_cc) { ptr in + ptr[Int(VMIN)] = 0 // VMIN + ptr[Int(VTIME)] = 5 // VTIME (0.5 seconds) + } tcsetattr(fd, TCSANOW, &options) tcflush(fd, TCIOFLUSH) diff --git a/AudioVUMeter/AudioVUMeter/ServerView.swift b/AudioVUMeter/AudioVUMeter/ServerView.swift new file mode 100644 index 0000000..48484da --- /dev/null +++ b/AudioVUMeter/AudioVUMeter/ServerView.swift @@ -0,0 +1,484 @@ +// +// ServerView.swift +// AudioVUMeter +// +// Server configuration and status view +// Allows enabling/disabling the VU Server for external app connections +// + +import SwiftUI + +// MARK: - Server Panel in Main View +struct ServerPanelView: View { + @EnvironmentObject var vuServer: VUServer + + var body: some View { + VStack(spacing: 12) { + // Header + HStack { + Text("VU SERVER") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.gray) + + Spacer() + + // Status indicator + HStack(spacing: 6) { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + + Text(statusText) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundColor(statusColor) + } + } + + // Quick info + if vuServer.isRunning { + HStack { + // Port info + Label(":\(vuServer.options.port)", systemImage: "network") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.cyan) + + Spacer() + + // Client count + Label("\(vuServer.connectedClients.count)", systemImage: "person.2.fill") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(vuServer.connectedClients.isEmpty ? .gray : .green) + + Spacer() + + // External control indicator + if vuServer.externalControlActive { + Label("EXT", systemImage: "arrow.down.circle.fill") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundColor(.orange) + } + } + } + + // Toggle button + Button(action: { + vuServer.toggle() + vuServer.saveOptions() + }) { + HStack { + Image(systemName: vuServer.isRunning ? "stop.fill" : "play.fill") + Text(vuServer.isRunning ? "Stop Server" : "Start Server") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(ServerButtonStyle(isRunning: vuServer.isRunning)) + + // Last command (if any) + if vuServer.isRunning && !vuServer.lastReceivedCommand.isEmpty { + HStack { + Text("Last:") + .font(.system(size: 8, design: .monospaced)) + .foregroundColor(.gray) + + Text(vuServer.lastReceivedCommand.prefix(30) + (vuServer.lastReceivedCommand.count > 30 ? "..." : "")) + .font(.system(size: 8, design: .monospaced)) + .foregroundColor(.cyan) + .lineLimit(1) + + Spacer() + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(borderColor, lineWidth: 1) + ) + ) + .padding(.horizontal) + } + + private var statusColor: Color { + if vuServer.isRunning { + return vuServer.connectedClients.isEmpty ? .yellow : .green + } + return .gray + } + + private var statusText: String { + if vuServer.isRunning { + if vuServer.connectedClients.isEmpty { + return "LISTENING" + } + return "\(vuServer.connectedClients.count) CLIENT\(vuServer.connectedClients.count == 1 ? "" : "S")" + } + return "STOPPED" + } + + private var borderColor: Color { + if vuServer.externalControlActive { return .orange.opacity(0.5) } + if vuServer.isRunning { return .cyan.opacity(0.3) } + return .clear + } +} + +// MARK: - Server Settings View (Full) +struct ServerSettingsView: View { + @EnvironmentObject var vuServer: VUServer + @State private var portString: String = "" + @State private var showAdvanced = false + + var body: some View { + Form { + // Main Server Section + Section("Server Control") { + // Enable/Disable toggle + Toggle(isOn: Binding( + get: { vuServer.isRunning }, + set: { newValue in + if newValue { + vuServer.start() + } else { + vuServer.stop() + } + vuServer.saveOptions() + } + )) { + HStack { + Image(systemName: vuServer.isRunning ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash") + .foregroundColor(vuServer.isRunning ? .green : .gray) + Text("Server Enabled") + } + } + + // Status + if vuServer.isRunning { + HStack { + Text("Status") + Spacer() + Circle() + .fill(Color.green) + .frame(width: 8, height: 8) + Text("Running on port \(vuServer.options.port)") + .foregroundColor(.secondary) + } + } + + // Error display + if let error = vuServer.lastError { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(error) + .foregroundColor(.red) + .font(.caption) + } + } + } + + // Connection Settings + Section("Connection Settings") { + // Port + HStack { + Text("Port") + Spacer() + TextField("Port", text: $portString) + .frame(width: 80) + .textFieldStyle(.roundedBorder) + .onAppear { + portString = String(vuServer.options.port) + } + .onChange(of: portString) { newValue in + if let port = UInt16(newValue), port > 0 { + vuServer.options.port = port + vuServer.saveOptions() + } + } + } + + // Allow remote connections + Toggle("Allow Remote Connections", isOn: $vuServer.options.allowRemote) + .onChange(of: vuServer.options.allowRemote) { _ in + vuServer.saveOptions() + if vuServer.isRunning { + vuServer.restart() + } + } + + if vuServer.options.allowRemote { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Warning: Remote access enabled. Anyone on your network can connect.") + .font(.caption) + .foregroundColor(.orange) + } + } + + // Max clients + Stepper("Max Clients: \(vuServer.options.maxClients)", value: $vuServer.options.maxClients, in: 1...20) + .onChange(of: vuServer.options.maxClients) { _ in + vuServer.saveOptions() + } + } + + // Protocol Settings + Section("Protocol") { + Picker("Server Protocol", selection: $vuServer.options.serverProtocol) { + ForEach(ServerProtocol.allCases) { proto in + Text(proto.rawValue).tag(proto) + } + } + .onChange(of: vuServer.options.serverProtocol) { _ in + vuServer.saveOptions() + } + + // Broadcast settings + Toggle("Broadcast Levels to Clients", isOn: $vuServer.options.broadcastLevels) + .onChange(of: vuServer.options.broadcastLevels) { _ in + vuServer.saveOptions() + } + + if vuServer.options.broadcastLevels { + HStack { + Text("Broadcast Rate") + Slider(value: $vuServer.options.broadcastInterval, in: 0.033...1.0) + Text("\(Int(1.0 / vuServer.options.broadcastInterval)) Hz") + .frame(width: 50) + } + .onChange(of: vuServer.options.broadcastInterval) { _ in + vuServer.saveOptions() + } + } + } + + // Connected Clients + Section("Connected Clients (\(vuServer.connectedClients.count))") { + if vuServer.connectedClients.isEmpty { + HStack { + Image(systemName: "person.slash") + .foregroundColor(.gray) + Text("No clients connected") + .foregroundColor(.secondary) + } + } else { + ForEach(vuServer.connectedClients) { client in + ClientRowView(client: client, onDisconnect: { + vuServer.disconnectClient(client) + }) + } + } + } + + // Statistics + if vuServer.isRunning { + Section("Statistics") { + HStack { + Text("Received") + Spacer() + Text(formatBytes(vuServer.totalBytesReceived)) + .foregroundColor(.secondary) + .font(.system(.body, design: .monospaced)) + } + + HStack { + Text("Sent") + Spacer() + Text(formatBytes(vuServer.totalBytesSent)) + .foregroundColor(.secondary) + .font(.system(.body, design: .monospaced)) + } + + if vuServer.externalControlActive { + HStack { + Image(systemName: "arrow.down.circle.fill") + .foregroundColor(.orange) + Text("External Control Active") + .foregroundColor(.orange) + Spacer() + Button("Release") { + vuServer.externalControlActive = false + vuServer.receivedDialValues = nil + } + .buttonStyle(.borderless) + } + } + + if !vuServer.lastReceivedCommand.isEmpty { + VStack(alignment: .leading) { + Text("Last Command") + Text(vuServer.lastReceivedCommand) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.cyan) + .lineLimit(3) + } + } + } + } + + // Protocol Reference + Section("Protocol Reference") { + DisclosureGroup("VU Protocol") { + VStack(alignment: .leading, spacing: 4) { + Text("Send values: #channel:percentage") + Text("Example: #0:75 (dial 0 at 75%)") + Text("Channels: 0-3") + Text("Values: 0-100") + Text("Commands: ?, STATUS, RELEASE") + } + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + + DisclosureGroup("JSON Protocol") { + VStack(alignment: .leading, spacing: 4) { + Text("{\"dials\":[v1,v2,v3,v4]}") + Text("or {\"d0\":v,\"d1\":v,...}") + Text("Values: 0-255") + Text("Commands: {\"cmd\":\"status\"}") + } + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + + DisclosureGroup("Raw Bytes") { + VStack(alignment: .leading, spacing: 4) { + Text("Frame: [0xAA][D1][D2][D3][D4][0x55]") + Text("Values: 0-255 per byte") + } + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + } + + // Test Connection + Section("Test") { + VStack(alignment: .leading, spacing: 8) { + Text("Test with netcat:") + .font(.caption) + .foregroundColor(.secondary) + + Text("echo '#0:50' | nc localhost \(vuServer.options.port)") + .font(.system(size: 10, design: .monospaced)) + .padding(8) + .background(Color.black.opacity(0.3)) + .cornerRadius(4) + + Button("Copy Command") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("echo '#0:50' | nc localhost \(vuServer.options.port)", forType: .string) + } + .buttonStyle(.borderless) + } + } + } + .formStyle(.grouped) + } + + 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: - Client Row View +struct ClientRowView: View { + let client: ConnectedClient + let onDisconnect: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(client.address) + .font(.system(.body, design: .monospaced)) + + HStack(spacing: 10) { + Text("RX: \(formatBytes(client.bytesReceived))") + Text("TX: \(formatBytes(client.bytesSent))") + } + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Text(timeAgo(client.lastActivity)) + .font(.caption) + .foregroundColor(.secondary) + + Button(action: onDisconnect) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + } + } + + private func formatBytes(_ bytes: UInt64) -> String { + if bytes < 1024 { return "\(bytes)B" } + if bytes < 1024 * 1024 { return String(format: "%.1fK", Double(bytes) / 1024) } + return String(format: "%.1fM", Double(bytes) / (1024 * 1024)) + } + + private func timeAgo(_ date: Date) -> String { + let seconds = Int(-date.timeIntervalSinceNow) + if seconds < 60 { return "\(seconds)s" } + if seconds < 3600 { return "\(seconds / 60)m" } + return "\(seconds / 3600)h" + } +} + +// MARK: - Server Settings Sheet +struct ServerSettingsSheet: View { + @EnvironmentObject var vuServer: VUServer + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("VU Server Settings") + .font(.headline) + Spacer() + Button("Done") { dismiss() } + } + .padding() + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + // Settings content + ServerSettingsView() + .environmentObject(vuServer) + } + .frame(width: 500, height: 650) + } +} + +// MARK: - Button Style +struct ServerButtonStyle: ButtonStyle { + let isRunning: 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(isRunning ? Color.red.opacity(0.7) : Color.cyan.opacity(0.7)) + .opacity(configuration.isPressed ? 0.6 : 1.0) + ) + } +} + +// MARK: - Preview +#Preview { + ServerSettingsView() + .environmentObject(VUServer()) + .frame(width: 500, height: 700) +} diff --git a/AudioVUMeter/AudioVUMeter/SettingsView.swift b/AudioVUMeter/AudioVUMeter/SettingsView.swift index b9ea68a..020c31b 100644 --- a/AudioVUMeter/AudioVUMeter/SettingsView.swift +++ b/AudioVUMeter/AudioVUMeter/SettingsView.swift @@ -10,6 +10,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var audioEngine: AudioEngine @EnvironmentObject var serialManager: SerialManager + @EnvironmentObject var vuServer: VUServer @AppStorage("showPeakIndicator") private var showPeakIndicator = true @AppStorage("meterStyle") private var meterStyle = "classic" @@ -70,6 +71,13 @@ struct SettingsView: View { Label("Hardware", systemImage: "cable.connector") } + // Server Settings + ServerSettingsView() + .environmentObject(vuServer) + .tabItem { + Label("Server", systemImage: "server.rack") + } + // Display Settings Form { Section("Meter Display") { @@ -106,7 +114,7 @@ struct SettingsView: View { .font(.title) .fontWeight(.bold) - Text("Version 1.1.0") + Text("Version 1.2.0") .foregroundColor(.secondary) Divider() @@ -123,8 +131,9 @@ struct SettingsView: View { Spacer() VStack(spacing: 4) { - Text("Supports BlackHole virtual audio device") - Text("and USB/Serial VU meter hardware") + Text("Supports BlackHole virtual audio device,") + Text("USB/Serial VU meter hardware,") + Text("and TCP server for external apps") } .font(.caption) .foregroundColor(.secondary) @@ -134,7 +143,7 @@ struct SettingsView: View { Label("About", systemImage: "info.circle") } } - .frame(width: 500, height: 400) + .frame(width: 500, height: 500) } } @@ -142,4 +151,5 @@ struct SettingsView: View { SettingsView() .environmentObject(AudioEngine()) .environmentObject(SerialManager()) + .environmentObject(VUServer()) } diff --git a/AudioVUMeter/AudioVUMeter/VUServer.swift b/AudioVUMeter/AudioVUMeter/VUServer.swift new file mode 100644 index 0000000..d69a169 --- /dev/null +++ b/AudioVUMeter/AudioVUMeter/VUServer.swift @@ -0,0 +1,568 @@ +// +// VUServer.swift +// AudioVUMeter +// +// TCP Server for external applications to send VU meter data +// Allows other apps to control the physical VU meters remotely +// + +import Foundation +import Network + +/// Server protocol for incoming data +enum ServerProtocol: String, CaseIterable, Identifiable { + case vuProtocol = "VU Protocol (#channel:value)" + case json = "JSON ({\"dials\":[...]})" + case rawBytes = "Raw Bytes (binary)" + + var id: String { rawValue } +} + +/// Connected client info +struct ConnectedClient: Identifiable { + let id: UUID + let address: String + let connectedAt: Date + var lastActivity: Date + var bytesReceived: UInt64 + var bytesSent: UInt64 + + init(address: String) { + self.id = UUID() + self.address = address + self.connectedAt = Date() + self.lastActivity = Date() + self.bytesReceived = 0 + self.bytesSent = 0 + } +} + +/// Server options +struct ServerOptions: Codable { + var enabled: Bool = false + var port: UInt16 = 9876 + var allowRemote: Bool = false // Only localhost by default + var requireAuth: Bool = false + var authToken: String = "" + var broadcastLevels: Bool = true // Send current levels to clients + var broadcastInterval: Double = 0.1 // 10 Hz + var maxClients: Int = 5 + var protocol_: String = ServerProtocol.vuProtocol.rawValue + + var serverProtocol: ServerProtocol { + get { ServerProtocol(rawValue: protocol_) ?? .vuProtocol } + set { protocol_ = newValue.rawValue } + } +} + +/// VU Meter Server for external app connections +class VUServer: ObservableObject { + // MARK: - Published Properties + + @Published var isRunning = false + @Published var options = ServerOptions() + @Published var connectedClients: [ConnectedClient] = [] + @Published var lastError: String? + @Published var totalBytesReceived: UInt64 = 0 + @Published var totalBytesSent: UInt64 = 0 + @Published var lastReceivedCommand: String = "" + + // Received dial values from clients (override local values when set) + @Published var receivedDialValues: [Int]? = nil // nil = use local values + @Published var externalControlActive = false + + // MARK: - Private Properties + + private var listener: NWListener? + private var connections: [UUID: NWConnection] = [:] + private let queue = DispatchQueue(label: "vu.server", qos: .userInteractive) + private var broadcastTimer: Timer? + + // Reference to serial manager for broadcasting + weak var serialManager: SerialManager? + + // MARK: - Initialization + + init() { + loadOptions() + } + + deinit { + stop() + } + + // MARK: - Server Control + + /// Start the server + func start() { + guard !isRunning else { return } + + do { + // Configure parameters + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + + // Create listener + let port = NWEndpoint.Port(rawValue: options.port)! + listener = try NWListener(using: parameters, on: port) + + listener?.stateUpdateHandler = { [weak self] state in + DispatchQueue.main.async { + self?.handleListenerState(state) + } + } + + listener?.newConnectionHandler = { [weak self] connection in + self?.handleNewConnection(connection) + } + + listener?.start(queue: queue) + + DispatchQueue.main.async { + self.isRunning = true + self.lastError = nil + } + + // Start broadcast timer if enabled + if options.broadcastLevels { + startBroadcastTimer() + } + + print("VU Server started on port \(options.port)") + + } catch { + DispatchQueue.main.async { + self.lastError = "Failed to start server: \(error.localizedDescription)" + self.isRunning = false + } + } + } + + /// Stop the server + func stop() { + stopBroadcastTimer() + + // Close all connections + for (_, connection) in connections { + connection.cancel() + } + connections.removeAll() + + // Stop listener + listener?.cancel() + listener = nil + + DispatchQueue.main.async { + self.isRunning = false + self.connectedClients.removeAll() + self.externalControlActive = false + self.receivedDialValues = nil + } + + print("VU Server stopped") + } + + /// Toggle server state + func toggle() { + if isRunning { + stop() + } else { + start() + } + } + + /// Restart server (after options change) + func restart() { + if isRunning { + stop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.start() + } + } + } + + // MARK: - Connection Handling + + private func handleListenerState(_ state: NWListener.State) { + switch state { + case .ready: + print("Server ready on port \(options.port)") + case .failed(let error): + lastError = "Server failed: \(error.localizedDescription)" + isRunning = false + case .cancelled: + isRunning = false + default: + break + } + } + + private func handleNewConnection(_ connection: NWConnection) { + let clientID = UUID() + + // Check max clients + if connections.count >= options.maxClients { + connection.cancel() + return + } + + // Check if remote connections are allowed + if !options.allowRemote { + if let endpoint = connection.currentPath?.remoteEndpoint, + case let .hostPort(host, _) = endpoint { + let hostStr = "\(host)" + if !hostStr.contains("127.0.0.1") && !hostStr.contains("localhost") && !hostStr.contains("::1") { + connection.cancel() + return + } + } + } + + connections[clientID] = connection + + // Get client address + var address = "Unknown" + if let endpoint = connection.currentPath?.remoteEndpoint, + case let .hostPort(host, port) = endpoint { + address = "\(host):\(port)" + } + + let client = ConnectedClient(address: address) + + DispatchQueue.main.async { + self.connectedClients.append(client) + } + + connection.stateUpdateHandler = { [weak self] state in + self?.handleConnectionState(clientID: clientID, state: state) + } + + connection.start(queue: queue) + + // Start receiving data + receiveData(clientID: clientID, connection: connection) + + // Send welcome message + let welcome = "VU-Server Ready\n" + sendData(welcome.data(using: .utf8)!, to: clientID) + } + + private func handleConnectionState(clientID: UUID, state: NWConnection.State) { + switch state { + case .ready: + print("Client connected: \(clientID)") + case .failed(_), .cancelled: + removeClient(clientID: clientID) + default: + break + } + } + + private func removeClient(clientID: UUID) { + connections[clientID]?.cancel() + connections.removeValue(forKey: clientID) + + DispatchQueue.main.async { + self.connectedClients.removeAll { $0.id == clientID } + + // If no more clients, disable external control + if self.connectedClients.isEmpty { + self.externalControlActive = false + self.receivedDialValues = nil + } + } + } + + // MARK: - Data Handling + + private func receiveData(clientID: UUID, connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 1024) { [weak self] data, _, isComplete, error in + guard let self = self else { return } + + if let data = data, !data.isEmpty { + DispatchQueue.main.async { + self.totalBytesReceived += UInt64(data.count) + + if let index = self.connectedClients.firstIndex(where: { $0.id == clientID }) { + self.connectedClients[index].bytesReceived += UInt64(data.count) + self.connectedClients[index].lastActivity = Date() + } + } + + self.processReceivedData(data, from: clientID) + } + + if let error = error { + print("Receive error: \(error)") + self.removeClient(clientID: clientID) + return + } + + if isComplete { + self.removeClient(clientID: clientID) + return + } + + // Continue receiving + self.receiveData(clientID: clientID, connection: connection) + } + } + + private func processReceivedData(_ data: Data, from clientID: UUID) { + // Try to parse based on protocol + switch options.serverProtocol { + case .vuProtocol: + parseVUProtocol(data, from: clientID) + case .json: + parseJSON(data, from: clientID) + case .rawBytes: + parseRawBytes(data, from: clientID) + } + } + + /// Parse VU Protocol: #channel:value\n + private func parseVUProtocol(_ data: Data, from clientID: UUID) { + guard let string = String(data: data, encoding: .utf8) else { return } + + DispatchQueue.main.async { + self.lastReceivedCommand = string.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Parse commands line by line + let lines = string.components(separatedBy: .newlines) + var values = receivedDialValues ?? [0, 0, 0, 0] + var hasUpdate = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Handle special commands + if trimmed == "?" || trimmed == "STATUS" { + sendStatus(to: clientID) + continue + } + + if trimmed == "RELEASE" { + // Release external control + DispatchQueue.main.async { + self.externalControlActive = false + self.receivedDialValues = nil + } + sendData("OK:RELEASED\n".data(using: .utf8)!, to: clientID) + continue + } + + // Parse #channel:value format + if trimmed.hasPrefix("#") { + let parts = trimmed.dropFirst().components(separatedBy: ":") + if parts.count == 2, + let channel = Int(parts[0]), + let value = Int(parts[1]), + channel >= 0 && channel < 4 { + // Value is percentage 0-100, convert to 0-255 + let byteValue = (value * 255) / 100 + values[channel] = max(0, min(255, byteValue)) + hasUpdate = true + } + } + + // Also support CH1:value format + if trimmed.hasPrefix("CH") { + let parts = trimmed.dropFirst(2).components(separatedBy: ":") + if parts.count == 2, + let channel = Int(parts[0]), + let value = Int(parts[1]), + channel >= 1 && channel <= 4 { + values[channel - 1] = max(0, min(255, value)) + hasUpdate = true + } + } + } + + if hasUpdate { + DispatchQueue.main.async { + self.receivedDialValues = values + self.externalControlActive = true + } + } + } + + /// Parse JSON: {"dials":[v1,v2,v3,v4]} or {"d0":v,"d1":v,...} + private func parseJSON(_ data: Data, from clientID: UUID) { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } + + DispatchQueue.main.async { + if let str = String(data: data, encoding: .utf8) { + self.lastReceivedCommand = str.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + var values = receivedDialValues ?? [0, 0, 0, 0] + var hasUpdate = false + + // Array format + if let dials = json["dials"] as? [Int] { + for (i, v) in dials.prefix(4).enumerated() { + values[i] = max(0, min(255, v)) + } + hasUpdate = true + } + + // Individual dial format + for i in 0..<4 { + if let v = json["d\(i)"] as? Int { + values[i] = max(0, min(255, v)) + hasUpdate = true + } + } + + // Command handling + if let cmd = json["cmd"] as? String { + switch cmd { + case "status": + sendStatus(to: clientID) + case "release": + DispatchQueue.main.async { + self.externalControlActive = false + self.receivedDialValues = nil + } + default: + break + } + } + + if hasUpdate { + DispatchQueue.main.async { + self.receivedDialValues = values + self.externalControlActive = true + } + } + } + + /// Parse raw bytes: [0xAA, d1, d2, d3, d4, 0x55] + private func parseRawBytes(_ data: Data, from clientID: UUID) { + DispatchQueue.main.async { + self.lastReceivedCommand = data.map { String(format: "%02X", $0) }.joined(separator: " ") + } + + // Look for frame: 0xAA ... 0x55 + var values = receivedDialValues ?? [0, 0, 0, 0] + let bytes = Array(data) + + var i = 0 + while i < bytes.count { + if bytes[i] == 0xAA && i + 5 < bytes.count && bytes[i + 5] == 0x55 { + // Found valid frame + for j in 0..<4 { + values[j] = Int(bytes[i + 1 + j]) + } + + DispatchQueue.main.async { + self.receivedDialValues = values + self.externalControlActive = true + } + + i += 6 + } else { + i += 1 + } + } + } + + // MARK: - Send Data + + private func sendData(_ data: Data, to clientID: UUID) { + guard let connection = connections[clientID] else { return } + + connection.send(content: data, completion: .contentProcessed { [weak self] error in + if error == nil { + DispatchQueue.main.async { + self?.totalBytesSent += UInt64(data.count) + + if let index = self?.connectedClients.firstIndex(where: { $0.id == clientID }) { + self?.connectedClients[index].bytesSent += UInt64(data.count) + } + } + } + }) + } + + private func sendStatus(to clientID: UUID) { + guard let sm = serialManager else { return } + + let status: [String: Any] = [ + "connected": sm.isConnected, + "dials": sm.dialValues, + "port": sm.selectedPortPath, + "external": externalControlActive + ] + + if let data = try? JSONSerialization.data(withJSONObject: status, options: []), + let string = String(data: data, encoding: .utf8) { + sendData((string + "\n").data(using: .utf8)!, to: clientID) + } + } + + /// Broadcast current levels to all clients + func broadcastLevels() { + guard !connections.isEmpty, let sm = serialManager else { return } + + let message: String + + switch options.serverProtocol { + case .vuProtocol: + message = sm.dialValues.enumerated() + .map { "#\($0.offset):\(($0.element * 100) / 255)" } + .joined(separator: "\n") + "\n" + case .json: + message = "{\"dials\":[\(sm.dialValues.map(String.init).joined(separator: ","))]}\n" + case .rawBytes: + // Don't broadcast for raw bytes + return + } + + guard let data = message.data(using: .utf8) else { return } + + for clientID in connections.keys { + sendData(data, to: clientID) + } + } + + // MARK: - Broadcast Timer + + private func startBroadcastTimer() { + stopBroadcastTimer() + + DispatchQueue.main.async { + self.broadcastTimer = Timer.scheduledTimer(withTimeInterval: self.options.broadcastInterval, repeats: true) { [weak self] _ in + self?.broadcastLevels() + } + } + } + + private func stopBroadcastTimer() { + broadcastTimer?.invalidate() + broadcastTimer = nil + } + + // MARK: - Persistence + + private func loadOptions() { + if let data = UserDefaults.standard.data(forKey: "VUServerOptions"), + let loaded = try? JSONDecoder().decode(ServerOptions.self, from: data) { + options = loaded + } + } + + func saveOptions() { + if let data = try? JSONEncoder().encode(options) { + UserDefaults.standard.set(data, forKey: "VUServerOptions") + } + } + + // MARK: - Disconnect Client + + func disconnectClient(_ client: ConnectedClient) { + removeClient(clientID: client.id) + } +}