Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Expose bounded read-only PCM observation for existing source spectrum

## Required existing behavior and boundary

The app already displays input-source spectrum using its2048-point FFT and a bounded pull from source rings. SDK#405 moved PCM layout authority out of adapter/app. App#101 must retain spectrum without copying private ring offsets or using Msb1RingReader, which changes audio consumer state. The SDK owns this codec-neutral read-only observation; app retains FFT/display and adapter will later map source IDs to observers. No post-effect or arbitrary graph taps.

## Smallest API

Export PcmSourceChunk {generation:bigint,startFrame:bigint,frames:number,endOfRegion:boolean,planes:readonly Float32Array[]} and Msb1RingObserver from @misofm/engine/browser. Constructor accepts an existing SharedArrayBuffer ring and uses the same SDK validation authority. Expose channels, frameCapacity, pull(consume:(chunk:PcmSourceChunk)=>void, maximumChunks?:number):number, counters():Msb1RingCounters, close():void. Add only already-present missing wire counters needed by existing app totals (underruns,drainBlocks,depth) to the shared counter snapshot authority; no new wire words.

## Frozen bounded read contract

- Independent observation cursor, no Atomics stores/adds or mutation of any playback shared word/PCM. Never advances read/write indices, attaches/detaches the ring, holds slots, or adds another producer. Observer runs on app/control thread only.
- Own one reusable planar scratch chunk and metadata object. Callback data is borrowed until return; frames bounds valid samples. After construction, pull allocates no PCM buffers/views and does bounded work. Default maximum min(capacity,32); explicit maximum integer1..32. Slow observers catch up; they never stall audio.
- Begin at oldest currently live chunk. Handle ring-index wrap, missed data, seek epoch/generation changes, stale/invalid/torn slots, mono/stereo and partial tail. On close release local ownership idempotently; subsequent pull returns0.
- Copy into scratch only after checking slot sequence/generation and current live interval. Recheck sequence/generation/live interval after copying and drop observations whose slot became reusable/overwritten. A sequence-only test is insufficient because writer may overwrite PCM before publishing new sequence. A deterministic during-copy mutation in the existing fixture must reject mixed-generation/reused data without changing shared state. This is best-effort visualization observation, not guaranteed lossless delivery.
- Snapshot counters from existing words through one SDK authority; do not claim multiword atomicity. No source document parser, session changes, ring/prelude ABI change, decoder/storage input, telemetry protocol or new framework.

## Allowed paths and execution

sdk/src/browser/pcm-ring.ts or one adjacent pcm-observer.ts using shared validation helpers; sdk/src/browser/index.ts; sdk/test/browser-pcm-evals.mjs; existing strict packed type/runtime smoke; sdk/README.md; this spec. No Rust, generated six artifacts, PCM worklet prelude, SDK boot/control or adapter/app edits. Begin isolated codex/dx-pcm-observation after SDK428 review PASS and its evidence checkpoint. Source baseline9ab79d13 contains reviewed405 and completed428 revision awaiting review. Astra medium implements; separate Astra medium reviewer checks. Root commits focused-green tranche before final evidence.

## Evidence

Existing focused SDK fixture proves representative shapes/tail, budget cap, independent observers, index wrap/overrun, seek/stale generation, deliberate slot reuse during copy, byte-identical shared buffer before/after read-only operations, reusable local planes and closed no-op. Use fixed expectations rather than a second layout implementation. Existing type/headless/generated/package gates and strict public consumer pass. Six generated engine artifacts and PCM prelude remain byte-identical. No extra browser matrix, benchmark or listening gate for this visualization-only control API.

Matching issue misofm/engine#434.

## Attempt 1 focused checkpoint

Astra implementation adds `Msb1RingObserver` beside the existing writer, sharing its private validator and existing browser export. Observer owns reusable scratch/metadata, bounds candidate attempts, catches up across index wrap and seek epochs (including equal low-word generation tags), and validates the live interval again after copying. The existing counter snapshot type now includes the three existing wire words. Close drops ring/scratch references and retains a final counter snapshot; reentrant pull is rejected to protect borrowed scratch.

Focused evidence: `node --test sdk/test/browser-pcm-evals.mjs` PASS (8 tests, `/private/tmp/dx434-focused.log`); `sdk/node_modules/.bin/tsc --project sdk/tsconfig.json` PASS (`/private/tmp/dx434-typecheck.log`). Fixtures include mono/stereo partial tails, independent cursors, 32-attempt default, invalid-slot budget, wrap/overrun, stale/torn slots, full seek generation, constructor/view allocation traps, reusable identities and byte-for-byte shared-state preservation. Deterministic copy hooks advance READ_INDEX and reserve/zero a reused slot while its old sequence remains published, or seek during copy; both deliver zero callbacks. Existing packed smoke now exercises the public observer type and runtime. Full headless/generated/package evidence follows root's exact-path checkpoint; not yet claimed.

## Attempt 1 final validation at acf30599

- `bash scripts/check-sdk-headless.sh /private/tmp/dx-393-current-artifacts`: PASS, 160 passed / 1 existing skip (`/private/tmp/dx434-headless.log`).
- `npm run check:generated` and `npm run check:assets` from `sdk/`: PASS (`/private/tmp/dx434-generated.log`, `/private/tmp/dx434-assets.log`). The package gate also runs the complete `scripts/check-sdk-generated.sh`, including generated banners and the host declaration mirror; PASS.
- `npm run check:package -- /private/tmp/dx-393-current-artifacts` from `sdk/`: PASS (`/private/tmp/dx434-package.log`), including 11 CLI tests, strict extracted-tarball consumer types and public observer runtime assertions. No extra browser matrix was run for this control-thread observation API.
- Direct byte comparison: all six staged generated engine artifacts equal the approved input directory; the PCM prelude equals both baseline `e3a52dce` and its staged copy (`/private/tmp/dx434-bytes.log`). No Wasm rebuild or repin. Production/source paths remained clean throughout full validation.

Implementation and required evidence are ready for the separate independent Astra review; this record does not claim review PASS or issue closure.

## Dedicated Astra medium PASS

Independent review at7e1713ec passes focused/type/package checks. Removing only the post-copy live-interval guard causes the existing slot-reuse regression to fail while sequence/generation guards remain; read-only race protection is discriminating. Six generated artifacts and PCM prelude are unchanged. Report attached to the observation PR. This completes SDK source observation, not adapter mapping or app adoption.
21 changes: 21 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,27 @@ Call `await engine.close()` when the browser session is finished. It disposes th
before closing its `AudioContext`, is safe to call repeatedly, and still closes the context if the
host's MessagePort has already failed.

For input-source spectrum, `Msb1RingObserver` from `@misofm/engine/browser` reads an
existing feed ring without consuming audio or changing shared bytes:

```ts
const observer = new Msb1RingObserver(feed.rings[0]);
observer.pull((chunk) => {
updateSourceSpectrum(chunk.planes, chunk.frames); // Use synchronously; scratch is reused.
});
const { underruns, drainBlocks, depth } = observer.counters();
observer.close();
```

Each observer starts at the oldest live chunk and has an independent cursor. Pull attempts at
most `min(ringCapacity, 32)` chunks by default, or an explicit integer from 1 to 32. Metadata and
planar scratch are borrowed until the callback returns; only `frames` samples are valid. Pull
creates no PCM buffers or views. It skips stale or concurrently reused slots and catches up after
missed data or seeks; visualization is best effort. Use it on the control thread and do not
reenter `pull` from its callback. Counters read existing wire words individually, not as an atomic
multiword snapshot. Closing releases local storage; later pulls return zero and counters retain
the final snapshot. Observation never holds slots or delays playback.

## Agents

```ts
Expand Down
123 changes: 122 additions & 1 deletion sdk/src/browser/pcm-ring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const MSB1_CONTROL = Object.freeze({ MAGIC: 0, VERSION: 1, CAPACITY: 2, C
const SLOT = { SEQUENCE: 0, GENERATION_TAG: 1, FRAMES: 2, FLAGS: 3 } as const;
const SLOT_I64 = { GENERATION: 2, START_FRAME: 3 } as const;
export interface Msb1RingLayout { readonly sourceId: string; readonly channels: number; readonly frameCapacity: number; readonly capacity: number }
export interface Msb1RingCounters { readonly wrote:number; readonly overflow:number; readonly submitted:number; readonly stale:number; readonly refused:number; readonly lastResult:number; readonly seeksApplied:number; readonly torn:number; readonly errors:number; readonly occupancy:number; readonly generationTag:number; readonly submittedGenerationTag:number }
export interface Msb1RingCounters { readonly wrote:number; readonly overflow:number; readonly submitted:number; readonly stale:number; readonly refused:number; readonly lastResult:number; readonly underruns:number; readonly drainBlocks:number; readonly depth:number; readonly seeksApplied:number; readonly torn:number; readonly errors:number; readonly occupancy:number; readonly generationTag:number; readonly submittedGenerationTag:number }
export function msb1RingBytes(channels:number, frameCapacity:number, capacity:number):number { return MSB1_HEADER_OFFSET + capacity * MSB1_SLOT_HEADER_BYTES + capacity * channels * frameCapacity * 4; }
export function createMsb1Ring(layout: Msb1RingLayout): SharedArrayBuffer {
if (!power(layout.capacity)) throw new RangeError("MSB1 capacity must be a power of two");
Expand All @@ -34,3 +34,124 @@ export class Msb1RingWriter {
}
function bind(shared:SharedArrayBuffer){if(!(shared instanceof SharedArrayBuffer))throw new TypeError("MSB1 requires SharedArrayBuffer");const control=new Int32Array(shared,0,32);if(Atomics.load(control,0)!==MSB1_MAGIC||control[1]!==1)throw new TypeError("Shared buffer is not MSB1");const capacity=control[2]!,channels=control[3]!,frameCapacity=control[4]!,headerOffset=control[5]!,pcmOffset=control[6]!;if(!power(capacity)||!positive(channels)||!positive(frameCapacity))throw new TypeError("MSB1 header is invalid");const headers=new Int32Array(shared,headerOffset,capacity*8),planes=Array.from({length:capacity},(_,s)=>Array.from({length:channels},(_,ch)=>new Float32Array(shared,pcmOffset+(s*channels+ch)*frameCapacity*4,frameCapacity)));return {control,controlI64:new BigInt64Array(shared,MSB1_CONTROL_I64_OFFSET,2),headers,headersI64:new BigInt64Array(shared,headerOffset,capacity*4),planes,capacity,channels,frameCapacity}}
function occupancy(c:Int32Array){return (Atomics.load(c,8)-Atomics.load(c,9))&(MSB1_WRAP-1)} function positive(v:number){return Number.isSafeInteger(v)&&v>0} function power(v:number){return positive(v)&&(v&(v-1))===0}

/** Borrowed scratch storage: only `frames` samples are valid, until the callback returns. */
export interface PcmSourceChunk {
readonly generation: bigint;
readonly startFrame: bigint;
readonly frames: number;
readonly endOfRegion: boolean;
readonly planes: readonly Float32Array[];
}

/** Best-effort source observation on the control thread; never owns an audio consumer slot. */
export class Msb1RingObserver {
readonly channels: number;
readonly frameCapacity: number;
#view: ReturnType<typeof bind> | undefined;
#chunk: { -readonly [K in keyof PcmSourceChunk]: PcmSourceChunk[K] } | undefined;
#cursor: number | undefined;
#epoch = -1;
#tag = 0;
#pulling = false;
#closedCounters: Msb1RingCounters | undefined;

constructor(ring: SharedArrayBuffer) {
const view = bind(ring);
this.#view = view;
this.channels = view.channels;
this.frameCapacity = view.frameCapacity;
this.#chunk = {
generation: 0n, startFrame: 0n, frames: 0, endOfRegion: false,
planes: Object.freeze(Array.from({ length: view.channels }, () => new Float32Array(view.frameCapacity))),
};
}

pull(consume: (chunk: PcmSourceChunk) => void, maximumChunks?: number): number {
const view = this.#view;
const chunk = this.#chunk;
if (!view || !chunk) return 0;
const budget = maximumChunks ?? Math.min(view.capacity, 32);
if (!Number.isInteger(budget) || budget < 1 || budget > 32) throw new RangeError("maximumChunks must be an integer from 1 to 32");
if (this.#pulling) throw new TypeError("MSB1 observation cannot be reentered");
this.#pulling = true;
let delivered = 0;
try {
const c = view.control, h = view.headers, h64 = view.headersI64;
const mask = MSB1_WRAP - 1;
// Bound attempts, including stale/torn candidates, rather than only successful callbacks.
for (let attempt = 0; attempt < budget && this.#view === view; attempt++) {
const epoch = Atomics.load(c, MSB1_CONTROL.SEEK_EPOCH);
const tag = Atomics.load(c, MSB1_CONTROL.GENERATION_TAG);
const read = Atomics.load(c, MSB1_CONTROL.READ_INDEX);
const write = Atomics.load(c, MSB1_CONTROL.WRITE_INDEX);
const live = (write - read) & mask;
if (live > view.capacity) break;
if (this.#epoch !== epoch || this.#tag !== tag || this.#cursor === undefined || ((this.#cursor - read) & mask) > live) {
this.#cursor = read;
this.#epoch = epoch;
this.#tag = tag;
}
const index = this.#cursor;
if (index === write) break;
this.#cursor = (index + 1) & mask;
const slot = index & (view.capacity - 1), word = slot * 8, wide = slot * 4;
if (Atomics.load(h, word + SLOT.SEQUENCE) !== index || Atomics.load(h, word + SLOT.GENERATION_TAG) !== tag) continue;
const frames = Atomics.load(h, word + SLOT.FRAMES);
if (frames < 1 || frames > view.frameCapacity) continue;
const flags = Atomics.load(h, word + SLOT.FLAGS);
const generation = Atomics.load(h64, wide + SLOT_I64.GENERATION);
const startFrame = Atomics.load(h64, wide + SLOT_I64.START_FRAME);
if (Number(BigInt.asIntN(32, generation)) !== tag) continue;
// A seek can retain the same low-word tag; its full generation still excludes old slots.
const seekGeneration = Atomics.load(view.controlI64, 0);
if (epoch !== 0 && Number(BigInt.asIntN(32, seekGeneration)) === tag && generation !== seekGeneration) continue;
for (let channel = 0; channel < this.channels; channel++) chunk.planes[channel]!.set(view.planes[slot]![channel]!);
// A producer clears/reuses PCM before publishing a new sequence. Sequence alone cannot
// detect that race: the candidate must STILL belong to the audio consumer's live interval.
const afterRead = Atomics.load(c, MSB1_CONTROL.READ_INDEX);
const afterWrite = Atomics.load(c, MSB1_CONTROL.WRITE_INDEX);
const afterLive = (afterWrite - afterRead) & mask;
if (afterLive > view.capacity || ((index - afterRead) & mask) >= afterLive ||
Atomics.load(c, MSB1_CONTROL.SEEK_EPOCH) !== epoch || Atomics.load(c, MSB1_CONTROL.GENERATION_TAG) !== tag ||
Atomics.load(h, word + SLOT.SEQUENCE) !== index || Atomics.load(h, word + SLOT.GENERATION_TAG) !== tag ||
Atomics.load(h, word + SLOT.FRAMES) !== frames || Atomics.load(h, word + SLOT.FLAGS) !== flags ||
Atomics.load(h64, wide + SLOT_I64.GENERATION) !== generation || Atomics.load(h64, wide + SLOT_I64.START_FRAME) !== startFrame) continue;
chunk.generation = generation;
chunk.startFrame = startFrame;
chunk.frames = frames;
chunk.endOfRegion = (flags & MSB1_FLAG_END_OF_REGION) !== 0;
consume(chunk);
delivered++;
}
return delivered;
} finally {
this.#pulling = false;
}
}

/** Individual atomic word reads, not a multiword atomic snapshot. After close, returns the final snapshot. */
counters(): Msb1RingCounters {
return this.#view ? ringCounters(this.#view.control) : this.#closedCounters!;
}

close(): void {
if (!this.#view) return;
this.#closedCounters = ringCounters(this.#view.control);
this.#view = undefined;
this.#chunk = undefined;
}
}

function ringCounters(c: Int32Array): Msb1RingCounters {
const submittedGenerationTag = Atomics.load(c, MSB1_CONTROL.SUBMITTED_GENERATION_TAG);
return {
wrote: Atomics.load(c, MSB1_CONTROL.WROTE), overflow: Atomics.load(c, MSB1_CONTROL.OVERFLOW),
submitted: Atomics.load(c, MSB1_CONTROL.SUBMITTED), stale: Atomics.load(c, MSB1_CONTROL.STALE),
refused: Atomics.load(c, MSB1_CONTROL.REFUSED), lastResult: Atomics.load(c, MSB1_CONTROL.LAST_RESULT),
underruns: Atomics.load(c, MSB1_CONTROL.UNDERRUNS), drainBlocks: Atomics.load(c, MSB1_CONTROL.DRAIN_BLOCKS),
depth: Atomics.load(c, MSB1_CONTROL.DEPTH), seeksApplied: Atomics.load(c, MSB1_CONTROL.SEEKS_APPLIED),
torn: Atomics.load(c, MSB1_CONTROL.TORN), errors: Atomics.load(c, MSB1_CONTROL.ERRORS),
occupancy: occupancy(c), generationTag: Atomics.load(c, MSB1_CONTROL.GENERATION_TAG), submittedGenerationTag,
};
}
Loading