import StrictEventEmitter from 'strict-event-emitter-types' ; import { EventEmitter } from 'events' ; import { CDJStatus , DeviceID , MixstatusMode } from 'src/types' ; import { bpmToSeconds } from 'src/utils' ; import { isPlaying , isStopping } from './utils' ; export type MixstatusConfig = { /**
     * Selects the mixstatus reporting mode
     */ mode : MixstatusMode ; default timeBetweenSets : number ; default useOnAirStatus : boolean ; default allowedInterruptBeats : number ; default beatsUntilReported : number ; } ; const defaultConfig = { mode : MixstatusMode . SmartTiming , timeBetweenSets : 30 , allowedInterruptBeats : 8 , beatsUntilReported : 128 , useOnAirStatus : true , } ; /**
 * The interface the mix status event emitter should follow
 */ type MixstatusEvents = { /**
     * Fired when a track is considered to be on-air and is being heard by the
     * audiance
     */ nowPlaying : ( state : CDJStatus . State ) => void ; /**
     * Fired when a track has stopped and is completley offair
     */ stopped : ( opt : { deviceId : DeviceID ; } ) => void ; /**
     * Fired when a DJ set first starts
     */ setStarted : ( ) => void ; /**
     * Fired when tracks have been stopped
     */ setEnded : ( ) => void ; } ; type Emitter = StrictEventEmitter < EventEmitter , MixstatusEvents > ; /**
 * MixstatusProcessor is a configurable processor which when fed device state
 * will attempt to accurately determine events that happen within the DJ set.
 *
 * The following events are fired:
 *
 * - nowPlaying: The track is considered playing and on air to the audience.
 * - stopped:    The track was stopped / paused / went off-air.
 *
 * Additionally the following non-track status are reported:
 *
 * - setStarted: The first track has begun playing.
 * - setEnded:   The TimeBetweenSets has passed since any tracks were live.
 *
 * See Config for configuration options.
 *
 * Config options may be changed after the processor has been created and is
 * actively receiving state updates.
 */ export class MixstatusProcessor { /**
     * Used to fire track mix status events
     */ #emitter : Emitter = new EventEmitter ( ) ; /**
     * Records the most recent state of each player
     */ #lastState = new Map < DeviceID , CDJStatus . State > ( ) ; /**
     * Records when each device last started playing a track
     */ #lastStartTime = new Map < DeviceID , number > ( ) ; /**
     * Records when a device entered a 'may stop' state. If it's in the state for
     * long enough it will be reported as stopped.
     */ #lastStoppedTimes = new Map < DeviceID , number > ( ) ; /**
     * Records which players have been reported as 'live'
     */ #livePlayers = new Set < DeviceID > ( ) ; /**
     * Incidates if we're currentiny in an active DJ set
     */ #isSetActive = false ; /**
     * When we are waiting for a set to end, use this to cancel the timer.
     */ #cancelSetEnding ? : ( ) => void ; /**
     * The configuration for this instance of the processor
     */ #config : MixstatusConfig ; constructor ( config ? ) { this . #config = { ... defaultConfig , ... config } ; } /**
     * Update the configration
     */ configure ( config ? ) { this . #config = { ... this . #config , ... config } ; } on : Emitter [ 'on' ] = this . #emitter . addListener . bind ( this . #emitter ) ; off : Emitter [ 'off' ] = this . #emitter . removeListener . bind ( this . #emitter ) ; once : Emitter [ 'once' ] = this . #emitter . once . bind ( this . #emitter ) ; /**
     * Helper to account for the useOnAirStatus config. If not configured
     * with this flag the state will always be determined as on air.
     */ #onAir = ( state ) => this . #config . useOnAirStatus ? state . isOnAir : true ; /**
     * Report a player as 'live'. Will not report the state if the player has
     * already previously been reported as live.
     */ #promotePlayer = ( state ) => { const { deviceId } = state ; if ( ! this . #onAir ( state ) || ! isPlaying ( state ) ) { return ; } if ( this . #livePlayers . has ( deviceId ) ) { return ; } if ( ! this . #isSetActive ) { this . #isSetActive = true ; this . #emitter . emit ( 'setStarted' ) ; } if ( this . #cancelSetEnding ) { this . #cancelSetEnding ( ) ; } this . #livePlayers . add ( deviceId ) ; this . #emitter . emit ( 'nowPlaying' , state ) ; } ; /**
     * Locate the player that has been playing for the longest time and is onair,
     * and report that device as now playing.
     */ #promoteNextPlayer = ( ) => { const longestPlayingId = [ ... this . #lastStartTime . entries ( ) ] . map ( ( [ deviceId , startedAt ] ) => ( { deviceId , startedAt , state : this . #lastState . get ( deviceId ) , } ) ) . filter ( s => ! this . #livePlayers . has ( s . deviceId ) ) . filter ( s => s . state && isPlaying ( s . state ) ) . sort ( ( a , b ) => b . startedAt - a . startedAt ) . pop ( ) ?. deviceId ; if ( longestPlayingId === undefined ) { this . #setMayStop ( ) ; return ; } const nextPlayerState = this . #lastState . get ( longestPlayingId ) ! ; this . #promotePlayer ( nextPlayerState ) ; } ; #markPlayerStopped = ( { deviceId } ) => { this . #lastStoppedTimes . delete ( deviceId ) ; this . #lastStartTime . delete ( deviceId ) ; this . #livePlayers . delete ( deviceId ) ; this . #promoteNextPlayer ( ) ; this . #emitter . emit ( 'stopped' , { deviceId } ) ; } ; #setMayStop = async ( ) => { if ( ! this . #isSetActive ) { return ; } if ( [ ... this . #lastState . values ( ) ] . some ( s => isPlaying ( s ) && this . #onAir ( s ) ) ) { return ; } const shouldEnd = await new Promise < boolean > ( resolve => { const endTimeout = setTimeout ( ( ) => resolve ( true ) , this . #config . timeBetweenSets * 1000 ) ; this . #cancelSetEnding = ( ) => { clearTimeout ( endTimeout ) ; resolve ( false ) ; } ; } ) ; this . #cancelSetEnding = undefined ; if ( ! shouldEnd || ! this . #isSetActive ) { return ; } this . #emitter . emit ( 'setEnded' ) ; } ; /**
     * Called to indicate that we think this player may be the first one to start
     * playing. Will check if no other players are playing, if so it will report
     * the player as now playing.
     */ #playerMayBeFirst = ( state ) => { const otherPlayersPlaying = [ ... this . #lastState . values ( ) ] . filter ( otherState => otherState . deviceId !== state . deviceId ) . some ( otherState => this . #onAir ( otherState ) && isPlaying ( otherState ) ) ; if ( otherPlayersPlaying ) { return ; } this . #promotePlayer ( state ) ; } ; /**
     * Called when the player is in a state where it is no longer playing, but
     * may come back onair. Examples are slip pause, or 'cutting' a track on the
     * mixer taking it offair.
     */ #playerMayStop = ( { deviceId } ) => { this . #lastStoppedTimes . set ( deviceId , Date . now ( ) ) ; } ; /**
     * Called to indicate that a device has reported a different playState than
     * it had previously reported.
     */ #handlePlaystateChange = ( lastState , state ) => { const { deviceId } = state ; const isFollowingMaster = this . #config . mode === MixstatusMode . FollowsMaster && state . isMaster ; const nowPlaying = isPlaying ( state ) ; const wasPlaying = isPlaying ( lastState ) ; const isNowPlaying = nowPlaying && ! wasPlaying ; if ( this . #lastStoppedTimes . has ( deviceId ) && nowPlaying && this . #onAir ( state ) ) { this . #lastStoppedTimes . delete ( deviceId ) ; return ; } if ( isNowPlaying && isFollowingMaster ) { this . #promotePlayer ( state ) ; } if ( isNowPlaying ) { this . #lastStartTime . set ( deviceId , Date . now ( ) ) ; this . #playerMayBeFirst ( state ) ; return ; } if ( wasPlaying && isStopping ( state ) ) { this . #markPlayerStopped ( state ) ; return ; } if ( wasPlaying && ! nowPlaying ) { this . #playerMayStop ( state ) ; } } ; #handleOnairChange = ( state ) => { const { deviceId } = state ; this . #playerMayBeFirst ( state ) ; if ( ! this . #livePlayers . has ( deviceId ) ) { return ; } if ( ! this . #onAir ( state ) ) { this . #playerMayStop ( state ) ; return ; } this . #lastStoppedTimes . delete ( deviceId ) ; } ; /**
     * Feed a CDJStatus state object to the mix state processor
     */ handleState ( state ) { const { deviceId , playState } = state ; const lastState = this . #lastState . get ( deviceId ) ; this . #lastState . set ( deviceId , state ) ; if ( lastState === undefined && this . #onAir ( state ) && isPlaying ( state ) ) { this . #lastStartTime . set ( deviceId , Date . now ( ) ) ; this . #playerMayBeFirst ( state ) ; return ; } if ( lastState && lastState . playState !== playState ) { this . #handlePlaystateChange ( lastState , state ) ; } if ( lastState && this . #onAir ( lastState ) !== this . #onAir ( state ) ) { this . #handleOnairChange ( state ) ; } if ( this . #config . mode === MixstatusMode . FollowsMaster && lastState ?. isMaster === false && state . isMaster ) { this . #promotePlayer ( state ) ; return ; } const startedAt = this . #lastStartTime . get ( deviceId ) ; const requiredPlayTime = this . #config . beatsUntilReported * bpmToSeconds ( state . trackBPM ! , state . sliderPitch ) * 1000 ; if ( this . #config . mode === MixstatusMode . SmartTiming && startedAt !== undefined && requiredPlayTime <= Date . now ( ) - startedAt ) { this . #promotePlayer ( state ) ; } const stoppedAt = this . #lastStoppedTimes . get ( deviceId ) ; const requiredStopTime = this . #config . allowedInterruptBeats * bpmToSeconds ( state . trackBPM ! , state . sliderPitch ) * 1000 ; if ( stoppedAt !== undefined && requiredStopTime <= Date . now ( ) - stoppedAt ) { this . #markPlayerStopped ( state ) ; } } /**
     * Manually reports the track that has been playing the longest which has not
     * yet been reported as live.
     */ triggerNextTrack ( ) { this . #promoteNextPlayer ( ) ; } }