2026-10-15 17:11:17 -04:00
/**
* 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
2026-10-20 10:25:39 -04:00
* on the side matching the buss mode), matrix (matrix outputs), minput (matrix inputs), cg (control groups), geq (graphic EQs). All numbers are 1-based.
2026-10-15 17:11:17 -04:00
*
* 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)
*/
2026-10-20 10:25:39 -04:00
import { LIMITS , K , addr , query , parse , fmtCue , cleanName } from './protocol.js'
2026-10-15 17:11:17 -04:00
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
}
2026-10-20 10:25:39 -04:00
function clampInt ( v , lo , hi ) {
return Math . min ( hi , Math . max ( lo , v ))
}
2026-10-15 17:11:17 -04:00
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
}
}
2026-10-20 10:25:39 -04:00
/** 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
2026-10-15 17:11:17 -04:00
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 ()
2026-10-20 10:25:39 -04:00
this . minputs = new Map () // matrix inputs: { name }
2026-10-15 17:11:17 -04:00
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 }
2026-10-20 10:25:39 -04:00
this . meters = new Map () // meter id -> { value, at, loud } (loud[k]: last time a reading was k steps below full scale or louder)
2026-10-15 17:11:17 -04:00
}
// ---------- inbound ----------
applyMessage ( address , args = []) {
const ev = parse ( address , args )
if ( ! ev ) return null
ev . changed = false
ev . structural = false
switch ( ev . kind ) {
case 'consoleName' :
2026-10-20 10:25:39 -04:00
ev . value = cleanName ( ev . value )
2026-10-15 17:11:17 -04:00
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' :
2026-10-20 10:25:39 -04:00
ev . value = cleanName ( ev . value ) // the surface pads and wraps names: trim and collapse whitespace once, here
2026-10-15 17:11:17 -04:00
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' : {
2026-10-20 10:25:39 -04:00
ev . name = cleanName ( ev . name )
2026-10-15 17:11:17 -04:00
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 ) {
2026-10-20 10:25:39 -04:00
const m = this . meters . get ( id ) || { value : 127 , at : 0 , loud : new Float64Array ( LOUD_STEPS + 1 ) }
2026-10-15 17:11:17 -04:00
m . value = value
m . at = now
2026-10-20 10:25:39 -04:00
for ( let k = Math . max ( 0 , value ); k <= LOUD_STEPS ; k ++ ) m . loud [ k ] = now
2026-10-15 17:11:17 -04:00
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
2026-10-20 10:25:39 -04:00
case 'minput' :
return this . minputs
2026-10-15 17:11:17 -04:00
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
}
}
2026-10-20 10:25:39 -04:00
/** An entity exists when its name answered. */
2026-10-15 17:11:17 -04:00
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 )
}
2026-10-20 10:25:39 -04:00
/** 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 )]
}
2026-10-15 17:11:17 -04:00
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 )
2026-10-20 12:57:01 -04:00
const plainBusses = entity ( 'buss' )
2026-10-15 17:11:17 -04:00
return {
inputs : entity ( 'input' ),
busses ,
2026-10-20 12:57:01 -04:00
// 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' ),
2026-10-15 17:11:17 -04:00
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 )))
2026-10-20 10:25:39 -04:00
for ( let n = 1 ; n <= this . limit ( 'minput' ); n ++ ) if ( want ( 'minput' , n )) q . push ( query ( addr . name ( 'minput' , n )))
2026-10-15 17:11:17 -04:00
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' ),
2026-10-20 10:25:39 -04:00
matrixIn : found ( 'minput' ),
2026-10-15 17:11:17 -04:00
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
}
}