WebSocket (/ws):
- Replaces 2s HTTP polling with persistent WS connection
- Server pushes status updates every 500ms to all clients
- Server pushes FFT spectrum frames at ~30fps
- Bidirectional: client sends commands as JSON ({cmd, delta, level, ...})
- Auto-reconnect on disconnect (3s backoff)
- Status pushed immediately on connect
Visualisation (internal/viz/capture.go):
- WASAPI loopback capture via IAudioClient (same COM approach as volume)
- Captures whatever is playing through the default render device
- 2048-sample Hanning-windowed FFT (pure Go, no deps)
- 64 log-spaced bars, 40Hz-20kHz
- Fast attack / slow decay smoothing per bar
Canvas renderer (app.js):
- requestAnimationFrame loop, DPR-aware resize
- Green->yellow->red HSL gradient by amplitude
- Peak-hold indicators with 1.2%/frame decay
- Graceful: canvas stays dark if no viz data (Winamp paused/stopped)
Architecture:
- internal/server/hub.go: gorilla/websocket hub (register/broadcast/unregister)
- internal/server/server.go: full //go:build windows, REST API kept for debug
- frontend uses WS for all commands, REST only for killist list fetch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
| @@ -3,6 +3,7 @@ module git.svabi.ch/jan/roadamp | |||||
| go 1.25.0 | go 1.25.0 | ||||
| require ( | require ( | ||||
| github.com/gorilla/websocket v1.5.3 // indirect | |||||
| golang.org/x/sys v0.45.0 // indirect | golang.org/x/sys v0.45.0 // indirect | ||||
| gopkg.in/yaml.v3 v3.0.1 // indirect | gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| ) | ) | ||||
| @@ -1,3 +1,5 @@ | |||||
| github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= | |||||
| github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | |||||
| golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= | golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= | ||||
| golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| @@ -0,0 +1,120 @@ | |||||
| //go:build windows | |||||
| package server | |||||
| import ( | |||||
| "log" | |||||
| "time" | |||||
| "github.com/gorilla/websocket" | |||||
| ) | |||||
| const ( | |||||
| writeWait = 10 * time.Second | |||||
| pongWait = 60 * time.Second | |||||
| pingPeriod = pongWait * 9 / 10 | |||||
| maxMessageSize = 512 | |||||
| ) | |||||
| // hub maintains the set of active WebSocket clients and broadcasts messages. | |||||
| type hub struct { | |||||
| clients map[*wsClient]struct{} | |||||
| broadcast chan []byte | |||||
| register chan *wsClient | |||||
| unregister chan *wsClient | |||||
| onCmd func([]byte) // called for each message from any client | |||||
| } | |||||
| func newHub(onCmd func([]byte)) *hub { | |||||
| return &hub{ | |||||
| clients: make(map[*wsClient]struct{}), | |||||
| broadcast: make(chan []byte, 64), | |||||
| register: make(chan *wsClient, 8), | |||||
| unregister: make(chan *wsClient, 8), | |||||
| onCmd: onCmd, | |||||
| } | |||||
| } | |||||
| func (h *hub) run() { | |||||
| for { | |||||
| select { | |||||
| case c := <-h.register: | |||||
| h.clients[c] = struct{}{} | |||||
| case c := <-h.unregister: | |||||
| if _, ok := h.clients[c]; ok { | |||||
| delete(h.clients, c) | |||||
| close(c.send) | |||||
| } | |||||
| case msg := <-h.broadcast: | |||||
| for c := range h.clients { | |||||
| select { | |||||
| case c.send <- msg: | |||||
| default: | |||||
| // Slow client — drop and disconnect. | |||||
| close(c.send) | |||||
| delete(h.clients, c) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| // ── wsClient ────────────────────────────────────────────────────────────────── | |||||
| type wsClient struct { | |||||
| hub *hub | |||||
| conn *websocket.Conn | |||||
| send chan []byte | |||||
| } | |||||
| func (c *wsClient) readPump() { | |||||
| defer func() { | |||||
| c.hub.unregister <- c | |||||
| c.conn.Close() | |||||
| }() | |||||
| c.conn.SetReadLimit(maxMessageSize) | |||||
| c.conn.SetReadDeadline(time.Now().Add(pongWait)) | |||||
| c.conn.SetPongHandler(func(string) error { | |||||
| c.conn.SetReadDeadline(time.Now().Add(pongWait)) | |||||
| return nil | |||||
| }) | |||||
| for { | |||||
| _, msg, err := c.conn.ReadMessage() | |||||
| if err != nil { | |||||
| if websocket.IsUnexpectedCloseError(err, | |||||
| websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { | |||||
| log.Printf("ws read: %v", err) | |||||
| } | |||||
| break | |||||
| } | |||||
| if c.hub.onCmd != nil { | |||||
| c.hub.onCmd(msg) | |||||
| } | |||||
| } | |||||
| } | |||||
| func (c *wsClient) writePump() { | |||||
| ticker := time.NewTicker(pingPeriod) | |||||
| defer func() { | |||||
| ticker.Stop() | |||||
| c.conn.Close() | |||||
| }() | |||||
| for { | |||||
| select { | |||||
| case msg, ok := <-c.send: | |||||
| c.conn.SetWriteDeadline(time.Now().Add(writeWait)) | |||||
| if !ok { | |||||
| c.conn.WriteMessage(websocket.CloseMessage, []byte{}) | |||||
| return | |||||
| } | |||||
| if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { | |||||
| return | |||||
| } | |||||
| case <-ticker.C: | |||||
| c.conn.SetWriteDeadline(time.Now().Add(writeWait)) | |||||
| if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { | |||||
| return | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -1,8 +1,11 @@ | |||||
| // Package server wires together the HTTP API, the Winamp controller, | |||||
| // killist, resume, and the embedded web frontend. | |||||
| //go:build windows | |||||
| // Package server wires together the HTTP API, WebSocket hub, Winamp controller, | |||||
| // WASAPI viz capture, killist, and resume. | |||||
| package server | package server | ||||
| import ( | import ( | ||||
| "context" | |||||
| "encoding/json" | "encoding/json" | ||||
| "fmt" | "fmt" | ||||
| "log" | "log" | ||||
| @@ -15,24 +18,27 @@ import ( | |||||
| "git.svabi.ch/jan/roadamp/internal/killist" | "git.svabi.ch/jan/roadamp/internal/killist" | ||||
| "git.svabi.ch/jan/roadamp/internal/resume" | "git.svabi.ch/jan/roadamp/internal/resume" | ||||
| "git.svabi.ch/jan/roadamp/internal/volume" | "git.svabi.ch/jan/roadamp/internal/volume" | ||||
| "git.svabi.ch/jan/roadamp/internal/viz" | |||||
| "git.svabi.ch/jan/roadamp/internal/winamp" | "git.svabi.ch/jan/roadamp/internal/winamp" | ||||
| "github.com/gorilla/websocket" | |||||
| "gopkg.in/yaml.v3" | "gopkg.in/yaml.v3" | ||||
| ) | ) | ||||
| // Config holds runtime configuration loaded from config.yaml. | |||||
| // ── Config ──────────────────────────────────────────────────────────────────── | |||||
| type Config struct { | type Config struct { | ||||
| Port int `yaml:"port"` | |||||
| WinampPath string `yaml:"winamp_path"` | |||||
| Port int `yaml:"port"` | |||||
| WinampPath string `yaml:"winamp_path"` | |||||
| KillListFile string `yaml:"killist_file"` | KillListFile string `yaml:"killist_file"` | ||||
| ResumeFile string `yaml:"resume_file"` | |||||
| ResumeFile string `yaml:"resume_file"` | |||||
| } | } | ||||
| func loadConfig(path string) (Config, error) { | func loadConfig(path string) (Config, error) { | ||||
| cfg := Config{ | cfg := Config{ | ||||
| Port: 8080, | |||||
| WinampPath: `C:\Program Files\Winamp\Winamp.exe`, | |||||
| Port: 8080, | |||||
| WinampPath: `C:\Program Files\Winamp\Winamp.exe`, | |||||
| KillListFile: "killist.dat", | KillListFile: "killist.dat", | ||||
| ResumeFile: "resume.dat", | |||||
| ResumeFile: "resume.dat", | |||||
| } | } | ||||
| data, err := os.ReadFile(path) | data, err := os.ReadFile(path) | ||||
| if os.IsNotExist(err) { | if os.IsNotExist(err) { | ||||
| @@ -44,12 +50,14 @@ func loadConfig(path string) (Config, error) { | |||||
| return cfg, yaml.Unmarshal(data, &cfg) | return cfg, yaml.Unmarshal(data, &cfg) | ||||
| } | } | ||||
| // Server is the roadamp HTTP server. | |||||
| // ── Server ──────────────────────────────────────────────────────────────────── | |||||
| type Server struct { | type Server struct { | ||||
| cfg Config | |||||
| wa *winamp.Controller | |||||
| kl *killist.KillList | |||||
| mux *http.ServeMux | |||||
| cfg Config | |||||
| wa *winamp.Controller | |||||
| kl *killist.KillList | |||||
| hub *hub | |||||
| mux *http.ServeMux | |||||
| } | } | ||||
| func New(configPath string) (*Server, error) { | func New(configPath string) (*Server, error) { | ||||
| @@ -61,21 +69,17 @@ func New(configPath string) (*Server, error) { | |||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("killist: %w", err) | return nil, fmt.Errorf("killist: %w", err) | ||||
| } | } | ||||
| s := &Server{ | |||||
| cfg: cfg, | |||||
| wa: winamp.New(), | |||||
| kl: kl, | |||||
| mux: http.NewServeMux(), | |||||
| } | |||||
| s := &Server{cfg: cfg, wa: winamp.New(), kl: kl, mux: http.NewServeMux()} | |||||
| s.hub = newHub(s.handleCommand) | |||||
| s.routes() | s.routes() | ||||
| return s, nil | return s, nil | ||||
| } | } | ||||
| func (s *Server) Run() error { | func (s *Server) Run() error { | ||||
| // Attempt to restore resume state on startup. | |||||
| go s.hub.run() | |||||
| go s.broadcastStatus() | |||||
| go s.broadcastViz() | |||||
| go s.restoreResume() | go s.restoreResume() | ||||
| // Background killist checker. | |||||
| go s.killChecker() | go s.killChecker() | ||||
| addr := fmt.Sprintf(":%d", s.cfg.Port) | addr := fmt.Sprintf(":%d", s.cfg.Port) | ||||
| @@ -83,29 +87,161 @@ func (s *Server) Run() error { | |||||
| return http.ListenAndServe(addr, s.mux) | return http.ListenAndServe(addr, s.mux) | ||||
| } | } | ||||
| // ── Routes ────────────────────────────────────────────────────────────────── | |||||
| // ── Routes ──────────────────────────────────────────────────────────────────── | |||||
| func (s *Server) routes() { | func (s *Server) routes() { | ||||
| // Static frontend | |||||
| s.mux.Handle("/", http.FileServer(http.Dir("web/static"))) | s.mux.Handle("/", http.FileServer(http.Dir("web/static"))) | ||||
| // API | |||||
| // WebSocket (primary interface) | |||||
| s.mux.HandleFunc("/ws", s.handleWS) | |||||
| // REST API (kept for curl/debugging) | |||||
| s.mux.HandleFunc("/api/status", s.handleStatus) | s.mux.HandleFunc("/api/status", s.handleStatus) | ||||
| s.mux.HandleFunc("/api/play", s.handlePlay) | s.mux.HandleFunc("/api/play", s.handlePlay) | ||||
| s.mux.HandleFunc("/api/pause", s.handlePause) | s.mux.HandleFunc("/api/pause", s.handlePause) | ||||
| s.mux.HandleFunc("/api/stop", s.handleStop) | s.mux.HandleFunc("/api/stop", s.handleStop) | ||||
| s.mux.HandleFunc("/api/next", s.handleNext) | s.mux.HandleFunc("/api/next", s.handleNext) | ||||
| s.mux.HandleFunc("/api/prev", s.handlePrev) | s.mux.HandleFunc("/api/prev", s.handlePrev) | ||||
| s.mux.HandleFunc("/api/seek", s.handleSeek) // ?delta=±N (seconds) | |||||
| s.mux.HandleFunc("/api/volume", s.handleVolume) // GET | POST ?level=0-100 | |||||
| s.mux.HandleFunc("/api/mute", s.handleMute) // GET | POST ?muted=true|false | |||||
| s.mux.HandleFunc("/api/seek", s.handleSeek) | |||||
| s.mux.HandleFunc("/api/volume", s.handleVolume) | |||||
| s.mux.HandleFunc("/api/mute", s.handleMute) | |||||
| s.mux.HandleFunc("/api/killist", s.handleKillist) | s.mux.HandleFunc("/api/killist", s.handleKillist) | ||||
| s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) | s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) | ||||
| } | } | ||||
| // ── Handlers ───────────────────────────────────────────────────────────────── | |||||
| // ── WebSocket ───────────────────────────────────────────────────────────────── | |||||
| var wsUpgrader = websocket.Upgrader{ | |||||
| ReadBufferSize: 1024, | |||||
| WriteBufferSize: 4096, | |||||
| CheckOrigin: func(r *http.Request) bool { return true }, | |||||
| } | |||||
| func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { | |||||
| conn, err := wsUpgrader.Upgrade(w, r, nil) | |||||
| if err != nil { | |||||
| log.Printf("ws upgrade: %v", err) | |||||
| return | |||||
| } | |||||
| c := &wsClient{hub: s.hub, conn: conn, send: make(chan []byte, 32)} | |||||
| s.hub.register <- c | |||||
| // Send current status immediately upon connect. | |||||
| if msg, err := s.statusMsg(); err == nil { | |||||
| c.send <- msg | |||||
| } | |||||
| go c.writePump() | |||||
| go c.readPump() | |||||
| } | |||||
| // ── WebSocket broadcast workers ─────────────────────────────────────────────── | |||||
| // broadcastStatus pushes a status message to all clients every 500 ms. | |||||
| func (s *Server) broadcastStatus() { | |||||
| for range time.Tick(500 * time.Millisecond) { | |||||
| msg, err := s.statusMsg() | |||||
| if err != nil { | |||||
| continue | |||||
| } | |||||
| select { | |||||
| case s.hub.broadcast <- msg: | |||||
| default: | |||||
| } | |||||
| } | |||||
| } | |||||
| // broadcastViz starts the WASAPI loopback capturer and forwards spectrum | |||||
| // frames to all WebSocket clients at ~30 fps. | |||||
| func (s *Server) broadcastViz() { | |||||
| cap := viz.NewCapturer() | |||||
| ctx, cancel := context.WithCancel(context.Background()) | |||||
| defer cancel() | |||||
| go cap.Start(ctx) | |||||
| type statusResponse struct { | |||||
| for bars := range cap.C { | |||||
| data, err := json.Marshal(struct { | |||||
| Type string `json:"type"` | |||||
| Bars []float32 `json:"bars"` | |||||
| }{"viz", bars}) | |||||
| if err != nil { | |||||
| continue | |||||
| } | |||||
| select { | |||||
| case s.hub.broadcast <- data: | |||||
| default: | |||||
| } | |||||
| } | |||||
| } | |||||
| // ── Command dispatcher (WebSocket) ──────────────────────────────────────────── | |||||
| type wsCommand struct { | |||||
| Cmd string `json:"cmd"` | |||||
| Delta int `json:"delta"` | |||||
| Level int `json:"level"` | |||||
| Muted bool `json:"muted"` | |||||
| Title string `json:"title"` | |||||
| } | |||||
| func (s *Server) handleCommand(raw []byte) { | |||||
| var cmd wsCommand | |||||
| if err := json.Unmarshal(raw, &cmd); err != nil { | |||||
| return | |||||
| } | |||||
| switch cmd.Cmd { | |||||
| case "play": | |||||
| s.wa.Play() | |||||
| case "pause": | |||||
| s.wa.Pause() | |||||
| case "stop": | |||||
| if s.wa.IsPaused() { | |||||
| _ = resume.Save(s.cfg.ResumeFile, resume.State{ | |||||
| PlaylistLength: s.wa.GetPlaylistLength(), | |||||
| PlaylistPos: s.wa.GetPlaylistPosition(), | |||||
| OffsetSeconds: s.wa.GetPosition(), | |||||
| TrackTitle: s.wa.GetTitle(), | |||||
| }) | |||||
| } | |||||
| s.wa.Stop() | |||||
| case "next": | |||||
| s.wa.NextTrack() | |||||
| case "prev": | |||||
| s.wa.PrevTrack() | |||||
| case "seek": | |||||
| pos := s.wa.GetPosition() + cmd.Delta | |||||
| if pos < 0 { | |||||
| pos = 0 | |||||
| } | |||||
| s.wa.Seek(pos) | |||||
| case "volume": | |||||
| _ = volume.Set(cmd.Level) | |||||
| case "mute": | |||||
| _ = volume.SetMute(cmd.Muted) | |||||
| case "killist_add": | |||||
| if title := s.wa.GetTitle(); title != "" { | |||||
| _ = s.kl.Add(title) | |||||
| } | |||||
| case "killist_remove": | |||||
| _ = s.kl.Remove(cmd.Title) | |||||
| case "winamp_start": | |||||
| if !s.wa.IsRunning() { | |||||
| _ = exec.Command(s.cfg.WinampPath).Start() | |||||
| } | |||||
| } | |||||
| // Push a fresh status after any command. | |||||
| if msg, err := s.statusMsg(); err == nil { | |||||
| select { | |||||
| case s.hub.broadcast <- msg: | |||||
| default: | |||||
| } | |||||
| } | |||||
| } | |||||
| // ── Shared status builder ───────────────────────────────────────────────────── | |||||
| type statusMsg struct { | |||||
| Type string `json:"type"` | |||||
| Running bool `json:"running"` | Running bool `json:"running"` | ||||
| State string `json:"state"` | State string `json:"state"` | ||||
| Title string `json:"title"` | Title string `json:"title"` | ||||
| @@ -114,50 +250,92 @@ type statusResponse struct { | |||||
| PlaylistPos int `json:"playlist_pos"` | PlaylistPos int `json:"playlist_pos"` | ||||
| PlaylistLength int `json:"playlist_length"` | PlaylistLength int `json:"playlist_length"` | ||||
| Version string `json:"version"` | Version string `json:"version"` | ||||
| Volume int `json:"volume"` // system master volume 0–100 | |||||
| Volume int `json:"volume"` | |||||
| Muted bool `json:"muted"` | Muted bool `json:"muted"` | ||||
| } | } | ||||
| func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { | |||||
| resp := statusResponse{Running: s.wa.IsRunning()} | |||||
| if resp.Running { | |||||
| func (s *Server) statusMsg() ([]byte, error) { | |||||
| msg := statusMsg{Type: "status", Running: s.wa.IsRunning()} | |||||
| if msg.Running { | |||||
| switch s.wa.PlayState() { | switch s.wa.PlayState() { | ||||
| case 1: | case 1: | ||||
| resp.State = "playing" | |||||
| msg.State = "playing" | |||||
| case 3: | case 3: | ||||
| resp.State = "paused" | |||||
| msg.State = "paused" | |||||
| default: | default: | ||||
| resp.State = "stopped" | |||||
| msg.State = "stopped" | |||||
| } | } | ||||
| resp.Title = s.wa.GetTitle() | |||||
| resp.Position = s.wa.GetPosition() | |||||
| resp.Length = s.wa.GetLength() | |||||
| resp.PlaylistPos = s.wa.GetPlaylistPosition() | |||||
| resp.PlaylistLength = s.wa.GetPlaylistLength() | |||||
| resp.Version = s.wa.GetVersion() | |||||
| msg.Title = s.wa.GetTitle() | |||||
| msg.Position = s.wa.GetPosition() | |||||
| msg.Length = s.wa.GetLength() | |||||
| msg.PlaylistPos = s.wa.GetPlaylistPosition() | |||||
| msg.PlaylistLength = s.wa.GetPlaylistLength() | |||||
| msg.Version = s.wa.GetVersion() | |||||
| } | } | ||||
| // System volume is available regardless of whether Winamp is running. | |||||
| if vol, err := volume.Get(); err == nil { | if vol, err := volume.Get(); err == nil { | ||||
| resp.Volume = vol | |||||
| msg.Volume = vol | |||||
| } | } | ||||
| if muted, err := volume.GetMute(); err == nil { | if muted, err := volume.GetMute(); err == nil { | ||||
| resp.Muted = muted | |||||
| msg.Muted = muted | |||||
| } | } | ||||
| jsonOK(w, resp) | |||||
| return json.Marshal(msg) | |||||
| } | } | ||||
| func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { | |||||
| ok := s.wa.Play() | |||||
| jsonOK(w, map[string]bool{"ok": ok}) | |||||
| // ── Background workers ──────────────────────────────────────────────────────── | |||||
| func (s *Server) killChecker() { | |||||
| for range time.Tick(2 * time.Second) { | |||||
| if !s.wa.IsPlaying() { | |||||
| continue | |||||
| } | |||||
| if title := s.wa.GetTitle(); s.kl.Contains(title) { | |||||
| log.Printf("killist: skipping %q", title) | |||||
| s.wa.NextTrack() | |||||
| } | |||||
| } | |||||
| } | } | ||||
| func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { | |||||
| ok := s.wa.Pause() | |||||
| jsonOK(w, map[string]bool{"ok": ok}) | |||||
| func (s *Server) restoreResume() { | |||||
| deadline := time.Now().Add(30 * time.Second) | |||||
| for time.Now().Before(deadline) { | |||||
| if s.wa.IsRunning() { | |||||
| break | |||||
| } | |||||
| time.Sleep(500 * time.Millisecond) | |||||
| } | |||||
| st, err := resume.Load(s.cfg.ResumeFile) | |||||
| if err != nil || st == nil { | |||||
| return | |||||
| } | |||||
| if s.wa.GetPlaylistLength() != st.PlaylistLength { | |||||
| _ = resume.Delete(s.cfg.ResumeFile) | |||||
| return | |||||
| } | |||||
| s.wa.Play() | |||||
| s.wa.Seek(st.OffsetSeconds) | |||||
| s.wa.Pause() | |||||
| _ = resume.Delete(s.cfg.ResumeFile) | |||||
| log.Printf("resume: restored %q at %ds", st.TrackTitle, st.OffsetSeconds) | |||||
| } | } | ||||
| // ── REST handlers (for curl/debug) ─────────────────────────────────────────── | |||||
| func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { | |||||
| msg, err := s.statusMsg() | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), 500) | |||||
| return | |||||
| } | |||||
| w.Header().Set("Content-Type", "application/json") | |||||
| w.Write(msg) | |||||
| } | |||||
| func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Play()) } | |||||
| func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Pause()) } | |||||
| func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.NextTrack()) } | |||||
| func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.PrevTrack()) } | |||||
| func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { | func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { | ||||
| // Save resume state before stopping. | |||||
| if s.wa.IsPaused() { | if s.wa.IsPaused() { | ||||
| _ = resume.Save(s.cfg.ResumeFile, resume.State{ | _ = resume.Save(s.cfg.ResumeFile, resume.State{ | ||||
| PlaylistLength: s.wa.GetPlaylistLength(), | PlaylistLength: s.wa.GetPlaylistLength(), | ||||
| @@ -166,18 +344,7 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { | |||||
| TrackTitle: s.wa.GetTitle(), | TrackTitle: s.wa.GetTitle(), | ||||
| }) | }) | ||||
| } | } | ||||
| ok := s.wa.Stop() | |||||
| jsonOK(w, map[string]bool{"ok": ok}) | |||||
| } | |||||
| func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) { | |||||
| ok := s.wa.NextTrack() | |||||
| jsonOK(w, map[string]bool{"ok": ok}) | |||||
| } | |||||
| func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) { | |||||
| ok := s.wa.PrevTrack() | |||||
| jsonOK(w, map[string]bool{"ok": ok}) | |||||
| jsonOK(w, s.wa.Stop()) | |||||
| } | } | ||||
| func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) { | func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) { | ||||
| @@ -190,48 +357,33 @@ func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) { | |||||
| if pos < 0 { | if pos < 0 { | ||||
| pos = 0 | pos = 0 | ||||
| } | } | ||||
| ok := s.wa.Seek(pos) | |||||
| jsonOK(w, map[string]bool{"ok": ok}) | |||||
| jsonOK(w, s.wa.Seek(pos)) | |||||
| } | } | ||||
| func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) { | func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) { | ||||
| if r.Method == http.MethodGet { | if r.Method == http.MethodGet { | ||||
| vol, err := volume.Get() | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| vol, _ := volume.Get() | |||||
| jsonOK(w, map[string]int{"volume": vol}) | jsonOK(w, map[string]int{"volume": vol}) | ||||
| return | return | ||||
| } | } | ||||
| lvl, err := strconv.Atoi(r.URL.Query().Get("level")) | lvl, err := strconv.Atoi(r.URL.Query().Get("level")) | ||||
| if err != nil { | if err != nil { | ||||
| http.Error(w, "invalid level (0–100)", http.StatusBadRequest) | |||||
| return | |||||
| } | |||||
| if err := volume.Set(lvl); err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| http.Error(w, "invalid level", http.StatusBadRequest) | |||||
| return | return | ||||
| } | } | ||||
| _ = volume.Set(lvl) | |||||
| jsonOK(w, map[string]int{"volume": lvl}) | jsonOK(w, map[string]int{"volume": lvl}) | ||||
| } | } | ||||
| func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) { | func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) { | ||||
| if r.Method == http.MethodGet { | if r.Method == http.MethodGet { | ||||
| muted, err := volume.GetMute() | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| muted, _ := volume.GetMute() | |||||
| jsonOK(w, map[string]bool{"muted": muted}) | jsonOK(w, map[string]bool{"muted": muted}) | ||||
| return | return | ||||
| } | } | ||||
| val := r.URL.Query().Get("muted") | val := r.URL.Query().Get("muted") | ||||
| muted := val == "true" || val == "1" | muted := val == "true" || val == "1" | ||||
| if err := volume.SetMute(muted); err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| _ = volume.SetMute(muted) | |||||
| jsonOK(w, map[string]bool{"muted": muted}) | jsonOK(w, map[string]bool{"muted": muted}) | ||||
| } | } | ||||
| @@ -245,20 +397,11 @@ func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) { | |||||
| http.Error(w, "no track playing", http.StatusConflict) | http.Error(w, "no track playing", http.StatusConflict) | ||||
| return | return | ||||
| } | } | ||||
| if err := s.kl.Add(title); err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| _ = s.kl.Add(title) | |||||
| jsonOK(w, map[string]string{"added": title}) | jsonOK(w, map[string]string{"added": title}) | ||||
| case http.MethodDelete: | case http.MethodDelete: | ||||
| title := r.URL.Query().Get("title") | |||||
| if err := s.kl.Remove(title); err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| jsonOK(w, map[string]string{"removed": title}) | |||||
| default: | |||||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |||||
| _ = s.kl.Remove(r.URL.Query().Get("title")) | |||||
| jsonOK(w, map[string]bool{"ok": true}) | |||||
| } | } | ||||
| } | } | ||||
| @@ -267,60 +410,13 @@ func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) { | |||||
| jsonOK(w, map[string]string{"status": "already_running"}) | jsonOK(w, map[string]string{"status": "already_running"}) | ||||
| return | return | ||||
| } | } | ||||
| cmd := exec.Command(s.cfg.WinampPath) | |||||
| if err := cmd.Start(); err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| if err := exec.Command(s.cfg.WinampPath).Start(); err != nil { | |||||
| http.Error(w, err.Error(), 500) | |||||
| return | return | ||||
| } | } | ||||
| jsonOK(w, map[string]string{"status": "started"}) | jsonOK(w, map[string]string{"status": "started"}) | ||||
| } | } | ||||
| // ── Background workers ─────────────────────────────────────────────────────── | |||||
| func (s *Server) killChecker() { | |||||
| for range time.Tick(2 * time.Second) { | |||||
| if !s.wa.IsPlaying() { | |||||
| continue | |||||
| } | |||||
| title := s.wa.GetTitle() | |||||
| if s.kl.Contains(title) { | |||||
| log.Printf("killist: skipping %q", title) | |||||
| s.wa.NextTrack() | |||||
| } | |||||
| } | |||||
| } | |||||
| func (s *Server) restoreResume() { | |||||
| // Wait a moment for Winamp to start up. | |||||
| deadline := time.Now().Add(30 * time.Second) | |||||
| for time.Now().Before(deadline) { | |||||
| if s.wa.IsRunning() { | |||||
| break | |||||
| } | |||||
| time.Sleep(500 * time.Millisecond) | |||||
| } | |||||
| if !s.wa.IsRunning() { | |||||
| return | |||||
| } | |||||
| st, err := resume.Load(s.cfg.ResumeFile) | |||||
| if err != nil || st == nil { | |||||
| return | |||||
| } | |||||
| // Only restore if playlist length still matches (same session). | |||||
| if s.wa.GetPlaylistLength() != st.PlaylistLength { | |||||
| _ = resume.Delete(s.cfg.ResumeFile) | |||||
| return | |||||
| } | |||||
| s.wa.Play() | |||||
| s.wa.Seek(st.OffsetSeconds) | |||||
| s.wa.Pause() | |||||
| _ = resume.Delete(s.cfg.ResumeFile) | |||||
| log.Printf("resume: restored %q at %ds", st.TrackTitle, st.OffsetSeconds) | |||||
| } | |||||
| // ── Helpers ─────────────────────────────────────────────────────────────────── | |||||
| func jsonOK(w http.ResponseWriter, v any) { | func jsonOK(w http.ResponseWriter, v any) { | ||||
| w.Header().Set("Content-Type", "application/json") | w.Header().Set("Content-Type", "application/json") | ||||
| json.NewEncoder(w).Encode(v) | json.NewEncoder(w).Encode(v) | ||||
| @@ -0,0 +1,405 @@ | |||||
| //go:build windows | |||||
| // Package viz captures the Windows audio loopback via WASAPI and emits | |||||
| // FFT spectrum data for visualisation in the web frontend. | |||||
| package viz | |||||
| import ( | |||||
| "context" | |||||
| "fmt" | |||||
| "log" | |||||
| "math" | |||||
| "math/cmplx" | |||||
| "syscall" | |||||
| "time" | |||||
| "unsafe" | |||||
| "golang.org/x/sys/windows" | |||||
| ) | |||||
| // NumBars is the number of frequency bars emitted per frame. | |||||
| const NumBars = 64 | |||||
| const ( | |||||
| fftN = 2048 // FFT window size (power of 2) | |||||
| // WASAPI | |||||
| audclntShareModeShared = 0 | |||||
| audclntStreamFlagsLoopback = 0x00020000 | |||||
| audclntBufferFlagsSilent = 0x2 | |||||
| bufDuration = 1_000_000 // 100 ms in 100-ns units | |||||
| // Wave format tags | |||||
| waveFormatPCM = 1 | |||||
| waveFormatFloat = 3 | |||||
| waveFormatExtensibleTag = 0xFFFE | |||||
| ) | |||||
| // ── GUIDs ───────────────────────────────────────────────────────────────────── | |||||
| var ( | |||||
| clsidMMDeviceEnumerator = windows.GUID{ | |||||
| Data1: 0xBCDE0395, Data2: 0xE52F, Data3: 0x467C, | |||||
| Data4: [8]byte{0x8E, 0x3D, 0xC4, 0x57, 0x92, 0x91, 0x69, 0x2E}, | |||||
| } | |||||
| iidIMMDeviceEnumerator = windows.GUID{ | |||||
| Data1: 0xA95664D2, Data2: 0x9614, Data3: 0x4F35, | |||||
| Data4: [8]byte{0xA7, 0x46, 0xDE, 0x8D, 0xB6, 0x36, 0x17, 0xE6}, | |||||
| } | |||||
| iidIAudioClient = windows.GUID{ | |||||
| Data1: 0x1CB9AD4C, Data2: 0xDBFA, Data3: 0x4c32, | |||||
| Data4: [8]byte{0xB1, 0x78, 0xC2, 0xF5, 0x68, 0xA7, 0x03, 0xB2}, | |||||
| } | |||||
| iidIAudioCaptureClient = windows.GUID{ | |||||
| Data1: 0xC8ADBD64, Data2: 0xE71E, Data3: 0x48a0, | |||||
| Data4: [8]byte{0xA4, 0xDE, 0x18, 0x5C, 0x39, 0x5C, 0xD3, 0x17}, | |||||
| } | |||||
| subFormatFloat = windows.GUID{ | |||||
| Data1: 0x00000003, Data2: 0x0000, Data3: 0x0010, | |||||
| Data4: [8]byte{0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71}, | |||||
| } | |||||
| ) | |||||
| // ── WAVEFORMAT structs ──────────────────────────────────────────────────────── | |||||
| type waveFormatEx struct { | |||||
| FormatTag uint16 | |||||
| Channels uint16 | |||||
| SamplesPerSec uint32 | |||||
| AvgBytesPerSec uint32 | |||||
| BlockAlign uint16 | |||||
| BitsPerSample uint16 | |||||
| Size uint16 | |||||
| } | |||||
| type waveFormatExtensibleEx struct { | |||||
| Format waveFormatEx | |||||
| Samples uint16 | |||||
| ChannelMask uint32 | |||||
| SubFormat windows.GUID | |||||
| } | |||||
| // ── DLL procs ───────────────────────────────────────────────────────────────── | |||||
| var ( | |||||
| ole32 = windows.NewLazySystemDLL("ole32.dll") | |||||
| coInitializeEx = ole32.NewProc("CoInitializeEx") | |||||
| coUninitialize = ole32.NewProc("CoUninitialize") | |||||
| coCreateInstance = ole32.NewProc("CoCreateInstance") | |||||
| coTaskMemFree = ole32.NewProc("CoTaskMemFree") | |||||
| ) | |||||
| // ── COM vtable helpers ──────────────────────────────────────────────────────── | |||||
| var ptrSize = unsafe.Sizeof(uintptr(0)) | |||||
| func procAt(comObj uintptr, methodIdx int) uintptr { | |||||
| vtbl := *(*uintptr)(unsafe.Pointer(comObj)) | |||||
| return *(*uintptr)(unsafe.Pointer(vtbl + uintptr(methodIdx)*ptrSize)) | |||||
| } | |||||
| func comRelease(p uintptr) { | |||||
| if p != 0 { | |||||
| syscall.Syscall(procAt(p, 2), 1, p, 0, 0) | |||||
| } | |||||
| } | |||||
| // ── Capturer ────────────────────────────────────────────────────────────────── | |||||
| // Capturer streams FFT spectrum bars from the system audio loopback. | |||||
| type Capturer struct { | |||||
| // C receives slices of NumBars float32 values in [0.0, 1.0] at ~30 fps. | |||||
| // Slow consumers cause frames to be dropped (non-blocking send). | |||||
| C chan []float32 | |||||
| } | |||||
| // NewCapturer creates a Capturer ready to Start. | |||||
| func NewCapturer() *Capturer { | |||||
| return &Capturer{C: make(chan []float32, 4)} | |||||
| } | |||||
| // Start begins the capture loop; blocks until ctx is cancelled. | |||||
| // Errors are logged but never fatal — the channel simply stays empty. | |||||
| func (c *Capturer) Start(ctx context.Context) { | |||||
| if err := c.run(ctx); err != nil { | |||||
| log.Printf("viz: %v", err) | |||||
| } | |||||
| } | |||||
| func (c *Capturer) run(ctx context.Context) error { | |||||
| coInitializeEx.Call(0, 0) // COINIT_MULTITHREADED | |||||
| defer coUninitialize.Call() | |||||
| // ── IMMDeviceEnumerator ────────────────────────────────────────────────── | |||||
| var enumerator uintptr | |||||
| if hr, _, _ := coCreateInstance.Call( | |||||
| uintptr(unsafe.Pointer(&clsidMMDeviceEnumerator)), 0, 0x17, | |||||
| uintptr(unsafe.Pointer(&iidIMMDeviceEnumerator)), | |||||
| uintptr(unsafe.Pointer(&enumerator)), | |||||
| ); hr != 0 { | |||||
| return fmt.Errorf("CoCreateInstance(MMDeviceEnumerator): 0x%08X", hr) | |||||
| } | |||||
| defer comRelease(enumerator) | |||||
| // ── Default render device ──────────────────────────────────────────────── | |||||
| // GetDefaultAudioEndpoint(eRender, eConsole, &device) — vtable index 4, 4 args | |||||
| var device uintptr | |||||
| if hr, _, _ := syscall.Syscall6( | |||||
| procAt(enumerator, 4), 4, | |||||
| enumerator, 0, 0, uintptr(unsafe.Pointer(&device)), 0, 0, | |||||
| ); hr != 0 { | |||||
| return fmt.Errorf("GetDefaultAudioEndpoint: 0x%08X", hr) | |||||
| } | |||||
| defer comRelease(device) | |||||
| // ── IAudioClient ──────────────────────────────────────────────────────── | |||||
| // IMMDevice::Activate(riid, clsCtx, pParams, &ppv) — vtable index 3, 5 args | |||||
| var ac uintptr | |||||
| if hr, _, _ := syscall.Syscall6( | |||||
| procAt(device, 3), 5, | |||||
| device, uintptr(unsafe.Pointer(&iidIAudioClient)), 0x17, 0, | |||||
| uintptr(unsafe.Pointer(&ac)), 0, | |||||
| ); hr != 0 { | |||||
| return fmt.Errorf("Activate(IAudioClient): 0x%08X", hr) | |||||
| } | |||||
| defer comRelease(ac) | |||||
| // ── Mix format ────────────────────────────────────────────────────────── | |||||
| var fmtPtr uintptr | |||||
| if hr, _, _ := syscall.Syscall( | |||||
| procAt(ac, 8), 2, // GetMixFormat | |||||
| ac, uintptr(unsafe.Pointer(&fmtPtr)), 0, | |||||
| ); hr != 0 { | |||||
| return fmt.Errorf("GetMixFormat: 0x%08X", hr) | |||||
| } | |||||
| defer coTaskMemFree.Call(fmtPtr) | |||||
| wfx := (*waveFormatEx)(unsafe.Pointer(fmtPtr)) | |||||
| sampleRate := int(wfx.SamplesPerSec) | |||||
| channels := int(wfx.Channels) | |||||
| isFloat := wfx.FormatTag == waveFormatFloat | |||||
| if wfx.FormatTag == waveFormatExtensibleTag && wfx.Size >= 22 { | |||||
| ext := (*waveFormatExtensibleEx)(unsafe.Pointer(fmtPtr)) | |||||
| isFloat = ext.SubFormat == subFormatFloat | |||||
| } | |||||
| log.Printf("viz: loopback format %d Hz, %d ch, %d bit, float=%v", | |||||
| sampleRate, channels, wfx.BitsPerSample, isFloat) | |||||
| if !isFloat || wfx.BitsPerSample != 32 { | |||||
| return fmt.Errorf("viz: unsupported format (need float32); got tag=%04X bits=%d", | |||||
| wfx.FormatTag, wfx.BitsPerSample) | |||||
| } | |||||
| // ── Initialize loopback ────────────────────────────────────────────────── | |||||
| if hr, _, _ := syscall.Syscall9( | |||||
| procAt(ac, 3), 7, // IAudioClient::Initialize | |||||
| ac, | |||||
| audclntShareModeShared, | |||||
| audclntStreamFlagsLoopback, | |||||
| uintptr(bufDuration), 0, // hnsBufferDuration, hnsPeriodicity | |||||
| fmtPtr, 0, // pFormat, AudioSessionGuid | |||||
| 0, 0, | |||||
| ); hr != 0 { | |||||
| return fmt.Errorf("IAudioClient::Initialize: 0x%08X", hr) | |||||
| } | |||||
| // ── IAudioCaptureClient ────────────────────────────────────────────────── | |||||
| var acc uintptr | |||||
| if hr, _, _ := syscall.Syscall( | |||||
| procAt(ac, 14), 3, // GetService | |||||
| ac, | |||||
| uintptr(unsafe.Pointer(&iidIAudioCaptureClient)), | |||||
| uintptr(unsafe.Pointer(&acc)), | |||||
| ); hr != 0 { | |||||
| return fmt.Errorf("GetService(IAudioCaptureClient): 0x%08X", hr) | |||||
| } | |||||
| defer comRelease(acc) | |||||
| // ── Start ──────────────────────────────────────────────────────────────── | |||||
| if hr, _, _ := syscall.Syscall(procAt(ac, 10), 1, ac, 0, 0); hr != 0 { | |||||
| return fmt.Errorf("IAudioClient::Start: 0x%08X", hr) | |||||
| } | |||||
| defer syscall.Syscall(procAt(ac, 11), 1, ac, 0, 0) // Stop | |||||
| // ── Capture loop ───────────────────────────────────────────────────────── | |||||
| buf := make([]float64, 0, fftN*2) | |||||
| smooth := make([]float32, NumBars) | |||||
| tick := time.NewTicker(10 * time.Millisecond) | |||||
| defer tick.Stop() | |||||
| for { | |||||
| select { | |||||
| case <-ctx.Done(): | |||||
| return nil | |||||
| case <-tick.C: | |||||
| buf = drainLoopback(acc, channels, buf) | |||||
| for len(buf) >= fftN { | |||||
| bars := spectrum(buf[:fftN], sampleRate, smooth) | |||||
| copy(smooth, bars) | |||||
| select { | |||||
| case c.C <- bars: | |||||
| default: | |||||
| } | |||||
| buf = buf[fftN:] | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| // drainLoopback reads all pending audio frames into buf and returns it. | |||||
| func drainLoopback(acc uintptr, channels int, buf []float64) []float64 { | |||||
| for { | |||||
| // GetNextPacketSize | |||||
| var packetFrames uint32 | |||||
| if hr, _, _ := syscall.Syscall( | |||||
| procAt(acc, 5), 2, | |||||
| acc, uintptr(unsafe.Pointer(&packetFrames)), 0, | |||||
| ); hr != 0 || packetFrames == 0 { | |||||
| break | |||||
| } | |||||
| // GetBuffer(ppData, &numFrames, &flags, NULL, NULL) — 6 args | |||||
| var dataPtr uintptr | |||||
| var numFrames uint32 | |||||
| var flags uint32 | |||||
| if hr, _, _ := syscall.Syscall6( | |||||
| procAt(acc, 3), 6, | |||||
| acc, | |||||
| uintptr(unsafe.Pointer(&dataPtr)), | |||||
| uintptr(unsafe.Pointer(&numFrames)), | |||||
| uintptr(unsafe.Pointer(&flags)), | |||||
| 0, 0, | |||||
| ); hr != 0 { | |||||
| break | |||||
| } | |||||
| if flags&audclntBufferFlagsSilent == 0 && dataPtr != 0 && numFrames > 0 { | |||||
| samples := unsafe.Slice((*float32)(unsafe.Pointer(dataPtr)), int(numFrames)*channels) | |||||
| for i := 0; i < int(numFrames); i++ { | |||||
| var mono float64 | |||||
| for ch := 0; ch < channels; ch++ { | |||||
| mono += float64(samples[i*channels+ch]) | |||||
| } | |||||
| buf = append(buf, mono/float64(channels)) | |||||
| } | |||||
| } | |||||
| // ReleaseBuffer | |||||
| syscall.Syscall(procAt(acc, 4), 2, acc, uintptr(numFrames), 0) | |||||
| } | |||||
| return buf | |||||
| } | |||||
| // ── Spectrum analysis ───────────────────────────────────────────────────────── | |||||
| // spectrum applies a Hanning window, runs the FFT, maps to NumBars | |||||
| // log-spaced frequency bins, and applies fast-attack/slow-decay smoothing. | |||||
| func spectrum(samples []float64, sampleRate int, prev []float32) []float32 { | |||||
| n := len(samples) | |||||
| // Hanning window | |||||
| cx := make([]complex128, n) | |||||
| for i, s := range samples { | |||||
| w := 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n-1))) | |||||
| cx[i] = complex(s*w, 0) | |||||
| } | |||||
| ditFFT(cx) | |||||
| // Magnitude of positive frequencies, normalised | |||||
| bins := make([]float64, n/2) | |||||
| scale := 2.0 / float64(n) | |||||
| for i := range bins { | |||||
| bins[i] = cmplx.Abs(cx[i]) * scale | |||||
| } | |||||
| // Log-spaced output bars: 40 Hz → 20 kHz | |||||
| const fMin, fMax = 40.0, 20_000.0 | |||||
| freqRes := float64(sampleRate) / float64(n) | |||||
| bars := make([]float32, NumBars) | |||||
| for b := 0; b < NumBars; b++ { | |||||
| t := float64(b) / float64(NumBars-1) | |||||
| f := fMin * math.Pow(fMax/fMin, t) | |||||
| var fNext float64 | |||||
| if b < NumBars-1 { | |||||
| t2 := float64(b+1) / float64(NumBars-1) | |||||
| fNext = fMin * math.Pow(fMax/fMin, t2) | |||||
| } else { | |||||
| fNext = fMax | |||||
| } | |||||
| lo := clamp(int(f/freqRes), 0, len(bins)-1) | |||||
| hi := clamp(int(fNext/freqRes), lo+1, len(bins)) | |||||
| var sum float64 | |||||
| for i := lo; i < hi; i++ { | |||||
| sum += bins[i] | |||||
| } | |||||
| avg := sum / float64(hi-lo) | |||||
| // dB → [0, 1] | |||||
| dB := 20 * math.Log10(avg+1e-9) | |||||
| norm := float32((dB + 80) / 80) | |||||
| if norm < 0 { | |||||
| norm = 0 | |||||
| } | |||||
| if norm > 1 { | |||||
| norm = 1 | |||||
| } | |||||
| // Fast attack, slow decay | |||||
| if norm > prev[b] { | |||||
| bars[b] = norm | |||||
| } else { | |||||
| bars[b] = prev[b] * 0.88 | |||||
| } | |||||
| } | |||||
| return bars | |||||
| } | |||||
| func clamp(v, lo, hi int) int { | |||||
| if v < lo { | |||||
| return lo | |||||
| } | |||||
| if v > hi { | |||||
| return hi | |||||
| } | |||||
| return v | |||||
| } | |||||
| // ── Cooley-Tukey FFT ───────────────────────────────────────────────────────── | |||||
| // ditFFT is an in-place, decimation-in-time FFT. len(x) must be a power of 2. | |||||
| func ditFFT(x []complex128) { | |||||
| n := len(x) | |||||
| // Bit-reversal permutation | |||||
| j := 0 | |||||
| for i := 1; i < n; i++ { | |||||
| bit := n >> 1 | |||||
| for j&bit != 0 { | |||||
| j ^= bit | |||||
| bit >>= 1 | |||||
| } | |||||
| j ^= bit | |||||
| if i < j { | |||||
| x[i], x[j] = x[j], x[i] | |||||
| } | |||||
| } | |||||
| // Butterfly stages | |||||
| for length := 2; length <= n; length <<= 1 { | |||||
| half := length >> 1 | |||||
| wStep := cmplx.Exp(complex(0, -math.Pi/float64(half))) | |||||
| for i := 0; i < n; i += length { | |||||
| w := complex(1, 0) | |||||
| for k := 0; k < half; k++ { | |||||
| u := x[i+k] | |||||
| v := x[i+k+half] * w | |||||
| x[i+k] = u + v | |||||
| x[i+k+half] = u - v | |||||
| w *= wStep | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -1,14 +1,8 @@ | |||||
| 'use strict'; | 'use strict'; | ||||
| const api = (path, opts = {}) => | |||||
| fetch(path, opts).then(r => r.json()).catch(() => null); | |||||
| // ── State ───────────────────────────────────────────────────────────────────── | |||||
| let currentVolume = 50; // 0–100 (Windows system volume) | |||||
| let pollTimer = null; | |||||
| // ── DOM refs ────────────────────────────────────────────────────────────────── | // ── DOM refs ────────────────────────────────────────────────────────────────── | ||||
| const $ = id => document.getElementById(id); | const $ = id => document.getElementById(id); | ||||
| const statusDot = $('winamp-status'); | const statusDot = $('winamp-status'); | ||||
| const stateLabel = $('state-label'); | const stateLabel = $('state-label'); | ||||
| const trackTitle = $('track-title'); | const trackTitle = $('track-title'); | ||||
| @@ -22,167 +16,231 @@ const btnMute = $('btn-mute'); | |||||
| const btnPlay = $('btn-play'); | const btnPlay = $('btn-play'); | ||||
| const killistPanel = $('killist-panel'); | const killistPanel = $('killist-panel'); | ||||
| const killistItems = $('killist-items'); | const killistItems = $('killist-items'); | ||||
| const canvas = $('viz'); | |||||
| const ctx2d = canvas.getContext('2d'); | |||||
| // ── State ───────────────────────────────────────────────────────────────────── | |||||
| let currentVolume = 50; | |||||
| let ws = null; | |||||
| let reconnectTimer = null; | |||||
| // Viz state | |||||
| const NUM_BARS = 64; | |||||
| const peaks = new Float32Array(NUM_BARS); | |||||
| let lastBars = new Float32Array(NUM_BARS); | |||||
| let rafId = null; | |||||
| // ── WebSocket ───────────────────────────────────────────────────────────────── | |||||
| function connect() { | |||||
| const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | |||||
| ws = new WebSocket(`${proto}://${location.host}/ws`); | |||||
| ws.addEventListener('open', () => { | |||||
| statusDot.className = 'ok'; | |||||
| stateLabel.textContent = 'Verbunden'; | |||||
| clearTimeout(reconnectTimer); | |||||
| }); | |||||
| ws.addEventListener('close', () => { | |||||
| statusDot.className = 'err'; | |||||
| stateLabel.textContent = 'Verbindung unterbrochen…'; | |||||
| ws = null; | |||||
| reconnectTimer = setTimeout(connect, 3000); | |||||
| }); | |||||
| ws.addEventListener('error', () => ws.close()); | |||||
| ws.addEventListener('message', e => { | |||||
| let msg; | |||||
| try { msg = JSON.parse(e.data); } catch { return; } | |||||
| if (msg.type === 'status') applyStatus(msg); | |||||
| if (msg.type === 'viz') applyViz(msg.bars); | |||||
| }); | |||||
| } | |||||
| // ── Playback controls ───────────────────────────────────────────────────────── | |||||
| btnPlay.addEventListener('click', async () => { | |||||
| const st = await api('/api/status'); | |||||
| if (!st) return; | |||||
| if (st.state === 'playing') { | |||||
| await api('/api/pause', { method: 'POST' }); | |||||
| function send(obj) { | |||||
| if (ws && ws.readyState === WebSocket.OPEN) { | |||||
| ws.send(JSON.stringify(obj)); | |||||
| } | |||||
| } | |||||
| // ── Status handler ──────────────────────────────────────────────────────────── | |||||
| function applyStatus(st) { | |||||
| if (!st.running) { | |||||
| statusDot.className = 'err'; | |||||
| stateLabel.textContent = 'Winamp nicht gestartet'; | |||||
| trackTitle.textContent = '–'; | |||||
| playlistPos.textContent = ''; | |||||
| return; | |||||
| } | |||||
| statusDot.className = 'ok'; | |||||
| const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' }; | |||||
| stateLabel.textContent = stateMap[st.state] ?? st.state; | |||||
| trackTitle.textContent = st.title || '–'; | |||||
| playlistPos.textContent = st.playlist_length | |||||
| ? `${st.playlist_pos} / ${st.playlist_length}` : ''; | |||||
| if (st.length > 0) { | |||||
| progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%'; | |||||
| timeCurrent.textContent = fmtTime(st.position); | |||||
| timeLength.textContent = fmtTime(st.length); | |||||
| } else { | } else { | ||||
| await api('/api/play', { method: 'POST' }); | |||||
| progressFill.style.width = '0%'; | |||||
| timeCurrent.textContent = '0:00'; | |||||
| timeLength.textContent = '0:00'; | |||||
| } | } | ||||
| poll(); | |||||
| }); | |||||
| $('btn-stop').addEventListener('click', async () => { | |||||
| await api('/api/stop', { method: 'POST' }); poll(); | |||||
| }); | |||||
| $('btn-next').addEventListener('click', async () => { | |||||
| await api('/api/next', { method: 'POST' }); poll(); | |||||
| }); | |||||
| $('btn-prev').addEventListener('click', async () => { | |||||
| await api('/api/prev', { method: 'POST' }); poll(); | |||||
| btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; | |||||
| if (typeof st.volume === 'number') { | |||||
| currentVolume = st.volume; | |||||
| updateVolumeFill(st.muted); | |||||
| updateMuteBtn(st.muted); | |||||
| } | |||||
| } | |||||
| // ── Controls ────────────────────────────────────────────────────────────────── | |||||
| btnPlay.addEventListener('click', () => { | |||||
| // Optimistic toggle — server will push the real state back immediately. | |||||
| const playing = btnPlay.textContent === '⏸'; | |||||
| send({ cmd: playing ? 'pause' : 'play' }); | |||||
| }); | }); | ||||
| // ── Seek buttons ────────────────────────────────────────────────────────────── | |||||
| $('btn-stop').addEventListener('click', () => send({ cmd: 'stop' })); | |||||
| $('btn-next').addEventListener('click', () => send({ cmd: 'next' })); | |||||
| $('btn-prev').addEventListener('click', () => send({ cmd: 'prev' })); | |||||
| document.querySelectorAll('.btn-seek').forEach(btn => { | document.querySelectorAll('.btn-seek').forEach(btn => { | ||||
| btn.addEventListener('click', async () => { | |||||
| const delta = parseInt(btn.dataset.delta, 10); | |||||
| await api(`/api/seek?delta=${delta}`, { method: 'POST' }); | |||||
| poll(); | |||||
| }); | |||||
| btn.addEventListener('click', () => | |||||
| send({ cmd: 'seek', delta: parseInt(btn.dataset.delta, 10) })); | |||||
| }); | }); | ||||
| // ── Progress bar click-to-seek ──────────────────────────────────────────────── | |||||
| $('progress-bar').addEventListener('click', async e => { | $('progress-bar').addEventListener('click', async e => { | ||||
| // We need current length — read from last status (stored in DOM for now via timeLength). | |||||
| const total = parseTime(timeLength.textContent); | |||||
| if (!total) return; | |||||
| const rect = e.currentTarget.getBoundingClientRect(); | const rect = e.currentTarget.getBoundingClientRect(); | ||||
| const frac = (e.clientX - rect.left) / rect.width; | |||||
| const st = await api('/api/status'); | |||||
| if (!st || !st.length) return; | |||||
| const target = Math.round(frac * st.length); | |||||
| const delta = target - st.position; | |||||
| await api(`/api/seek?delta=${delta}`, { method: 'POST' }); | |||||
| poll(); | |||||
| const target = Math.round((e.clientX - rect.left) / rect.width * total); | |||||
| const current = parseTime(timeCurrent.textContent); | |||||
| send({ cmd: 'seek', delta: target - current }); | |||||
| }); | }); | ||||
| // ── Volume ──────────────────────────────────────────────────────────────────── | // ── Volume ──────────────────────────────────────────────────────────────────── | ||||
| $('btn-vol-up').addEventListener('click', async () => { | |||||
| $('btn-vol-up').addEventListener('click', () => { | |||||
| currentVolume = Math.min(100, currentVolume + 5); | currentVolume = Math.min(100, currentVolume + 5); | ||||
| await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | |||||
| send({ cmd: 'volume', level: currentVolume }); | |||||
| updateVolumeFill(); | updateVolumeFill(); | ||||
| }); | }); | ||||
| $('btn-vol-down').addEventListener('click', async () => { | |||||
| $('btn-vol-down').addEventListener('click', () => { | |||||
| currentVolume = Math.max(0, currentVolume - 5); | currentVolume = Math.max(0, currentVolume - 5); | ||||
| await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | |||||
| send({ cmd: 'volume', level: currentVolume }); | |||||
| updateVolumeFill(); | updateVolumeFill(); | ||||
| }); | }); | ||||
| $('volume-bar').addEventListener('click', async e => { | |||||
| $('volume-bar').addEventListener('click', e => { | |||||
| const rect = e.currentTarget.getBoundingClientRect(); | const rect = e.currentTarget.getBoundingClientRect(); | ||||
| currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100); | currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100); | ||||
| await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | |||||
| send({ cmd: 'volume', level: currentVolume }); | |||||
| updateVolumeFill(); | updateVolumeFill(); | ||||
| }); | }); | ||||
| btnMute.addEventListener('click', async () => { | |||||
| const cur = await api('/api/mute'); | |||||
| const newMuted = !(cur?.muted); | |||||
| await api(`/api/mute?muted=${newMuted}`, { method: 'POST' }); | |||||
| updateMuteBtn(newMuted); | |||||
| btnMute.addEventListener('click', () => { | |||||
| const nowMuted = btnMute.classList.contains('muted'); | |||||
| send({ cmd: 'mute', muted: !nowMuted }); | |||||
| updateMuteBtn(!nowMuted); | |||||
| updateVolumeFill(!nowMuted); | |||||
| }); | }); | ||||
| function updateVolumeFill(muted = false) { | |||||
| volumeFill.style.width = currentVolume + '%'; | |||||
| volumePct.textContent = currentVolume + ' %'; | |||||
| function updateVolumeFill(muted = btnMute.classList.contains('muted')) { | |||||
| volumeFill.style.width = currentVolume + '%'; | |||||
| volumePct.textContent = currentVolume + ' %'; | |||||
| volumeFill.classList.toggle('muted', muted); | volumeFill.classList.toggle('muted', muted); | ||||
| } | } | ||||
| function updateMuteBtn(muted) { | function updateMuteBtn(muted) { | ||||
| btnMute.textContent = muted ? '🔇' : '🔊'; | btnMute.textContent = muted ? '🔇' : '🔊'; | ||||
| btnMute.classList.toggle('muted', muted); | btnMute.classList.toggle('muted', muted); | ||||
| volumeFill.classList.toggle('muted', muted); | |||||
| } | } | ||||
| // ── KillList ────────────────────────────────────────────────────────────────── | // ── KillList ────────────────────────────────────────────────────────────────── | ||||
| $('btn-kill').addEventListener('click', async () => { | |||||
| const res = await api('/api/killist', { method: 'POST' }); | |||||
| if (res?.added) { | |||||
| showToast(`🚫 ${res.added}`); | |||||
| } | |||||
| $('btn-kill').addEventListener('click', () => { | |||||
| send({ cmd: 'killist_add' }); | |||||
| showToast('🚫 Track zur Skip-Liste hinzugefügt'); | |||||
| }); | }); | ||||
| $('btn-show-killist').addEventListener('click', async () => { | $('btn-show-killist').addEventListener('click', async () => { | ||||
| await refreshKillist(); | |||||
| killistPanel.classList.remove('hidden'); | |||||
| }); | |||||
| $('btn-close-killist').addEventListener('click', () => { | |||||
| killistPanel.classList.add('hidden'); | |||||
| }); | |||||
| async function refreshKillist() { | |||||
| const list = await api('/api/killist'); | |||||
| if (!list) return; | |||||
| const list = await fetch('/api/killist').then(r => r.json()).catch(() => []); | |||||
| killistItems.innerHTML = ''; | killistItems.innerHTML = ''; | ||||
| list.forEach(title => { | |||||
| const li = document.createElement('li'); | |||||
| (list || []).forEach(title => { | |||||
| const li = document.createElement('li'); | |||||
| li.innerHTML = `<span>${escHtml(title)}</span>`; | li.innerHTML = `<span>${escHtml(title)}</span>`; | ||||
| const btn = document.createElement('button'); | const btn = document.createElement('button'); | ||||
| btn.textContent = '✕'; | btn.textContent = '✕'; | ||||
| btn.onclick = async () => { | |||||
| await api(`/api/killist?title=${encodeURIComponent(title)}`, { method: 'DELETE' }); | |||||
| await refreshKillist(); | |||||
| btn.onclick = () => { | |||||
| send({ cmd: 'killist_remove', title }); | |||||
| li.remove(); | |||||
| }; | }; | ||||
| li.appendChild(btn); | li.appendChild(btn); | ||||
| killistItems.appendChild(li); | killistItems.appendChild(li); | ||||
| }); | }); | ||||
| killistPanel.classList.remove('hidden'); | |||||
| }); | |||||
| $('btn-close-killist').addEventListener('click', () => | |||||
| killistPanel.classList.add('hidden')); | |||||
| // ── Visualisation (Canvas) ──────────────────────────────────────────────────── | |||||
| function applyViz(bars) { | |||||
| if (!bars || bars.length === 0) return; | |||||
| lastBars = new Float32Array(bars); | |||||
| } | } | ||||
| // ── Polling ─────────────────────────────────────────────────────────────────── | |||||
| async function poll() { | |||||
| const st = await api('/api/status'); | |||||
| if (!st) { | |||||
| statusDot.className = 'err'; | |||||
| statusDot.textContent = '●'; | |||||
| stateLabel.textContent = 'Keine Verbindung'; | |||||
| trackTitle.textContent = '–'; | |||||
| return; | |||||
| } | |||||
| function renderFrame() { | |||||
| rafId = requestAnimationFrame(renderFrame); | |||||
| if (!st.running) { | |||||
| statusDot.className = 'err'; | |||||
| stateLabel.textContent = 'Winamp nicht gestartet'; | |||||
| trackTitle.textContent = '–'; | |||||
| return; | |||||
| // Resize canvas to CSS size (handles window resize / DPR) | |||||
| const dpr = window.devicePixelRatio || 1; | |||||
| const cssW = canvas.clientWidth; | |||||
| const cssH = canvas.clientHeight; | |||||
| if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) { | |||||
| canvas.width = cssW * dpr; | |||||
| canvas.height = cssH * dpr; | |||||
| ctx2d.scale(dpr, dpr); | |||||
| } | } | ||||
| statusDot.className = 'ok'; | |||||
| const stateMap = { playing: '▶ Spielt', paused: '⏸ Pause', stopped: '⏹ Stop' }; | |||||
| stateLabel.textContent = stateMap[st.state] ?? st.state; | |||||
| const w = cssW; | |||||
| const h = cssH; | |||||
| const n = lastBars.length || NUM_BARS; | |||||
| trackTitle.textContent = st.title || '–'; | |||||
| playlistPos.textContent = st.playlist_length | |||||
| ? `${st.playlist_pos} / ${st.playlist_length}` | |||||
| : ''; | |||||
| // Background | |||||
| ctx2d.fillStyle = '#000'; | |||||
| ctx2d.fillRect(0, 0, w, h); | |||||
| if (st.length > 0) { | |||||
| progressFill.style.width = (st.position / st.length * 100).toFixed(1) + '%'; | |||||
| timeCurrent.textContent = fmtTime(st.position); | |||||
| timeLength.textContent = fmtTime(st.length); | |||||
| } else { | |||||
| progressFill.style.width = '0%'; | |||||
| } | |||||
| const gap = 1; | |||||
| const barW = (w - gap * (n - 1)) / n; | |||||
| // Reflect play/pause state on button | |||||
| btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; | |||||
| for (let i = 0; i < n; i++) { | |||||
| const val = lastBars[i] || 0; | |||||
| // System volume (always present in status response) | |||||
| if (typeof st.volume === 'number') { | |||||
| currentVolume = st.volume; | |||||
| updateVolumeFill(st.muted); | |||||
| updateMuteBtn(st.muted); | |||||
| } | |||||
| } | |||||
| // Peak: fast rise, slow fall (2% per frame) | |||||
| if (val > peaks[i]) peaks[i] = val; | |||||
| else peaks[i] = Math.max(0, peaks[i] - 0.012); | |||||
| function startPolling(intervalMs = 2000) { | |||||
| if (pollTimer) clearInterval(pollTimer); | |||||
| poll(); | |||||
| pollTimer = setInterval(poll, intervalMs); | |||||
| const x = i * (barW + gap); | |||||
| const barH = val * h; | |||||
| // Bar colour: green (120°) → yellow (60°) → red (0°) based on amplitude | |||||
| const hue = Math.round((1 - val) * 120); | |||||
| ctx2d.fillStyle = `hsl(${hue},100%,42%)`; | |||||
| ctx2d.fillRect(x, h - barH, barW, barH); | |||||
| // Peak indicator — thin white line | |||||
| if (peaks[i] > 0.02) { | |||||
| const py = h - peaks[i] * h - 1; | |||||
| ctx2d.fillStyle = 'rgba(255,255,255,0.75)'; | |||||
| ctx2d.fillRect(x, py, barW, 2); | |||||
| } | |||||
| } | |||||
| } | } | ||||
| // ── Helpers ─────────────────────────────────────────────────────────────────── | // ── Helpers ─────────────────────────────────────────────────────────────────── | ||||
| @@ -192,21 +250,27 @@ function fmtTime(secs) { | |||||
| return `${m}:${s}`; | return `${m}:${s}`; | ||||
| } | } | ||||
| function escHtml(str) { | |||||
| return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); | |||||
| function parseTime(str) { | |||||
| const [m, s] = (str || '0:00').split(':').map(Number); | |||||
| return m * 60 + (s || 0); | |||||
| } | |||||
| function escHtml(s) { | |||||
| return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | |||||
| } | } | ||||
| let toastTimer; | let toastTimer; | ||||
| function showToast(msg) { | function showToast(msg) { | ||||
| let el = document.getElementById('toast'); | |||||
| let el = $('toast'); | |||||
| if (!el) { | if (!el) { | ||||
| el = document.createElement('div'); | el = document.createElement('div'); | ||||
| el.id = 'toast'; | el.id = 'toast'; | ||||
| el.style.cssText = ` | |||||
| position:fixed;bottom:24px;left:50%;transform:translateX(-50%); | |||||
| background:#333;color:#fff;padding:10px 20px;border-radius:8px; | |||||
| font-size:14px;z-index:999;opacity:0;transition:opacity .2s; | |||||
| `; | |||||
| el.style.cssText = [ | |||||
| 'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)', | |||||
| 'background:#333', 'color:#fff', 'padding:10px 20px', 'border-radius:8px', | |||||
| 'font-size:14px', 'z-index:999', 'opacity:0', 'transition:opacity .2s', | |||||
| 'pointer-events:none', | |||||
| ].join(';'); | |||||
| document.body.appendChild(el); | document.body.appendChild(el); | ||||
| } | } | ||||
| el.textContent = msg; | el.textContent = msg; | ||||
| @@ -216,4 +280,5 @@ function showToast(msg) { | |||||
| } | } | ||||
| // ── Boot ────────────────────────────────────────────────────────────────────── | // ── Boot ────────────────────────────────────────────────────────────────────── | ||||
| startPolling(2000); | |||||
| connect(); | |||||
| renderFrame(); | |||||
| @@ -18,6 +18,8 @@ | |||||
| <div id="playlist-pos"></div> | <div id="playlist-pos"></div> | ||||
| </div> | </div> | ||||
| <canvas id="viz" height="80"></canvas> | |||||
| <div id="progress-wrap"> | <div id="progress-wrap"> | ||||
| <div id="progress-bar"> | <div id="progress-bar"> | ||||
| <div id="progress-fill"></div> | <div id="progress-fill"></div> | ||||
| @@ -62,6 +62,15 @@ html, body { | |||||
| margin-top: 4px; | margin-top: 4px; | ||||
| } | } | ||||
| /* Visualisation canvas */ | |||||
| #viz { | |||||
| width: 100%; | |||||
| height: 80px; | |||||
| border-radius: var(--radius); | |||||
| background: #000; | |||||
| display: block; | |||||
| } | |||||
| /* Progress */ | /* Progress */ | ||||
| #progress-wrap { | #progress-wrap { | ||||
| display: flex; | display: flex; | ||||