You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
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.
exportinterfaceParsedCtrlPacket{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 40colourRGBA: [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
exportinterfaceScopeState{// --- populated from SMPL packets ---instanceID: number;lastSeenMs: number;// Date.now() of last SMPL packetsequenceNumber: number;ppq: number;// PPQ of most recent sample in latest SMPL packetbpm: number;displayRangeBeats: number;accumBins: Float32Array;// [4096] — scatter-written, last-write-winsrmsAccum: Float64Array;// [128] — sum of squares per 1/128 beat slotrmsCount: Uint32Array;// [128]cancelAccum: Float64Array;// [256] — sum of squares per 1/256 beat slotcancelCount: Uint32Array;// [256]gapCount: number;// total SMPL sequence gaps detected// --- populated from CTRL packets ---channelLabel: string;// "" until first CTRL receivedcolourRGBA: [number,number,number,number];// [0x88,0x88,0x88,0xFF] defaultsampleRate: number;// ASSUMED_SAMPLE_RATE until first CTRL/AnnouncemaxBufferSize: number;isOnline: boolean;// false on Goodbye; true on Announce/LabelChange/RangeChangectrlLastSeenMs: number;// Date.now() of last CTRL packet}exportfunctionrmsSlots(state: ScopeState): Float32Array// sqrt(accum/count) per slotexportfunctioncancelSlots(state: ScopeState): Float32ArrayexportfunctionclearCycle(state: ScopeState): void// zero accumulators on beat boundary
src/bridge/ScopeBridge.ts
Singleton ScopeBridge class extending EventEmitter.
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 bothlastSeenMs (SMPL) andctrlLastSeenMs (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 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";exportfunctioncreateAudioScopeTool(bridge: ScopeBridge){returntool(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:
waveform — accumBins downsampled to 128 as JSON array
rms — rmsSlots(state) as JSON array
cancellation — cancelSlots(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
label — state.channelLabel or "(no label)" if empty
# 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)
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.
Quick start — npx @huberp/scope-bridge to launch the browser visualiser.
Programmatic API — ScopeBridge constructor, start(), stop(), getState(), on('state'), on('ctrl') with TypeScript example.
agentloop integration — how to use createAudioScopeTool.
Packet format tables — both SMPL and CTRL byte offset tables, with notes that they mirror their respective C++ structs.
Known limitations — ASSUMED_SAMPLE_RATE fallback (resolved after first CTRL/Announce), localhost-only.
Protocol version history — table of version → changes.
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
Context
phu-beat-sync-multi-scopebroadcasts audio and control state as UDP multicast packets on239.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 forhuberp/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 group239.255.42.1.SMPL—RawSamplesPacket(port49422)Source of truth:
lib/network/SampleBroadcaster.hSent at ~30 Hz while the DAW is playing.
RawSamplesPacketdoes not carrysampleRate— this is resolved by theCTRLchannel (see below).CTRL—CtrlPacket(port49423)Source of truth:
lib/network/CtrlBroadcaster.hTotal: 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,CTRLheartbeats keep instance identity alive on all peers.CTRLis the authoritative source forsampleRate.ASSUMED_SAMPLE_RATE = 44100is used only as a fallback until the firstAnnounceis 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.mddocuments this coupling explicitly.Port allocation summary
SMPLCTRLRepository structure
Create a new repository
huberp/scope-bridgewith the following layout: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"| CANVASImplementation requirements
src/protocol/PacketParser.tsSMPL_MAGIC = 0x534D504C,PROTOCOL_VERSION = 3,HEADER_SIZE = 38,MAX_SAMPLES = 1470.ASSUMED_SAMPLE_RATE = 44100— used as fallback untilCTRL/Announcedelivers the real value. Documented as a known limitation. Appears exactly once in the codebase.ParsedPacket { magic, version, instanceID, sequenceNumber, ppqOfFirstSample, bpm, displayRangeBeats, numSamples, samples: Float32Array }.parsePacket(buf: Buffer): ParsedPacket | null. Returnsnullif: buffer shorter thanHEADER_SIZE, magic mismatch, version mismatch,numSamples > MAX_SAMPLES, or buffer shorter thanHEADER_SIZE + numSamples * 4.buf.readUInt32LE,buf.readDoubleLE,buf.readFloatLE— no external deps.sampleRateargument is used byScopeBridgeonce aCTRL/Announcehas been received for the instance.src/protocol/CtrlPacketParser.tsCTRL_MAGIC = 0x4354524C,CTRL_VERSION = 1,CTRL_PACKET_SIZE = 76.parseCtrlPacket(buf: Buffer): ParsedCtrlPacket | null. Returnsnullif: buffer shorter thanCTRL_PACKET_SIZE, magic mismatch, version mismatch, oreventTypenot in1..4.channelLabelasbuf.subarray(40, 72), find first null byte, decode as UTF-8.buf.readUInt32LE,buf.readDoubleLE,buf.readFloatLE,buf.readUInt8.src/bridge/ScopeState.tssrc/bridge/ScopeBridge.tsSingleton
ScopeBridgeclass extendingEventEmitter.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:setReuseAddress(true), bind0.0.0.0:smplPort,addMembership(group, '127.0.0.1')setReuseAddress(true), bind0.0.0.0:ctrlPort,addMembership(group, '127.0.0.1')stop(): void— drops both memberships, closes both sockets, clears interval.On each SMPL packet:
parsePacket(buf), discardnull.ScopeStateforinstanceID.lastSeenMs,sequenceNumber,bpm,displayRangeBeats,ppq.gapCount.state.sampleRate(notASSUMED_SAMPLE_RATE) insamplePpq()once set from CTRL.iinpacket.samples[0..numSamples-1]:ppq_i = samplePpq(packet, i, state.sampleRate)normPos = fmod(ppq_i, displayRangeBeats) / displayRangeBeatsbin = Math.floor(normPos * 4096)→state.accumBins[bin] = sampleslot128 = Math.floor(normPos * 128)→state.rmsAccum[slot] += sample²; state.rmsCount[slot]++slot256 = Math.floor(normPos * 256)→state.cancelAccum[slot] += sample²; state.cancelCount[slot]++fmod(ppq_i, displayRangeBeats)decreases vs previous sample, callclearCycle(state)before accumulating.'packet'(ParsedPacket).'state'(ScopeState).On each CTRL packet:
parseCtrlPacket(buf), discardnull.ScopeStateforinstanceID.Announce,LabelChange,RangeChange: updatechannelLabel,colourRGBA,sampleRate,maxBufferSize,displayRangeBeats,bpm, setisOnline = true, updatectrlLastSeenMs.Goodbye: setisOnline = falseimmediately. Do not remove the entry — keep it so consumers can display an offline state until the audio stale TTL also expires.'ctrl'(ParsedCtrlPacket).Stale pruning: an instance is removed from the state map only when both
lastSeenMs(SMPL) andctrlLastSeenMs(CTRL) have exceededstaleTtlMs. This means:staleTtlMsof 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 withinstaleTtlMs.getSnapshot(): Map<number, ScopeState>— deep-copy snapshot of all active states.src/viewer/ViewerServer.tssrc/viewer/public/athttp://localhost:PORT(default 8080).wsWebSocket server on the same port.ScopeBridgeinternally. 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...] } ] }isOnline = falseare included (so the browser can dim them). Instances are omitted only after being pruned fromScopeBridge.ViewerServertracks a dirty flag per instance and only pushes on actual state changes.src/viewer/public/index.htmlSingle self-contained HTML file, no bundler, no framework, vanilla JS +
<canvas>. Implements:ws://localhost:8080, parses JSON, updates display. Reconnects on close (exponential backoff, max 5 s).accumBins(128 points) as a beat-synchronised oscilloscope. Lane stroke colour fromcolourRGBA(fall back to palette if no CTRL received yet, grey#888888ifisOnline = false).channelLabelas text at the top-left of each lane. Falls back toInstance ${instanceID}if label is empty. Appends" (offline)"and dims to 40% opacity ifisOnline = false.rmsSlots[s], coloured bycolourRGBA.cancelSlots(0=green, 1=red). Only shown when ≥ 2 instances active.sampleRate(shown as"? Hz"until first CTRL received), gap count per instance.src/tools/audioScopeTool.tsagentloop-compatible LangChain tool. Peer dependencies:
@langchain/core,zod— NOT production deps, onlypeerDependencies.Metric return values:
waveform—accumBinsdownsampled to 128 as JSON arrayrms—rmsSlots(state)as JSON arraycancellation—cancelSlots(state)as JSON arraybpm— stringppq— string with interpretation:"ppq: 3.25 (beat 4 of bar 1 at 120bpm)"instances— JSON array of active instanceIDs withbpm,ppq,channelLabel,isOnlinelabel—state.channelLabelor"(no label)"if emptyidentity— JSON:{ instanceID, channelLabel, colourRGBA, sampleRate, isOnline }All metrics that use
samplePpq()internally passstate.sampleRate.src/index.ts— public APIbin/viewer.tsCLI entry point. Parses
--portarg, instantiatesViewerServer, starts it, openshttp://localhost:PORTin the default browser (openpackage orchild_process.exec), handlesSIGINTfor graceful shutdown.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.ymlRelease workflow —
.github/workflows/release.yml.github/copilot-instructions.mdREADME.md(outline)npx @huberp/scope-bridgeto launch the browser visualiser.ScopeBridgeconstructor,start(),stop(),getState(),on('state'),on('ctrl')with TypeScript example.createAudioScopeTool.ASSUMED_SAMPLE_RATEfallback (resolved after first CTRL/Announce), localhost-only.phu-beat-sync-multi-scopefor C++ protocol changes.Tests
tests/fixtures/mockPacket.tsBuilds a valid SMPL
Bufferfor given field values. Used by all SMPL tests.tests/fixtures/mockCtrlPacket.tsBuilds a valid CTRL
Bufferfor givenCtrlEventType,channelLabel,sampleRate,colourRGBA. Used by all CTRL tests.tests/PacketParser.test.tsnullon buffer too shortnullon magic mismatchnullon version mismatchnullwhennumSamples > MAX_SAMPLESnumSamples = 0samplePpq()returns correct values for first and last sample with default sampleRatesamplePpq()uses providedsampleRateoverride correctlytests/CtrlPacketParser.test.tsAnnouncepacket — all fields matchchannelLabelincluding null-terminationnullon buffer shorter than 76 bytesnullon magic mismatchnullon version mismatchnullon unknowneventTypecolourRGBAreads as 4 separate bytes correctlytests/ScopeBridge.test.tsaccumBinscorrectly from SMPL packet with known samplesrmsAccumcorrectly (compare to reference sum-of-squares)gapCount'packet'and'state'on valid SMPL packetCTRL/Announce:state.channelLabel,state.colourRGBA,state.sampleRateupdated;isOnline = trueCTRL/Goodbye:state.isOnline = falseimmediately; entry not prunedsamplePpq()usesstate.sampleRateafter firstCTRL/Announce, notASSUMED_SAMPLE_RATEgetActiveInstances()after receiving only a CTRL packet (no audio yet)ctrlLastSeenMsis withinstaleTtlMs, even if audio has gone stale'ctrl'on valid CTRL packetlastSeenMsandctrlLastSeenMsexceedstaleTtlMsAcceptance criteria
npm testpasses with ≥ 80% coverage onsrc/protocol/andsrc/bridge/npm run buildproducesdist/with.d.tsdeclarationsnpx @huberp/scope-bridgestarts the viewer and opens the browserGoodbye) are dimmed in the browser, not hiddencreateAudioScopeToolcan be imported and registered inhuberp/agentloopwithout errorsaudioScopeToollabelandidentitymetrics return correct values from CTRL statev*.*.*tagcopilot-instructions.mddocuments both SMPL and CTRL wire formats with byte offset tablesREADME.mdcovers all sections listed aboveASSUMED_SAMPLE_RATEappears exactly once and is documented as a fallback-only constantsamplePpq()usesstate.sampleRateonce firstCTRL/Announcereceived; falls back toASSUMED_SAMPLE_RATEbefore thatgetActiveInstances()when CTRL heartbeat is active but DAW is paused (no SMPL)staleTtlMs