0ee7ddddcd
Legacy SwiftPM target archived to launcher/_archive-AV-Live-Body/. New native Xcode app lives at avlivebody-mac/; no swift run path. startBodyApp now logs + no-ops with FIXME(rewrite-2026-05-18).
831 lines
35 KiB
Swift
831 lines
35 KiB
Swift
import AppKit
|
|
import Combine
|
|
import Foundation
|
|
|
|
struct LogLine: Identifiable, Equatable {
|
|
let id = UUID()
|
|
let timestamp = Date()
|
|
let source: String
|
|
let text: String
|
|
}
|
|
|
|
enum LaunchMode: String, CaseIterable, Identifiable {
|
|
case full // sclang + oscope + web + data_feeds
|
|
case dataOnly // uniquement data_feeds (open data + pose YOLO)
|
|
case bodyMesh // Multi-HMR worker + AV-Live-Body (mesh seul)
|
|
var id: String { rawValue }
|
|
var displayName: String {
|
|
switch self {
|
|
case .full: return "Full AV-Live"
|
|
case .dataOnly: return "Data-only (SC + Metal viz + feeds + web)"
|
|
case .bodyMesh: return "Body Mesh (Multi-HMR + RealityKit)"
|
|
}
|
|
}
|
|
}
|
|
|
|
final class ProcessManager: ObservableObject {
|
|
// Observable state
|
|
@Published var sclangRunning = false
|
|
@Published var oscopeRunning = false
|
|
@Published var webRunning = false
|
|
@Published var dataFeedsRunning = false
|
|
@Published private(set) var logs: [LogLine] = []
|
|
|
|
// Persisted paths (UserDefaults, key/value)
|
|
@Published var sclangPath: String { didSet { defaults.set(sclangPath, forKey: "sclangPath") } }
|
|
@Published var soundAlgoLoadFile: String { didSet { defaults.set(soundAlgoLoadFile, forKey: "soundAlgoLoadFile") } }
|
|
@Published var oscopePath: String { didSet { defaults.set(oscopePath, forKey: "oscopePath") } }
|
|
@Published var nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
|
|
@Published var webServerScript: String { didSet { defaults.set(webServerScript, forKey: "webServerScript") } }
|
|
@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") } }
|
|
@Published var useMultiHMR: Bool { didSet { defaults.set(useMultiHMR, forKey: "useMultiHMR") } }
|
|
/// 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:
|
|
// Data-only = SC + Metal viz + feeds + web (pas d'oscope-of)
|
|
if oscopeRunning { stopOscope() }
|
|
if dataFeedsRunning { restartDataFeeds() }
|
|
case .full:
|
|
break
|
|
case .bodyMesh:
|
|
// Mode bodyMesh : Multi-HMR headless + AV-Live-Body, MAIS on
|
|
// garde SC + data_feeds (audio + sonification des feeds).
|
|
// On coupe seulement oscope-of et le web UI.
|
|
if oscopeRunning { stopOscope() }
|
|
if webRunning { stopWeb() }
|
|
if dataFeedsRunning { restartDataFeeds() } // re-load config data-only
|
|
useMultiHMR = true
|
|
}
|
|
}
|
|
|
|
private let defaults = UserDefaults.standard
|
|
private var sclangProc: Process?
|
|
private var oscopeProc: Process?
|
|
private var webProc: Process?
|
|
private var dataFeedsProc: Process?
|
|
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
|
|
|
|
init() {
|
|
let fm = FileManager.default
|
|
let home = fm.homeDirectoryForCurrentUser.path
|
|
|
|
// Discover the AV-Live tree by trying a list of likely locations.
|
|
// First match wins. Includes the path derived from the .app bundle
|
|
// when the launcher is installed inside an AV-Live checkout.
|
|
let avLiveCandidates: [String] = {
|
|
var c: [String] = []
|
|
// Walk up from the bundle: <root>/launcher/build/AVLiveLauncher.app
|
|
let bundleParents = (0..<5).reduce(into: [URL]()) { acc, _ in
|
|
let last = acc.last ?? Bundle.main.bundleURL
|
|
acc.append(last.deletingLastPathComponent())
|
|
}
|
|
for url in bundleParents {
|
|
let candidate = url.path
|
|
if fm.fileExists(atPath: candidate + "/sound_algo/00_load.scd") {
|
|
c.append(candidate)
|
|
}
|
|
}
|
|
// Plus the well-known paths
|
|
c.append("\(home)/AV-Live")
|
|
c.append("\(home)/Documents/Projets/AV-Live")
|
|
return c
|
|
}()
|
|
let avLive = avLiveCandidates.first(where: {
|
|
fm.fileExists(atPath: $0 + "/sound_algo/00_load.scd")
|
|
}) ?? "\(home)/AV-Live"
|
|
|
|
sclangPath = defaults.string(forKey: "sclangPath")
|
|
?? "/Applications/SuperCollider.app/Contents/MacOS/sclang"
|
|
// Prefer boot.scd (single-block, auto-executable by sclang CLI) over
|
|
// 00_load.scd which is split into ~32 IDE-only Cmd+Enter blocks.
|
|
let bootCandidate = "\(avLive)/sound_algo/boot.scd"
|
|
let legacyCandidate = "\(avLive)/sound_algo/00_load.scd"
|
|
soundAlgoLoadFile = defaults.string(forKey: "soundAlgoLoadFile")
|
|
?? (fm.fileExists(atPath: bootCandidate) ? bootCandidate : legacyCandidate)
|
|
|
|
// openFrameworks Release produces a .app bundle in bin/. Default to the
|
|
// executable inside the bundle, with fallback to the bare binary if
|
|
// the user built with a custom Makefile target.
|
|
let bundled = "\(avLive)/oscope-of/bin/oscope-of.app/Contents/MacOS/oscope-of"
|
|
let bare = "\(avLive)/oscope-of/bin/oscope-of"
|
|
let stored = defaults.string(forKey: "oscopePath")
|
|
if let s = stored, !s.isEmpty {
|
|
oscopePath = s
|
|
} else if fm.fileExists(atPath: bundled) {
|
|
oscopePath = bundled
|
|
} else {
|
|
oscopePath = bare
|
|
}
|
|
webServerScript = defaults.string(forKey: "webServerScript")
|
|
?? "\(avLive)/sound_algo/web/server.js"
|
|
|
|
// Discover node : try /usr/local/bin (Intel Homebrew or stock pkg),
|
|
// /opt/homebrew/bin (Apple Silicon Homebrew), then `which node`
|
|
// resolved against an extended PATH.
|
|
let nodeCandidates = ["/usr/local/bin/node", "/opt/homebrew/bin/node",
|
|
"/usr/bin/node"]
|
|
nodePath = defaults.string(forKey: "nodePath")
|
|
?? (nodeCandidates.first(where: { fm.isExecutableFile(atPath: $0) })
|
|
?? "/usr/local/bin/node")
|
|
|
|
webPort = (defaults.object(forKey: "webPort") as? Int) ?? 3000
|
|
autoStart = (defaults.object(forKey: "autoStart") as? Bool) ?? true
|
|
autoOpenBrowser = (defaults.object(forKey: "autoOpenBrowser") as? Bool) ?? true
|
|
|
|
// uv (Astral) — usually installed via curl|sh or brew. Use ~/.local/bin
|
|
// for the curl installer, /opt/homebrew/bin for Apple Silicon brew.
|
|
let uvCandidates = ["/opt/homebrew/bin/uv", "/usr/local/bin/uv",
|
|
"\(home)/.local/bin/uv", "/usr/bin/uv"]
|
|
uvPath = defaults.string(forKey: "uvPath")
|
|
?? (uvCandidates.first(where: { fm.isExecutableFile(atPath: $0) })
|
|
?? "/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
|
|
useMultiHMR = defaults.bool(forKey: "useMultiHMR")
|
|
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
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
|
guard let self = self else { return }
|
|
if !self.oscopeRunning { self.startOscope() }
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
|
|
guard let self = self else { return }
|
|
if !self.webRunning { self.startWeb() }
|
|
}
|
|
if autoStartDataFeeds {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
guard let self = self else { return }
|
|
if !self.dataFeedsRunning { self.startDataFeeds() }
|
|
}
|
|
}
|
|
if autoOpenBrowser {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
|
|
guard let self = self else { return }
|
|
if let url = URL(string: "http://localhost:\(self.webPort)/control/") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - web server
|
|
|
|
func startWeb() {
|
|
guard webProc == nil else { return }
|
|
guard FileManager.default.isExecutableFile(atPath: nodePath) else {
|
|
append(source: "launcher", text: "node not found at \(nodePath)")
|
|
return
|
|
}
|
|
guard FileManager.default.fileExists(atPath: webServerScript) else {
|
|
append(source: "launcher", text: "server.js not found at \(webServerScript)")
|
|
return
|
|
}
|
|
let p = Process()
|
|
p.executableURL = URL(fileURLWithPath: nodePath)
|
|
p.arguments = [webServerScript]
|
|
p.currentDirectoryURL = URL(fileURLWithPath: webServerScript)
|
|
.deletingLastPathComponent()
|
|
// node needs PATH to resolve helper subprocesses + npm dep binaries
|
|
var env = ProcessInfo.processInfo.environment
|
|
let extraPath = "/usr/local/bin:/opt/homebrew/bin"
|
|
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin") + ":" + extraPath
|
|
env["HTTP_PORT"] = String(webPort)
|
|
p.environment = env
|
|
attach(process: p, label: "web")
|
|
do {
|
|
try p.run()
|
|
webProc = p
|
|
DispatchQueue.main.async { self.webRunning = true }
|
|
p.terminationHandler = { [weak self] proc in
|
|
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 {
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
append(source: "launcher", text: "started web server on :\(webPort)")
|
|
} catch {
|
|
append(source: "launcher", text: "failed to start web server: \(error)")
|
|
}
|
|
}
|
|
|
|
func stopWeb() {
|
|
webWantsRestart = false
|
|
webProc?.terminate()
|
|
}
|
|
|
|
private var webWantsRestart = false
|
|
|
|
// ----------------- Web data-only dashboard ------------------------
|
|
|
|
private var dataWebProc: Process?
|
|
@Published var dataWebRunning = false
|
|
let dataWebPort: Int = 3211
|
|
|
|
/// Lance le dashboard web data-only (Express + WS bridge sur :3211,
|
|
/// ecoute OSC UDP :57124). Necessite node + le dossier
|
|
/// `data_only_viz/web/`.
|
|
func startDataWeb() {
|
|
guard dataWebProc == nil else { return }
|
|
guard FileManager.default.isExecutableFile(atPath: nodePath) else {
|
|
append(source: "launcher", text: "node not found at \(nodePath)")
|
|
return
|
|
}
|
|
let script = URL(fileURLWithPath: metalVizDir)
|
|
.appendingPathComponent("web/server.js").path
|
|
guard FileManager.default.fileExists(atPath: script) else {
|
|
append(source: "launcher",
|
|
text: "data-only web server.js manquant a \(script)")
|
|
return
|
|
}
|
|
let p = Process()
|
|
p.executableURL = URL(fileURLWithPath: nodePath)
|
|
p.arguments = [script]
|
|
p.currentDirectoryURL = URL(fileURLWithPath: script)
|
|
.deletingLastPathComponent()
|
|
var env = ProcessInfo.processInfo.environment
|
|
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin")
|
|
+ ":/usr/local/bin:/opt/homebrew/bin"
|
|
env["HTTP_PORT"] = String(dataWebPort)
|
|
p.environment = env
|
|
attach(process: p, label: "dataweb")
|
|
do {
|
|
try p.run()
|
|
dataWebProc = p
|
|
DispatchQueue.main.async { self.dataWebRunning = true }
|
|
p.terminationHandler = { [weak self] _ in
|
|
DispatchQueue.main.async {
|
|
self?.dataWebProc = nil
|
|
self?.dataWebRunning = false
|
|
}
|
|
}
|
|
append(source: "launcher",
|
|
text: "started data-only web on :\(dataWebPort)")
|
|
} catch {
|
|
append(source: "launcher",
|
|
text: "data-only web start failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func stopDataWeb() {
|
|
dataWebProc?.terminate()
|
|
}
|
|
|
|
func openDataDashboard() {
|
|
if let url = URL(string: "http://127.0.0.1:\(dataWebPort)/dashboard.html") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
func openDataMap() {
|
|
if let url = URL(string: "http://127.0.0.1:\(dataWebPort)/map.html") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
func restartWeb() {
|
|
guard webProc != nil else { startWeb(); return }
|
|
append(source: "launcher", text: "restarting web server…")
|
|
webWantsRestart = true
|
|
webProc?.terminate()
|
|
webWantsRestart = true
|
|
}
|
|
|
|
func openBrowser() {
|
|
if let url = URL(string: "http://localhost:\(webPort)/control/") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
func openHydra() {
|
|
if let url = URL(string: "http://localhost:\(webPort)/hydra/") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
// MARK: - Album catalog (filesystem-derived)
|
|
|
|
struct Album: Identifiable {
|
|
let letter: String
|
|
let slug: String // e.g. "acid_journey"
|
|
let displayTitle: String // e.g. "Acid Journey"
|
|
var id: String { letter }
|
|
}
|
|
|
|
/// Scans <avLive>/sound_algo/tracks/ for X_slug subdirectories and
|
|
/// returns an Album entry per letter, sorted alphabetically.
|
|
func discoverAlbums() -> [Album] {
|
|
let fm = FileManager.default
|
|
let tracksRoot = URL(fileURLWithPath: soundAlgoLoadFile)
|
|
.deletingLastPathComponent()
|
|
.appendingPathComponent("tracks", isDirectory: true)
|
|
guard let entries = try? fm.contentsOfDirectory(
|
|
atPath: tracksRoot.path) else {
|
|
return []
|
|
}
|
|
var albums: [Album] = []
|
|
for name in entries {
|
|
// X_slug pattern with X = single uppercase letter
|
|
guard name.count >= 3,
|
|
name[name.index(name.startIndex, offsetBy: 1)] == "_",
|
|
let first = name.first, first.isUppercase, first.isLetter else {
|
|
continue
|
|
}
|
|
let dir = tracksRoot.appendingPathComponent(name).path
|
|
var isDir: ObjCBool = false
|
|
guard fm.fileExists(atPath: dir, isDirectory: &isDir),
|
|
isDir.boolValue else { continue }
|
|
let letter = String(first)
|
|
let slug = String(name.dropFirst(2))
|
|
let title = slug.split(separator: "_")
|
|
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
|
.joined(separator: " ")
|
|
albums.append(Album(letter: letter, slug: slug, displayTitle: title))
|
|
}
|
|
return albums.sorted { $0.letter < $1.letter }
|
|
}
|
|
|
|
func playAlbum(_ letter: String, gap: Int = 8) {
|
|
osc.send("/control/playAlbum", letter, gap)
|
|
}
|
|
|
|
func playTrack(_ letter: String, _ n: Int) {
|
|
osc.send("/control/playTrack", letter, n)
|
|
}
|
|
|
|
func stopAlbum() {
|
|
osc.send("/control/stopAlbum")
|
|
}
|
|
|
|
// MARK: - sclang
|
|
|
|
func startSclang() {
|
|
guard sclangProc == nil else { return }
|
|
guard FileManager.default.fileExists(atPath: sclangPath) else {
|
|
append(source: "launcher", text: "sclang not found at \(sclangPath)")
|
|
return
|
|
}
|
|
// 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 = [loadFile]
|
|
// sclang resolves relative paths from the current working dir; cd to the load file's dir
|
|
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
|
|
let status = proc.terminationStatus
|
|
DispatchQueue.main.async {
|
|
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) \(loadFile)")
|
|
} catch {
|
|
append(source: "launcher", text: "failed to start sclang: \(error)")
|
|
}
|
|
}
|
|
|
|
func stopSclang() {
|
|
sclangWantsRestart = false
|
|
sclangProc?.terminate()
|
|
// sclang's terminate doesn't always tear scsynth down cleanly —
|
|
// run a belt-and-braces pkill so the next boot can grab :57110.
|
|
let kill = Process()
|
|
kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
|
|
kill.arguments = ["-f", "scsynth"]
|
|
try? kill.run()
|
|
}
|
|
|
|
/// Restart sclang : fire and forget. Sets the auto-restart flag,
|
|
/// terminates the current process. terminationHandler observes the
|
|
/// flag and re-spawns after scsynth is reaped.
|
|
func restartSclang() {
|
|
guard sclangProc != nil else { startSclang(); return }
|
|
append(source: "launcher", text: "restarting sclang…")
|
|
sclangWantsRestart = true
|
|
stopSclang()
|
|
// stopSclang resets the flag — re-set it because we want restart
|
|
sclangWantsRestart = true
|
|
}
|
|
|
|
/// Watches both .av-live-restart-sclang and .av-live-restart-web
|
|
/// sentinel files. Triggered by the bridge's reboot handlers.
|
|
private var sentinelTimer: Timer?
|
|
func startSentinelWatcher() {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
let sclangSentinel = home.appendingPathComponent(".av-live-restart-sclang").path
|
|
let webSentinel = home.appendingPathComponent(".av-live-restart-web").path
|
|
sentinelTimer?.invalidate()
|
|
sentinelTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
if FileManager.default.fileExists(atPath: sclangSentinel) {
|
|
try? FileManager.default.removeItem(atPath: sclangSentinel)
|
|
self?.append(source: "launcher", text: "sentinel detected — restarting sclang")
|
|
self?.restartSclang()
|
|
}
|
|
if FileManager.default.fileExists(atPath: webSentinel) {
|
|
try? FileManager.default.removeItem(atPath: webSentinel)
|
|
self?.append(source: "launcher", text: "sentinel detected — restarting web")
|
|
self?.restartWeb()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func maybeRestartSclang() {
|
|
guard sclangWantsRestart else { return }
|
|
sclangWantsRestart = false
|
|
// Small delay so scsynth pkill finishes and the audio device is free
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
|
self?.startSclang()
|
|
}
|
|
}
|
|
|
|
// MARK: - oscope-of
|
|
|
|
func startOscope() {
|
|
guard oscopeProc == nil else { return }
|
|
guard FileManager.default.fileExists(atPath: oscopePath) else {
|
|
append(source: "launcher", text: "oscope-of binary not found at \(oscopePath)")
|
|
return
|
|
}
|
|
let p = Process()
|
|
p.executableURL = URL(fileURLWithPath: oscopePath)
|
|
p.currentDirectoryURL = oscopeProjectRoot(from: oscopePath)
|
|
attach(process: p, label: "oscope")
|
|
do {
|
|
try p.run()
|
|
oscopeProc = p
|
|
DispatchQueue.main.async { self.oscopeRunning = true }
|
|
p.terminationHandler = { [weak self] proc in
|
|
let status = proc.terminationStatus
|
|
DispatchQueue.main.async {
|
|
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)")
|
|
} catch {
|
|
append(source: "launcher", text: "failed to start oscope-of: \(error)")
|
|
}
|
|
}
|
|
|
|
func stopOscope() {
|
|
oscopeProc?.terminate()
|
|
}
|
|
|
|
/// openFrameworks expects the working dir to be the project root (the
|
|
/// directory containing `bin/`) so it can find `bin/data/`. The binary
|
|
/// path can be either `<root>/bin/oscope-of` (bare) or
|
|
/// `<root>/bin/oscope-of.app/Contents/MacOS/oscope-of` (release bundle) —
|
|
/// walk up to the parent of `bin/` either way.
|
|
private func oscopeProjectRoot(from binaryPath: String) -> URL {
|
|
var url = URL(fileURLWithPath: binaryPath).deletingLastPathComponent()
|
|
while url.path != "/" {
|
|
if url.lastPathComponent == "bin" {
|
|
return url.deletingLastPathComponent()
|
|
}
|
|
url.deleteLastPathComponent()
|
|
}
|
|
return URL(fileURLWithPath: binaryPath).deletingLastPathComponent()
|
|
}
|
|
|
|
// MARK: - data_feeds bridge (Python → OSC)
|
|
|
|
/// Lance `uv run python bridge.py -v` depuis `dataFeedsDir`. uv s'occupe
|
|
/// de creer/sync le venv au premier run. Le pont diffuse sur :57121
|
|
/// (sclang) et :57123 (oF) selon config.toml.
|
|
func startDataFeeds() {
|
|
guard dataFeedsProc == nil else { return }
|
|
guard FileManager.default.isExecutableFile(atPath: uvPath) else {
|
|
append(source: "launcher", text: "uv not found at \(uvPath) — install with: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
|
return
|
|
}
|
|
let bridgePy = dataFeedsDir + "/bridge.py"
|
|
guard FileManager.default.fileExists(atPath: bridgePy) else {
|
|
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 = args
|
|
p.currentDirectoryURL = URL(fileURLWithPath: dataFeedsDir)
|
|
// uv resout les binaries depuis ~/.local/bin et /opt/homebrew/bin
|
|
var env = ProcessInfo.processInfo.environment
|
|
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin")
|
|
+ ":/usr/local/bin:/opt/homebrew/bin:\(env["HOME"] ?? "")/.local/bin"
|
|
// Force la couleur off pour des logs propres dans la TextView
|
|
env["NO_COLOR"] = "1"
|
|
p.environment = env
|
|
attach(process: p, label: "feeds")
|
|
do {
|
|
try p.run()
|
|
dataFeedsProc = p
|
|
DispatchQueue.main.async { self.dataFeedsRunning = true }
|
|
p.terminationHandler = { [weak self] proc in
|
|
let status = proc.terminationStatus
|
|
DispatchQueue.main.async {
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
append(source: "launcher", text: "started data_feeds bridge (\(dataFeedsDir))")
|
|
} catch {
|
|
append(source: "launcher", text: "failed to start data_feeds: \(error)")
|
|
}
|
|
}
|
|
|
|
func stopDataFeeds() {
|
|
dataFeedsWantsRestart = false
|
|
dataFeedsProc?.terminate()
|
|
}
|
|
|
|
func restartDataFeeds() {
|
|
guard dataFeedsProc != nil else { startDataFeeds(); return }
|
|
append(source: "launcher", text: "restarting data_feeds…")
|
|
dataFeedsWantsRestart = true
|
|
dataFeedsProc?.terminate()
|
|
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).
|
|
var args = ["--project", metalVizDir,
|
|
"run", "python", "-m", "data_only_viz.main",
|
|
"-v", "--pose", "--fullscreen"]
|
|
if useMultiHMR {
|
|
args.append("--multi-hmr")
|
|
}
|
|
p.arguments = args
|
|
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))")
|
|
if useMultiHMR {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
|
[weak self] in self?.startBodyApp()
|
|
}
|
|
}
|
|
} 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: - AV-Live-Body (Multi-HMR mesh renderer)
|
|
|
|
private var bodyAppProc: Process?
|
|
@Published var bodyAppRunning = false
|
|
|
|
/// Lance l'app SwiftPM AV-Live-Body (RealityKit) qui ecoute les
|
|
/// vertices SMPL-X sur :57130. Necessite useMultiHMR=true.
|
|
func startBodyApp() {
|
|
// FIXME(rewrite-2026-05-18): wire to avlivebody-mac/AVLiveBody.app
|
|
// once installation path is decided. The legacy SwiftPM target at
|
|
// launcher/AV-Live-Body has been archived to
|
|
// launcher/_archive-AV-Live-Body/ and superseded by the native
|
|
// Xcode app at avlivebody-mac/ (xcodegen + xcodebuild). There is
|
|
// no `swift run` equivalent for an Xcode app target, so the
|
|
// spawn path is disabled until we settle on a post-build .app
|
|
// install location (DerivedData vs ~/Applications vs bundled).
|
|
NSLog("AVLiveBody is now standalone at avlivebody-mac/ — open from Xcode (build target AVLiveBody)")
|
|
append(source: "launcher",
|
|
text: "AVLiveBody spawn disabled — open avlivebody-mac/ in Xcode")
|
|
}
|
|
|
|
func stopBodyApp() {
|
|
bodyAppProc?.terminate()
|
|
}
|
|
|
|
// MARK: - utilities
|
|
|
|
func stopAll() {
|
|
sclangProc?.terminate()
|
|
oscopeProc?.terminate()
|
|
webProc?.terminate()
|
|
dataFeedsProc?.terminate()
|
|
metalVizProc?.terminate()
|
|
bodyAppProc?.terminate()
|
|
dataWebProc?.terminate()
|
|
}
|
|
|
|
func clearLogs() {
|
|
DispatchQueue.main.async { self.logs.removeAll() }
|
|
}
|
|
|
|
private func attach(process: Process, label: String) {
|
|
let outPipe = Pipe()
|
|
let errPipe = Pipe()
|
|
process.standardOutput = outPipe
|
|
process.standardError = errPipe
|
|
attachReader(outPipe.fileHandleForReading, source: label)
|
|
attachReader(errPipe.fileHandleForReading, source: label + "!")
|
|
}
|
|
|
|
private func attachReader(_ fh: FileHandle, source: String) {
|
|
fh.readabilityHandler = { [weak self] handle in
|
|
let data = handle.availableData
|
|
guard !data.isEmpty,
|
|
let str = String(data: data, encoding: .utf8) else { return }
|
|
for raw in str.split(separator: "\n", omittingEmptySubsequences: false) {
|
|
let line = String(raw)
|
|
if !line.isEmpty {
|
|
self?.append(source: source, text: line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func append(source: String, text: String) {
|
|
let entry = LogLine(source: source, text: text)
|
|
DispatchQueue.main.async {
|
|
self.logs.append(entry)
|
|
if self.logs.count > self.maxLogLines {
|
|
self.logs.removeFirst(self.logs.count - self.maxLogLines)
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
sentinelTimer?.invalidate()
|
|
sentinelTimer = nil
|
|
}
|
|
}
|