feat(launcher): data-only mode + mode picker
Add ModePickerWindow.swift for live vs data-only. Update MenuBarContent and ProcessManager for modes. Bump Info.plist version, update app entry point.
This commit is contained in:
@@ -32,5 +32,13 @@
|
||||
<string>GPL-3.0-or-later © L'Electron Rare</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<!-- TCC : autorise les sous-processus (data_feeds Python) a ouvrir la
|
||||
webcam pour la detection de pose YOLO. Sans cette cle, macOS
|
||||
refuse silencieusement l'acces depuis cv2.VideoCapture. -->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>AV-Live utilise la webcam pour la detection de pose temps reel (YOLOv8) en mode data-only.</string>
|
||||
<!-- Necessaire si une dependance audio est ajoutee (whisper, etc.). -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>AV-Live peut utiliser le micro pour des sources audio temps reel.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 <letter> over OSC
|
||||
AlbumLauncher(processManager: processManager)
|
||||
} else {
|
||||
// Mode data-only : section installation des dependances + statut
|
||||
DataOnlyPanel(processManager: processManager)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Album quick-launcher : sends /control/playAlbum <letter> 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 <source>
|
||||
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 <int|string>.
|
||||
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] = []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user