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:
L'électron rare
2026-05-13 09:33:47 +02:00
parent 8913393e4c
commit fac0f7e055
5 changed files with 653 additions and 66 deletions
+8
View File
@@ -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() {