Files
L'électron rare 26ae5aae8f 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.
2026-05-07 17:46:29 +02:00

94 lines
2.8 KiB
Swift

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
}
}