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:
L'électron rare
2026-05-07 17:46:29 +02:00
parent d35f913469
commit 26ae5aae8f
3 changed files with 208 additions and 1 deletions
@@ -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() {