feat(launcher): album quick-launcher
Context: clicking through 23 album cards in the browser UI is the
existing path, but during a live set you often want to jump to an
album without breaking out of the menubar workflow. The launcher had
no way to actually drive sclang — Process spawn / stop only.
Approach: add a tiny OSC 1.0 sender written from scratch in Swift
(no dependency, ~80 LOC of bit twiddling) that ships UDP packets to
sclang's :57121 listener — same address the web bridge uses for
/control/* messages. Discover albums by scanning <avLive>/sound_algo/
tracks/X_<slug>/ subdirectories at popover open, build a horizontal
scroll of bordered buttons (one per album) showing the letter +
human-cased title. A Stepper picks the gap between tracks (0..32s),
a stop icon button next to it sends /control/stopAlbum.
Changes:
- launcher/OSCSender.swift : minimal OSC encoder + UDP transport.
Supports string / int / float args, big-endian int32 + float32, OSC
string null-terminator + 4-byte padding, type tag string starting
with ','. Uses BSD socket() / sendto() — fire and forget, no
reception
- launcher/ProcessManager.swift :
* new osc property (OSCSender bound to 127.0.0.1:57121)
* Album struct (letter, slug, displayTitle)
* discoverAlbums() walks the tracks/ directory looking for
A_xxx / B_xxx / ... single-uppercase-letter prefixed folders,
title-cases the slug for display
* playAlbum(letter, gap) → /control/playAlbum
* playTrack(letter, n) → /control/playTrack
* stopAlbum() → /control/stopAlbum
- launcher/MenuBarContent.swift :
* AlbumLauncher view : header with title + gap Stepper + stop button,
horizontal ScrollView of 64x30 album buttons (letter big +
truncated title small), tooltip 'X — Title' on hover
* popover frame width 360 → 380 to fit the row better
* MonospacedFont uses .system(.caption, design: .monospaced) (macOS
11 compatible) instead of .caption.monospaced() (12+)
Impact: clicking an album button in the menubar fires the same OSC
message the web UI sends — the audio engine starts the album in
sequence with the chosen gap, no browser tab needed. Live sets can
operate purely from the menubar.
This commit is contained in:
@@ -50,6 +50,11 @@ struct MenuBarContent: View {
|
||||
|
||||
Divider()
|
||||
|
||||
// Album quick-launcher : sends /control/playAlbum <letter> 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
|
||||
|
||||
@@ -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<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = result
|
||||
}
|
||||
}
|
||||
@@ -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 <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() {
|
||||
|
||||
Reference in New Issue
Block a user