From a393e2b636b86fa9c326bb1037c463ad7679414f Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 3 Oct 2026 19:14:54 +0000 Subject: [PATCH 1/2] tests: put the real-audio diagnostics where their subjects live v0.5.0 audit, test-hygiene pass. The part-3c encoder relocation dragged the real-audio diagnostic harnesses into link/prolink because they touched the encoders, leaving detector diagnostics in the wire package under a misleading name. - bpm_test.go is split: the TEST_MUSIC_DIR / TEST_AUDIO_FILE beat and phrase harnesses move to analysis/realaudio_test.go; the PSSI/beat grid/PQT2 encoder dumps stay in prolink as encoders_realaudio_test.go. The moved harnesses also switch to the format-aware encoder delay - the old ones called DetectBeats (the lossy default), silently shifting lossless files' printed grids by the 25ms MP3 compensation. - bench_test.go becomes pipeline_timing_test.go with a doc comment: it is a whole-pipeline profile table (and deliberately stays in prolink, which can time both the DSP and the encoders), not a Go benchmark. - proto/dbmessage_test.go drops two tests skipped since the INITIAL commit: they exercised a marshal->parse combination that does not exist on the wire (responses are field-framed, deck requests are raw-framed). A note marks what real coverage needs: golden fixtures captured from a deck session. TestUTF16Encoding stays. All moved/renamed tests remain env-gated; zero CI behavior change. --- analysis/realaudio_test.go | 120 +++++++++++ link/prolink/bpm_test.go | 189 ------------------ link/prolink/encoders_realaudio_test.go | 104 ++++++++++ ...{bench_test.go => pipeline_timing_test.go} | 6 + proto/dbmessage_test.go | 87 +------- 5 files changed, 238 insertions(+), 268 deletions(-) create mode 100644 analysis/realaudio_test.go delete mode 100644 link/prolink/bpm_test.go create mode 100644 link/prolink/encoders_realaudio_test.go rename link/prolink/{bench_test.go => pipeline_timing_test.go} (88%) diff --git a/analysis/realaudio_test.go b/analysis/realaudio_test.go new file mode 100644 index 0000000..730fdee --- /dev/null +++ b/analysis/realaudio_test.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package analysis + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// Real-audio diagnostic harnesses for beat/phrase detection, env-gated so +// machines without (private, copyrighted) audio still pass. Moved here from +// link/prolink, where the part-3c encoder relocation had stranded them — +// their subject is the detector, not the wire encoders (those dumps live in +// link/prolink/encoders_realaudio_test.go). + +// TestDetectBeats runs beat detection across every audio file in a +// directory and sanity-checks that a BPM comes out. +// +// TEST_MUSIC_DIR=/path/to/tracks go test ./analysis/ -run TestDetectBeats -v +func TestDetectBeats(t *testing.T) { + dir := os.Getenv("TEST_MUSIC_DIR") + if dir == "" { + t.Skip("Set TEST_MUSIC_DIR to run BPM detection tests") + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read dir: %v", err) + } + + for _, e := range entries { + if e.IsDir() { + continue + } + switch filepath.Ext(e.Name()) { + case ".mp3", ".m4a", ".flac", ".wav", ".aiff", ".aif": + default: + continue + } + + path := filepath.Join(dir, e.Name()) + t.Run(e.Name(), func(t *testing.T) { + samples, err := DecodePCM(path, AnalysisRate) + if err != nil { + t.Fatalf("decode: %v", err) + } + + result := DetectBeatsWithEncoderDelay(samples, AnalysisRate, EncoderDelayMs(path)) + dur := float64(len(samples)) / float64(AnalysisRate) + + t.Logf("BPM=%.2f beats=%d downbeat=%.1fms duration=%.1fs", + result.BPM, len(result.Beats), result.Downbeat, dur) + + if result.BPM == 0 { + t.Error("BPM detection failed (returned 0)") + } + }) + } +} + +// TestDetectBeatsSingle analyzes one file (TEST_AUDIO_FILE) and prints the +// detection + phrase breakdown. Uses the format-aware encoder delay — the +// old harness called DetectBeats (the lossy default), silently shifting +// lossless files' grids by the 25ms MP3 encoder-delay compensation. +func TestDetectBeatsSingle(t *testing.T) { + path := os.Getenv("TEST_AUDIO_FILE") + if path == "" { + t.Skip("Set TEST_AUDIO_FILE to run single-file BPM test") + } + + samples, err := DecodePCM(path, AnalysisRate) + if err != nil { + t.Fatalf("decode: %v", err) + } + + result := DetectBeatsWithEncoderDelay(samples, AnalysisRate, EncoderDelayMs(path)) + dur := float64(len(samples)) / float64(AnalysisRate) + + fmt.Printf("File: %s\n", filepath.Base(path)) + fmt.Printf("Duration: %.1fs\n", dur) + fmt.Printf("BPM: %.2f\n", result.BPM) + fmt.Printf("Beats: %d\n", len(result.Beats)) + fmt.Printf("Downbeat: %.1fms\n", result.Downbeat) + + if len(result.Beats) > 8 { + fmt.Printf("First 8 beats (ms): ") + for i := 0; i < 8; i++ { + fmt.Printf("%.1f ", result.Beats[i]) + } + fmt.Println() + + fmt.Printf("IBIs (ms): ") + for i := 1; i < 8 && i < len(result.Beats); i++ { + fmt.Printf("%.1f ", result.Beats[i]-result.Beats[i-1]) + } + fmt.Println() + } + + phrases := DetectPhrases(samples, AnalysisRate, result.BPM, result.Downbeat) + fmt.Printf("\nPhrases: %d\n", len(phrases)) + phraseNames := map[uint16]string{ + 1: "Intro", 2: "Up", 3: "Down", 5: "Chorus", 6: "Outro", + } + for i, p := range phrases { + name := phraseNames[p.Kind] + if name == "" { + name = fmt.Sprintf("Unknown(%d)", p.Kind) + } + startSec := float64(p.StartBeat-1) * 60.0 / result.BPM + endSec := float64(p.EndBeat) * 60.0 / result.BPM + fmt.Printf(" %2d. %-8s beats %4d-%4d (%.1fs - %.1fs) energy=%.3f\n", + i+1, name, p.StartBeat, p.EndBeat, startSec, endSec, p.Energy) + } + + if result.BPM == 0 { + t.Error("BPM detection failed") + } +} diff --git a/link/prolink/bpm_test.go b/link/prolink/bpm_test.go deleted file mode 100644 index 7bcce13..0000000 --- a/link/prolink/bpm_test.go +++ /dev/null @@ -1,189 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package prolink - -import ( - "encoding/binary" - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/vynulldev/vynull/analysis" -) - -func TestDetectBeats(t *testing.T) { - // Scan for audio files in a test directory or use env var. - dir := os.Getenv("TEST_MUSIC_DIR") - if dir == "" { - t.Skip("Set TEST_MUSIC_DIR to run BPM detection tests") - } - - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatalf("read dir: %v", err) - } - - for _, e := range entries { - if e.IsDir() { - continue - } - ext := filepath.Ext(e.Name()) - switch ext { - case ".mp3", ".m4a", ".flac", ".wav", ".aiff", ".aif": - default: - continue - } - - path := filepath.Join(dir, e.Name()) - t.Run(e.Name(), func(t *testing.T) { - samples, err := analysis.DecodePCM(path, analysis.AnalysisRate) - if err != nil { - t.Fatalf("decode: %v", err) - } - - result := analysis.DetectBeats(samples, analysis.AnalysisRate) - dur := float64(len(samples)) / float64(analysis.AnalysisRate) - - t.Logf("BPM=%.2f beats=%d downbeat=%.1fms duration=%.1fs", - result.BPM, len(result.Beats), result.Downbeat, dur) - - if result.BPM == 0 { - t.Error("BPM detection failed (returned 0)") - } - }) - } -} - -// TestDetectBeatsSingle analyzes a single file specified by TEST_AUDIO_FILE. -func TestDetectBeatsSingle(t *testing.T) { - path := os.Getenv("TEST_AUDIO_FILE") - if path == "" { - t.Skip("Set TEST_AUDIO_FILE to run single-file BPM test") - } - - samples, err := analysis.DecodePCM(path, analysis.AnalysisRate) - if err != nil { - t.Fatalf("decode: %v", err) - } - - result := analysis.DetectBeats(samples, analysis.AnalysisRate) - dur := float64(len(samples)) / float64(analysis.AnalysisRate) - - fmt.Printf("File: %s\n", filepath.Base(path)) - fmt.Printf("Duration: %.1fs\n", dur) - fmt.Printf("BPM: %.2f\n", result.BPM) - fmt.Printf("Beats: %d\n", len(result.Beats)) - fmt.Printf("Downbeat: %.1fms\n", result.Downbeat) - - if len(result.Beats) > 8 { - fmt.Printf("First 8 beats (ms): ") - for i := 0; i < 8; i++ { - fmt.Printf("%.1f ", result.Beats[i]) - } - fmt.Println() - - // Show inter-beat intervals for first 8 beats. - fmt.Printf("IBIs (ms): ") - for i := 1; i < 8 && i < len(result.Beats); i++ { - fmt.Printf("%.1f ", result.Beats[i]-result.Beats[i-1]) - } - fmt.Println() - } - - // Phrase detection. - phrases := analysis.DetectPhrases(samples, analysis.AnalysisRate, result.BPM, result.Downbeat) - fmt.Printf("\nPhrases: %d\n", len(phrases)) - phraseNames := map[uint16]string{ - 1: "Intro", 2: "Up", 3: "Down", 5: "Chorus", 6: "Outro", - } - for i, p := range phrases { - name := phraseNames[p.Kind] - if name == "" { - name = fmt.Sprintf("Unknown(%d)", p.Kind) - } - startSec := float64(p.StartBeat-1) * 60.0 / result.BPM - endSec := float64(p.EndBeat) * 60.0 / result.BPM - fmt.Printf(" %2d. %-8s beats %4d-%4d (%.1fs - %.1fs) energy=%.3f\n", - i+1, name, p.StartBeat, p.EndBeat, startSec, endSec, p.Energy) - } - - // Generate PSSI and show raw size. - pssi := GeneratePSSI(phrases, result.BPM) - if pssi != nil { - fmt.Printf("\nPSSI blob: %d bytes\n", len(pssi)) - } else { - fmt.Printf("\nPSSI: nil (no phrases detected)\n") - } - - // Beat grid debug output. - if len(result.Beats) > 0 { - beatGrid := analysis.GenerateBeatGridFromBeats(result) - fmt.Printf("\nBeat Grid (0x2204 format): %d bytes\n", len(beatGrid)) - if len(beatGrid) >= 20 { - numBeats := int(binary.LittleEndian.Uint32(beatGrid[4:8])) - fmt.Printf(" Preamble: numBeats=%d\n", numBeats) - fmt.Printf(" Beat Bar BPM Time(ms) Time(s)\n") - shown := 0 - for i := 0; i < numBeats && shown < 20; i++ { - off := 20 + i*16 - if off+8 > len(beatGrid) { - break - } - beatNum := binary.LittleEndian.Uint16(beatGrid[off:]) - tempo := binary.LittleEndian.Uint16(beatGrid[off+2:]) - timeMs := binary.LittleEndian.Uint32(beatGrid[off+4:]) - fmt.Printf(" %4d %d %6.2f %8d %6.2f\n", - i+1, beatNum, float64(tempo)/100, timeMs, float64(timeMs)/1000) - shown++ - } - if numBeats > 20 { - fmt.Printf(" ... (%d more beats)\n", numBeats-20) - } - } - - pqt2 := GeneratePQT2(result.BPM, result.Beats, 0) - if pqt2 != nil { - fmt.Printf("\nPQT2 blob: %d bytes\n", len(pqt2)) - // Decode PQT2 header - if len(pqt2) >= 60 { - off := 4 // skip LE prefix - hdrLen := binary.BigEndian.Uint32(pqt2[off+4:]) - tagLen := binary.BigEndian.Uint32(pqt2[off+8:]) - entryCount := binary.BigEndian.Uint32(pqt2[off+40:]) - firstBeat := binary.BigEndian.Uint16(pqt2[off+24:]) - firstTempo := binary.BigEndian.Uint16(pqt2[off+26:]) - firstTime := binary.BigEndian.Uint32(pqt2[off+28:]) - lastBeat := binary.BigEndian.Uint16(pqt2[off+32:]) - lastTempo := binary.BigEndian.Uint16(pqt2[off+34:]) - lastTime := binary.BigEndian.Uint32(pqt2[off+36:]) - fmt.Printf(" Header: hdr=%d tag=%d entries=%d\n", hdrLen, tagLen, entryCount) - fmt.Printf(" First beat: num=%d bpm=%.2f time=%dms\n", - firstBeat, float64(firstTempo)/100, firstTime) - fmt.Printf(" Last beat: num=%d bpm=%.2f time=%dms (%.1fs)\n", - lastBeat, float64(lastTempo)/100, lastTime, float64(lastTime)/1000) - // Show first 10 entries - dataOff := off + int(hdrLen) - fmt.Printf(" Entries (beat_time_ms %% 1000):\n ") - for i := 0; i < int(entryCount) && i < 20; i++ { - if dataOff+i*2+2 > len(pqt2) { - break - } - val := binary.BigEndian.Uint16(pqt2[dataOff+i*2:]) - fmt.Printf("%4d ", val) - if (i+1)%10 == 0 { - fmt.Printf("\n ") - } - } - if entryCount > 20 { - fmt.Printf("... (%d more)", entryCount-20) - } - fmt.Println() - } - } - } - - if result.BPM == 0 { - t.Error("BPM detection failed") - } -} diff --git a/link/prolink/encoders_realaudio_test.go b/link/prolink/encoders_realaudio_test.go new file mode 100644 index 0000000..37788fc --- /dev/null +++ b/link/prolink/encoders_realaudio_test.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package prolink + +import ( + "encoding/binary" + "fmt" + "os" + "testing" + + "github.com/vynulldev/vynull/analysis" +) + +// TestEncodersRealAudio decodes one real file (TEST_AUDIO_FILE), runs +// detection, and dumps the wire encoders' output — PSSI, the 0x2204 beat +// grid, and PQT2 — with decoded headers/entries for eyeballing against +// captures. The detection-side diagnostics live in +// analysis/realaudio_test.go; this file is only about the encoder bytes. +func TestEncodersRealAudio(t *testing.T) { + path := os.Getenv("TEST_AUDIO_FILE") + if path == "" { + t.Skip("Set TEST_AUDIO_FILE to run the encoder dump") + } + + samples, err := analysis.DecodePCM(path, analysis.AnalysisRate) + if err != nil { + t.Fatalf("decode: %v", err) + } + result := analysis.DetectBeatsWithEncoderDelay(samples, analysis.AnalysisRate, analysis.EncoderDelayMs(path)) + phrases := analysis.DetectPhrases(samples, analysis.AnalysisRate, result.BPM, result.Downbeat) + + pssi := GeneratePSSI(phrases, result.BPM) + if pssi != nil { + fmt.Printf("PSSI blob: %d bytes\n", len(pssi)) + } else { + fmt.Printf("PSSI: nil (no phrases detected)\n") + } + + if len(result.Beats) == 0 { + t.Fatal("no beats detected") + } + + beatGrid := analysis.GenerateBeatGridFromBeats(result) + fmt.Printf("\nBeat Grid (0x2204 format): %d bytes\n", len(beatGrid)) + if len(beatGrid) >= 20 { + numBeats := int(binary.LittleEndian.Uint32(beatGrid[4:8])) + fmt.Printf(" Preamble: numBeats=%d\n", numBeats) + fmt.Printf(" Beat Bar BPM Time(ms) Time(s)\n") + shown := 0 + for i := 0; i < numBeats && shown < 20; i++ { + off := 20 + i*16 + if off+8 > len(beatGrid) { + break + } + beatNum := binary.LittleEndian.Uint16(beatGrid[off:]) + tempo := binary.LittleEndian.Uint16(beatGrid[off+2:]) + timeMs := binary.LittleEndian.Uint32(beatGrid[off+4:]) + fmt.Printf(" %4d %d %6.2f %8d %6.2f\n", + i+1, beatNum, float64(tempo)/100, timeMs, float64(timeMs)/1000) + shown++ + } + if numBeats > 20 { + fmt.Printf(" ... (%d more beats)\n", numBeats-20) + } + } + + pqt2 := GeneratePQT2(result.BPM, result.Beats, 0) + if pqt2 != nil { + fmt.Printf("\nPQT2 blob: %d bytes\n", len(pqt2)) + if len(pqt2) >= 60 { + off := 4 // skip LE prefix + hdrLen := binary.BigEndian.Uint32(pqt2[off+4:]) + tagLen := binary.BigEndian.Uint32(pqt2[off+8:]) + entryCount := binary.BigEndian.Uint32(pqt2[off+40:]) + firstBeat := binary.BigEndian.Uint16(pqt2[off+24:]) + firstTempo := binary.BigEndian.Uint16(pqt2[off+26:]) + firstTime := binary.BigEndian.Uint32(pqt2[off+28:]) + lastBeat := binary.BigEndian.Uint16(pqt2[off+32:]) + lastTempo := binary.BigEndian.Uint16(pqt2[off+34:]) + lastTime := binary.BigEndian.Uint32(pqt2[off+36:]) + fmt.Printf(" Header: hdr=%d tag=%d entries=%d\n", hdrLen, tagLen, entryCount) + fmt.Printf(" First beat: num=%d bpm=%.2f time=%dms\n", + firstBeat, float64(firstTempo)/100, firstTime) + fmt.Printf(" Last beat: num=%d bpm=%.2f time=%dms (%.1fs)\n", + lastBeat, float64(lastTempo)/100, lastTime, float64(lastTime)/1000) + dataOff := off + int(hdrLen) + fmt.Printf(" Entries (beat_time_ms %% 1000):\n ") + for i := 0; i < int(entryCount) && i < 20; i++ { + if dataOff+i*2+2 > len(pqt2) { + break + } + val := binary.BigEndian.Uint16(pqt2[dataOff+i*2:]) + fmt.Printf("%4d ", val) + if (i+1)%10 == 0 { + fmt.Printf("\n ") + } + } + if entryCount > 20 { + fmt.Printf("... (%d more)", entryCount-20) + } + fmt.Println() + } + } +} diff --git a/link/prolink/bench_test.go b/link/prolink/pipeline_timing_test.go similarity index 88% rename from link/prolink/bench_test.go rename to link/prolink/pipeline_timing_test.go index d14021a..3de139a 100644 --- a/link/prolink/bench_test.go +++ b/link/prolink/pipeline_timing_test.go @@ -11,6 +11,12 @@ import ( "github.com/vynulldev/vynull/analysis" ) +// TestAnalysisTiming profiles the full analysis-to-wire pipeline on one +// real file (TEST_AUDIO_FILE): decode, beat/key detection, then every +// encoder. It lives in prolink (not analysis) because it times the +// encoders too and prolink already imports analysis; the file was named +// bench_test.go historically, but it's a diagnostic profile table, not a +// Go benchmark. func TestAnalysisTiming(t *testing.T) { path := os.Getenv("TEST_AUDIO_FILE") if path == "" { diff --git a/proto/dbmessage_test.go b/proto/dbmessage_test.go index c9970c0..e089349 100644 --- a/proto/dbmessage_test.go +++ b/proto/dbmessage_test.go @@ -4,85 +4,14 @@ package proto import "testing" -func TestDBMessageRoundTrip(t *testing.T) { - t.Skip("wire tags differ from parse tags; tested via integration") - msg := &DBMessage{ - TxID: 0x00000042, - Type: DBMsgMenuItem, - Args: []DBArg{ - ArgI16(1), - ArgI32(100), - ArgStr("Test Track"), - ArgStr("Test Artist"), - ArgI32(0), - ArgI32(0), - }, - } - - data := MarshalDBMessage(msg) - - // Verify frame: [0x11] [magic 4] [0x11] [data...] - if data[0] != 0x11 { - t.Fatalf("expected leading 0x11, got 0x%02x", data[0]) - } - if data[1] != DBMagic[0] || data[2] != DBMagic[1] || data[3] != DBMagic[2] || data[4] != DBMagic[3] { - t.Fatalf("magic mismatch: %x", data[1:5]) - } - if data[5] != 0x11 { - t.Fatalf("expected separator 0x11, got 0x%02x", data[5]) - } - - // Parse back (strip frame: leading 0x11 + magic + separator 0x11 = 6 bytes). - parsed, err := ParseDBMessage(data[6:]) - if err != nil { - t.Fatalf("ParseDBMessage: %v", err) - } - - if parsed.TxID != msg.TxID { - t.Errorf("TxID = 0x%x, want 0x%x", parsed.TxID, msg.TxID) - } - if parsed.Type != msg.Type { - t.Errorf("Type = 0x%x, want 0x%x", parsed.Type, msg.Type) - } - if len(parsed.Args) != len(msg.Args) { - t.Fatalf("arg count = %d, want %d", len(parsed.Args), len(msg.Args)) - } - if parsed.Args[0].Int16 != 1 { - t.Errorf("arg0 = %d, want 1", parsed.Args[0].Int16) - } - if parsed.Args[1].Int32 != 100 { - t.Errorf("arg1 = %d, want 100", parsed.Args[1].Int32) - } - if parsed.Args[2].Str != "Test Track" { - t.Errorf("arg2 = %q, want %q", parsed.Args[2].Str, "Test Track") - } - if parsed.Args[3].Str != "Test Artist" { - t.Errorf("arg3 = %q, want %q", parsed.Args[3].Str, "Test Artist") - } -} - -func TestDBMessageBinaryArg(t *testing.T) { - t.Skip("wire tags differ from parse tags; tested via integration") - blob := []byte{0xde, 0xad, 0xbe, 0xef} - msg := &DBMessage{ - TxID: 1, - Type: DBMsgMenuItem, - Args: []DBArg{ArgBlob(blob)}, - } - - data := MarshalDBMessage(msg) - parsed, err := ParseDBMessage(data[6:]) // skip frame - if err != nil { - t.Fatalf("ParseDBMessage: %v", err) - } - - if len(parsed.Args) != 1 { - t.Fatalf("arg count = %d, want 1", len(parsed.Args)) - } - if string(parsed.Args[0].Bytes) != string(blob) { - t.Errorf("blob = %x, want %x", parsed.Args[0].Bytes, blob) - } -} +// NOTE on coverage: MarshalDBMessage (field-framed responses) and +// ParseDBMessage (raw-framed deck requests) implement two different wire +// dialects, so a marshal→parse round-trip does not exist on the real wire. +// Two permanently-skipped "round trip" tests sat here from the initial +// commit until the v0.5.0 audit removed them. Honest wire-level coverage +// needs golden fixtures captured from a real deck session (request bytes +// in, expected DBMessage out; response DBMessage in, expected bytes out) — +// capture with the tooling in vynull-tools when the rig is next up. func TestUTF16Encoding(t *testing.T) { tests := []string{ From 9c2f2cbedecf70c5222991d0339c58c03a1fb48a Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 3 Oct 2026 19:17:27 +0000 Subject: [PATCH 2/2] docs: README accuracy pass for v0.5.0 - Serve-a-rekordbox-USB had zero README presence despite being the v0.5.0 headline: new feature bullet plus the old 'Rekordbox USB Mode' section rewritten as 'Serve a rekordbox USB' - no sudo (it works in default rekordbox mode), what the deck/web UI actually get (stick playlists, rekordbox's grids/waveforms/cues, one shared track-ID space, read-only), and a OneLibrary note (dual-format sticks work via their classic export half; the encrypted OneLibrary database is not yet supported). - Colour-waveform bullet claimed 'FFT spectral analysis'; PWV4/PWV5 moved to time-domain Butterworth filter banks long ago. - BPM/beat-grid bullets described the pre-tempogram engine; now match the shipped pipeline (tempo prior + coherence-verified snap; multiband tempogram phase with windowed clarity weighting and the gated half-beat correction). --- README.md | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 47b7d2b..1aa4975 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,15 @@ Use it at your own risk, and back up your rekordbox library before importing any - **Browse tracks** on CDJs by artist, album, genre, BPM, key, label, year, remixer, folder - **Import your library** — from **rekordbox** (`rekordbox.xml`, an encrypted `master.db`, or a full library-backup `.zip`) or **Traktor** (`collection.nml`). A rekordbox import brings tracks, MyTags (with categories), track colors, (smart) playlists, cue points (hot + memory, with colors/loops), ANLZ analysis (waveforms/beat grids/phrases), and artwork; a Traktor import brings tracks, playlists, and cues - **Smart playlists** — rekordbox rule sets imported and evaluated live (BPM/key/genre/date/tag/… conditions) +- **Serve a rekordbox USB directly** — point `--music-dir` at an exported stick: its playlists appear on the decks and in the web UI, tracks keep rekordbox's own beat grids, waveforms and hot/memory cues, and the stick's database replaces the tag scan (web UI, decks and stick share one track-ID space) - **DJM mixer awareness** — surfaces DJM channel/master state on the link - **Native FLAC/WAV/AIFF playback** — CDJ decodes lossless formats directly over NFS -- **Color waveforms** on the CDJ (PWV4 overview + PWV5 scrolling, generated via FFT spectral analysis); honors the CDJ "waveform color" setting (blue / RGB / 3-band) in both the deck and the web UI -- **BPM detection** with dynamic programming beat tracker and multi-ratio correction +- **Color waveforms** on the CDJ (PWV4 overview + PWV5 scrolling, generated with time-domain Butterworth filter banks calibrated against rekordbox's rendering); honors the CDJ "waveform color" setting (blue / RGB / 3-band) in both the deck and the web UI +- **BPM detection** with autocorrelation + a perceptual tempo prior, coherence-verified integer/half-integer snap, and a DP beat tracker - **Key detection** using chromagram analysis (octaves 4-7, Krumhansl-Kessler profiles) -- **Beat grid** generation with QM-DSP-inspired DP tracking, zero-phase scoring, and BPM rounding +- **Beat grid** generation phase-locked via a multiband onset tempogram (modelled on rekordbox's reverse-engineered 25-band novelty), windowed clarity-weighted phase, and a gated half-beat correction - **Phrase/song structure** detection (PSSI format) -- **Cue point management** — save/load via CDJ + add/edit/delete via HTTP API/web UI with color support +- **Cue point management** — hot and memory cues: save/load from the CDJ, create either kind in the web UI (+ CUE / + MEM), add/edit/delete via HTTP API, with color support - **NFS v2 file server** streams audio to CDJs (with optional FLAC/WAV/AIFF to MP3 transcoding) - **Lazy analysis** mode for instant startup (tracks analyzed on-demand when CDJs request them); artwork is also extracted lazily - **Library mode** with no music directory required (add tracks dynamically via HTTP API) @@ -177,15 +178,30 @@ Scan a music directory at startup: ./vynull --interface eth1 --music-dir /path/to/music --lazy-analysis ``` -### Rekordbox USB Mode +### Serve a rekordbox USB -If you have an existing rekordbox-exported USB drive: +Point `--music-dir` at an existing rekordbox-exported USB drive and Vynull +serves the stick's own database instead of scanning tags: ```bash -# CDJ mode needs UDP 111 — see "Port 111 (CDJ mode only)" for sudo-free options -sudo ./vynull --interface eth1 --music-dir /media/usb --mode cdj +./vynull --interface eth1 --music-dir /media/usb ``` +Decks and the web UI get the stick's playlists, rekordbox's own beat +grids, waveforms and hot/memory cues (our analyzer only runs for tracks +with missing analysis), and everything shares the stick's track IDs. The +stick's content is treated as read-only — its playlists can't be edited +from the web UI. + +OneLibrary sticks work when exported with older-player compatibility: +Vynull reads the classic `PIONEER/rekordbox/export.pdb` half of the +dual-format layout (the encrypted OneLibrary database itself is not yet +supported). + +The same works in CDJ mode (`--mode cdj`, appears as a CDJ-USB source +instead of a rekordbox laptop) — that mode needs UDP 111; see "Port 111 +(CDJ mode only)" for sudo-free options. + ### Generate USB Structure Create a rekordbox-compatible USB structure from a music directory: