Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6a9c5d1
docs: design draft for a CDJ emulator (virtual playing deck)
vynulldev Oct 9, 2026
3c830e2
cdj-emulator: dynamic playing status + virtual deck engine (Phase 1a …
vynulldev Oct 9, 2026
bef4e5d
cdj-emulator: wire the virtual deck into the status broadcast (--simu…
vynulldev Oct 9, 2026
b324a3f
cdj-emulator: /api/sim + 'vynull sim' control surface (Phase 1a)
vynulldev Oct 9, 2026
f5280d4
cdj-emulator: feed the virtual deck into our own monitor
vynulldev Oct 9, 2026
497619e
cdj-emulator: web control panel for the virtual deck (Phase 1c)
vynulldev Oct 9, 2026
bf0f8f4
cdj-emulator: sim load uses getOrAnalyze, not a cache-only Get
vynulldev Oct 9, 2026
848463d
cdj-emulator: fix panic on sim EJECT (mutex overwrite)
vynulldev Oct 9, 2026
093793a
cdj-emulator: multiple virtual decks via a SimManager (1-4 players)
vynulldev Oct 9, 2026
bfa0a47
cdj-emulator: multi-deck web panel (add/remove/renumber)
vynulldev Oct 9, 2026
6d40000
cdj-emulator: multi-deck 'vynull sim' CLI
vynulldev Oct 9, 2026
ee4ef79
docs: cdj-emulator design note covers multi-deck (SimManager)
vynulldev Oct 9, 2026
b86d0dc
cdj-emulator: per-deck keep-alive announcements (--simulate)
vynulldev Oct 9, 2026
ea2283c
cdj-emulator: smooth the web beat indicator between polls
vynulldev Oct 9, 2026
a0be53d
cdj-emulator: anchor the beat animator to sub-beat phase (no flash)
vynulldev Oct 9, 2026
baeab97
cdj-emulator: don't announce our source number as a phantom player
vynulldev Oct 9, 2026
e1f0cf7
dbserver: log request type + descriptor at debug
vynulldev Oct 9, 2026
8ee49fd
cdj-emulator: run as a rekordbox source so metadata clients resolve t…
vynulldev Oct 9, 2026
07ea6a9
docs: cdj-emulator note reflects the rekordbox-source model
vynulldev Oct 9, 2026
bf6ccc7
cdj-emulator: set the player number at status byte 0x21 (multi-deck m…
vynulldev Oct 9, 2026
ec93f62
cdj-emulator: on-air / tempo-master control per deck
vynulldev Oct 9, 2026
812b15b
docs: cdj-emulator note records on-air control + prolink-tools valida…
vynulldev Oct 9, 2026
72d869b
dbserver: resolve artwork lazily so played tracks get their cover
vynulldev Oct 9, 2026
e3b4c95
web: load into virtual CDJs from the Library LOAD menu
vynulldev Oct 9, 2026
5823d16
cdj-emulator: per-device model name + add-device form
vynulldev Oct 10, 2026
cc3ec83
cdj-emulator: model profiles scaffold
vynulldev Oct 10, 2026
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
45 changes: 26 additions & 19 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/api/import/status", s.handleImportStatus)
mux.HandleFunc("/api/library/remap-paths", s.handleRemapPaths)
mux.HandleFunc("/api/load", s.handleLoadTrack)
mux.HandleFunc("/api/sim/", s.handleSim)

mux.HandleFunc("/api/diag", s.handleDiag)
mux.HandleFunc("/api/diag/logs", s.handleDiagLogs)
Expand Down Expand Up @@ -2467,30 +2468,25 @@ func (s *Server) handleWaveform(w http.ResponseWriter, r *http.Request) {
// GET /api/artwork/{trackID} — returns the cached album art (JPEG)
// for trackID. 404 when the track has no art. Used by the library
// table's optional ART column.
func (s *Server) handleArtwork(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
trackID := parseTrackIDFromPath(r.URL.Path, "/api/artwork/")
if trackID == 0 || s.Library == nil {
http.Error(w, "track ID required", http.StatusBadRequest)
return
// EnsureArtwork returns a track's library artwork ID, extracting the embedded
// cover on first request (one ffmpeg probe, then cached and persisted). Returns
// 0 when the track is unknown, has no art, or a probe for it is already in
// flight. Shared by the HTTP artwork endpoint and the dbserver (so a track a
// deck plays gets its cover resolved on demand, not only via the web UI).
func (s *Server) EnsureArtwork(trackID uint32) uint32 {
if s.Library == nil {
return 0
}
t := s.Library.Track(trackID)
if t == nil {
http.NotFound(w, r)
return
return 0
}
// Lazy extraction: if this track's file has never been probed, do it now
// (one ffmpeg probe, then cached for good). This replaces the old startup
// artwork sweep — we only pay for tracks whose art is actually requested,
// and the result (ArtID + ArtChecked) is persisted via a debounced save.
// Dedup concurrent requests for the same track, bound total concurrency,
// and write the track fields under the library lock (vs the debounced Save).
// (one ffmpeg probe, then cached for good). Dedup concurrent requests for
// the same track, bound total concurrency, and persist via a debounced save.
if t.ArtID == 0 && !t.ArtChecked && t.FilePath != "" {
if _, busy := s.artInFlight.LoadOrStore(trackID, struct{}{}); busy {
// Another request is already probing this track; don't double-probe.
// The thumbnail appears once that finishes (next poll re-fetches).
http.NotFound(w, r)
return
return 0 // another request is already probing; it'll appear shortly
}
artExtractSem <- struct{}{} // cap concurrent ffmpeg probes
data := analysis.ExtractArtwork(t.FilePath)
Expand All @@ -2503,11 +2499,22 @@ func (s *Server) handleArtwork(w http.ResponseWriter, r *http.Request) {
s.artInFlight.Delete(trackID)
s.scheduleArtworkSave()
}
if t.ArtID == 0 {
return t.ArtID
}

func (s *Server) handleArtwork(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
trackID := parseTrackIDFromPath(r.URL.Path, "/api/artwork/")
if trackID == 0 || s.Library == nil {
http.Error(w, "track ID required", http.StatusBadRequest)
return
}
artID := s.EnsureArtwork(trackID)
if artID == 0 {
http.NotFound(w, r)
return
}
art := s.Library.Artwork.Get(t.ArtID)
art := s.Library.Artwork.Get(artID)
if art == nil || len(art.Data) == 0 {
http.NotFound(w, r)
return
Expand Down
278 changes: 278 additions & 0 deletions api/sim.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package api

import (
"encoding/json"
"net/http"
"strconv"
"strings"

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

// simListResponse is the manager-level view returned by GET /api/sim/status and
// after every add/remove/renumber: all decks plus the deck cap.
type simListResponse struct {
Decks []device.SimDeckStatus `json:"decks"`
Max int `json:"max"`
}

// handleSim drives the virtual playing decks (the CDJ emulator, --simulate).
//
// Manager-level routes under /api/sim/:
//
// status GET list every deck (+ the deck cap)
// profiles GET the built-in model profiles (for a model picker)
// add POST {number?, name?} add a deck (0/omitted = lowest free)
// remove POST {number} remove a deck
// renumber POST {number, to} change a deck's player number
//
// Per-deck routes under /api/sim/{number}/:
//
// status GET that deck's state
// load POST {track_id|file_path}
// play|pause|cue|eject POST
// seek POST {position_ms}
// pitch POST {pitch_pct}
// onair POST {on} mixer channel up (now-playing)
// master POST {on} tempo master
// identity POST {name} advertised model name
//
// See docs/design/cdj-emulator.md.
func (s *Server) handleSim(w http.ResponseWriter, r *http.Request) {
if s.Device == nil || s.Device.Sim == nil {
http.Error(w, "simulate not enabled (start with --simulate)", http.StatusServiceUnavailable)
return
}
mgr := s.Device.Sim
rest := strings.TrimPrefix(r.URL.Path, "/api/sim/")

// Per-deck: "<number>/<action>".
if i := strings.IndexByte(rest, '/'); i > 0 {
num, err := strconv.Atoi(rest[:i])
if err != nil || num < 1 || num > device.MaxSimDecks {
http.Error(w, "bad player number", http.StatusNotFound)
return
}
deck := mgr.Get(uint8(num))
if deck == nil {
http.Error(w, "no such virtual player", http.StatusNotFound)
return
}
s.handleSimDeck(w, r, uint8(num), deck, rest[i+1:])
return
}

// Manager-level.
switch rest {
case "profiles":
if r.Method != http.MethodGet {
http.Error(w, "GET required", http.StatusMethodNotAllowed)
return
}
writeJSON(w, map[string]any{"profiles": device.DeviceProfiles()})
return
case "status":
if r.Method != http.MethodGet {
http.Error(w, "GET required", http.StatusMethodNotAllowed)
return
}
case "add":
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
var req struct {
Number uint8 `json:"number"`
Name string `json:"name"`
}
json.NewDecoder(r.Body).Decode(&req) // body optional
assigned, err := mgr.Add(req.Number)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
// Set the model name immediately so the deck's very first
// keep-alive/status advertises it (clients latch a device's identity at
// first announce, so setting it after the fact is too late).
if name := strings.TrimSpace(req.Name); name != "" {
mgr.SetName(assigned, name)
}
case "remove":
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
var req struct {
Number uint8 `json:"number"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := mgr.Remove(req.Number); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
case "renumber":
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
var req struct {
Number uint8 `json:"number"`
To uint8 `json:"to"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := mgr.Renumber(req.Number, req.To); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
default:
http.Error(w, "unknown sim action: "+rest, http.StatusNotFound)
return
}

writeJSON(w, simListResponse{Decks: mgr.Statuses(), Max: device.MaxSimDecks})
}

// handleSimDeck handles the per-deck actions. On success it responds with the
// deck's status (with its number filled in).
func (s *Server) handleSimDeck(w http.ResponseWriter, r *http.Request, num uint8, deck *device.SimDeck, action string) {
respond := func() {
st := deck.Status()
st.Number = num
writeJSON(w, st)
}

if action == "status" {
if r.Method != http.MethodGet {
http.Error(w, "GET required", http.StatusMethodNotAllowed)
return
}
respond()
return
}
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}

switch action {
case "play":
deck.Play()
case "pause":
deck.Pause()
case "cue":
deck.Cue()
case "eject":
deck.Eject()
case "seek":
var req struct {
PositionMs float64 `json:"position_ms"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
deck.Seek(req.PositionMs)
case "pitch":
var req struct {
PitchPct float64 `json:"pitch_pct"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
deck.SetPitch(req.PitchPct)
case "onair":
var req struct {
On bool `json:"on"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
deck.SetOnAir(req.On)
case "master":
var req struct {
On bool `json:"on"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
deck.SetMaster(req.On)
case "identity":
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
deck.SetName(strings.TrimSpace(req.Name))
case "load":
if !s.simLoad(w, r, deck) {
return
}
default:
http.Error(w, "unknown sim action: "+action, http.StatusNotFound)
return
}
respond()
}

// simLoad resolves a track to its analysis and loads it onto deck. It writes an
// error response and returns false on failure.
func (s *Server) simLoad(w http.ResponseWriter, r *http.Request, deck *device.SimDeck) bool {
var req struct {
TrackID uint32 `json:"track_id"`
FilePath string `json:"file_path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return false
}
if req.TrackID == 0 && req.FilePath != "" {
req.TrackID = s.resolveTrackID(req.FilePath)
}
if req.TrackID == 0 {
http.Error(w, "track_id or file_path required", http.StatusBadRequest)
return false
}
if s.Analysis == nil {
http.Error(w, "analysis store not available", http.StatusServiceUnavailable)
return false
}
// getOrAnalyze (not Analysis.Get) so a track analyzed in an earlier session
// loads: it resolves the file path from the library/PDB and pulls the
// on-disk cache, analyzing synchronously only as a last resort.
res := s.getOrAnalyze(req.TrackID)
if res == nil {
http.Error(w, "track not found, or analysis unavailable", http.StatusNotFound)
return false
}

// The deck plays a track linked from our rekordbox library: the source is
// our own device number (rekordbox slot, rekordbox type). So our monitor
// resolves it locally (externalSource is false, since TrackDevice == our
// SelfDevice) and metadata clients (prolink-tools) query our dbserver as
// the rekordbox source.
deck.Load(
req.TrackID,
s.Device.DeviceNumber,
proto.SlotRekordbox,
1,
res.Beats,
res.DownbeatIndex,
float64(res.Duration)*1000,
res.BPM,
)
return true
}
Loading
Loading