Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: GPL-3.0-or-later
.PHONY: build run run-cdj generate clean
.PHONY: build run run-cdj generate e2e clean

build:
go build -o vynull .
Expand All @@ -15,5 +15,10 @@ run-cdj: build
generate: build
./vynull --generate $(OUT) --music-dir $(MUSIC)

# End-to-end suite: drives the real binary over HTTP against synthesized
# media (needs ffmpeg + the DJ-Link UDP ports free, i.e. no running instance)
e2e:
VYNULL_E2E=1 go test ./e2e/ -v

clean:
rm -f vynull
88 changes: 48 additions & 40 deletions device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,21 +622,56 @@ func (d *VirtualDevice) listenBeats(ctx context.Context) {
if !ok {
continue
}
d.mixerStatusesMu.Lock()
if d.mixerStatuses == nil {
d.mixerStatuses = make(map[uint8]*proto.MixerStatus)
d.recordMixerChannels(mx)
}
}

// recordMixerChannels folds a 0x03 channel-state packet into the mixer
// status map: merge channel state with whatever 0x29/0x30 presence info we
// already have so the API can surface both at once.
func (d *VirtualDevice) recordMixerChannels(mx *proto.MixerStatus) {
d.mixerStatusesMu.Lock()
defer d.mixerStatusesMu.Unlock()
if d.mixerStatuses == nil {
d.mixerStatuses = make(map[uint8]*proto.MixerStatus)
}
if prev := d.mixerStatuses[mx.DeviceNumber]; prev != nil {
prev.ChannelOnAir = mx.ChannelOnAir
prev.ChannelStateKnown = true
} else {
d.mixerStatuses[mx.DeviceNumber] = mx
}
}

// recordMixerStatus folds a 0x29/0x30 status packet into the mixer status
// map, returning whether this mixer had no status entry yet (callers log the
// first packet). A stripped 0x30 (newer DJMs) carries only presence — no
// channel or master state. Merge it over what the 0x03 channel listener has
// learned instead of clobbering, or the mixer views flip between "channel
// state" and "detected" at packet cadence as the two writers fight. A rich
// 0x29 carries everything and may replace the entry wholesale.
func (d *VirtualDevice) recordMixerStatus(mx *proto.MixerStatus) (first bool) {
d.mixerStatusesMu.Lock()
defer d.mixerStatusesMu.Unlock()
if d.mixerStatuses == nil {
d.mixerStatuses = make(map[uint8]*proto.MixerStatus)
}
prev, seen := d.mixerStatuses[mx.DeviceNumber]
if prev != nil && !mx.ChannelStateKnown && prev.ChannelStateKnown {
mx.ChannelOnAir = prev.ChannelOnAir
mx.ChannelStateKnown = true
if mx.MasterBPM == 0 {
mx.MasterBPM = prev.MasterBPM
}
prev := d.mixerStatuses[mx.DeviceNumber]
// Merge channel state with whatever 0x29/0x30 presence info
// we already have so the API can surface both at once.
if prev != nil {
prev.ChannelOnAir = mx.ChannelOnAir
prev.ChannelStateKnown = true
} else {
d.mixerStatuses[mx.DeviceNumber] = mx
if mx.MasterDevice == 0 {
mx.MasterDevice = prev.MasterDevice
}
if mx.BeatInBar == 0 {
mx.BeatInBar = prev.BeatInBar
}
d.mixerStatusesMu.Unlock()
}
d.mixerStatuses[mx.DeviceNumber] = mx
return !seen
}

func (d *VirtualDevice) listenStatus(ctx context.Context) {
Expand Down Expand Up @@ -695,34 +730,7 @@ func (d *VirtualDevice) listenStatus(ctx context.Context) {
}
if isMixer {
if mx, ok := proto.ParseMixerStatus(buf[:n]); ok {
d.mixerStatusesMu.Lock()
if d.mixerStatuses == nil {
d.mixerStatuses = make(map[uint8]*proto.MixerStatus)
}
prev, alreadyLogged := d.mixerStatuses[mx.DeviceNumber]
// A stripped 0x30 (newer DJMs) carries only presence —
// no channel or master state. Merge it over what the
// 0x03 channel listener has learned instead of
// clobbering, or the mixer views flip between "channel
// state" and "detected" at packet cadence as the two
// writers fight. A rich 0x29 carries everything and
// may replace the entry wholesale.
if prev != nil && !mx.ChannelStateKnown && prev.ChannelStateKnown {
mx.ChannelOnAir = prev.ChannelOnAir
mx.ChannelStateKnown = true
if mx.MasterBPM == 0 {
mx.MasterBPM = prev.MasterBPM
}
if mx.MasterDevice == 0 {
mx.MasterDevice = prev.MasterDevice
}
if mx.BeatInBar == 0 {
mx.BeatInBar = prev.BeatInBar
}
}
d.mixerStatuses[mx.DeviceNumber] = mx
d.mixerStatusesMu.Unlock()
if !alreadyLogged {
if d.recordMixerStatus(mx) {
log.Printf("mixer: first 0x29 from %s (%s) device %d — len=%d masterBPM=%.2f masterDev=%d onAir=%04b beat=%d\n%s",
mx.Name, addr.IP, mx.DeviceNumber, n, mx.MasterBPM, mx.MasterDevice, mx.ChannelOnAir, mx.BeatInBar, hex.Dump(buf[:n]))
}
Expand Down
134 changes: 134 additions & 0 deletions device/mixerstatus_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package device

import (
"encoding/binary"
"testing"

"github.com/vynulldev/vynull/proto"
)

// Wire-packet builders for the three mixer broadcast types, laid out per the
// offsets documented on proto.ParseMixerStatus. Each goes through the real
// parser, so the tests cover parse + merge, not a hand-built MixerStatus.

func synthPktHeader(typ uint8, size int) []byte {
p := make([]byte, size)
copy(p, proto.Magic[:])
p[0x0a] = typ
return p
}

// synthChannels builds a 0x03 channel-state broadcast (newer DJMs, port
// 50001): name at 0x0b, device number at 0x21, per-channel on-air bytes at
// 0x24-0x27.
func synthChannels(name string, devNum uint8, onAir [4]bool) []byte {
p := synthPktHeader(proto.TypeMixerChannels, 53)
copy(p[0x0b:], name)
p[0x21] = devNum
for ch, on := range onAir {
if on {
p[0x24+ch] = 0x01
}
}
return p
}

// synthStatusNew builds a stripped 0x30 status broadcast (newer DJMs, port
// 50002): name at 0x0b, device number at 0x21, no channel or master state.
func synthStatusNew(name string, devNum uint8) []byte {
p := synthPktHeader(proto.TypeMixerStatusNew, 36)
copy(p[0x0b:], name)
p[0x21] = devNum
return p
}

// synthStatusLegacy builds a rich 0x29 status broadcast (older DJMs): name
// at 0x0c, device number at 0x21, master BPM ×100 at 0x2c, on-air bitfield
// at 0x36.
func synthStatusLegacy(name string, devNum uint8, masterBPM float64, onAirBits uint8) []byte {
p := synthPktHeader(proto.TypeMixerStatusLegacy, 56)
copy(p[0x0c:], name)
p[0x21] = devNum
binary.BigEndian.PutUint16(p[0x2c:], uint16(masterBPM*100))
p[0x36] = onAirBits
return p
}

func parse(t *testing.T, pkt []byte) *proto.MixerStatus {
t.Helper()
mx, ok := proto.ParseMixerStatus(pkt)
if !ok {
t.Fatalf("synthesized packet type 0x%02x did not parse", pkt[0x0a])
}
return mx
}

// TestMixerStatus0x30DoesNotClobberChannelState replays the newer-DJM
// packet interleaving that made mixer views flip between the rich
// channel-state line and the bare "detected" fallback: 0x03 channel packets
// on the on-air port teach the channel state, and stripped 0x30 status
// packets used to overwrite the shared entry wholesale, wiping
// ChannelStateKnown until the next 0x03. The snapshot must stay rich across
// any number of interleaved 0x30s.
func TestMixerStatus0x30DoesNotClobberChannelState(t *testing.T) {
d := &VirtualDevice{}

// Channel state arrives first (0x03, channels 1+2 on-air).
d.recordMixerChannels(parse(t, synthChannels("DJM-A9", 33, [4]bool{true, true, false, false})))
snap := d.MixerSnapshot()
mx, ok := snap[33]
if !ok || !mx.ChannelStateKnown || mx.ChannelOnAir != 0b0011 {
t.Fatalf("after 0x03: %+v (want known, on-air 0011)", mx)
}

// The real-world flip trigger: stripped 0x30s interleaved with 0x03s.
for i := 0; i < 20; i++ {
d.recordMixerStatus(parse(t, synthStatusNew("DJM-A9", 33)))
mx = d.MixerSnapshot()[33]
if !mx.ChannelStateKnown {
t.Fatalf("0x30 #%d wiped ChannelStateKnown — views would flip to the 'detected' fallback", i+1)
}
if mx.ChannelOnAir != 0b0011 {
t.Fatalf("0x30 #%d lost channel state: on-air %04b, want 0011", i+1, mx.ChannelOnAir)
}
d.recordMixerChannels(parse(t, synthChannels("DJM-A9", 33, [4]bool{true, true, false, false})))
}

// A channel change mid-stream still lands.
d.recordMixerChannels(parse(t, synthChannels("DJM-A9", 33, [4]bool{false, false, true, true})))
d.recordMixerStatus(parse(t, synthStatusNew("DJM-A9", 33)))
if mx = d.MixerSnapshot()[33]; mx.ChannelOnAir != 0b1100 {
t.Fatalf("channel change lost through 0x30: on-air %04b, want 1100", mx.ChannelOnAir)
}
}

// TestMixerStatusLegacy0x29ReplacesWholesale: a rich 0x29 carries channel
// and master state inline, so it must replace the entry (not merge stale
// fields over fresh ones).
func TestMixerStatusLegacy0x29ReplacesWholesale(t *testing.T) {
d := &VirtualDevice{}
d.recordMixerChannels(parse(t, synthChannels("DJM-900", 33, [4]bool{true, false, false, false})))
d.recordMixerStatus(parse(t, synthStatusLegacy("DJM-900", 33, 128.0, 0b0110)))
mx := d.MixerSnapshot()[33]
if !mx.ChannelStateKnown || mx.ChannelOnAir != 0b0110 || mx.MasterBPM != 128.0 {
t.Fatalf("0x29 not applied wholesale: %+v", mx)
}
}

// TestMixerStatusFirstPacketFlag: recordMixerStatus reports the first status
// packet from a mixer (the listener logs it), and only the first.
func TestMixerStatusFirstPacketFlag(t *testing.T) {
d := &VirtualDevice{}
if !d.recordMixerStatus(parse(t, synthStatusNew("DJM-A9", 33))) {
t.Error("first status packet not flagged")
}
if d.recordMixerStatus(parse(t, synthStatusNew("DJM-A9", 33))) {
t.Error("second status packet flagged as first")
}
// A 0x30 arriving before any 0x03 must leave state honestly unknown.
if mx := d.MixerSnapshot()[33]; mx.ChannelStateKnown {
t.Errorf("0x30 alone must not claim channel state: %+v", mx)
}
}
Loading
Loading