Skip to content

Commit 499e1ba

Browse files
kixelatedclaude
andauthored
js: make @moq/watch component signals explicit inputs vs outputs (#1592)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f3f9417 commit 499e1ba

40 files changed

Lines changed: 1211 additions & 907 deletions

demo/web/src/discover.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Moq, Signals } from "@moq/hang";
1+
import { Signals } from "@moq/hang";
22
import type MoqWatch from "@moq/watch/element";
33

44
/**
@@ -31,7 +31,7 @@ export default class MoqDiscover extends HTMLElement {
3131
// Reactively render suggestions when broadcasts or selected name changes.
3232
this.#signals.run((effect) => {
3333
const broadcasts = effect.get(watch.connection.announced);
34-
const selected = effect.get(watch.broadcast.name).toString();
34+
const selected = effect.get(watch.broadcast.input.name).toString();
3535

3636
this.#clearSuggestions();
3737

@@ -69,7 +69,7 @@ export default class MoqDiscover extends HTMLElement {
6969
});
7070
}
7171
tag.addEventListener("click", () => {
72-
watch.broadcast.name.set(Moq.Path.from(name));
72+
watch.name = name;
7373
});
7474
this.#suggestions.appendChild(tag);
7575
}

js/hang/src/container/consumer.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import type { Time } from "@moq/net";
22
import * as Moq from "@moq/net";
3-
import { Effect, type Getter, Signal } from "@moq/signals";
3+
import { Effect, type Getter, type GetterInit, getter, Signal } from "@moq/signals";
44

55
import type { Format } from "./format";
66
import type { BufferedRanges, Frame } from "./types";
77

88
export interface ConsumerProps {
99
format: Format;
10-
// Target latency in milliseconds (default: 0)
11-
latency?: Signal<Time.Milli> | Time.Milli;
10+
// Target latency in milliseconds (default: 0). Read-only: a Getter (e.g. another
11+
// component's output) is accepted directly.
12+
latency?: GetterInit<Time.Milli>;
1213
}
1314

1415
interface Group {
@@ -22,7 +23,7 @@ interface Group {
2223
export class Consumer {
2324
#track: Moq.Track;
2425
#format: Format;
25-
#latency: Signal<Time.Milli>;
26+
#latency: Getter<Time.Milli>;
2627
#groups: Group[] = [];
2728
#active?: number; // the active group sequence number
2829

@@ -37,7 +38,7 @@ export class Consumer {
3738
constructor(track: Moq.Track, props: ConsumerProps) {
3839
this.#track = track;
3940
this.#format = props.format;
40-
this.#latency = Signal.from(props.latency ?? Moq.Time.Milli.zero);
41+
this.#latency = getter(props.latency ?? Moq.Time.Milli.zero);
4142

4243
this.#signals.spawn(this.#run.bind(this));
4344
this.#signals.cleanup(() => {

js/moq-boy/src/index.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ export class Game {
7575
readonly status = new Moq.Signals.Signal<GameStatus | undefined>(undefined);
7676
readonly viewerId = new Moq.Signals.Signal<string | undefined>(undefined);
7777

78+
// The canvas to render into, owned here and wired into the renderer (read-only there).
79+
// The UI sets this once the <canvas> is mounted.
80+
readonly canvas = new Moq.Signals.Signal<HTMLCanvasElement | undefined>(undefined);
81+
82+
// The video rendition target, owned here and wired into the video source as an input.
83+
readonly #target = new Moq.Signals.Signal<Watch.Video.Target | undefined>(undefined);
84+
7885
// Watch API objects — exposed so UI can access canvas, etc.
7986
readonly broadcast: Watch.Broadcast;
8087
readonly sync: Watch.Sync;
@@ -110,30 +117,37 @@ export class Game {
110117
});
111118
this.#signals.cleanup(() => this.broadcast.close());
112119

113-
this.sync = new Watch.Sync({ latency: this.latency, connection: connection.established });
114-
this.#signals.cleanup(() => this.sync.close());
115-
116-
this.videoSource = new Watch.Video.Source(this.sync, { broadcast: this.broadcast });
120+
// Sources produce the per-rendition jitter that Sync reads, so they're created
121+
// before Sync to avoid a construction cycle.
122+
this.videoSource = new Watch.Video.Source({ broadcast: this.broadcast, target: this.#target });
117123
this.#signals.cleanup(() => this.videoSource.close());
118124

125+
this.audioSource = new Watch.Audio.Source({ broadcast: this.broadcast });
126+
this.#signals.cleanup(() => this.audioSource.close());
127+
128+
this.sync = new Watch.Sync({
129+
latency: this.latency,
130+
connection: connection.established,
131+
video: this.videoSource.output.jitter,
132+
audio: this.audioSource.output.jitter,
133+
});
134+
this.#signals.cleanup(() => this.sync.close());
135+
119136
this.#signals.run(this.#runPixelBudget.bind(this));
120137

121138
// Video is enabled on the grid or when this game is expanded.
122139
const videoEnabled = new Moq.Signals.Signal(true);
123140
this.#signals.run(this.#runVideoEnabled.bind(this, videoEnabled));
124141

125-
this.videoDecoder = new Watch.Video.Decoder(this.videoSource, { enabled: videoEnabled });
142+
this.videoDecoder = new Watch.Video.Decoder(this.videoSource, this.sync, { enabled: videoEnabled });
126143
this.#signals.cleanup(() => this.videoDecoder.close());
127144

128-
// Renderer needs a canvas — created by the UI layer, set via setCanvas().
129-
this.videoRenderer = new Watch.Video.Renderer(this.videoDecoder);
145+
// Renderer needs a canvas — created by the UI layer, set via `canvas`.
146+
this.videoRenderer = new Watch.Video.Renderer(this.videoDecoder, { canvas: this.canvas });
130147
this.#signals.cleanup(() => this.videoRenderer.close());
131148

132149
// Audio pipeline — the emitter controls muted (volume) and paused (download).
133-
this.audioSource = new Watch.Audio.Source(this.sync, { broadcast: this.broadcast });
134-
this.#signals.cleanup(() => this.audioSource.close());
135-
136-
this.audioDecoder = new Watch.Audio.Decoder(this.audioSource);
150+
this.audioDecoder = new Watch.Audio.Decoder(this.audioSource, this.sync);
137151
this.#signals.cleanup(() => this.audioDecoder.close());
138152

139153
const audioPaused = new Moq.Signals.Signal(true);
@@ -149,7 +163,7 @@ export class Game {
149163
// Resume AudioContext on first user interaction (browser autoplay policy).
150164
for (const event of ["click", "touchstart", "touchend", "mousedown", "keydown"]) {
151165
this.#signals.event(document, event, () => {
152-
const ctx = this.audioDecoder.context.peek();
166+
const ctx = this.audioDecoder.output.context.peek();
153167
if (ctx?.state === "suspended") ctx.resume();
154168
});
155169
}
@@ -179,11 +193,11 @@ export class Game {
179193
/** Collect media timestamps at each pipeline stage for latency measurement. */
180194
#timestamps(): { label: string; ts: number }[] {
181195
const entries: { label: string; ts: number }[] = [];
182-
const received = this.sync.timestamp.peek();
196+
const received = this.sync.output.timestamp.peek();
183197
if (received != null) entries.push({ label: "received", ts: received });
184-
const decoded = this.videoDecoder.timestamp.peek();
198+
const decoded = this.videoDecoder.output.timestamp.peek();
185199
if (decoded != null) entries.push({ label: "decoded", ts: decoded });
186-
const rendered = this.videoRenderer.timestamp.peek();
200+
const rendered = this.videoRenderer.output.timestamp.peek();
187201
if (rendered != null) entries.push({ label: "rendered", ts: rendered });
188202
return entries;
189203
}
@@ -205,7 +219,7 @@ export class Game {
205219
const exp = effect.get(this.expanded);
206220
// Native GB is 160x144 = 23040 pixels. When expanded, allow 4x for quality.
207221
const pixels = exp === this.sessionId ? GB_PIXELS * 4 : GB_PIXELS;
208-
this.videoSource.target.set({ pixels });
222+
this.#target.set({ pixels });
209223
}
210224

211225
#runVideoEnabled(videoEnabled: Moq.Signals.Signal<boolean>, effect: Moq.Signals.Effect) {
@@ -219,7 +233,7 @@ export class Game {
219233
}
220234

221235
#runStatus(effect: Moq.Signals.Effect) {
222-
const active = effect.get(this.broadcast.active);
236+
const active = effect.get(this.broadcast.output.active);
223237
if (!active) return;
224238

225239
const statusTrack = active.subscribe("status", 10);

js/moq-boy/src/ui/components/GameCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ function GameCardInner() {
2222
let canvasRef!: HTMLCanvasElement;
2323
const signals = new Signals.Effect();
2424

25-
// Set canvas on the video renderer once mounted.
25+
// Set canvas on the game once mounted; it wires it into the renderer.
2626
onMount(() => {
27-
game.videoRenderer.canvas.set(canvasRef);
27+
game.canvas.set(canvasRef);
2828
});
2929

3030
// Keyboard input — preventDefault when expanded or hovered.

js/moq-boy/src/ui/components/StatsPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default function StatsPanel() {
77
const ctx = useGameUI();
88
const game = ctx.game;
99

10-
const jitter = createAccessor(game.sync.jitter);
10+
const jitter = createAccessor(game.sync.output.jitter);
1111

1212
const onJitterInput = (e: Event) => {
1313
const el = e.currentTarget as HTMLInputElement;

js/signals/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"scripts": {
1616
"build": "rimraf dist && tsc -b && bun ../common/package.ts",
1717
"check": "tsc --noEmit",
18+
"test": "bun test --only-failures",
1819
"release": "bun ../common/release.ts"
1920
},
2021
"devDependencies": {

js/signals/src/index.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,48 @@ export class Signal<T> implements Getter<T>, Setter<T> {
186186
}
187187

188188
type SetterType<S> = S extends Setter<infer T> ? T : never;
189-
type GetterType<G> = G extends Getter<infer T> ? T : never;
189+
export type GetterType<G> = G extends Getter<infer T> ? T : never;
190+
191+
// A record of named signals, used to group a component's inputs or outputs.
192+
export type SignalMap = Record<string, Getter<unknown>>;
193+
194+
// A read-only view over a SignalMap: every entry collapses to its Getter and the
195+
// record itself is readonly. Consumers can peek/subscribe but can neither call set()
196+
// nor swap a signal out, so the owning component keeps sole write access.
197+
export type Readonlys<T extends SignalMap> = {
198+
readonly [K in keyof T]: Getter<GetterType<T[K]>>;
199+
};
200+
201+
// Re-type a record of Signals as read-only Getters. This is the identity function at
202+
// runtime; it only narrows the static type. Keep the original (writable) reference
203+
// private for the component to set, and expose the result as the public output.
204+
//
205+
// readonly #output = { status: new Signal("offline") };
206+
// readonly output = readonlys(this.#output); // status is now a Getter to callers
207+
export function readonlys<T extends SignalMap>(signals: T): Readonlys<T> {
208+
return signals as unknown as Readonlys<T>;
209+
}
210+
211+
// A value or an existing readable for it: the argument form accepted by `getter()`
212+
// and, per-field, by `Inputs`. Mirrors the `T | Signal<T>` shape of `Signal.from`.
213+
export type GetterInit<T> = T | Getter<T>;
214+
215+
// Build a read-only Getter from a value or an existing readable. The read-only
216+
// counterpart to `Signal.from`: a branded Signal (including the output of `readonlys`)
217+
// is reused as-is, so one component's output can be wired straight into another's
218+
// input; any other value is wrapped in a fresh Signal. The brand check means a
219+
// hand-rolled Getter that isn't backed by a Signal would be treated as a value.
220+
export function getter<T>(value: GetterInit<T>): Getter<T> {
221+
if (typeof value === "object" && value !== null && SIGNAL_BRAND in value) {
222+
return value as Getter<T>;
223+
}
224+
return new Signal(value as T);
225+
}
226+
227+
// Derive a component's constructor argument from its input map: every input becomes
228+
// optional and accepts a raw value, a Signal, or another component's output Getter
229+
// (the `getter()` contract). Removes the hand-written, drift-prone argument interface.
230+
export type Inputs<I extends SignalMap> = { [K in keyof I]?: GetterInit<GetterType<I[K]>> };
190231

191232
// Excludes common falsy values from a type
192233
type Falsy = false | 0 | "" | null | undefined;

js/signals/src/io.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { expect, test } from "bun:test";
2+
import { type Getter, getter, readonlys, Signal } from "./index.ts";
3+
4+
test("getter wraps a raw value in a fresh Signal", () => {
5+
const g = getter(5);
6+
expect(g.peek()).toBe(5);
7+
});
8+
9+
test("getter reuses an existing Signal instead of wrapping it", () => {
10+
const s = new Signal(1);
11+
expect(getter(s)).toBe(s);
12+
});
13+
14+
test("getter reuses a readonlys() output (so outputs wire into inputs)", () => {
15+
const s = new Signal("hello");
16+
const view = readonlys({ value: s }).value;
17+
// The read-only view is the same branded Signal, so getter() passes it through.
18+
expect(getter(view)).toBe(s);
19+
});
20+
21+
test("readonlys exposes live reads without a writable handle", () => {
22+
const s = new Signal(1);
23+
const out = readonlys({ count: s });
24+
expect(out.count.peek()).toBe(1);
25+
s.set(2);
26+
expect(out.count.peek()).toBe(2);
27+
});
28+
29+
test("an output Getter feeds another component's input end to end", () => {
30+
// Mimic: produced.output.value -> consumed input via getter().
31+
const produced = new Signal(0);
32+
const output: Getter<number> = readonlys({ value: produced }).value;
33+
34+
const consumedInput = getter(output);
35+
produced.set(42);
36+
expect(consumedInput.peek()).toBe(42);
37+
});

js/signals/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"extends": "../tsconfig.json",
33
"compilerOptions": {
44
"outDir": "dist",
5-
"rootDir": "./src"
5+
"rootDir": "./src",
6+
"types": ["bun"]
67
},
78
"include": ["src"]
89
}

js/watch/src/audio/backend.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,22 @@
1-
import type { Getter, Signal } from "@moq/signals";
1+
import type { Getter } from "@moq/signals";
22
import type { BufferedRanges } from "../backend";
33
import type { Source } from "./source";
44

5-
// Audio specific signals that work regardless of the backend source (mse vs webcodecs).
5+
// Audio specific outputs that work regardless of the backend source (mse vs webcodecs).
66
export interface Backend {
77
// The source of the audio.
88
source: Source;
99

10-
// The volume of the audio, between 0 and 1.
11-
volume: Signal<number>;
10+
readonly output: {
11+
// The stats of the audio.
12+
readonly stats: Getter<Stats | undefined>;
1213

13-
// Whether the audio is muted.
14-
muted: Signal<boolean>;
14+
// Buffered time ranges (for MSE backend).
15+
readonly buffered: Getter<BufferedRanges>;
1516

16-
// The stats of the audio.
17-
stats: Getter<Stats | undefined>;
18-
19-
// Buffered time ranges (for MSE backend).
20-
buffered: Getter<BufferedRanges>;
21-
22-
// The AudioContext used for playback (WebCodecs backend only).
23-
context: Getter<AudioContext | undefined>;
17+
// The AudioContext used for playback (WebCodecs backend only).
18+
readonly context: Getter<AudioContext | undefined>;
19+
};
2420
}
2521

2622
export interface Stats {

0 commit comments

Comments
 (0)