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