diff --git a/launcher/Sources/AVLiveLauncher/MenuBarContent.swift b/launcher/Sources/AVLiveLauncher/MenuBarContent.swift index 8ed7efa..ace4a27 100644 --- a/launcher/Sources/AVLiveLauncher/MenuBarContent.swift +++ b/launcher/Sources/AVLiveLauncher/MenuBarContent.swift @@ -50,6 +50,11 @@ struct MenuBarContent: View { Divider() + // Album quick-launcher : sends /control/playAlbum over OSC + AlbumLauncher(processManager: processManager) + + Divider() + HStack { Button(action: openLogs) { Label("Logs", systemImage: "text.alignleft") @@ -63,7 +68,7 @@ struct MenuBarContent: View { } } .padding(14) - .frame(width: 360) + .frame(width: 380) .sheet(isPresented: $showSettings) { SettingsView(processManager: processManager, dismiss: { showSettings = false }) @@ -71,6 +76,60 @@ struct MenuBarContent: View { } } +private struct AlbumLauncher: View { + @ObservedObject var processManager: ProcessManager + @State private var albums: [ProcessManager.Album] = [] + @State private var gap: Int = 8 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Albums").font(.subheadline).bold() + Spacer() + Text("gap").font(.caption).foregroundColor(.secondary) + Stepper(value: $gap, in: 0...32) { + Text("\(gap)s").font(.system(.caption, design: .monospaced)) + } + .labelsHidden() + .frame(width: 60) + Button(action: processManager.stopAlbum) { + Image(systemName: "stop.fill") + } + .help("Stop album") + } + if albums.isEmpty { + Text("no tracks/ folder found") + .font(.caption).foregroundColor(.secondary) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(albums) { album in + Button(action: { + processManager.playAlbum(album.letter, gap: gap) + }) { + VStack(spacing: 1) { + Text(album.letter) + .font(.system(.caption, design: .monospaced).bold()) + Text(album.displayTitle) + .font(.system(size: 9)) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(width: 64, height: 30) + .padding(.horizontal, 2) + } + .help("\(album.letter) — \(album.displayTitle)") + .buttonStyle(.bordered) + } + } + .padding(.vertical, 2) + } + } + } + .onAppear { albums = processManager.discoverAlbums() } + } +} + private struct ProcessRow: View { let title: String let subtitle: String diff --git a/launcher/Sources/AVLiveLauncher/OSCSender.swift b/launcher/Sources/AVLiveLauncher/OSCSender.swift new file mode 100644 index 0000000..0d5c2da --- /dev/null +++ b/launcher/Sources/AVLiveLauncher/OSCSender.swift @@ -0,0 +1,93 @@ +import Darwin +import Foundation + +// Minimal OSC 1.0 client — only what AV-Live needs (UDP send, no +// reception, only string + int + float args). Avoids pulling in a +// dependency for ~80 LOC of bit twiddling. + +final class OSCSender { + private let host: String + private let port: UInt16 + + init(host: String = "127.0.0.1", port: UInt16 = 57121) { + self.host = host + self.port = port + } + + func send(_ address: String, _ args: Any...) { + let data = encode(address: address, args: args) + sendUDP(data: data) + } + + // MARK: - Encoding (OSC 1.0 — strings null-terminated and padded + // to 4-byte boundaries, ints big-endian int32, floats big-endian + // float32, type tag string starts with ',') + + private func encode(address: String, args: [Any]) -> Data { + var out = Data() + out.append(oscString(address)) + + var tags = "," + var argsData = Data() + for a in args { + if let s = a as? String { + tags += "s" + argsData.append(oscString(s)) + } else if let i = a as? Int { + tags += "i" + argsData.append(oscInt32(Int32(i))) + } else if let f = a as? Float { + tags += "f" + argsData.append(oscFloat32(f)) + } else if let d = a as? Double { + tags += "f" + argsData.append(oscFloat32(Float(d))) + } + } + out.append(oscString(tags)) + out.append(argsData) + return out + } + + private func oscString(_ s: String) -> Data { + var d = s.data(using: .utf8) ?? Data() + d.append(0) + let pad = (4 - d.count % 4) % 4 + d.append(Data(count: pad)) + return d + } + + private func oscInt32(_ v: Int32) -> Data { + var be = v.bigEndian + return Data(bytes: &be, count: 4) + } + + private func oscFloat32(_ v: Float) -> Data { + var be = v.bitPattern.bigEndian + return Data(bytes: &be, count: 4) + } + + // MARK: - Transport + + private func sendUDP(data: Data) { + let fd = socket(AF_INET, SOCK_DGRAM, 0) + guard fd >= 0 else { return } + defer { close(fd) } + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr(host) + + let result = data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Int in + let buf = raw.baseAddress! + return withUnsafePointer(to: &addr) { saInPtr in + saInPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saPtr in + sendto(fd, buf, data.count, 0, + saPtr, socklen_t(MemoryLayout.size)) + } + } + } + _ = result + } +} diff --git a/launcher/Sources/AVLiveLauncher/ProcessManager.swift b/launcher/Sources/AVLiveLauncher/ProcessManager.swift index 1fb15a2..a749e55 100644 --- a/launcher/Sources/AVLiveLauncher/ProcessManager.swift +++ b/launcher/Sources/AVLiveLauncher/ProcessManager.swift @@ -30,6 +30,7 @@ final class ProcessManager: ObservableObject { private var sclangProc: Process? private var oscopeProc: Process? private var webProc: Process? + let osc = OSCSender(host: "127.0.0.1", port: 57121) private let logQueue = DispatchQueue(label: "cc.saillant.avlive.log") private let maxLogLines = 2000 @@ -182,6 +183,60 @@ final class ProcessManager: ObservableObject { } } + // 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 /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() {