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() {