Skip to content

parked: New npm package @huberp/scope-bridge — UDP listener, state accumulator, browser visualiser, agentloop tool, CI/release workflows #18

Description

@huberp

Context

phu-beat-sync-multi-scope broadcasts audio and control state as UDP multicast packets on 239.255.42.1 (localhost only). This issue creates a standalone TypeScript npm package that listens to both broadcast channels, accumulates audio and identity state per instance, exposes a clean programmatic API, ships a browser-based oscilloscope visualiser as a showcase, and provides a ready-made tool for huberp/agentloop.

Related: phu-beat-sync-multi-scope #17


Protocol reference

The plugin sends two packet types on separate ports, both #pragma pack(push,1), little-endian, multicast group 239.255.42.1.

SMPLRawSamplesPacket (port 49422)

Source of truth: lib/network/SampleBroadcaster.h

Offset  Size  Type      Field
0       4     uint32    magic = 0x534D504C ("SMPL")
4       4     uint32    version = 3
8       4     uint32    instanceID
12      4     uint32    sequenceNumber  (monotonic, wraps)
16      8     float64   ppqOfFirstSample
24      8     float64   bpm
32      4     float32   displayRangeBeats
36      2     uint16    numSamples  (≤ 1470)
38      numSamples×4  float32[]  samples  (mono, [-1,+1])

Sent at ~30 Hz while the DAW is playing. RawSamplesPacket does not carry sampleRate — this is resolved by the CTRL channel (see below).

CTRLCtrlPacket (port 49423)

Source of truth: lib/network/CtrlBroadcaster.h

Offset  Size  Type      Field
0       4     uint32    magic = 0x4354524C ("CTRL")
4       4     uint32    version = 1
8       4     uint32    instanceID
12      4     uint32    sequenceNumber  (monotonic)
16      1     uint8     eventType  (1=Announce, 2=LabelChange, 3=RangeChange, 4=Goodbye)
17      3     uint8[3]  _pad
20      8     float64   sampleRate
28      4     uint32    maxBufferSize
32      4     float32   displayRangeBeats
36      4     float32   bpm
40      32    char[32]  channelLabel  (null-terminated UTF-8)
72      4     uint8[4]  colourRGBA

Total: 76 bytes. Sent on meaningful state changes and as an explicit 5-second heartbeat (Announce) regardless of DAW playback state. Even when no audio is playing, CTRL heartbeats keep instance identity alive on all peers.

CTRL is the authoritative source for sampleRate. ASSUMED_SAMPLE_RATE = 44100 is used only as a fallback until the first Announce is received for a given instance.

Protocol version dependency: if either C++ struct changes, the TypeScript parsers in this package must be updated in lockstep. The .github/copilot-instructions.md documents this coupling explicitly.


Port allocation summary

Port Magic Payload Rate
49422 SMPL Audio samples (~6 KB) ~30 Hz while DAW playing
49423 CTRL Instance identity (~76 bytes) On-change + 5 s heartbeat

Repository structure

Create a new repository huberp/scope-bridge with the following layout:

scope-bridge/
├── .github/
│   ├── copilot-instructions.md
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── src/
│   ├── protocol/
│   │   ├── PacketParser.ts        ← SMPL binary parser
│   │   └── CtrlPacketParser.ts    ← CTRL binary parser
│   ├── bridge/
│   │   ├── ScopeBridge.ts         ← dual-socket UDP listener + state accumulator
│   │   └── ScopeState.ts          ← state types (audio + identity)
│   ├── tools/
│   │   └── audioScopeTool.ts      ← agentloop-compatible LangChain tool
│   ├── viewer/
│   │   ├── ViewerServer.ts        ← Express + WebSocket bridge
│   │   └── public/
│   │       └── index.html         ← canvas oscilloscope (single file, no bundler)
│   └── index.ts                   ← public API re-exports
├── tests/
│   ├── PacketParser.test.ts
│   ├── CtrlPacketParser.test.ts
│   ├── ScopeBridge.test.ts
│   └── fixtures/
│       ├── mockPacket.ts          ← SMPL packet builder
│       └── mockCtrlPacket.ts      ← CTRL packet builder
├── bin/
│   └── viewer.ts
├── package.json
├── tsconfig.json
├── tsconfig.build.json
├── jest.config.js
├── .eslintrc.json
├── .gitignore
└── README.md

Data flow

flowchart TD
    subgraph PLUGIN["phu-beat-sync-multi-scope (JUCE C++)"]
        SB["SampleBroadcaster\n~30 Hz audio\nport 49422"]
        CB["CtrlBroadcaster\non-change + 5 s heartbeat\nport 49423"]
    end

    subgraph BRIDGE["@huberp/scope-bridge"]
        SUDP["dgram socket\nbind 0.0.0.0:49422\njoin 239.255.42.1"]
        CUDP["dgram socket\nbind 0.0.0.0:49423\njoin 239.255.42.1"]
        PARSE["PacketParser (SMPL)"]
        CPARSE["CtrlPacketParser (CTRL)"]
        STATE["ScopeBridge (singleton)\nScopeState per instanceID\n• accumBins, rmsAccum, cancelAccum\n• channelLabel, colourRGBA\n• sampleRate, isOnline"]
        SUDP --> PARSE --> STATE
        CUDP --> CPARSE --> STATE
    end

    subgraph CONSUMERS["Consumers"]
        V["ViewerServer\nExpress + ws"]
        T["audioScopeTool\nLangChain tool for agentloop"]
        API["Programmatic API\ngetState / on('packet') / on('ctrl') / on('state')"]
    end

    subgraph BROWSER["Browser tab\nlocalhost:8080"]
        CANVAS["index.html\n<canvas> oscilloscope\nwaveform + RMS bars\ncancellation heatmap\nper-instance labels + colours"]
    end

    SB -->|RawSamplesPacket| SUDP
    CB -->|CtrlPacket| CUDP
    STATE --> V
    STATE --> T
    STATE --> API
    V -->|"ws://localhost:8080"| CANVAS
Loading

Implementation requirements

src/protocol/PacketParser.ts

  • Export SMPL_MAGIC = 0x534D504C, PROTOCOL_VERSION = 3, HEADER_SIZE = 38, MAX_SAMPLES = 1470.
  • Export ASSUMED_SAMPLE_RATE = 44100 — used as fallback until CTRL/Announce delivers the real value. Documented as a known limitation. Appears exactly once in the codebase.
  • Export interface ParsedPacket { magic, version, instanceID, sequenceNumber, ppqOfFirstSample, bpm, displayRangeBeats, numSamples, samples: Float32Array }.
  • Export parsePacket(buf: Buffer): ParsedPacket | null. Returns null if: buffer shorter than HEADER_SIZE, magic mismatch, version mismatch, numSamples > MAX_SAMPLES, or buffer shorter than HEADER_SIZE + numSamples * 4.
  • Use buf.readUInt32LE, buf.readDoubleLE, buf.readFloatLE — no external deps.
  • Per-sample PPQ helper:
    export function samplePpq(packet: ParsedPacket, i: number, sampleRate?: number): number {
      return packet.ppqOfFirstSample + i * (packet.bpm / (60.0 * (sampleRate ?? ASSUMED_SAMPLE_RATE)));
    }
    The optional sampleRate argument is used by ScopeBridge once a CTRL/Announce has been received for the instance.

src/protocol/CtrlPacketParser.ts

  • Export CTRL_MAGIC = 0x4354524C, CTRL_VERSION = 1, CTRL_PACKET_SIZE = 76.
  • Export enum:
    export enum CtrlEventType { Announce = 1, LabelChange = 2, RangeChange = 3, Goodbye = 4 }
  • Export interface:
    export interface ParsedCtrlPacket {
      magic:             number;
      version:           number;
      instanceID:        number;
      sequenceNumber:    number;
      eventType:         CtrlEventType;
      sampleRate:        number;
      maxBufferSize:     number;
      displayRangeBeats: number;
      bpm:               number;
      channelLabel:      string;   // decoded from null-terminated UTF-8 at offset 40
      colourRGBA:        [number, number, number, number];
    }
  • Export parseCtrlPacket(buf: Buffer): ParsedCtrlPacket | null. Returns null if: buffer shorter than CTRL_PACKET_SIZE, magic mismatch, version mismatch, or eventType not in 1..4.
  • Read channelLabel as buf.subarray(40, 72), find first null byte, decode as UTF-8.
  • No external deps — buf.readUInt32LE, buf.readDoubleLE, buf.readFloatLE, buf.readUInt8.

src/bridge/ScopeState.ts

export interface ScopeState {
  // --- populated from SMPL packets ---
  instanceID:        number;
  lastSeenMs:        number;       // Date.now() of last SMPL packet
  sequenceNumber:    number;
  ppq:               number;       // PPQ of most recent sample in latest SMPL packet
  bpm:               number;
  displayRangeBeats: number;
  accumBins:         Float32Array; // [4096] — scatter-written, last-write-wins
  rmsAccum:          Float64Array; // [128]  — sum of squares per 1/128 beat slot
  rmsCount:          Uint32Array;  // [128]
  cancelAccum:       Float64Array; // [256]  — sum of squares per 1/256 beat slot
  cancelCount:       Uint32Array;  // [256]
  gapCount:          number;       // total SMPL sequence gaps detected

  // --- populated from CTRL packets ---
  channelLabel:      string;                          // "" until first CTRL received
  colourRGBA:        [number, number, number, number]; // [0x88,0x88,0x88,0xFF] default
  sampleRate:        number;                          // ASSUMED_SAMPLE_RATE until first CTRL/Announce
  maxBufferSize:     number;
  isOnline:          boolean;    // false on Goodbye; true on Announce/LabelChange/RangeChange
  ctrlLastSeenMs:    number;     // Date.now() of last CTRL packet
}

export function rmsSlots(state: ScopeState): Float32Array   // sqrt(accum/count) per slot
export function cancelSlots(state: ScopeState): Float32Array
export function clearCycle(state: ScopeState): void          // zero accumulators on beat boundary

src/bridge/ScopeBridge.ts

Singleton ScopeBridge class extending EventEmitter.

Constructor: new ScopeBridge({ smplPort?: number, ctrlPort?: number, multicastGroup?: string, staleTtlMs?: number }) — defaults: smplPort=49422, ctrlPort=49423, multicastGroup='239.255.42.1', staleTtlMs=15000.

start(): void — opens two independent dgram sockets:

  • SMPL socket: setReuseAddress(true), bind 0.0.0.0:smplPort, addMembership(group, '127.0.0.1')
  • CTRL socket: setReuseAddress(true), bind 0.0.0.0:ctrlPort, addMembership(group, '127.0.0.1')
  • Starts a stale-prune interval (every 3 s).

stop(): void — drops both memberships, closes both sockets, clears interval.

On each SMPL packet:

  • Call parsePacket(buf), discard null.
  • Look up or create ScopeState for instanceID.
  • Update lastSeenMs, sequenceNumber, bpm, displayRangeBeats, ppq.
  • Log sequence gaps, increment gapCount.
  • Use state.sampleRate (not ASSUMED_SAMPLE_RATE) in samplePpq() once set from CTRL.
  • For each sample i in packet.samples[0..numSamples-1]:
    • ppq_i = samplePpq(packet, i, state.sampleRate)
    • normPos = fmod(ppq_i, displayRangeBeats) / displayRangeBeats
    • bin = Math.floor(normPos * 4096)state.accumBins[bin] = sample
    • slot128 = Math.floor(normPos * 128)state.rmsAccum[slot] += sample²; state.rmsCount[slot]++
    • slot256 = Math.floor(normPos * 256)state.cancelAccum[slot] += sample²; state.cancelCount[slot]++
    • Detect cycle boundary: if fmod(ppq_i, displayRangeBeats) decreases vs previous sample, call clearCycle(state) before accumulating.
  • Emit 'packet' (ParsedPacket).
  • Emit 'state' (ScopeState).

On each CTRL packet:

  • Call parseCtrlPacket(buf), discard null.
  • Look up or create ScopeState for instanceID.
  • On Announce, LabelChange, RangeChange: update channelLabel, colourRGBA, sampleRate, maxBufferSize, displayRangeBeats, bpm, set isOnline = true, update ctrlLastSeenMs.
  • On Goodbye: set isOnline = false immediately. Do not remove the entry — keep it so consumers can display an offline state until the audio stale TTL also expires.
  • Emit 'ctrl' (ParsedCtrlPacket).

Stale pruning: an instance is removed from the state map only when both lastSeenMs (SMPL) and ctrlLastSeenMs (CTRL) have exceeded staleTtlMs. This means:

  • A paused-DAW instance (no SMPL, CTRL heartbeat active every 5 s) stays visible as online.
  • A crashed instance disappears after staleTtlMs of silence on both channels (~15 s).

Public query methods:

  • getState(instanceID?: number): ScopeState | undefined — returns state for given ID or first active.
  • getActiveInstances(): number[] — instanceIDs active on either channel within staleTtlMs.
  • getSnapshot(): Map<number, ScopeState> — deep-copy snapshot of all active states.

src/viewer/ViewerServer.ts

  • Starts Express serving src/viewer/public/ at http://localhost:PORT (default 8080).
  • Starts a ws WebSocket server on the same port.
  • Uses ScopeBridge internally. On 'state' event, serialises a snapshot and pushes to all connected WebSocket clients as JSON:
    {
      "instances": [
        {
          "instanceID":        1234,
          "bpm":               120.0,
          "ppq":               3.14,
          "sampleRate":        48000,
          "channelLabel":      "Kick",
          "colourRGBA":        [255, 80, 80, 255],
          "isOnline":          true,
          "accumBins":         [...128 values, downsampled from 4096, every 32nd bin...],
          "rmsSlots":          [...128 values...],
          "cancelSlots":       [...128 values...]
        }
      ]
    }
  • Instances with isOnline = false are included (so the browser can dim them). Instances are omitted only after being pruned from ScopeBridge.
  • ViewerServer tracks a dirty flag per instance and only pushes on actual state changes.

src/viewer/public/index.html

Single self-contained HTML file, no bundler, no framework, vanilla JS + <canvas>. Implements:

  • WebSocket client — connects to ws://localhost:8080, parses JSON, updates display. Reconnects on close (exponential backoff, max 5 s).
  • Waveform canvas — one lane per instance. Draws accumBins (128 points) as a beat-synchronised oscilloscope. Lane stroke colour from colourRGBA (fall back to palette if no CTRL received yet, grey #888888 if isOnline = false).
  • Lane header — renders channelLabel as text at the top-left of each lane. Falls back to Instance ${instanceID} if label is empty. Appends " (offline)" and dims to 40% opacity if isOnline = false.
  • RMS bar chart — 128 bars per instance below the waveform, height = rmsSlots[s], coloured by colourRGBA.
  • Cancellation heatmap — 128 cells per instance pair, colour mapped from cancelSlots (0=green, 1=red). Only shown when ≥ 2 instances active.
  • Status bar — active instance count, BPM, PPQ, sampleRate (shown as "? Hz" until first CTRL received), gap count per instance.
  • Total size: target < 300 lines including styles.

src/tools/audioScopeTool.ts

agentloop-compatible LangChain tool. Peer dependencies: @langchain/core, zod — NOT production deps, only peerDependencies.

import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { ScopeBridge } from "../bridge/ScopeBridge";
import { rmsSlots, cancelSlots } from "../bridge/ScopeState";

export function createAudioScopeTool(bridge: ScopeBridge) {
  return tool(
    async ({ instanceId, metric }) => { ... },
    {
      name: "audioScope",
      description: "Query live audio metrics from phu-beat-sync-multi-scope plugin instances. " +
        "Returns waveform, rms, cancellation, bpm, ppq, instances list, label, or identity.",
      schema: z.object({
        instanceId: z.number().optional(),
        metric: z.enum(["waveform", "rms", "cancellation", "bpm", "ppq", "instances", "label", "identity"]),
      }),
    }
  );
}

Metric return values:

  • waveformaccumBins downsampled to 128 as JSON array
  • rmsrmsSlots(state) as JSON array
  • cancellationcancelSlots(state) as JSON array
  • bpm — string
  • ppq — string with interpretation: "ppq: 3.25 (beat 4 of bar 1 at 120bpm)"
  • instances — JSON array of active instanceIDs with bpm, ppq, channelLabel, isOnline
  • labelstate.channelLabel or "(no label)" if empty
  • identity — JSON: { instanceID, channelLabel, colourRGBA, sampleRate, isOnline }

All metrics that use samplePpq() internally pass state.sampleRate.

src/index.ts — public API

export { ScopeBridge } from './bridge/ScopeBridge';
export type { ScopeState } from './bridge/ScopeState';
export { rmsSlots, cancelSlots, clearCycle } from './bridge/ScopeState';
export {
  parsePacket, SMPL_MAGIC, PROTOCOL_VERSION, HEADER_SIZE,
  MAX_SAMPLES, ASSUMED_SAMPLE_RATE, samplePpq
} from './protocol/PacketParser';
export {
  parseCtrlPacket, CTRL_MAGIC, CTRL_VERSION, CTRL_PACKET_SIZE, CtrlEventType
} from './protocol/CtrlPacketParser';
export type { ParsedCtrlPacket } from './protocol/CtrlPacketParser';
export { ViewerServer } from './viewer/ViewerServer';
export { createAudioScopeTool } from './tools/audioScopeTool';

bin/viewer.ts

CLI entry point. Parses --port arg, instantiates ViewerServer, starts it, opens http://localhost:PORT in the default browser (open package or child_process.exec), handles SIGINT for graceful shutdown.

Usage: npx @huberp/scope-bridge [--port 8080]

package.json

{
  "name": "@huberp/scope-bridge",
  "version": "0.1.0",
  "description": "UDP listener and state bridge for phu-beat-sync-multi-scope VST3/AU plugin broadcasts",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "bin": { "scope-bridge": "dist/bin/viewer.js" },
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "build:clean": "rm -rf dist && npm run build",
    "start": "tsx bin/viewer.ts",
    "test": "jest",
    "test:coverage": "jest --coverage",
    "lint": "eslint src tests"
  },
  "dependencies": {
    "express": "^4.18.0",
    "ws": "^8.16.0"
  },
  "peerDependencies": {
    "@langchain/core": ">=0.2.0",
    "zod": ">=3.0.0"
  },
  "peerDependenciesMeta": {
    "@langchain/core": { "optional": true },
    "zod": { "optional": true }
  },
  "devDependencies": {
    "@types/express": "^4.17.0",
    "@types/ws": "^8.5.0",
    "@types/jest": "^29.0.0",
    "@types/node": "^20.0.0",
    "jest": "^29.0.0",
    "ts-jest": "^29.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.4.0"
  },
  "keywords": ["audio", "vst3", "juce", "udp", "multicast", "oscilloscope", "daw"],
  "license": "MIT"
}

CI workflow — .github/workflows/ci.yml

name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run lint
      - run: npm test -- --coverage
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: dist/ }

Release workflow — .github/workflows/release.yml

name: Release
on:
  push:
    tags: ['v*.*.*']
jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'
          cache: 'npm'
      - run: npm ci
      - run: npm test
      - run: npm run build
      - run: npm publish --access public --provenance
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      - uses: actions/create-release@v1
        env: { GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }
        with:
          tag_name: ${{ github.ref_name }}
          release_name: ${{ github.ref_name }}
          draft: false
          prerelease: false

.github/copilot-instructions.md

# Copilot Instructions — @huberp/scope-bridge

## What this package does
Listens to UDP multicast packets sent by the `phu-beat-sync-multi-scope` VST3/AU plugin
(JUCE C++, repo: https://github.com/huberp/phu-beat-sync-multi-scope).
Parses both SMPL (audio) and CTRL (identity) broadcasts, accumulates per-instance state,
and exposes it via a programmatic API, browser visualiser, and agentloop-compatible tool.

## Two packet channels — CRITICAL

### SMPL (audio) — port 49422
Source of truth: `lib/network/SampleBroadcaster.h``struct RawSamplesPacket`
TypeScript mirror: `src/protocol/PacketParser.ts`

| Offset | Size | Type    | Field               |
|--------|------|---------|---------------------|
| 0      | 4    | uint32  | magic = 0x534D504C  |
| 4      | 4    | uint32  | version = 3         |
| 8      | 4    | uint32  | instanceID          |
| 12     | 4    | uint32  | sequenceNumber      |
| 16     | 8    | float64 | ppqOfFirstSample    |
| 24     | 8    | float64 | bpm                 |
| 32     | 4    | float32 | displayRangeBeats   |
| 36     | 2    | uint16  | numSamples          |
| 38     | n×4  | float32 | samples[numSamples] |

### CTRL (identity) — port 49423
Source of truth: `lib/network/CtrlBroadcaster.h``struct CtrlPacket`
TypeScript mirror: `src/protocol/CtrlPacketParser.ts`

| Offset | Size | Type     | Field              |
|--------|------|----------|--------------------|
| 0      | 4    | uint32   | magic = 0x4354524C |
| 4      | 4    | uint32   | version = 1        |
| 8      | 4    | uint32   | instanceID         |
| 12     | 4    | uint32   | sequenceNumber     |
| 16     | 1    | uint8    | eventType          |
| 17     | 3    | uint8[3] | _pad               |
| 20     | 8    | float64  | sampleRate         |
| 28     | 4    | uint32   | maxBufferSize      |
| 32     | 4    | float32  | displayRangeBeats  |
| 36     | 4    | float32  | bpm                |
| 40     | 32   | char[32] | channelLabel       |
| 72     | 4    | uint8[4] | colourRGBA         |

**Both parsers must be kept in sync with their respective C++ structs at all times.**

## sampleRate resolution
`ASSUMED_SAMPLE_RATE = 44100` is the initial value of `state.sampleRate` and the fallback
in `samplePpq()`. Once a `CTRL/Announce` packet is received for a given instanceID,
`state.sampleRate` is updated and `samplePpq()` uses the real value from that point on.
`ASSUMED_SAMPLE_RATE` appears exactly once in the codebase.

## CTRL heartbeat semantics
CTRL packets are sent by the plugin every 5 seconds as an `Announce` heartbeat,
even when the DAW is paused (no SMPL packets). An instance is only pruned from
ScopeBridge when BOTH `lastSeenMs` (SMPL) AND `ctrlLastSeenMs` (CTRL) have exceeded
`staleTtlMs`. A `Goodbye` event sets `isOnline = false` immediately without pruning.

## State accumulation
`ScopeBridge` accumulates per-sample into three buffers per instance:
- `accumBins[4096]`  — last-write-wins scatter for waveform display
- `rmsAccum[128]`    — sum-of-squares per 1/128 beat slot for RMS
- `cancelAccum[256]` — sum-of-squares per 1/256 beat slot for cancellation

Per-sample PPQ: `ppq_i = ppqOfFirstSample + i * (bpm / (60.0 * state.sampleRate))`

Cycle boundary: when `fmod(ppq_i, displayRangeBeats)` decreases vs previous sample,
call `clearCycle(state)` before accumulating.

## agentloop integration
`createAudioScopeTool(bridge)` returns a LangChain tool compatible with huberp/agentloop.
`@langchain/core` and `zod` are peerDependencies — do NOT add to `dependencies`.

## Architecture constraints
- Zero production dependencies beyond `express` and `ws`
- `dgram`, `events`, `Buffer` are Node.js built-ins — no polyfills
- `ScopeBridge` must be safe to use as a singleton across process lifetime
- `ViewerServer` is optional — never imported by `ScopeBridge` or the tool
- No browser globals in `src/bridge/` or `src/protocol/`

README.md (outline)

  1. What it is — listens to both SMPL and CTRL broadcasts from phu-beat-sync-multi-scope, accumulates audio and identity state, exposes API + visualiser + agentloop tool.
  2. Quick startnpx @huberp/scope-bridge to launch the browser visualiser.
  3. Programmatic APIScopeBridge constructor, start(), stop(), getState(), on('state'), on('ctrl') with TypeScript example.
  4. agentloop integration — how to use createAudioScopeTool.
  5. Packet format tables — both SMPL and CTRL byte offset tables, with notes that they mirror their respective C++ structs.
  6. Known limitationsASSUMED_SAMPLE_RATE fallback (resolved after first CTRL/Announce), localhost-only.
  7. Protocol version history — table of version → changes.
  8. Contributing — link to phu-beat-sync-multi-scope for C++ protocol changes.

Tests

tests/fixtures/mockPacket.ts

Builds a valid SMPL Buffer for given field values. Used by all SMPL tests.

tests/fixtures/mockCtrlPacket.ts

Builds a valid CTRL Buffer for given CtrlEventType, channelLabel, sampleRate, colourRGBA. Used by all CTRL tests.

tests/PacketParser.test.ts

  • Parses a valid packet — all fields match
  • Returns null on buffer too short
  • Returns null on magic mismatch
  • Returns null on version mismatch
  • Returns null when numSamples > MAX_SAMPLES
  • Correctly reads numSamples = 0
  • samplePpq() returns correct values for first and last sample with default sampleRate
  • samplePpq() uses provided sampleRate override correctly

tests/CtrlPacketParser.test.ts

  • Parses a valid Announce packet — all fields match
  • Correctly decodes channelLabel including null-termination
  • Returns null on buffer shorter than 76 bytes
  • Returns null on magic mismatch
  • Returns null on version mismatch
  • Returns null on unknown eventType
  • colourRGBA reads as 4 separate bytes correctly

tests/ScopeBridge.test.ts

  • Accumulates accumBins correctly from SMPL packet with known samples
  • Accumulates rmsAccum correctly (compare to reference sum-of-squares)
  • Detects SMPL sequence gap and increments gapCount
  • Emits 'packet' and 'state' on valid SMPL packet
  • Does not emit on invalid SMPL packet (bad magic)
  • On CTRL/Announce: state.channelLabel, state.colourRGBA, state.sampleRate updated; isOnline = true
  • On CTRL/Goodbye: state.isOnline = false immediately; entry not pruned
  • samplePpq() uses state.sampleRate after first CTRL/Announce, not ASSUMED_SAMPLE_RATE
  • Instance visible in getActiveInstances() after receiving only a CTRL packet (no audio yet)
  • Instance not pruned while ctrlLastSeenMs is within staleTtlMs, even if audio has gone stale
  • Emits 'ctrl' on valid CTRL packet
  • Prunes instance only when both lastSeenMs and ctrlLastSeenMs exceed staleTtlMs
  • Cycle boundary clears accumulators

Acceptance criteria

  • Repository scaffolded with all files listed in the structure above
  • npm test passes with ≥ 80% coverage on src/protocol/ and src/bridge/
  • npm run build produces dist/ with .d.ts declarations
  • npx @huberp/scope-bridge starts the viewer and opens the browser
  • Browser visualiser renders waveform + RMS bars, lane labels, and lane colours from CTRL data
  • Offline instances (after Goodbye) are dimmed in the browser, not hidden
  • createAudioScopeTool can be imported and registered in huberp/agentloop without errors
  • audioScopeTool label and identity metrics return correct values from CTRL state
  • CI workflow runs on PR and passes
  • Release workflow publishes to npm on v*.*.* tag
  • copilot-instructions.md documents both SMPL and CTRL wire formats with byte offset tables
  • README.md covers all sections listed above
  • ASSUMED_SAMPLE_RATE appears exactly once and is documented as a fallback-only constant
  • samplePpq() uses state.sampleRate once first CTRL/Announce received; falls back to ASSUMED_SAMPLE_RATE before that
  • Instance visible in getActiveInstances() when CTRL heartbeat is active but DAW is paused (no SMPL)
  • Instance pruned only after both SMPL and CTRL have been silent for staleTtlMs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions