diff --git a/launcher/Resources/Info.plist b/launcher/Resources/Info.plist index 3d39f8c..a2d4b33 100644 --- a/launcher/Resources/Info.plist +++ b/launcher/Resources/Info.plist @@ -32,5 +32,13 @@ GPL-3.0-or-later © L'Electron Rare NSPrincipalClass NSApplication + + NSCameraUsageDescription + AV-Live utilise la webcam pour la detection de pose temps reel (YOLOv8) en mode data-only. + + NSMicrophoneUsageDescription + AV-Live peut utiliser le micro pour des sources audio temps reel. diff --git a/launcher/Sources/AVLiveLauncher/AVLiveLauncherApp.swift b/launcher/Sources/AVLiveLauncher/AVLiveLauncherApp.swift index 0e00a27..9c38194 100644 --- a/launcher/Sources/AVLiveLauncher/AVLiveLauncherApp.swift +++ b/launcher/Sources/AVLiveLauncher/AVLiveLauncherApp.swift @@ -18,6 +18,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem! private var popover: NSPopover! private var logWindow: NSWindow? + private var modePickerWindow: NSWindow? private let processManager = ProcessManager() func applicationDidFinishLaunching(_ notification: Notification) { @@ -47,13 +48,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) ) - // Auto-start everything if enabled (default true). Slight delay so - // the menubar UI has time to appear before any subprocess output - // floods the log buffer. - if processManager.autoStart { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.processManager.startAll() + // Mode picker au boot : si skipModePicker est faux, on demande a + // l'utilisateur. Sinon on enchaine direct avec le mode persiste. + let skipPicker = UserDefaults.standard.bool(forKey: "skipModePicker") + if skipPicker { + if processManager.autoStart { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.processManager.startAll() + } } + } else { + showModePicker() } // Watch the sentinel file written by the web bridge's @@ -61,6 +66,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate { processManager.startSentinelWatcher() } + private func showModePicker() { + let w = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + w.title = "AV-Live — choisir le mode" + w.center() + w.isReleasedWhenClosed = false + w.level = .floating + w.contentViewController = NSHostingController( + rootView: ModePickerView( + processManager: processManager, + onChoice: { [weak self, weak w] _ in + w?.close() + self?.modePickerWindow = nil + if let pm = self?.processManager, pm.autoStart { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + pm.startAll() + } + } + } + ) + ) + modePickerWindow = w + w.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + func applicationWillTerminate(_ notification: Notification) { processManager.stopAll() } diff --git a/launcher/Sources/AVLiveLauncher/MenuBarContent.swift b/launcher/Sources/AVLiveLauncher/MenuBarContent.swift index c7873b3..3cad8d9 100644 --- a/launcher/Sources/AVLiveLauncher/MenuBarContent.swift +++ b/launcher/Sources/AVLiveLauncher/MenuBarContent.swift @@ -7,62 +7,95 @@ struct MenuBarContent: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - Text("AV-Live") - .font(.headline) + HStack { + Text("AV-Live") + .font(.headline) + Spacer() + Picker("", selection: Binding( + get: { processManager.mode }, + set: { processManager.mode = $0 } + )) { + ForEach(LaunchMode.allCases) { m in + Text(m.displayName).tag(m) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 200) + } Divider() + // -- Process rows pilotes par le mode ----------------------- + // SC + oF sont communs aux deux modes. Seul le web UI est + // reserve au mode Full. ProcessRow( title: "SuperCollider", - subtitle: "sclang + scsynth + web bridge", + subtitle: processManager.mode == .dataOnly + ? "sclang + scsynth (pas de web bridge)" + : "sclang + scsynth + web bridge", isRunning: processManager.sclangRunning, start: processManager.startSclang, stop: processManager.stopSclang ) - - ProcessRow( - title: "Oscilloscope", - subtitle: "oscope-of visualizer", - isRunning: processManager.oscopeRunning, - start: processManager.startOscope, - stop: processManager.stopOscope - ) - - ProcessRow( - title: "Web UI", - subtitle: "Express :\(processManager.webPort) — control + Hydra", - isRunning: processManager.webRunning, - start: processManager.startWeb, - stop: processManager.stopWeb - ) - + if processManager.mode == .dataOnly { + ProcessRow( + title: "Metal Viz", + subtitle: "Python + pyobjc + Metal (data-only)", + isRunning: processManager.metalVizRunning, + start: processManager.startMetalViz, + stop: processManager.stopMetalViz + ) + } else { + ProcessRow( + title: "Oscilloscope", + subtitle: "oscope-of visualizer", + isRunning: processManager.oscopeRunning, + start: processManager.startOscope, + stop: processManager.stopOscope + ) + } + if processManager.mode == .full { + ProcessRow( + title: "Web UI", + subtitle: "Express :\(processManager.webPort) — control + Hydra", + isRunning: processManager.webRunning, + start: processManager.startWeb, + stop: processManager.stopWeb + ) + } ProcessRow( title: "Data Feeds", - subtitle: "USGS · SWPC · Grid · Lightning · Pose · …", + subtitle: processManager.mode == .dataOnly + ? "config.data-only.toml — pose YOLO + opendata" + : "USGS · SWPC · Grid · Lightning · Pose · …", isRunning: processManager.dataFeedsRunning, start: processManager.startDataFeeds, stop: processManager.stopDataFeeds ) - HStack { - Button(action: processManager.openBrowser) { - Label("Control", systemImage: "slider.horizontal.3") + if processManager.mode == .full { + HStack { + Button(action: processManager.openBrowser) { + Label("Control", systemImage: "slider.horizontal.3") + } + .disabled(!processManager.webRunning) + Button(action: processManager.openHydra) { + Label("Hydra", systemImage: "waveform") + } + .disabled(!processManager.webRunning) + Spacer() } - .disabled(!processManager.webRunning) - Button(action: processManager.openHydra) { - Label("Hydra", systemImage: "waveform") - } - .disabled(!processManager.webRunning) - Spacer() + Divider() + // Album quick-launcher : sends /control/playAlbum over OSC + AlbumLauncher(processManager: processManager) + } else { + // Mode data-only : section installation des dependances + statut + DataOnlyPanel(processManager: processManager) } Divider() - // Album quick-launcher : sends /control/playAlbum over OSC - AlbumLauncher(processManager: processManager) - - Divider() - HStack { Button(action: openLogs) { Label("Logs", systemImage: "text.alignleft") @@ -84,6 +117,235 @@ struct MenuBarContent: View { } } +private struct DataOnlyPanel: View { + @ObservedObject var processManager: ProcessManager + @State private var depsStatus: String = "checking…" + @State private var installing: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Pose dependencies").font(.subheadline).bold() + Spacer() + Circle() + .fill(depsStatus == "installed" ? Color.green + : (installing ? Color.orange : Color.red)) + .frame(width: 10, height: 10) + Text(depsStatus).font(.caption).foregroundColor(.secondary) + } + HStack { + Button(action: installPoseDeps) { + Label(installing ? "Installing…" : "Install pose deps", + systemImage: "arrow.down.circle") + } + .disabled(installing) + Button(action: refreshDepsStatus) { + Image(systemName: "arrow.clockwise") + } + .help("Refresh dependency check") + Spacer() + } + Text("OSC out: 127.0.0.1:57121 (SC) + 127.0.0.1:57123 (oF)") + .font(.caption).foregroundColor(.secondary) + + Divider() + Text("Source open-data → preset").font(.subheadline).bold() + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + PresetButton(source: "USGS", effect: "seismic", scene: "geo", viz: "voronoi", processManager: processManager) + PresetButton(source: "Blitz", effect: "lightning", scene: "pulse", viz: "storm", processManager: processManager) + PresetButton(source: "Wind", effect: "solar drone", scene: "weather", viz: "tunnel", processManager: processManager) + PresetButton(source: "Kp/Bz", effect: "geomag", scene: "geo", viz: "plasma", processManager: processManager) + } + HStack(spacing: 4) { + PresetButton(source: "X-ray", effect: "flare", scene: "weather", viz: "bars", processManager: processManager) + PresetButton(source: "OpenSky",effect: "aviation", scene: "flight", viz: "kaleido", processManager: processManager) + PresetButton(source: "Bsky", effect: "social pulse",scene: "pulse", viz: "bars", processManager: processManager) + PresetButton(source: "Pose", effect: "body", scene: "body", viz: "metaballs", processManager: processManager) + } + HStack(spacing: 4) { + PresetButton(source: "Cosmos", effect: "all sources", scene: "full", viz: "starfield", processManager: processManager) + Spacer() + } + } + + Divider() + Text("Audio scene").font(.subheadline).bold() + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + SceneButton(label: "all", processManager: processManager) + SceneButton(label: "full", processManager: processManager) + SceneButton(label: "quiet", processManager: processManager) + SceneButton(label: "stop", processManager: processManager) + } + HStack(spacing: 4) { + SceneButton(label: "cavity", processManager: processManager) + SceneButton(label: "geo", processManager: processManager) + SceneButton(label: "body", processManager: processManager) + SceneButton(label: "weather", processManager: processManager) + } + HStack(spacing: 4) { + SceneButton(label: "flight", processManager: processManager) + SceneButton(label: "pulse", processManager: processManager) + Spacer() + } + } + + Divider() + Text("Visual mode").font(.subheadline).bold() + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + VizModeButton(idx: 0, label: "storm", processManager: processManager) + VizModeButton(idx: 1, label: "tunnel", processManager: processManager) + VizModeButton(idx: 2, label: "plasma", processManager: processManager) + VizModeButton(idx: 3, label: "kaleido", processManager: processManager) + } + HStack(spacing: 4) { + VizModeButton(idx: 4, label: "voronoi", processManager: processManager) + VizModeButton(idx: 5, label: "metaballs", processManager: processManager) + VizModeButton(idx: 6, label: "stars", processManager: processManager) + VizModeButton(idx: 7, label: "bars", processManager: processManager) + } + } + } + .onAppear { refreshDepsStatus() } + } + + private func refreshDepsStatus() { + // Detecte la presence du venv et des modules pose en background : + // p.waitUntilExit() peut bloquer plusieurs centaines de ms sur un + // cold cache (disque, NSFileCoordinator), ce qui freezerait le UI. + let venvPython = processManager.dataFeedsDir + "/.venv/bin/python" + guard FileManager.default.isExecutableFile(atPath: venvPython) else { + depsStatus = "no venv" + return + } + depsStatus = "checking…" + DispatchQueue.global(qos: .userInitiated).async { + let p = Process() + p.executableURL = URL(fileURLWithPath: venvPython) + p.arguments = ["-c", "import cv2, ultralytics, numpy"] + let null = Pipe(); p.standardOutput = null; p.standardError = null + var status: String + do { + try p.run() + p.waitUntilExit() + status = (p.terminationStatus == 0) ? "installed" : "missing" + } catch { + status = "error" + } + DispatchQueue.main.async { depsStatus = status } + } + } + + private func installPoseDeps() { + installing = true + depsStatus = "syncing…" + DispatchQueue.global(qos: .userInitiated).async { + let p = Process() + p.executableURL = URL(fileURLWithPath: processManager.uvPath) + p.arguments = ["sync", "--extra", "pose"] + p.currentDirectoryURL = URL(fileURLWithPath: processManager.dataFeedsDir) + var env = ProcessInfo.processInfo.environment + env["PATH"] = (env["PATH"] ?? "") + + ":/opt/homebrew/bin:/usr/local/bin:\(env["HOME"] ?? "")/.local/bin" + p.environment = env + do { + try p.run() + p.waitUntilExit() + } catch { + // ignore — refreshDepsStatus va remonter l'echec + } + DispatchQueue.main.async { + installing = false + refreshDepsStatus() + } + } + } +} + +private struct PresetButton: View { + let source: String // nom de la source open-data (USGS, Blitz, ...) + let effect: String // effet musical/visuel (seismic, lightning, ...) + let scene: String // scene SC associee + let viz: String // viz mode Metal associe + let processManager: ProcessManager + @ObservedObject var pm: ProcessManager + init(source: String, effect: String, scene: String, viz: String, processManager: ProcessManager) { + self.source = source; self.effect = effect + self.scene = scene; self.viz = viz + self.processManager = processManager + self.pm = processManager + } + private var isActive: Bool { + pm.activePreset == source + } + var body: some View { + Button(action: { + processManager.osc.send("/control/doScene", scene) + processManager.oscViz.send("/control/vizMode", viz) + // Notifie le HUD du preset actif via /control/preset + processManager.oscViz.send("/control/preset", source) + DispatchQueue.main.async { pm.activePreset = source } + }) { + VStack(spacing: 1) { + Text(source) + .font(.system(.caption, design: .monospaced).bold()) + Text(effect) + .font(.system(size: 9)) + .foregroundColor(.secondary) + .lineLimit(1) + } + .frame(minWidth: 60, minHeight: 32) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isActive ? Color.accentColor.opacity(0.25) : .clear) + ) + } + .buttonStyle(.bordered) + .help("Source: \(source) · Scene: \(scene) · Viz: \(viz)") + .disabled(!processManager.sclangRunning && !processManager.metalVizRunning) + } +} + +private struct VizModeButton: View { + let idx: Int + let label: String + let processManager: ProcessManager + var body: some View { + Button(action: { + // Routage direct vers le visualizer Metal (data_only_viz) + // sur 57123. Le viz Python ecoute /control/vizMode . + processManager.oscViz.send("/control/vizMode", idx) + }) { + Text(label) + .font(.system(.caption, design: .monospaced)) + .frame(minWidth: 56) + } + .buttonStyle(.bordered) + .disabled(!processManager.metalVizRunning) + } +} + +private struct SceneButton: View { + let label: String + let processManager: ProcessManager + var body: some View { + Button(action: { + // Le patch data-only utilise /control/doScene ; l'ancien + // /control/dataScene est garde en fallback compat (data_only_program.scd) + processManager.osc.send("/control/doScene", label) + processManager.osc.send("/control/dataScene", label) + }) { + Text(label) + .font(.system(.caption, design: .monospaced)) + .frame(minWidth: 48) + } + .buttonStyle(.bordered) + .disabled(!processManager.sclangRunning) + } +} + private struct AlbumLauncher: View { @ObservedObject var processManager: ProcessManager @State private var albums: [ProcessManager.Album] = [] diff --git a/launcher/Sources/AVLiveLauncher/ModePickerWindow.swift b/launcher/Sources/AVLiveLauncher/ModePickerWindow.swift new file mode 100644 index 0000000..b4f7f3e --- /dev/null +++ b/launcher/Sources/AVLiveLauncher/ModePickerWindow.swift @@ -0,0 +1,95 @@ +import AppKit +import SwiftUI + +/// Fenetre modale affichee au demarrage pour choisir le mode (Full / +/// Data-only). L'utilisateur peut cocher "ne plus afficher" : la +/// preference est persistee dans UserDefaults (cle `skipModePicker`). +struct ModePickerView: View { + @ObservedObject var processManager: ProcessManager + let onChoice: (LaunchMode) -> Void + @State private var skipNextTime: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("AV-Live") + .font(.system(size: 22, weight: .semibold)) + Text("Quel mode lancer ?") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack(spacing: 12) { + ModeCard( + title: "Full AV-Live", + subtitle: "SC + oscope-of + web UI + data_feeds", + icon: "waveform.path.ecg.rectangle", + color: .accentColor + ) { + finish(.full) + } + ModeCard( + title: "Data-only", + subtitle: "SC + oscope-of + data_feeds (pose YOLO)\nPas de web UI, pas d'album", + icon: "antenna.radiowaves.left.and.right", + color: .green + ) { + finish(.dataOnly) + } + } + + Divider() + + Toggle("Ne plus afficher (utilisera le dernier mode choisi)", + isOn: $skipNextTime) + .font(.caption) + } + .padding(24) + .frame(width: 520) + .onAppear { + // Pre-coche selon la valeur persistee + skipNextTime = UserDefaults.standard.bool(forKey: "skipModePicker") + } + } + + private func finish(_ choice: LaunchMode) { + processManager.mode = choice + UserDefaults.standard.set(skipNextTime, forKey: "skipModePicker") + onChoice(choice) + } +} + +private struct ModeCard: View { + let title: String + let subtitle: String + let icon: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: icon) + .font(.system(size: 28)) + .foregroundColor(color) + Text(title) + .font(.headline) + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(3) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, minHeight: 110, alignment: .topLeading) + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(color.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(color.opacity(0.4), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} diff --git a/launcher/Sources/AVLiveLauncher/ProcessManager.swift b/launcher/Sources/AVLiveLauncher/ProcessManager.swift index 35b329d..132cfe1 100644 --- a/launcher/Sources/AVLiveLauncher/ProcessManager.swift +++ b/launcher/Sources/AVLiveLauncher/ProcessManager.swift @@ -9,6 +9,18 @@ struct LogLine: Identifiable, Equatable { let text: String } +enum LaunchMode: String, CaseIterable, Identifiable { + case full // sclang + oscope + web + data_feeds + case dataOnly // uniquement data_feeds (open data + pose YOLO) + var id: String { rawValue } + var displayName: String { + switch self { + case .full: return "Full AV-Live" + case .dataOnly: return "Data-only (SC + oF + feeds, no web)" + } + } +} + final class ProcessManager: ObservableObject { // Observable state @Published var sclangRunning = false @@ -26,9 +38,51 @@ final class ProcessManager: ObservableObject { @Published var webPort: Int { didSet { defaults.set(webPort, forKey: "webPort") } } @Published var uvPath: String { didSet { defaults.set(uvPath, forKey: "uvPath") } } @Published var dataFeedsDir: String { didSet { defaults.set(dataFeedsDir, forKey: "dataFeedsDir") } } + @Published var metalVizDir: String { didSet { defaults.set(metalVizDir, forKey: "metalVizDir") } } @Published var autoStart: Bool { didSet { defaults.set(autoStart, forKey: "autoStart") } } @Published var autoOpenBrowser: Bool { didSet { defaults.set(autoOpenBrowser, forKey: "autoOpenBrowser") } } @Published var autoStartDataFeeds: Bool { didSet { defaults.set(autoStartDataFeeds, forKey: "autoStartDataFeeds") } } + /// Process distinct du visualizer oF : en data-only on lance le + /// visualizer Python+Metal (data_only_viz) au lieu de oscope-of. + private var metalVizProc: Process? + @Published var metalVizRunning = false + /// Nom de la source open-data du preset actif (USGS, Blitz, Wind, + /// Kp/Bz, X-ray, OpenSky, Bsky, Pose, Cosmos). Affiche en surbrillance + /// le bouton correspondant dans le panel data-only. + @Published var activePreset: String = "" + private var metalVizWantsRestart = false + + /// "full" (sclang + oscope + web + data_feeds) ou "data-only" + /// (uniquement data_feeds avec config.data-only.toml). Persiste sur disque. + /// Au changement de mode, on coupe les process incompatibles pour + /// eviter qu'ils restent zombies hors UI (ex: web tournant en .dataOnly). + @Published var mode: LaunchMode { + didSet { + defaults.set(mode.rawValue, forKey: "mode") + if oldValue != mode { + applyModeTransition(from: oldValue, to: mode) + } + } + } + + private func applyModeTransition(from old: LaunchMode, to new: LaunchMode) { + append(source: "launcher", + text: "mode switched: \(old.rawValue) → \(new.rawValue)") + switch new { + case .dataOnly: + // En data-only : web UI n'a plus de sens (l'UI Hydra/control + // dialogue avec sclang, qu'on garde, mais le serveur Node ajoute + // une dependance inutile et peut tourner en zombie). + if webRunning { stopWeb() } + // data_feeds doit tourner avec config.data-only.toml : on + // relance s'il etait deja up pour qu'il prenne le bon profil. + if dataFeedsRunning { restartDataFeeds() } + case .full: + // Rien a couper : tout est compatible. data_feeds reste sur + // son config (l'utilisateur peut le restart manuellement). + break + } + } private let defaults = UserDefaults.standard private var sclangProc: Process? @@ -38,6 +92,9 @@ final class ProcessManager: ObservableObject { private var dataFeedsWantsRestart = false private var sclangWantsRestart = false let osc = OSCSender(host: "127.0.0.1", port: 57121) + /// OSC sender dedie au visualizer Metal (data_only_viz) sur 57123, + /// pour les /control/vizMode et autres commandes visuelles directes. + let oscViz = OSCSender(host: "127.0.0.1", port: 57123) private let logQueue = DispatchQueue(label: "cc.saillant.avlive.log") private let maxLogLines = 2000 @@ -117,12 +174,32 @@ final class ProcessManager: ObservableObject { ?? "/opt/homebrew/bin/uv") dataFeedsDir = defaults.string(forKey: "dataFeedsDir") ?? "\(avLive)/data_feeds" + metalVizDir = defaults.string(forKey: "metalVizDir") + ?? "\(avLive)/data_only_viz" autoStartDataFeeds = (defaults.object(forKey: "autoStartDataFeeds") as? Bool) ?? false + mode = LaunchMode(rawValue: defaults.string(forKey: "mode") ?? "") + ?? .full } /// Start everything that's currently stopped. Used by AppDelegate on /// launch when autoStart is enabled. + /// - .full : sclang + oscope + web + (optionnel) data_feeds + /// - .dataOnly : sclang + oscope + data_feeds (pas de web/album) func startAll() { + if mode == .dataOnly { + // Data-only : SC + Python Metal viz + data_feeds bridge. + // PAS de oscope-of (oF) — remplace par data_only_viz Python. + if !sclangRunning { startSclang() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + guard let self = self else { return } + if !self.metalVizRunning { self.startMetalViz() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in + guard let self = self else { return } + if !self.dataFeedsRunning { self.startDataFeeds() } + } + return + } if !sclangRunning { startSclang() } // Stagger so sclang has a head start booting scsynth before // oscope-of opens its OSC listener @@ -179,14 +256,18 @@ final class ProcessManager: ObservableObject { webProc = p DispatchQueue.main.async { self.webRunning = true } p.terminationHandler = { [weak self] proc in - self?.append(source: "web", text: "exited with status \(proc.terminationStatus)") + let status = proc.terminationStatus + // Toutes les lectures/ecritures des flags d'etat passent par + // le main thread : pas de data race avec restartWeb(). DispatchQueue.main.async { - self?.webProc = nil - self?.webRunning = false - if self?.webWantsRestart == true { - self?.webWantsRestart = false + guard let self = self else { return } + self.append(source: "web", text: "exited with status \(status)") + self.webProc = nil + self.webRunning = false + if self.webWantsRestart { + self.webWantsRestart = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - self?.startWeb() + self.startWeb() } } } @@ -286,29 +367,52 @@ final class ProcessManager: ObservableObject { append(source: "launcher", text: "sclang not found at \(sclangPath)") return } - guard FileManager.default.fileExists(atPath: soundAlgoLoadFile) else { - append(source: "launcher", text: "load file not found at \(soundAlgoLoadFile)") + // En mode data-only on lance le patch dedie data_only/boot.scd : + // engine minimaliste (FX rack), 10 SynthDefs \\do_*, 9 scenes, et + // demarrage automatique de oscope-of. AUCUNE dependance vers la + // palette live principale. + var loadFile = soundAlgoLoadFile + if mode == .dataOnly { + let dataOnlyBoot = URL(fileURLWithPath: soundAlgoLoadFile) + .deletingLastPathComponent() + .appendingPathComponent("data_only/boot.scd").path + if FileManager.default.fileExists(atPath: dataOnlyBoot) { + loadFile = dataOnlyBoot + } else { + // Fallback : ancien boot.data-only.scd si encore present + let legacy = URL(fileURLWithPath: soundAlgoLoadFile) + .deletingLastPathComponent() + .appendingPathComponent("boot.data-only.scd").path + if FileManager.default.fileExists(atPath: legacy) { + loadFile = legacy + } + } + } + guard FileManager.default.fileExists(atPath: loadFile) else { + append(source: "launcher", text: "load file not found at \(loadFile)") return } let p = Process() p.executableURL = URL(fileURLWithPath: sclangPath) - p.arguments = [soundAlgoLoadFile] + p.arguments = [loadFile] // sclang resolves relative paths from the current working dir; cd to the load file's dir - p.currentDirectoryURL = URL(fileURLWithPath: soundAlgoLoadFile).deletingLastPathComponent() + p.currentDirectoryURL = URL(fileURLWithPath: loadFile).deletingLastPathComponent() attach(process: p, label: "sclang") do { try p.run() sclangProc = p DispatchQueue.main.async { self.sclangRunning = true } p.terminationHandler = { [weak self] proc in - self?.append(source: "sclang", text: "exited with status \(proc.terminationStatus)") + let status = proc.terminationStatus DispatchQueue.main.async { - self?.sclangProc = nil - self?.sclangRunning = false - self?.maybeRestartSclang() + guard let self = self else { return } + self.append(source: "sclang", text: "exited with status \(status)") + self.sclangProc = nil + self.sclangRunning = false + self.maybeRestartSclang() } } - append(source: "launcher", text: "started sclang \(sclangPath) \(soundAlgoLoadFile)") + append(source: "launcher", text: "started sclang \(sclangPath) \(loadFile)") } catch { append(source: "launcher", text: "failed to start sclang: \(error)") } @@ -385,10 +489,12 @@ final class ProcessManager: ObservableObject { oscopeProc = p DispatchQueue.main.async { self.oscopeRunning = true } p.terminationHandler = { [weak self] proc in - self?.append(source: "oscope", text: "exited with status \(proc.terminationStatus)") + let status = proc.terminationStatus DispatchQueue.main.async { - self?.oscopeProc = nil - self?.oscopeRunning = false + guard let self = self else { return } + self.append(source: "oscope", text: "exited with status \(status)") + self.oscopeProc = nil + self.oscopeRunning = false } } append(source: "launcher", text: "started oscope-of \(oscopePath)") @@ -433,9 +539,17 @@ final class ProcessManager: ObservableObject { append(source: "launcher", text: "bridge.py not found at \(bridgePy)") return } + // Selectionne le profil de config : data-only utilise un toml dedie + // qui n'active QUE les flux opendata + pose YOLO. Si le fichier + // n'existe pas, fallback silencieux sur config.toml standard. + let dataOnlyCfg = dataFeedsDir + "/config.data-only.toml" + let useDataOnly = (mode == .dataOnly) + && FileManager.default.fileExists(atPath: dataOnlyCfg) + var args = ["run", "python", "bridge.py", "-v"] + if useDataOnly { args.append(contentsOf: ["-c", "config.data-only.toml"]) } let p = Process() p.executableURL = URL(fileURLWithPath: uvPath) - p.arguments = ["run", "python", "bridge.py", "-v"] + p.arguments = args p.currentDirectoryURL = URL(fileURLWithPath: dataFeedsDir) // uv resout les binaries depuis ~/.local/bin et /opt/homebrew/bin var env = ProcessInfo.processInfo.environment @@ -450,14 +564,16 @@ final class ProcessManager: ObservableObject { dataFeedsProc = p DispatchQueue.main.async { self.dataFeedsRunning = true } p.terminationHandler = { [weak self] proc in - self?.append(source: "feeds", text: "exited with status \(proc.terminationStatus)") + let status = proc.terminationStatus DispatchQueue.main.async { - self?.dataFeedsProc = nil - self?.dataFeedsRunning = false - if self?.dataFeedsWantsRestart == true { - self?.dataFeedsWantsRestart = false + guard let self = self else { return } + self.append(source: "feeds", text: "exited with status \(status)") + self.dataFeedsProc = nil + self.dataFeedsRunning = false + if self.dataFeedsWantsRestart { + self.dataFeedsWantsRestart = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - self?.startDataFeeds() + self.startDataFeeds() } } } @@ -481,6 +597,76 @@ final class ProcessManager: ObservableObject { dataFeedsWantsRestart = true } + // MARK: - Metal viz (Python+pyobjc, mode data-only) + + /// Lance `uv run python -m data_only_viz.main` dans data_only_viz/. + /// uv synchronise le venv (pyobjc-* + python-osc) au premier run. + func startMetalViz() { + guard metalVizProc == nil else { return } + guard FileManager.default.isExecutableFile(atPath: uvPath) else { + append(source: "launcher", text: "uv not found at \(uvPath)") + return + } + let main = metalVizDir + "/main.py" + guard FileManager.default.fileExists(atPath: main) else { + append(source: "launcher", text: "data_only_viz/main.py not found at \(main)") + return + } + let p = Process() + p.executableURL = URL(fileURLWithPath: uvPath) + // `--project` pointe uv sur le pyproject.toml de data_only_viz/. + // cwd au parent pour que `-m data_only_viz.main` resolve les + // imports relatifs `from .osc_listener import ...`. + // `--pose` active la captation webcam + YOLOv8-pose dans le meme + // process (le bundle launcher fournit le contexte TCC camera). + p.arguments = ["--project", metalVizDir, + "run", "python", "-m", "data_only_viz.main", + "-v", "--pose", "--fullscreen"] + p.currentDirectoryURL = URL(fileURLWithPath: metalVizDir).deletingLastPathComponent() + var env = ProcessInfo.processInfo.environment + env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin") + + ":/usr/local/bin:/opt/homebrew/bin:\(env["HOME"] ?? "")/.local/bin" + env["NO_COLOR"] = "1" + p.environment = env + attach(process: p, label: "metalviz") + do { + try p.run() + metalVizProc = p + DispatchQueue.main.async { self.metalVizRunning = true } + p.terminationHandler = { [weak self] proc in + let status = proc.terminationStatus + DispatchQueue.main.async { + guard let self = self else { return } + self.append(source: "metalviz", text: "exited with status \(status)") + self.metalVizProc = nil + self.metalVizRunning = false + if self.metalVizWantsRestart { + self.metalVizWantsRestart = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + self.startMetalViz() + } + } + } + } + append(source: "launcher", text: "started metal viz (\(metalVizDir))") + } catch { + append(source: "launcher", text: "failed to start metal viz: \(error)") + } + } + + func stopMetalViz() { + metalVizWantsRestart = false + metalVizProc?.terminate() + } + + func restartMetalViz() { + guard metalVizProc != nil else { startMetalViz(); return } + append(source: "launcher", text: "restarting metal viz…") + metalVizWantsRestart = true + metalVizProc?.terminate() + metalVizWantsRestart = true + } + // MARK: - utilities func stopAll() { @@ -488,6 +674,7 @@ final class ProcessManager: ObservableObject { oscopeProc?.terminate() webProc?.terminate() dataFeedsProc?.terminate() + metalVizProc?.terminate() } func clearLogs() {