panel: Wave control map (balls/rings/knobs/buttons -> CDL params w/ sensitivity), grade state + displays, framed wire protocol w/ partial-frame buffering — 12 tests
This commit is contained in:
parent
74d1287856
commit
7b8563d02b
5 changed files with 387 additions and 6 deletions
176
Sources/ForgePanel/ControlMap.swift
Normal file
176
Sources/ForgePanel/ControlMap.swift
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import Foundation
|
||||||
|
import ForgeColor
|
||||||
|
|
||||||
|
/// Tangent Wave control IDs — mirror of the panel's control layout.
|
||||||
|
/// Real IDs assigned by Tangent Hub control map; these are our canonical set,
|
||||||
|
/// remapped at Hub-registration time in the macOS phase.
|
||||||
|
public enum TangentControls {
|
||||||
|
// Trackballs: X/Y per ball. Ball 1 = lift(offset), 2 = gamma(power), 3 = gain(slope).
|
||||||
|
public static let liftBallX: Int = 101
|
||||||
|
public static let liftBallY: Int = 102
|
||||||
|
public static let gammaBallX: Int = 111
|
||||||
|
public static let gammaBallY: Int = 112
|
||||||
|
public static let gainBallX: Int = 121
|
||||||
|
public static let gainBallY: Int = 122
|
||||||
|
// Rings (master per zone).
|
||||||
|
public static let liftRing: Int = 103
|
||||||
|
public static let gammaRing: Int = 113
|
||||||
|
public static let gainRing: Int = 123
|
||||||
|
// Knobs.
|
||||||
|
public static let satKnob: Int = 201
|
||||||
|
public static let contrastKnob: Int = 202
|
||||||
|
// Buttons.
|
||||||
|
public static let bypassButton: Int = 301
|
||||||
|
public static let grabStillButton: Int = 302
|
||||||
|
public static let nextSlotButton: Int = 303
|
||||||
|
public static let prevSlotButton: Int = 304
|
||||||
|
public static let resetButton: Int = 305
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panel input event.
|
||||||
|
public enum PanelInput: Equatable, Sendable {
|
||||||
|
case encoder(id: Int, delta: Int)
|
||||||
|
case button(id: Int, pressed: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CDL parameter targeted by a panel control.
|
||||||
|
public enum CDLParam: String, Equatable, Sendable {
|
||||||
|
case slopeRed, slopeGreen, slopeBlue, slopeMaster
|
||||||
|
case offsetRed, offsetGreen, offsetBlue, offsetMaster
|
||||||
|
case powerRed, powerGreen, powerBlue, powerMaster
|
||||||
|
case saturation
|
||||||
|
case contrast
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action produced by mapping a panel input.
|
||||||
|
public enum PanelAction: Equatable, Sendable {
|
||||||
|
case adjustCDL(param: CDLParam, amount: Float)
|
||||||
|
case toggleBypass
|
||||||
|
case grabStill
|
||||||
|
case nextSlot
|
||||||
|
case prevSlot
|
||||||
|
case resetGrade
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps panel controls to actions with per-parameter sensitivity.
|
||||||
|
public struct ControlMap: Sendable {
|
||||||
|
public var encoderMap: [Int: CDLParam]
|
||||||
|
public var buttonMap: [Int: PanelAction]
|
||||||
|
private var sensitivities: [CDLParam: Float]
|
||||||
|
|
||||||
|
public func sensitivity(for param: CDLParam) -> Float {
|
||||||
|
sensitivities[param] ?? 0.001
|
||||||
|
}
|
||||||
|
|
||||||
|
public func action(for input: PanelInput) -> PanelAction? {
|
||||||
|
switch input {
|
||||||
|
case .encoder(let id, let delta):
|
||||||
|
guard let param = encoderMap[id] else { return nil }
|
||||||
|
return .adjustCDL(param: param, amount: Float(delta) * sensitivity(for: param))
|
||||||
|
case .button(let id, let pressed):
|
||||||
|
guard pressed else { return nil } // act on press only
|
||||||
|
return buttonMap[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default Wave mapping: balls X->red-ish axis, Y->green/blue axis simplification
|
||||||
|
/// (full 2D chromatic ball math lands with real panel calibration).
|
||||||
|
public static let waveDefault = ControlMap(
|
||||||
|
encoderMap: [
|
||||||
|
TangentControls.liftBallX: .offsetRed,
|
||||||
|
TangentControls.liftBallY: .offsetBlue,
|
||||||
|
TangentControls.liftRing: .offsetMaster,
|
||||||
|
TangentControls.gammaBallX: .powerRed,
|
||||||
|
TangentControls.gammaBallY: .powerBlue,
|
||||||
|
TangentControls.gammaRing: .powerMaster,
|
||||||
|
TangentControls.gainBallX: .slopeRed,
|
||||||
|
TangentControls.gainBallY: .slopeBlue,
|
||||||
|
TangentControls.gainRing: .slopeMaster,
|
||||||
|
TangentControls.satKnob: .saturation,
|
||||||
|
TangentControls.contrastKnob: .contrast,
|
||||||
|
],
|
||||||
|
buttonMap: [
|
||||||
|
TangentControls.bypassButton: .toggleBypass,
|
||||||
|
TangentControls.grabStillButton: .grabStill,
|
||||||
|
TangentControls.nextSlotButton: .nextSlot,
|
||||||
|
TangentControls.prevSlotButton: .prevSlot,
|
||||||
|
TangentControls.resetButton: .resetGrade,
|
||||||
|
],
|
||||||
|
sensitivities: [
|
||||||
|
.slopeRed: 0.002, .slopeGreen: 0.002, .slopeBlue: 0.002, .slopeMaster: 0.002,
|
||||||
|
.offsetRed: 0.001, .offsetGreen: 0.001, .offsetBlue: 0.001, .offsetMaster: 0.001,
|
||||||
|
.powerRed: 0.002, .powerGreen: 0.002, .powerBlue: 0.002, .powerMaster: 0.002,
|
||||||
|
.saturation: 0.002, .contrast: 0.002,
|
||||||
|
])
|
||||||
|
|
||||||
|
public init(encoderMap: [Int: CDLParam], buttonMap: [Int: PanelAction], sensitivities: [CDLParam: Float]) {
|
||||||
|
self.encoderMap = encoderMap
|
||||||
|
self.buttonMap = buttonMap
|
||||||
|
self.sensitivities = sensitivities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panel-editable grade state (CDL layer the panel drives).
|
||||||
|
public struct PanelGradeState: Sendable, Equatable {
|
||||||
|
public var cdl: CDL = .identity
|
||||||
|
public var bypassed = false
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public mutating func apply(_ action: PanelAction) {
|
||||||
|
switch action {
|
||||||
|
case .adjustCDL(let param, let amount):
|
||||||
|
switch param {
|
||||||
|
case .slopeRed: cdl.slope.x += amount
|
||||||
|
case .slopeGreen: cdl.slope.y += amount
|
||||||
|
case .slopeBlue: cdl.slope.z += amount
|
||||||
|
case .slopeMaster:
|
||||||
|
cdl.slope += SIMD3(repeating: amount)
|
||||||
|
case .offsetRed: cdl.offset.x += amount
|
||||||
|
case .offsetGreen: cdl.offset.y += amount
|
||||||
|
case .offsetBlue: cdl.offset.z += amount
|
||||||
|
case .offsetMaster:
|
||||||
|
cdl.offset += SIMD3(repeating: amount)
|
||||||
|
case .powerRed: cdl.power.x += amount
|
||||||
|
case .powerGreen: cdl.power.y += amount
|
||||||
|
case .powerBlue: cdl.power.z += amount
|
||||||
|
case .powerMaster:
|
||||||
|
cdl.power += SIMD3(repeating: amount)
|
||||||
|
case .saturation:
|
||||||
|
cdl.saturation = max(0, cdl.saturation + amount)
|
||||||
|
case .contrast:
|
||||||
|
// Contrast pivot 0.435: slope up, offset compensates.
|
||||||
|
cdl.slope += SIMD3(repeating: amount)
|
||||||
|
cdl.offset -= SIMD3(repeating: amount * 0.435)
|
||||||
|
}
|
||||||
|
case .toggleBypass:
|
||||||
|
bypassed.toggle()
|
||||||
|
case .resetGrade:
|
||||||
|
cdl = .identity
|
||||||
|
bypassed = false
|
||||||
|
case .grabStill, .nextSlot, .prevSlot:
|
||||||
|
break // handled by app layer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short readout string for panel displays.
|
||||||
|
public func displayText(for param: CDLParam) -> String {
|
||||||
|
func t(_ v: Float) -> String { String(format: "%.3f", v) }
|
||||||
|
switch param {
|
||||||
|
case .slopeRed: return "SlpR \(t(cdl.slope.x))"
|
||||||
|
case .slopeGreen: return "SlpG \(t(cdl.slope.y))"
|
||||||
|
case .slopeBlue: return "SlpB \(t(cdl.slope.z))"
|
||||||
|
case .slopeMaster: return "Slp \(t((cdl.slope.x + cdl.slope.y + cdl.slope.z) / 3))"
|
||||||
|
case .offsetRed: return "OffR \(t(cdl.offset.x))"
|
||||||
|
case .offsetGreen: return "OffG \(t(cdl.offset.y))"
|
||||||
|
case .offsetBlue: return "OffB \(t(cdl.offset.z))"
|
||||||
|
case .offsetMaster: return "Off \(t((cdl.offset.x + cdl.offset.y + cdl.offset.z) / 3))"
|
||||||
|
case .powerRed: return "PwrR \(t(cdl.power.x))"
|
||||||
|
case .powerGreen: return "PwrG \(t(cdl.power.y))"
|
||||||
|
case .powerBlue: return "PwrB \(t(cdl.power.z))"
|
||||||
|
case .powerMaster: return "Pwr \(t((cdl.power.x + cdl.power.y + cdl.power.z) / 3))"
|
||||||
|
case .saturation: return "Sat \(t(cdl.saturation))"
|
||||||
|
case .contrast: return "Con \(t(cdl.slope.x))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// ForgePanel
|
|
||||||
71
Sources/ForgePanel/TangentWire.swift
Normal file
71
Sources/ForgePanel/TangentWire.swift
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Messages exchanged with the Tangent Hub bridge.
|
||||||
|
/// Wire framing: u32 little-endian length + JSON payload.
|
||||||
|
/// NOTE: real Tangent Hub TIPC is a binary protocol on TCP 64246; this JSON
|
||||||
|
/// framing is our internal bridge format — the macOS TIPC shim translates.
|
||||||
|
/// Framing/parse logic (buffering, partial frames) is identical either way.
|
||||||
|
public enum TangentMessage: Equatable, Sendable {
|
||||||
|
case controlUpdate(PanelInput)
|
||||||
|
case displayUpdate(line: Int, text: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TangentWireError: Error {
|
||||||
|
case badPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TangentWire {
|
||||||
|
|
||||||
|
public static func encode(_ msg: TangentMessage) -> Data {
|
||||||
|
var obj: [String: Any] = [:]
|
||||||
|
switch msg {
|
||||||
|
case .controlUpdate(.encoder(let id, let delta)):
|
||||||
|
obj = ["type": "encoder", "id": id, "delta": delta]
|
||||||
|
case .controlUpdate(.button(let id, let pressed)):
|
||||||
|
obj = ["type": "button", "id": id, "pressed": pressed]
|
||||||
|
case .displayUpdate(let line, let text):
|
||||||
|
obj = ["type": "display", "line": line, "text": text]
|
||||||
|
}
|
||||||
|
let payload = (try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])) ?? Data()
|
||||||
|
var out = Data()
|
||||||
|
var len = UInt32(payload.count).littleEndian
|
||||||
|
withUnsafeBytes(of: &len) { out.append(contentsOf: $0) }
|
||||||
|
out.append(payload)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode first complete frame from buffer, consuming it. nil if incomplete.
|
||||||
|
public static func decodeFirst(_ buffer: inout Data) throws -> TangentMessage? {
|
||||||
|
guard buffer.count >= 4 else { return nil }
|
||||||
|
// Byte-wise LE assembly (Data slices may be misaligned for u32 loads).
|
||||||
|
let b = [UInt8](buffer.prefix(4))
|
||||||
|
let length = UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
|
||||||
|
guard buffer.count >= 4 + Int(length) else { return nil }
|
||||||
|
let payload = buffer.subdata(in: buffer.startIndex + 4..<buffer.startIndex + 4 + Int(length))
|
||||||
|
buffer.removeFirst(4 + Int(length))
|
||||||
|
|
||||||
|
guard let obj = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
||||||
|
let type = obj["type"] as? String else {
|
||||||
|
throw TangentWireError.badPayload
|
||||||
|
}
|
||||||
|
switch type {
|
||||||
|
case "encoder":
|
||||||
|
guard let id = obj["id"] as? Int, let delta = obj["delta"] as? Int else {
|
||||||
|
throw TangentWireError.badPayload
|
||||||
|
}
|
||||||
|
return .controlUpdate(.encoder(id: id, delta: delta))
|
||||||
|
case "button":
|
||||||
|
guard let id = obj["id"] as? Int, let pressed = obj["pressed"] as? Bool else {
|
||||||
|
throw TangentWireError.badPayload
|
||||||
|
}
|
||||||
|
return .controlUpdate(.button(id: id, pressed: pressed))
|
||||||
|
case "display":
|
||||||
|
guard let line = obj["line"] as? Int, let text = obj["text"] as? String else {
|
||||||
|
throw TangentWireError.badPayload
|
||||||
|
}
|
||||||
|
return .displayUpdate(line: line, text: text)
|
||||||
|
default:
|
||||||
|
throw TangentWireError.badPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
Tests/ForgePanelTests/PanelTests.swift
Normal file
140
Tests/ForgePanelTests/PanelTests.swift
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
import ForgeGrade
|
||||||
|
import ForgeColor
|
||||||
|
@testable import ForgePanel
|
||||||
|
|
||||||
|
final class ControlMapTests: XCTestCase {
|
||||||
|
|
||||||
|
// Trackball delta maps to CDL offset per ball (lift/gamma/gain zones map to SOP).
|
||||||
|
func testTrackballToCDL() {
|
||||||
|
var grade = PanelGradeState()
|
||||||
|
let map = ControlMap.waveDefault
|
||||||
|
|
||||||
|
// Gain ball (ball 3) X delta -> slope red+.
|
||||||
|
let action = map.action(for: .encoder(id: TangentControls.gainBallX, delta: 10))
|
||||||
|
guard case .adjustCDL(let param, let amount) = action else { return XCTFail("wrong action") }
|
||||||
|
XCTAssertEqual(param, .slopeRed)
|
||||||
|
XCTAssertEqual(amount, 10 * map.sensitivity(for: .slopeRed), accuracy: 1e-8)
|
||||||
|
|
||||||
|
grade.apply(action!)
|
||||||
|
XCTAssertEqual(grade.cdl.slope.x, 1 + 10 * map.sensitivity(for: .slopeRed), accuracy: 1e-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Master ring adjusts all three slope channels.
|
||||||
|
func testMasterRing() {
|
||||||
|
var grade = PanelGradeState()
|
||||||
|
let map = ControlMap.waveDefault
|
||||||
|
let action = map.action(for: .encoder(id: TangentControls.gainRing, delta: 5))
|
||||||
|
guard case .adjustCDL(let param, _) = action, param == .slopeMaster else {
|
||||||
|
return XCTFail("wrong mapping")
|
||||||
|
}
|
||||||
|
grade.apply(action!)
|
||||||
|
XCTAssertEqual(grade.cdl.slope.x, grade.cdl.slope.y, accuracy: 1e-7)
|
||||||
|
XCTAssertEqual(grade.cdl.slope.y, grade.cdl.slope.z, accuracy: 1e-7)
|
||||||
|
XCTAssertGreaterThan(grade.cdl.slope.x, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sat knob.
|
||||||
|
func testSatKnob() {
|
||||||
|
var grade = PanelGradeState()
|
||||||
|
let map = ControlMap.waveDefault
|
||||||
|
let action = map.action(for: .encoder(id: TangentControls.satKnob, delta: -20))
|
||||||
|
grade.apply(action!)
|
||||||
|
XCTAssertLessThan(grade.cdl.saturation, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons dispatch commands.
|
||||||
|
func testButtons() {
|
||||||
|
let map = ControlMap.waveDefault
|
||||||
|
XCTAssertEqual(map.action(for: .button(id: TangentControls.bypassButton, pressed: true)), .toggleBypass)
|
||||||
|
XCTAssertEqual(map.action(for: .button(id: TangentControls.grabStillButton, pressed: true)), .grabStill)
|
||||||
|
XCTAssertEqual(map.action(for: .button(id: TangentControls.nextSlotButton, pressed: true)), .nextSlot)
|
||||||
|
XCTAssertEqual(map.action(for: .button(id: TangentControls.resetButton, pressed: true)), .resetGrade)
|
||||||
|
// Release events ignored.
|
||||||
|
XCTAssertNil(map.action(for: .button(id: TangentControls.bypassButton, pressed: false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmapped control -> nil.
|
||||||
|
func testUnmappedControl() {
|
||||||
|
XCTAssertNil(ControlMap.waveDefault.action(for: .encoder(id: 9999, delta: 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset action restores identity.
|
||||||
|
func testReset() {
|
||||||
|
var grade = PanelGradeState()
|
||||||
|
grade.apply(.adjustCDL(param: .slopeRed, amount: 0.5))
|
||||||
|
grade.apply(.adjustCDL(param: .offsetBlue, amount: -0.1))
|
||||||
|
XCTAssertNotEqual(grade.cdl, CDL.identity)
|
||||||
|
grade.apply(.resetGrade)
|
||||||
|
XCTAssertEqual(grade.cdl, CDL.identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display text for current params (panel readout strings).
|
||||||
|
func testDisplayText() {
|
||||||
|
var grade = PanelGradeState()
|
||||||
|
grade.apply(.adjustCDL(param: .slopeRed, amount: 0.234))
|
||||||
|
let text = grade.displayText(for: .slopeRed)
|
||||||
|
XCTAssertEqual(text, "SlpR 1.234")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TangentProtocolTests: XCTestCase {
|
||||||
|
|
||||||
|
// Frame encode: u32 LE length + JSON payload. Round trip.
|
||||||
|
func testFraming() throws {
|
||||||
|
let msg = TangentMessage.controlUpdate(.encoder(id: 42, delta: -3))
|
||||||
|
let framed = TangentWire.encode(msg)
|
||||||
|
var buf = framed
|
||||||
|
let decoded = try XCTUnwrap(TangentWire.decodeFirst(&buf))
|
||||||
|
guard case .controlUpdate(.encoder(let id, let delta)) = decoded else {
|
||||||
|
return XCTFail("wrong decode")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(id, 42)
|
||||||
|
XCTAssertEqual(delta, -3)
|
||||||
|
XCTAssertTrue(buf.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial frames buffer until complete.
|
||||||
|
func testPartialFrames() throws {
|
||||||
|
let msg = TangentMessage.controlUpdate(.button(id: 7, pressed: true))
|
||||||
|
let framed = TangentWire.encode(msg)
|
||||||
|
var buf = Data(framed.prefix(3))
|
||||||
|
XCTAssertNil(try TangentWire.decodeFirst(&buf))
|
||||||
|
buf.append(framed.suffix(from: 3))
|
||||||
|
XCTAssertNotNil(try TangentWire.decodeFirst(&buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two frames in one read both decode.
|
||||||
|
func testTwoFramesOneBuffer() throws {
|
||||||
|
var buf = TangentWire.encode(.controlUpdate(.encoder(id: 1, delta: 1)))
|
||||||
|
buf.append(TangentWire.encode(.controlUpdate(.encoder(id: 2, delta: 2))))
|
||||||
|
let first = try XCTUnwrap(TangentWire.decodeFirst(&buf))
|
||||||
|
let second = try XCTUnwrap(TangentWire.decodeFirst(&buf))
|
||||||
|
guard case .controlUpdate(.encoder(let id1, _)) = first,
|
||||||
|
case .controlUpdate(.encoder(let id2, _)) = second else {
|
||||||
|
return XCTFail()
|
||||||
|
}
|
||||||
|
XCTAssertEqual(id1, 1)
|
||||||
|
XCTAssertEqual(id2, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display update encodes.
|
||||||
|
func testDisplayUpdate() throws {
|
||||||
|
let msg = TangentMessage.displayUpdate(line: 0, text: "SlpR 1.234")
|
||||||
|
var buf = TangentWire.encode(msg)
|
||||||
|
let decoded = try XCTUnwrap(TangentWire.decodeFirst(&buf))
|
||||||
|
guard case .displayUpdate(let line, let text) = decoded else { return XCTFail() }
|
||||||
|
XCTAssertEqual(line, 0)
|
||||||
|
XCTAssertEqual(text, "SlpR 1.234")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garbage payload throws.
|
||||||
|
func testGarbageRejected() {
|
||||||
|
var lenBytes = Data()
|
||||||
|
var len = UInt32(4).littleEndian
|
||||||
|
withUnsafeBytes(of: &len) { lenBytes.append(contentsOf: $0) }
|
||||||
|
var buf = lenBytes + Data("zzzz".utf8)
|
||||||
|
XCTAssertThrowsError(try TangentWire.decodeFirst(&buf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class ForgePanelPlaceholderTests: XCTestCase {
|
|
||||||
func testPlaceholder() { XCTAssertTrue(true) }
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue