-
Notifications
You must be signed in to change notification settings - Fork 8
/
handler.go
412 lines (334 loc) · 11.3 KB
/
handler.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
// Package mixstatus provides functionality for determining when a track has
// changed in a mixing situation.
package mixstatus
import (
"sync"
"time"
"go.evanpurkhiser.com/prolink"
"go.evanpurkhiser.com/prolink/bpm"
)
// An Event is a string key for status change events
type Event string
// Event constants
const (
SetStarted Event = "set_started"
SetEnded Event = "set_ended"
NowPlaying Event = "now_playing"
Stopped Event = "stopped"
ComingSoon Event = "coming_soon"
)
// These are states where the track is passively playing
var playingStates = map[prolink.PlayState]bool{
prolink.PlayStateLooping: true,
prolink.PlayStatePlaying: true,
}
// These are the states where the track has been stopped
var stoppingStates = map[prolink.PlayState]bool{
prolink.PlayStateEnded: true,
prolink.PlayStateCued: true,
prolink.PlayStateLoading: true,
}
// Handler is a interface that may be implemented to receive mix status events
// This includes the CDJStatus object that triggered the change.
type Handler interface {
OnMixStatus(Event, *prolink.CDJStatus)
}
// HandlerFunc is an adapter to implement the handler.
type HandlerFunc func(Event, *prolink.CDJStatus)
// OnMixStatus implements the Handler interface.
func (f HandlerFunc) OnMixStatus(e Event, s *prolink.CDJStatus) { f(e, s) }
func noopHandlerFunc(e Event, s *prolink.CDJStatus) {}
// Config specifies configuration for the Handler.
type Config struct {
// AllowedInterruptBeats configures how many beats a track may not be live
// or playing for it to still be considered active.
AllowedInterruptBeats int
// BeatsUntilReported configures how many beats the track must consecutively
// be playing for (since the beat it was cued at) until the track is
// considered to be active.
BeatsUntilReported int
// TimeBetweenSets specifies the duration that no tracks must be on air.
// This can be thought of as how long 'air silence' is reasonable in a set
// before a separate one has begun.
TimeBetweenSets time.Duration
}
// NewProcessor constructs a new Processor to watch for track changes
func NewProcessor(config Config, handler Handler) *Processor {
if handler == nil {
handler = HandlerFunc(noopHandlerFunc)
}
processor := Processor{
Config: config,
handler: handler.OnMixStatus,
lock: sync.Mutex{},
lastStatus: map[prolink.DeviceID]*prolink.CDJStatus{},
lastStartTime: map[prolink.DeviceID]time.Time{},
interruptCancel: map[prolink.DeviceID]chan bool{},
wasReportedLive: map[prolink.DeviceID]bool{},
}
return &processor
}
// Processor is a configurable object which implements the
// prolink.StatusListener interface to more accurately detect when a track has
// changed in a mixing situation.
//
// The following track statuses are reported:
//
// - NowPlaying: The track is considered playing and on air to the audience.
// - Stopped: The track was stopped / paused.
// - ComingSoon: A new track has been loaded.
//
// 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 constructed and
// is actively receiving status updates.
//
// Track changes are detected based on a number of rules:
//
// - The track that has been in the play state with the CDJ in the "on air" state
// for the longest period of time (allowing for a configurable length of
// interruption with AllowedInterruptBeats) is considered to be the active
// track that incoming tracks will be compared against.
//
// - A incoming track will immediately be reported as NowPlaying if it is on
// air, playing, and the last active track has been cued.
//
// - A incoming track will be reported as NowPlaying if the active track has
// not been on air or has not been playing for the configured
// AllowedInterruptBeats.
//
// - A incoming track will be reported as NowPlaying if it has played
// consecutively (with AllowedInterruptBeats honored for the incoming track)
// for the configured BeatsUntilReported.
//
// - A track will be reported as Stopped when it was NowPlaying and was stopped
// (cued, reached the end of the track, or a new track was loaded.
//
// - A track will be reported as ComingSoon when a new track is selected.
type Processor struct {
Config Config
handler HandlerFunc
lock sync.Mutex
lastStatus map[prolink.DeviceID]*prolink.CDJStatus
lastStartTime map[prolink.DeviceID]time.Time
interruptCancel map[prolink.DeviceID]chan bool
wasReportedLive map[prolink.DeviceID]bool
setInProgress bool
setEndingCancel chan bool
// onAirCap indicates weather the CDJs are able to report their onair
// status (typically meaning a compatible DJM is connected). This is used
// to ignore onair checks if it is not available.
onAirCap bool
}
// reportPlayer triggers the track change handler if track on the given device
// has not already been reported live and is currently on air.
func (p *Processor) reportPlayer(pid prolink.DeviceID) {
// Track has already been reported
if p.wasReportedLive[pid] {
return
}
if p.onAirCap && !p.lastStatus[pid].IsOnAir {
return
}
p.wasReportedLive[pid] = true
if !p.setInProgress {
p.setInProgress = true
p.handler(SetStarted, p.lastStatus[pid])
}
if p.setEndingCancel != nil {
p.setEndingCancel <- true
}
p.handler(NowPlaying, p.lastStatus[pid])
}
// reportNextPlayer finds the longest playing track that has not been reported
// live and reports it as live.
func (p *Processor) reportNextPlayer() {
var earliestPID prolink.DeviceID
earliestTime := time.Now()
// Locate the player that's been playing for the longest
for pid, lastStartTime := range p.lastStartTime {
isEarlier := lastStartTime.Before(earliestTime)
if isEarlier && !p.wasReportedLive[pid] {
earliestTime = lastStartTime
earliestPID = pid
}
}
if earliestPID == 0 {
return
}
p.reportPlayer(earliestPID)
}
// setMayEnd signals that we should wait the specified timeout period for no
// tracks to become onair and playing to mark a set as having "ended".
func (p *Processor) setMayEnd() {
if !p.setInProgress {
return
}
// set may already be ending. Do not start a new waiter
if p.setEndingCancel != nil {
return
}
// Ensure all players are stopped
for _, s := range p.lastStatus {
if playingStates[s.PlayState] {
return
}
}
p.setEndingCancel = make(chan bool)
timer := time.NewTimer(p.Config.TimeBetweenSets)
select {
case <-p.setEndingCancel:
break
case <-timer.C:
p.handler(SetEnded, &prolink.CDJStatus{})
p.setInProgress = false
break
}
p.setEndingCancel = nil
}
// trackMayStop tracks that a track may be stopping. Wait the configured
// interrupt beat interval and report the next track as live if it has stopped.
// May be canceled if the track comes back on air.
func (p *Processor) trackMayStop(s *prolink.CDJStatus) {
// track already may stop. Do not start a new waiter.
if _, ok := p.interruptCancel[s.PlayerID]; ok {
return
}
p.interruptCancel[s.PlayerID] = make(chan bool)
// Wait for the AllowedInterruptBeats based off the current BPM
beatDuration := bpm.ToDuration(s.TrackBPM, s.SliderPitch)
timeout := beatDuration * time.Duration(p.Config.AllowedInterruptBeats)
timer := time.NewTimer(timeout)
select {
case <-p.interruptCancel[s.PlayerID]:
break
case <-timer.C:
delete(p.lastStartTime, s.PlayerID)
p.handler(Stopped, s)
p.wasReportedLive[s.PlayerID] = false
p.reportNextPlayer()
p.setMayEnd()
break
}
delete(p.interruptCancel, s.PlayerID)
}
// trackMayBeFirst checks that no other tracks are currently on air and
// playing, other than the current one who's status is being reported as
// playing, and will report it as live if this is true.
func (p *Processor) trackMayBeFirst(s *prolink.CDJStatus) {
for _, otherStatus := range p.lastStatus {
if otherStatus.PlayerID == s.PlayerID {
continue
}
isOnAir := !p.onAirCap || otherStatus.IsOnAir
// Another device is already on air and playing. This is not the first
if isOnAir && playingStates[otherStatus.PlayState] {
return
}
}
p.reportPlayer(s.PlayerID)
}
// playStateChange updates the lastPlayTime of the track on the player who's
// status is being reported.
func (p *Processor) playStateChange(lastState, s *prolink.CDJStatus) {
pid := s.PlayerID
nowPlaying := playingStates[s.PlayState]
wasPlaying := playingStates[lastState.PlayState]
// Track has begun playing. Mark the start time or cancel interrupt
// timers from when the track was previously stopped.
if !wasPlaying && nowPlaying {
cancelInterupt := p.interruptCancel[pid]
if cancelInterupt == nil {
p.lastStartTime[pid] = time.Now()
p.trackMayBeFirst(s)
} else {
cancelInterupt <- true
}
return
}
// Track was stopped. Immediately promote another track to be reported and
// report the track as being stopped.
if wasPlaying && p.wasReportedLive[pid] && stoppingStates[s.PlayState] {
if cancelInterupt, ok := p.interruptCancel[pid]; ok {
cancelInterupt <- true
}
if playingStates[p.lastStatus[s.PlayerID].PlayState] {
return
}
delete(p.lastStartTime, pid)
p.reportNextPlayer()
p.handler(Stopped, s)
p.wasReportedLive[s.PlayerID] = false
go p.setMayEnd()
return
}
if wasPlaying && !nowPlaying && p.wasReportedLive[pid] {
go p.trackMayStop(s)
}
}
// OnStatusUpdate implements the prolink.StatusHandler interface
func (p *Processor) OnStatusUpdate(s *prolink.CDJStatus) {
p.lock.Lock()
defer p.lock.Unlock()
pid := s.PlayerID
ls, ok := p.lastStatus[pid]
p.lastStatus[pid] = s
if !p.onAirCap && s.IsOnAir {
p.onAirCap = true
}
// If this is the first we've heard from this CDJ and it's on air and
// playing immediately report it
if !ok && (!p.onAirCap || s.IsOnAir) && playingStates[s.PlayState] {
p.lastStartTime[pid] = time.Now()
p.reportPlayer(s.PlayerID)
return
}
// Populate last play state with an empty status packet to initialize
if !ok {
ls = &prolink.CDJStatus{}
}
// Play state has changed
if ls.PlayState != s.PlayState {
p.playStateChange(ls, s)
}
// On-Air state has changed
if p.onAirCap && ls.IsOnAir != s.IsOnAir {
if !s.IsOnAir && playingStates[s.PlayState] {
go p.trackMayStop(s)
}
if s.IsOnAir && p.interruptCancel[pid] != nil {
p.interruptCancel[pid] <- true
}
}
// Only report a track as coming soon if at least one other track is
// currently playing.
shouldReportComingSoon := false
for _, reportedLive := range p.wasReportedLive {
if reportedLive {
shouldReportComingSoon = true
break
}
}
// New track loaded. Reset reported-live flag and report ComingSoon
if ls.TrackID != s.TrackID && shouldReportComingSoon {
p.wasReportedLive[pid] = false
p.handler(ComingSoon, s)
}
// If the track on this deck has been playing for more than the configured
// BeatsUntilReported (as calculated given the current BPM) report it
beatDuration := bpm.ToDuration(s.TrackBPM, s.SliderPitch)
timeTillReport := beatDuration * time.Duration(p.Config.BeatsUntilReported)
lst, ok := p.lastStartTime[pid]
if ok && lst.Add(timeTillReport).Before(time.Now()) {
p.reportPlayer(pid)
}
}
// SetHandler sets the handler type to be used.
func (p *Processor) SetHandler(handler Handler) {
p.handler = handler.OnMixStatus
}