Files
companion-module-digico-s31/src/state.js
T

504 lines
15 KiB
JavaScript
Raw Normal View History

/**
* 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, no
* name address), 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 } 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 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
}
}
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.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, peakAt }
}
// ---------- 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.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.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': {
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: 0, at: 0, peakAt: 0 }
m.value = value
m.at = now
if (value >= 127) m.peakAt = 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 '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 (matrix inputs have no name: they exist while matrix outputs do). */
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)
}
case 'minput':
return this.limit('matrix') > 0 && (this.counts.matrixIn !== undefined || this.matrix.size > 0)
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
if (ent === 'minput') return undefined
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)
}
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)
return {
inputs: entity('input'),
busses,
auxChoices: () => busses().filter((c) => this.bussMode(c.id) === 'aux'),
groupChoices: () => busses().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('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'),
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
}
}