/** * Everything the module knows about the console, keyed by entity number, plus the dropdown * choice builders and the query plans used to load it. * * Entities: input, buss (aux and group are two views of the same 25 busses, named and levelled * on the side matching the buss mode), matrix (matrix outputs), minput (matrix inputs), cg (control groups), geq (graphic EQs). All numbers are 1-based. * * applyMessage(address, args) is the single entry point for console traffic (and for the * module's own optimistic writes): it returns the parsed event annotated with * changed the stored value differs from before * structural names, buss modes (aux/group), counts or the snapshot list changed (definitions need a rebuild; * stereo/mono flags alone never are) */ import { LIMITS, K, addr, query, parse, fmtCue, cleanName } from './protocol.js' const EPS = 1e-4 /** Assign and report whether the value actually changed. */ function setField(obj, field, value) { const old = obj[field] obj[field] = value if (typeof value === 'number' && typeof old === 'number') return Math.abs(value - old) > EPS return old !== value } function clampInt(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) } function rec(map, key) { let r = map.get(key) if (!r) { r = {} map.set(key, r) } return r } /** One of the 25 busses. aux / group hold the fader, mute and solo of each side. */ export class Buss { constructor(n) { this.n = n this.auxName = undefined this.groupName = undefined this.type = undefined // 'aux' | 'group' once /Console/Aux_Outputs/types answered this.modeAux = undefined // 1 mono / 2 stereo, from /Console/Aux_Outputs/modes this.modeGroup = undefined // from /Console/Group_Outputs/modes this.aux = {} this.group = {} } /** The authoritative `types` array wins; without it, a buss named only on the Group side is a group. */ get mode() { if (this.type) return this.type const g = (this.groupName || '').trim() const a = (this.auxName || '').trim() return g && !a ? 'group' : 'aux' } get name() { return this.mode === 'group' ? this.groupName : this.auxName } get stereo() { const m = this.mode === 'group' ? (this.modeGroup ?? this.modeAux) : (this.modeAux ?? this.modeGroup) return m === undefined ? undefined : m === 2 } get fader() { return this[this.mode].fader } get mute() { return this[this.mode].mute } get solo() { return this[this.mode].solo } } /** Peak tracking keeps, per meter, the last time a reading was at most k steps below full scale, for k = 0..LOUD_STEPS. */ const LOUD_STEPS = 20 const FALLBACK_NAME = { input: 'Input', matrix: 'Matrix', cg: 'CG', geq: 'Graphic EQ', minput: 'Matrix In' } export class State { constructor() { this.loaded = false this.reset() this.choices = this.#makeChoices() } reset() { this.loaded = false this.console = { name: '', session: '' } this.counts = {} this.inputs = new Map() this.busses = new Map() this.matrix = new Map() this.minputs = new Map() // matrix inputs: { name } this.cgs = new Map() this.geqs = new Map() this.sends = new Map() // "ch:buss" -> { level, on, pan } this.groups = new Map() // "ch:buss" -> boolean this.matrixSends = new Map() // "in:out" -> { level, on } this.params = new Map() // K.param(...) -> boolean | number (per-channel switches and values, loaded lazily) this.snapshots = { current: undefined, count: undefined, list: new Map() } // index -> { name, cue } this.meters = new Map() // meter id -> { value, at, loud } (loud[k]: last time a reading was k steps below full scale or louder) } // ---------- inbound ---------- applyMessage(address, args = []) { const ev = parse(address, args) if (!ev) return null ev.changed = false ev.structural = false switch (ev.kind) { case 'consoleName': ev.value = cleanName(ev.value) ev.changed = setField(this.console, 'name', ev.value) break case 'session': ev.changed = setField(this.console, 'session', ev.value) break case 'count': if (Number.isFinite(ev.value)) { ev.changed = this.counts[ev.field] !== ev.value this.counts[ev.field] = ev.value ev.structural = ev.changed } break case 'bussTypes': { const r = this.#applyBussArray(ev.values, (b, v) => (b.type = v === 1 ? 'aux' : v === 0 ? 'group' : undefined)) ev.structural = r.mode // the buss mode shows in labels and decides which presets exist ev.changed = r.mode || r.stereo break } case 'bussModes': { // stereo / mono is shown in a variable only: it never rebuilds the definitions const field = ev.side === 'group' ? 'modeGroup' : 'modeAux' const r = this.#applyBussArray(ev.values, (b, v) => (b[field] = v)) ev.changed = r.mode || r.stereo break } case 'inputModes': ev.values.forEach((v, i) => { if (setField(rec(this.inputs, i + 1), 'stereo', v === 2)) ev.changed = true }) break case 'name': ev.value = cleanName(ev.value) // the surface pads and wraps names: trim and collapse whitespace once, here ev.changed = this.#applyName(ev) ev.structural = ev.changed break case 'fader': case 'mute': case 'solo': case 'pan': ev.changed = setField(this.#levelTarget(ev), ev.kind, ev.value) break case 'send': ev.changed = setField(rec(this.sends, `${ev.ch}:${ev.buss}`), ev.field, ev.value) break case 'group': { const key = `${ev.ch}:${ev.buss}` ev.changed = this.groups.get(key) !== ev.value this.groups.set(key, ev.value) break } case 'msend': ev.changed = setField(rec(this.matrixSends, `${ev.input}:${ev.out}`), ev.field, ev.value) break case 'param': { const old = this.params.get(ev.key) this.params.set(ev.key, ev.value) ev.changed = typeof old === 'number' && typeof ev.value === 'number' ? Math.abs(old - ev.value) > EPS : old !== ev.value break } case 'geq': { const g = rec(this.geqs, ev.n) if (ev.field === 'on') ev.changed = setField(g, 'on', ev.value) else { if (!g.gains) g.gains = new Map() ev.changed = g.gains.get(ev.band) !== ev.value g.gains.set(ev.band, ev.value) } break } case 'snapCurrent': ev.changed = this.snapshots.current !== ev.value this.snapshots.current = ev.value break case 'snapCount': ev.changed = this.snapshots.count !== ev.value this.snapshots.count = ev.value break case 'snapName': { ev.name = cleanName(ev.name) const old = this.snapshots.list.get(ev.index) this.snapshots.list.set(ev.index, { name: ev.name, cue: ev.cue }) ev.changed = !old || old.name !== ev.name || old.cue !== ev.cue ev.structural = ev.changed break } case 'meters': { const now = Date.now() for (const [id, value] of ev.pairs) { const m = this.meters.get(id) || { value: 127, at: 0, loud: new Float64Array(LOUD_STEPS + 1) } m.value = value m.at = now for (let k = Math.max(0, value); k <= LOUD_STEPS; k++) m.loud[k] = now this.meters.set(id, m) } ev.changed = ev.pairs.length > 0 break } default: break } return ev } /** Apply `fn(buss, value)` over a per-buss array; reports whether any buss mode or stereo flag changed. */ #applyBussArray(values, fn) { const r = { mode: false, stereo: false } values.forEach((v, i) => { const b = this.#buss(i + 1) const before = [b.mode, b.stereo] fn(b, v) if (b.mode !== before[0]) r.mode = true if (b.stereo !== before[1]) r.stereo = true }) return r } #buss(n) { let b = this.busses.get(n) if (!b) { b = new Buss(n) this.busses.set(n, b) } return b } #applyName(ev) { if (ev.ent === 'buss') { const b = this.#buss(ev.n) return setField(b, ev.side === 'group' ? 'groupName' : 'auxName', ev.value) } return setField(rec(this.#map(ev.ent), ev.n), 'name', ev.value) } #levelTarget(ev) { if (ev.ent === 'buss') return this.#buss(ev.n)[ev.side === 'group' ? 'group' : 'aux'] return rec(this.#map(ev.ent), ev.n) } #map(ent) { switch (ent) { case 'input': return this.inputs case 'matrix': return this.matrix case 'minput': return this.minputs case 'cg': return this.cgs case 'geq': return this.geqs default: throw new Error(`no entity map for ${ent}`) } } // ---------- existence, names ---------- /** Highest number worth asking about: the console's own count when it told us, else the S31 maximum. */ limit(ent) { const c = this.counts switch (ent) { case 'input': return c.inputs ?? LIMITS.inputs case 'buss': return c.busses ?? LIMITS.busses case 'matrix': return c.matrix ?? LIMITS.matrix case 'minput': return c.matrixIn ?? LIMITS.matrixIn case 'cg': return c.cgs ?? LIMITS.cgs case 'geq': return c.geqs ?? LIMITS.geqs default: return 0 } } /** An entity exists when its name answered. */ has(ent, n) { if (!(n >= 1 && n <= this.limit(ent))) return false switch (ent) { case 'buss': { const b = this.busses.get(n) return !!b && (b.auxName !== undefined || b.groupName !== undefined) } default: return this.#map(ent).get(n)?.name !== undefined } } /** Numbers of the entities that exist (their name answered). */ existing(ent) { const out = [] const max = this.limit(ent) for (let n = 1; n <= max; n++) if (this.has(ent, n)) out.push(n) return out } /** Entity numbers for dropdowns and variables: those that exist; before the first load finishes and while nothing has answered, every possible number. */ numbers(ent) { const out = this.existing(ent) if (!out.length && !this.loaded) for (let n = 1; n <= this.limit(ent); n++) out.push(n) return out } /** The name as the console reports it (may be ""), or undefined while unknown. */ rawName(ent, n) { if (ent === 'buss') return this.busses.get(n)?.name return this.#map(ent).get(n)?.name } /** Name for labels and variables: the console's, or "Input 7" / "Aux 3" / "Group 24" style fallbacks. */ displayName(ent, n) { const raw = this.rawName(ent, n) if (raw && raw.trim()) return raw if (ent === 'buss') return `${this.bussMode(n) === 'group' ? 'Group' : 'Aux'} ${n}` return `${FALLBACK_NAME[ent]} ${n}` } bussMode(n) { return this.busses.get(n)?.mode ?? 'aux' } /** Existing entities that carry a name; unnamed busses and control groups are unused slots and get no presets. */ named(ent) { return this.numbers(ent).filter((n) => (this.rawName(ent, n) || '').trim()) } /** Named busses whose active side is `mode` ('aux' | 'group'). */ namedBusses(mode) { return this.named('buss').filter((n) => !mode || this.bussMode(n) === mode) } // ---------- levels and switches ---------- getFader(ent, n) { if (ent === 'buss') return this.busses.get(n)?.fader return this.#map(ent).get(n)?.fader } getMute(ent, n) { if (ent === 'buss') return this.busses.get(n)?.mute return this.#map(ent).get(n)?.mute } getSolo(ent, n) { if (ent === 'buss') return this.busses.get(n)?.solo return this.#map(ent).get(n)?.solo } getPan(n) { return this.inputs.get(n)?.pan } getSend(ch, buss) { return this.sends.get(`${ch}:${buss}`) || {} } getGroup(ch, buss) { return this.groups.get(`${ch}:${buss}`) } getMatrixSend(inN, outN) { return this.matrixSends.get(`${inN}:${outN}`) || {} } getGeq(n) { return this.geqs.get(n) || {} } /** A per-channel switch (boolean) or value from PARAMS; a buss reads the side matching its mode. */ getParam(ent, n, param) { return this.params.get(K.param(ent, n, param, ent === 'buss' ? this.bussMode(n) : undefined)) } getMeter(id) { return this.meters.get(id) } /** Last time meter `id` read at or above `thresholdDb` dBFS (0 = never; thresholds from -20 to 0 are tracked). */ meterPeakAt(id, thresholdDb) { const m = this.meters.get(id) if (!m || !Number.isFinite(thresholdDb)) return 0 return m.loud[clampInt(Math.floor(-thresholdDb), 0, LOUD_STEPS)] } snapshotName(index) { return this.snapshots.list.get(index)?.name } // ---------- dropdown choices ---------- #makeChoices() { const entity = (ent, suffix) => () => this.numbers(ent).map((n) => ({ id: n, label: `${n} · ${this.displayName(ent, n)}${suffix ? suffix(n) : ''}` })) const bussSuffix = (n) => ` (${this.bussMode(n)})` const busses = entity('buss', bussSuffix) const plainBusses = entity('buss') return { inputs: entity('input'), busses, // filtered by type, so the "(aux)" / "(group)" suffix would only repeat what the dropdown already says auxChoices: () => plainBusses().filter((c) => this.bussMode(c.id) === 'aux'), groupChoices: () => plainBusses().filter((c) => this.bussMode(c.id) === 'group'), matrix: entity('matrix'), matrixInputs: entity('minput'), cgs: entity('cg'), geqs: entity('geq'), snapshots: () => [...this.snapshots.list] .sort((a, b) => a[0] - b[0]) .map(([index, s]) => ({ id: index, label: `${index} · ${s.name}${s.cue > 0 ? ` · cue ${fmtCue(s.cue)}` : ''}` })), } } // ---------- load plans ---------- /** * The queries every (re)load starts with. The console never answers the Input_Channels, Aux_Outputs and * Group_Outputs counts (it pushes all nine once, shortly after first contact), so topology comes from the * name queries; the four counts that do answer are asked for here. */ headerQueries() { return [ query(addr.consoleName), query(addr.count('matrix')), query(addr.count('matrixIn')), query(addr.count('cgs')), query(addr.count('geqs')), query(addr.bussTypes), query(addr.auxModes), query(addr.groupModes), query(addr.inputModes), query(addr.session), query(addr.currentSnapshot), query(addr.snapshotCount), query(addr.snapshotNames), ] } nameQueries(onlyMissing = false) { const q = [] const want = (ent, n, side) => { if (!onlyMissing) return true if (ent === 'buss') { const b = this.busses.get(n) return (side === 'group' ? b?.groupName : b?.auxName) === undefined } return this.#map(ent).get(n)?.name === undefined } for (let n = 1; n <= this.limit('input'); n++) if (want('input', n)) q.push(query(addr.name('input', n))) for (let n = 1; n <= this.limit('buss'); n++) { if (want('buss', n, 'aux')) q.push(query(addr.name('buss', n, 'aux'))) if (want('buss', n, 'group')) q.push(query(addr.name('buss', n, 'group'))) } for (let n = 1; n <= this.limit('matrix'); n++) if (want('matrix', n)) q.push(query(addr.name('matrix', n))) for (let n = 1; n <= this.limit('minput'); n++) if (want('minput', n)) q.push(query(addr.name('minput', n))) for (let n = 1; n <= this.limit('cg'); n++) if (want('cg', n)) q.push(query(addr.name('cg', n))) for (let n = 1; n <= this.limit('geq'); n++) if (want('geq', n)) q.push(query(addr.name('geq', n))) return q } bussValueQueries(n) { const side = this.bussMode(n) return [query(addr.fader('buss', n, side)), query(addr.mute('buss', n, side)), query(addr.solo('buss', n, side))] } /** Fader / mute / solo (/ pan) for every entity that exists. */ valueQueries() { const q = [] for (const n of this.existing('input')) { q.push(query(addr.fader('input', n)), query(addr.mute('input', n)), query(addr.solo('input', n)), query(addr.pan(n))) } for (const n of this.existing('buss')) q.push(...this.bussValueQueries(n)) for (const n of this.existing('matrix')) q.push(query(addr.fader('matrix', n)), query(addr.mute('matrix', n)), query(addr.solo('matrix', n))) for (const n of this.existing('cg')) q.push(query(addr.fader('cg', n)), query(addr.mute('cg', n)), query(addr.solo('cg', n))) return q } /** Counts of what was found, for the "load complete" log line. */ summary() { const found = (ent) => { let k = 0 for (let n = 1; n <= this.limit(ent); n++) if (this.has(ent, n)) k++ return k } const out = { inputs: found('input'), busses: found('buss'), aux: this.namedBusses('aux').length, group: this.namedBusses('group').length, matrix: found('matrix'), matrixIn: found('minput'), cgs: found('cg'), geqs: found('geq'), snapshots: this.snapshots.list.size, } out.total = out.inputs + out.busses + out.matrix + out.cgs + out.geqs return out } }