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
60 changes: 52 additions & 8 deletions dbserver/cuepoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,7 @@ func ParseCueBlob(blob []byte, trackID uint32) (*CuePoint, error) {
Status: binary.LittleEndian.Uint32(blob[0x18:]),
LoopMs: int32(binary.LittleEndian.Uint32(blob[0x20:])),
}
// 124-byte blobs put the picker palette index at byte 0x4e (with RGB
// at 0x4f-0x51), and reserve 0x34 as a fixed 0x42 marker. 76-byte
// blobs put the raw palette index directly at 0x34.
if len(blob) >= 124 && blob[0x34] == 0x42 {
cue.ColorID = uint32(blob[0x4e])
} else {
cue.ColorID = binary.LittleEndian.Uint32(blob[0x34:])
}
cue.ColorID = cueColorFromBlob(blob)

log.Printf("cuestore: parsed cue #%d type=%d time=%dms loop=%d color_id=%d (0x%x) track=%d",
cue.Number, cue.Type, cue.TimeMs, cue.LoopMs, cue.ColorID, cue.ColorID, trackID)
Expand Down Expand Up @@ -159,6 +152,43 @@ func cueColorRGB(idx uint32) (r, g, b byte) {
return 0xff, 0x6a, 0x00
}

// cueColorFromBlob extracts the palette color_id from a cue blob. 124-byte NXS2
// blobs carry the picker index at 0x4e; when that index is 0 the real colour is
// in the RGB bytes (0x4f-0x51) — the CDJ encodes its default hot-cue green that
// way, and taking idx 0 alone would paint it Pioneer orange (palette 0x00) — so
// recover the nearest palette id from the RGB. 76-byte blobs store the raw index
// directly at 0x34. Callers must ensure len(blob) >= 76.
func cueColorFromBlob(blob []byte) uint32 {
if len(blob) >= 124 && blob[0x34] == 0x42 {
if idx := uint32(blob[0x4e]); idx != 0 {
return idx
}
if r, g, b := blob[0x4f], blob[0x50], blob[0x51]; r|g|b != 0 {
return nearestCueColorID(r, g, b)
}
return 0
}
return binary.LittleEndian.Uint32(blob[0x34:])
}

// nearestCueColorID returns the palette color_id whose RGB is closest to the
// given triplet (squared-Euclidean distance; ties resolve to the lower id so
// the result is deterministic). Used when the CDJ hands us a cue with a zero
// palette index but a real colour in the RGB bytes, so the stored color_id —
// and the web UI swatch — reflect the colour the deck paints.
func nearestCueColorID(r, g, b byte) uint32 {
best := uint32(0)
bestDist := -1
for idx, c := range cueColorPalette {
dr, dg, db := int(c[0])-int(r), int(c[1])-int(g), int(c[2])-int(b)
d := dr*dr + dg*dg + db*db
if bestDist < 0 || d < bestDist || (d == bestDist && idx < best) {
bestDist, best = d, idx
}
}
return best
}

var cueColorPalette = map[uint32][3]byte{
0x00: {0xff, 0x6a, 0x00},
0x01: {0x30, 0x5a, 0xff}, 0x02: {0x50, 0x73, 0xff}, 0x03: {0x50, 0x8c, 0xff}, 0x04: {0x50, 0xa0, 0xff},
Expand Down Expand Up @@ -365,6 +395,20 @@ func (cs *CueStore) loadAll() {
cs.rawBlobs[bTrackID][bCueNum] = data
}
}

// Re-derive the colour of CDJ-saved cues from their raw blob. Cues stored
// before the RGB-colour recovery kept whatever color_id was persisted (a
// default green came through as 0 → orange); re-reading the blob now applies
// the fix on load. Web-UI/synthesized cues have no blob (or a stub), so they
// keep their stored colour.
for trackID, cues := range cs.data {
blobs := cs.rawBlobs[trackID]
for i := range cues {
if raw := blobs[cues[i].Number]; len(raw) >= 76 {
cues[i].ColorID = cueColorFromBlob(raw)
}
}
}
}

// MarshalCueResponse builds the cue point response for 0x2104.
Expand Down
86 changes: 86 additions & 0 deletions dbserver/cuepoints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package dbserver

import (
"os"
"path/filepath"
"testing"
)

// makeNXS2CueBlob builds a minimal 124-byte NXS2 cue blob with the given cue
// number, palette index (0x4e), and RGB (0x4f-0x51), matching what
// ParseCueBlob reads.
func makeNXS2CueBlob(number uint16, idx, r, g, b byte) []byte {
blob := make([]byte, 124)
blob[0x04] = byte(number)
blob[0x06] = 1 // type = cue
blob[0x34] = 0x42 // NXS2 picker-coloured marker
blob[0x4e] = idx
blob[0x4f], blob[0x50], blob[0x51] = r, g, b
return blob
}

func TestParseCueBlobCDJDefaultGreen(t *testing.T) {
// A default hot cue set on the CDJ arrives as palette idx 0 + RGB 00ff30
// (green). Taking idx 0 alone would paint it Pioneer orange; the RGB must
// win so the web UI shows green.
cue, err := ParseCueBlob(makeNXS2CueBlob(5, 0x00, 0x00, 0xff, 0x30), 42)
if err != nil {
t.Fatal(err)
}
if cue.ColorID != 0x16 {
t.Errorf("CDJ green cue: color_id = %#x, want 0x16 (green), not orange", cue.ColorID)
}
}

func TestParseCueBlobHonoursPaletteIndex(t *testing.T) {
// A non-zero palette index is authoritative; RGB is not consulted.
cue, _ := ParseCueBlob(makeNXS2CueBlob(1, 0x2a, 0x00, 0x00, 0x00), 42)
if cue.ColorID != 0x2a {
t.Errorf("color_id = %#x, want 0x2a (index honoured)", cue.ColorID)
}
}

func TestParseCueBlobNoColour(t *testing.T) {
// idx 0 with all-zero RGB is a genuinely uncoloured cue → stays 0.
cue, _ := ParseCueBlob(makeNXS2CueBlob(1, 0x00, 0x00, 0x00, 0x00), 42)
if cue.ColorID != 0 {
t.Errorf("color_id = %#x, want 0 (no colour)", cue.ColorID)
}
}

func TestLoadAllRederivesCueColour(t *testing.T) {
dir := t.TempDir()
// A cue stored before the fix: the JSON has color_id 0, but the raw CDJ
// blob carries green in its RGB bytes. Loading should re-derive green.
if err := os.WriteFile(filepath.Join(dir, "cues_7.json"),
[]byte(`[{"number":5,"type":1,"time_ms":0,"loop_ms":-1,"status":1,"color_id":0}]`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "cue_7_5.bin"),
makeNXS2CueBlob(5, 0x00, 0x00, 0xff, 0x30), 0o644); err != nil {
t.Fatal(err)
}
cues := NewCueStore(dir).GetCues(7)
if len(cues) != 1 || cues[0].ColorID != 0x16 {
t.Fatalf("re-derived colour = %+v, want one cue with color_id 0x16", cues)
}
}

func TestNearestCueColorID(t *testing.T) {
cases := []struct {
r, g, b byte
want uint32
name string
}{
{0x28, 0xe2, 0x14, 0x16, "exact palette green round-trips"},
{0x00, 0xff, 0x30, 0x16, "CDJ's brighter green maps to our green"},
{0xff, 0x6a, 0x00, 0x00, "orange maps back to idx 0"},
}
for _, c := range cases {
if got := nearestCueColorID(c.r, c.g, c.b); got != c.want {
t.Errorf("%s: nearest(%#x,%#x,%#x) = %#x, want %#x", c.name, c.r, c.g, c.b, got, c.want)
}
}
}
Loading