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
6 changes: 3 additions & 3 deletions demo/web/src/discover.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Moq, Signals } from "@moq/hang";
import { Signals } from "@moq/hang";
import type MoqWatch from "@moq/watch/element";

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

this.#clearSuggestions();

Expand Down Expand Up @@ -69,7 +69,7 @@ export default class MoqDiscover extends HTMLElement {
});
}
tag.addEventListener("click", () => {
watch.broadcast.name.set(Moq.Path.from(name));
watch.name = name;
});
this.#suggestions.appendChild(tag);
}
Expand Down
11 changes: 6 additions & 5 deletions js/hang/src/container/consumer.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { Time } from "@moq/net";
import * as Moq from "@moq/net";
import { Effect, type Getter, Signal } from "@moq/signals";
import { Effect, type Getter, getter, Signal } from "@moq/signals";

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

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

interface Group {
Expand All @@ -22,7 +23,7 @@ interface Group {
export class Consumer {
#track: Moq.Track;
#format: Format;
#latency: Signal<Time.Milli>;
#latency: Getter<Time.Milli>;
#groups: Group[] = [];
#active?: number; // the active group sequence number

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

this.#signals.spawn(this.#run.bind(this));
this.#signals.cleanup(() => {
Expand Down
48 changes: 31 additions & 17 deletions js/moq-boy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ export class Game {
readonly status = new Moq.Signals.Signal<GameStatus | undefined>(undefined);
readonly viewerId = new Moq.Signals.Signal<string | undefined>(undefined);

// The canvas to render into, owned here and wired into the renderer (read-only there).
// The UI sets this once the <canvas> is mounted.
readonly canvas = new Moq.Signals.Signal<HTMLCanvasElement | undefined>(undefined);

// The video rendition target, owned here and wired into the video source as an input.
readonly #target = new Moq.Signals.Signal<Watch.Video.Target | undefined>(undefined);

// Watch API objects — exposed so UI can access canvas, etc.
readonly broadcast: Watch.Broadcast;
readonly sync: Watch.Sync;
Expand Down Expand Up @@ -110,30 +117,37 @@ export class Game {
});
this.#signals.cleanup(() => this.broadcast.close());

this.sync = new Watch.Sync({ latency: this.latency, connection: connection.established });
this.#signals.cleanup(() => this.sync.close());

this.videoSource = new Watch.Video.Source(this.sync, { broadcast: this.broadcast });
// Sources no longer depend on Sync; they produce the per-rendition jitter that
// Sync consumes, so they're created first to avoid a construction cycle.
this.videoSource = new Watch.Video.Source({ broadcast: this.broadcast, target: this.#target });
this.#signals.cleanup(() => this.videoSource.close());

this.audioSource = new Watch.Audio.Source({ broadcast: this.broadcast });
this.#signals.cleanup(() => this.audioSource.close());

this.sync = new Watch.Sync({
latency: this.latency,
connection: connection.established,
video: this.videoSource.output.jitter,
audio: this.audioSource.output.jitter,
});
this.#signals.cleanup(() => this.sync.close());

this.#signals.run(this.#runPixelBudget.bind(this));

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

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

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

// Audio pipeline — the emitter controls muted (volume) and paused (download).
this.audioSource = new Watch.Audio.Source(this.sync, { broadcast: this.broadcast });
this.#signals.cleanup(() => this.audioSource.close());

this.audioDecoder = new Watch.Audio.Decoder(this.audioSource);
this.audioDecoder = new Watch.Audio.Decoder(this.audioSource, this.sync);
this.#signals.cleanup(() => this.audioDecoder.close());

const audioPaused = new Moq.Signals.Signal(true);
Expand All @@ -149,7 +163,7 @@ export class Game {
// Resume AudioContext on first user interaction (browser autoplay policy).
for (const event of ["click", "touchstart", "touchend", "mousedown", "keydown"]) {
this.#signals.event(document, event, () => {
const ctx = this.audioDecoder.context.peek();
const ctx = this.audioDecoder.output.context.peek();
if (ctx?.state === "suspended") ctx.resume();
});
}
Expand Down Expand Up @@ -179,11 +193,11 @@ export class Game {
/** Collect media timestamps at each pipeline stage for latency measurement. */
#timestamps(): { label: string; ts: number }[] {
const entries: { label: string; ts: number }[] = [];
const received = this.sync.timestamp.peek();
const received = this.sync.output.timestamp.peek();
if (received != null) entries.push({ label: "received", ts: received });
const decoded = this.videoDecoder.timestamp.peek();
const decoded = this.videoDecoder.output.timestamp.peek();
if (decoded != null) entries.push({ label: "decoded", ts: decoded });
const rendered = this.videoRenderer.timestamp.peek();
const rendered = this.videoRenderer.output.timestamp.peek();
if (rendered != null) entries.push({ label: "rendered", ts: rendered });
return entries;
}
Expand All @@ -205,7 +219,7 @@ export class Game {
const exp = effect.get(this.expanded);
// Native GB is 160x144 = 23040 pixels. When expanded, allow 4x for quality.
const pixels = exp === this.sessionId ? GB_PIXELS * 4 : GB_PIXELS;
this.videoSource.target.set({ pixels });
this.#target.set({ pixels });
}

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

#runStatus(effect: Moq.Signals.Effect) {
const active = effect.get(this.broadcast.active);
const active = effect.get(this.broadcast.output.active);
if (!active) return;

const statusTrack = active.subscribe("status", 10);
Expand Down
4 changes: 2 additions & 2 deletions js/moq-boy/src/ui/components/GameCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ function GameCardInner() {
let canvasRef!: HTMLCanvasElement;
const signals = new Signals.Effect();

// Set canvas on the video renderer once mounted.
// Set canvas on the game once mounted; it wires it into the renderer.
onMount(() => {
game.videoRenderer.canvas.set(canvasRef);
game.canvas.set(canvasRef);
});

// Keyboard input — preventDefault when expanded or hovered.
Expand Down
2 changes: 1 addition & 1 deletion js/moq-boy/src/ui/components/StatsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default function StatsPanel() {
const ctx = useGameUI();
const game = ctx.game;

const jitter = createAccessor(game.sync.jitter);
const jitter = createAccessor(game.sync.output.jitter);

const onJitterInput = (e: Event) => {
const el = e.currentTarget as HTMLInputElement;
Expand Down
39 changes: 38 additions & 1 deletion js/signals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,44 @@ export class Signal<T> implements Getter<T>, Setter<T> {
}

type SetterType<S> = S extends Setter<infer T> ? T : never;
type GetterType<G> = G extends Getter<infer T> ? T : never;
export type GetterType<G> = G extends Getter<infer T> ? T : never;

// A record of named signals, used to group a component's inputs or outputs.
export type SignalMap = Record<string, Getter<unknown>>;

// A read-only view over a SignalMap: every entry collapses to its Getter and the
// record itself is readonly. Consumers can peek/subscribe but can neither call set()
// nor swap a signal out, so the owning component keeps sole write access.
export type Readonlys<T extends SignalMap> = {
readonly [K in keyof T]: Getter<GetterType<T[K]>>;
};

// Re-type a record of Signals as read-only Getters. This is the identity function at
// runtime; it only narrows the static type. Keep the original (writable) reference
// private for the component to set, and expose the result as the public output.
//
// readonly #output = { status: new Signal("offline") };
// readonly output = readonlys(this.#output); // status is now a Getter to callers
export function readonlys<T extends SignalMap>(signals: T): Readonlys<T> {
return signals as unknown as Readonlys<T>;
}

// Build a read-only Getter from a value or an existing readable. The read-only
// counterpart to `Signal.from`: a branded Signal (including the output of `readonlys`)
// is reused as-is, so one component's output can be wired straight into another's
// input; any other value is wrapped in a fresh Signal. The brand check means a
// hand-rolled Getter that isn't backed by a Signal would be treated as a value.
export function getter<T>(value: T | Getter<T>): Getter<T> {
if (typeof value === "object" && value !== null && SIGNAL_BRAND in value) {
return value as Getter<T>;
}
return new Signal(value as T);
}

// Derive a component's constructor props from its input map: every input becomes
// optional and accepts a raw value, a Signal, or another component's output Getter
// (the `getter()` contract). Removes the hand-written, drift-prone props interface.
export type InputProps<I extends SignalMap> = { [K in keyof I]?: GetterType<I[K]> | I[K] };

// Excludes common falsy values from a type
type Falsy = false | 0 | "" | null | undefined;
Expand Down
24 changes: 10 additions & 14 deletions js/watch/src/audio/backend.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
import type { Getter, Signal } from "@moq/signals";
import type { Getter } from "@moq/signals";
import type { BufferedRanges } from "../backend";
import type { Source } from "./source";

// Audio specific signals that work regardless of the backend source (mse vs webcodecs).
// Audio specific outputs that work regardless of the backend source (mse vs webcodecs).
export interface Backend {
// The source of the audio.
source: Source;

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

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

// The stats of the audio.
stats: Getter<Stats | undefined>;

// Buffered time ranges (for MSE backend).
buffered: Getter<BufferedRanges>;

// The AudioContext used for playback (WebCodecs backend only).
context: Getter<AudioContext | undefined>;
// The AudioContext used for playback (WebCodecs backend only).
readonly context: Getter<AudioContext | undefined>;
};
}

export interface Stats {
Expand Down
Loading
Loading