diff --git a/demo/web/src/discover.ts b/demo/web/src/discover.ts index 0c2b8f50f..efde1122a 100644 --- a/demo/web/src/discover.ts +++ b/demo/web/src/discover.ts @@ -1,4 +1,4 @@ -import { Moq, Signals } from "@moq/hang"; +import { Signals } from "@moq/hang"; import type MoqWatch from "@moq/watch/element"; /** @@ -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(); @@ -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); } diff --git a/js/hang/src/container/consumer.ts b/js/hang/src/container/consumer.ts index 5a67fb9b8..50f6367ed 100644 --- a/js/hang/src/container/consumer.ts +++ b/js/hang/src/container/consumer.ts @@ -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; + // Target latency in milliseconds (default: 0). Read-only: a Getter (e.g. another + // component's output) is accepted directly. + latency?: Getter | Time.Milli; } interface Group { @@ -22,7 +23,7 @@ interface Group { export class Consumer { #track: Moq.Track; #format: Format; - #latency: Signal; + #latency: Getter; #groups: Group[] = []; #active?: number; // the active group sequence number @@ -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(() => { diff --git a/js/moq-boy/src/index.ts b/js/moq-boy/src/index.ts index 80d246711..ebf29f6a2 100644 --- a/js/moq-boy/src/index.ts +++ b/js/moq-boy/src/index.ts @@ -75,6 +75,13 @@ export class Game { readonly status = new Moq.Signals.Signal(undefined); readonly viewerId = new Moq.Signals.Signal(undefined); + // The canvas to render into, owned here and wired into the renderer (read-only there). + // The UI sets this once the is mounted. + readonly canvas = new Moq.Signals.Signal(undefined); + + // The video rendition target, owned here and wired into the video source as an input. + readonly #target = new Moq.Signals.Signal(undefined); + // Watch API objects — exposed so UI can access canvas, etc. readonly broadcast: Watch.Broadcast; readonly sync: Watch.Sync; @@ -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); @@ -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(); }); } @@ -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; } @@ -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, effect: Moq.Signals.Effect) { @@ -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); diff --git a/js/moq-boy/src/ui/components/GameCard.tsx b/js/moq-boy/src/ui/components/GameCard.tsx index dade44fa0..3c6cf930d 100644 --- a/js/moq-boy/src/ui/components/GameCard.tsx +++ b/js/moq-boy/src/ui/components/GameCard.tsx @@ -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. diff --git a/js/moq-boy/src/ui/components/StatsPanel.tsx b/js/moq-boy/src/ui/components/StatsPanel.tsx index abe68bba9..22891910f 100644 --- a/js/moq-boy/src/ui/components/StatsPanel.tsx +++ b/js/moq-boy/src/ui/components/StatsPanel.tsx @@ -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; diff --git a/js/signals/src/index.ts b/js/signals/src/index.ts index 4d4ba5c62..508556568 100644 --- a/js/signals/src/index.ts +++ b/js/signals/src/index.ts @@ -186,7 +186,44 @@ export class Signal implements Getter, Setter { } type SetterType = S extends Setter ? T : never; -type GetterType = G extends Getter ? T : never; +export type GetterType = G extends Getter ? T : never; + +// A record of named signals, used to group a component's inputs or outputs. +export type SignalMap = Record>; + +// 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 = { + readonly [K in keyof T]: Getter>; +}; + +// 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(signals: T): Readonlys { + return signals as unknown as Readonlys; +} + +// 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(value: T | Getter): Getter { + if (typeof value === "object" && value !== null && SIGNAL_BRAND in value) { + return value as Getter; + } + 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 = { [K in keyof I]?: GetterType | I[K] }; // Excludes common falsy values from a type type Falsy = false | 0 | "" | null | undefined; diff --git a/js/watch/src/audio/backend.ts b/js/watch/src/audio/backend.ts index 8685356e5..a47afab07 100644 --- a/js/watch/src/audio/backend.ts +++ b/js/watch/src/audio/backend.ts @@ -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; + readonly output: { + // The stats of the audio. + readonly stats: Getter; - // Whether the audio is muted. - muted: Signal; + // Buffered time ranges (for MSE backend). + readonly buffered: Getter; - // The stats of the audio. - stats: Getter; - - // Buffered time ranges (for MSE backend). - buffered: Getter; - - // The AudioContext used for playback (WebCodecs backend only). - context: Getter; + // The AudioContext used for playback (WebCodecs backend only). + readonly context: Getter; + }; } export interface Stats { diff --git a/js/watch/src/audio/decoder.ts b/js/watch/src/audio/decoder.ts index ce2ec2479..26d137f44 100644 --- a/js/watch/src/audio/decoder.ts +++ b/js/watch/src/audio/decoder.ts @@ -3,68 +3,79 @@ import * as Container from "@moq/hang/container"; import * as Util from "@moq/hang/util"; import type * as Moq from "@moq/net"; import { Time } from "@moq/net"; -import { Effect, type Getter, Signal } from "@moq/signals"; +import { Effect, type Getter, getter, type InputProps, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { BufferedRanges } from "../backend"; import { base64ToBytes } from "../base64"; +import type { Sync } from "../sync"; import { type AudioBuffer, createAudioBuffer } from "./buffer"; // Compiled and inlined as a blob URL via vite-plugin-worklet. import RenderWorklet from "./render-worklet.ts?worklet"; import type { Source } from "./source"; -export type DecoderProps = { +type DecoderInput = { // Enable to download the audio track. - enabled?: boolean | Signal; + enabled: Getter; }; -export interface AudioStats { - bytesReceived: number; -} - -// Downloads audio from a track and emits it to an AudioContext. -// The user is responsible for hooking up audio to speakers, an analyzer, etc. -export class Decoder { - source: Source; - enabled: Signal; - - #context = new Signal(undefined); - readonly context: Getter = this.#context; +type DecoderOutput = { + context: Signal; // The root of the audio graph, which can be used for custom visualizations. - #worklet = new Signal(undefined); // Downcast to AudioNode so it matches Publish.Audio - readonly root = this.#worklet as Getter; + root: Signal; - #sampleRate = new Signal(undefined); - readonly sampleRate: Getter = this.#sampleRate; - - #stats = new Signal(undefined); - readonly stats: Getter = this.#stats; + sampleRate: Signal; + stats: Signal; // Current playback timestamp from worklet - #timestamp = new Signal(undefined); - readonly timestamp: Getter = this.#timestamp; + timestamp: Signal; // Whether the audio buffer is stalled (waiting to fill) - #stalled = new Signal(true); - readonly stalled: Getter = this.#stalled; + stalled: Signal; + + // Combined buffered ranges (network jitter + decode buffer) + buffered: Signal; +}; + +export type DecoderProps = InputProps; + +export interface AudioStats { + bytesReceived: number; +} + +// Downloads audio from a track and emits it to an AudioContext. +// The user is responsible for hooking up audio to speakers, an analyzer, etc. +export class Decoder { + readonly input: Readonlys; + source: Source; + sync: Sync; + + readonly #output: DecoderOutput = { + context: new Signal(undefined), + root: new Signal(undefined), + sampleRate: new Signal(undefined), + stats: new Signal(undefined), + timestamp: new Signal(undefined), + stalled: new Signal(true), + buffered: new Signal([]), + }; + readonly output = readonlys(this.#output); // Decode buffer: audio sent to worklet but not yet played #decodeBuffered = new Signal([]); - // Combined buffered ranges (network jitter + decode buffer) - #buffered = new Signal([]); - readonly buffered: Getter = this.#buffered; - // Audio ring bridging main thread and worklet (shared memory or postMessage transport). #ring: AudioBuffer | undefined; #signals = new Effect(); - constructor(source: Source, props?: DecoderProps) { - this.source = source; - this.source.supported.set(supported); // super hacky + constructor(source: Source, sync: Sync, props?: DecoderProps) { + this.input = { + enabled: getter(props?.enabled ?? false), + }; - this.enabled = Signal.from(props?.enabled ?? false); + this.source = source; + this.sync = sync; this.#signals.run(this.#runWorklet.bind(this)); this.#signals.run(this.#runEnabled.bind(this)); @@ -79,7 +90,7 @@ export class Decoder { //const enabled = effect.get(this.enabled); //if (!enabled) return; - const config = effect.get(this.source.config); + const config = effect.get(this.source.output.config); if (!config) return; const sampleRate = config.sampleRate; @@ -92,7 +103,7 @@ export class Decoder { latencyHint: "interactive", // We don't use real-time because of the buffer. sampleRate, }); - effect.set(this.#context, context); + effect.set(this.#output.context, context); effect.cleanup(() => context.close()); @@ -114,7 +125,7 @@ export class Decoder { effect.cleanup(() => worklet.disconnect()); // Initial target latency in samples. - const latency = this.source.sync.buffer.peek(); + const latency = this.sync.output.buffer.peek(); const latencySamples = Math.ceil(sampleRate * Time.Second.fromMilli(latency)); // Let the factory pick the best transport (SharedArrayBuffer or postMessage). @@ -128,19 +139,19 @@ export class Decoder { // Mirror ring state (timestamp/stalled) onto our public signals. effect.run((inner) => { const ts = Time.Milli.fromMicro(inner.get(ring.timestamp)); - this.#timestamp.set(ts); + this.#output.timestamp.set(ts); this.#trimDecodeBuffered(ts); }); effect.run((inner) => { - this.#stalled.set(inner.get(ring.stalled)); + this.#output.stalled.set(inner.get(ring.stalled)); }); - effect.set(this.#worklet, worklet); + effect.set(this.#output.root, worklet); }); } #runEnabled(effect: Effect): void { - const values = effect.getAll([this.enabled, this.#context]); + const values = effect.getAll([this.input.enabled, this.#output.context]); if (!values) return; const [_, context] = values; @@ -151,31 +162,31 @@ export class Decoder { #runLatency(effect: Effect): void { // Gate on the worklet signal so this effect re-runs once the ring is created. - const worklet = effect.get(this.#worklet); + const worklet = effect.get(this.#output.root); if (!worklet) return; const ring = this.#ring; if (!ring) return; - const latency = effect.get(this.source.sync.buffer); + const latency = effect.get(this.sync.output.buffer); const latencySamples = Math.ceil(ring.rate * Time.Second.fromMilli(latency)); ring.setLatency(latencySamples); } #runDecoder(effect: Effect): void { - const enabled = effect.get(this.enabled); + const enabled = effect.get(this.input.enabled); if (!enabled) return; - const broadcast = effect.get(this.source.broadcast); + const broadcast = effect.get(this.source.input.broadcast); if (!broadcast) return; - const track = effect.get(this.source.track); + const track = effect.get(this.source.output.track); if (!track) return; - const config = effect.get(this.source.config); + const config = effect.get(this.source.output.config); if (!config) return; - const active = effect.get(broadcast.active); + const active = effect.get(broadcast.output.active); if (!active) return; const sub = active.subscribe(track, Catalog.PRIORITY.audio); @@ -194,7 +205,7 @@ export class Decoder { // TODO include JITTER_UNDERHEAD const consumer = new Container.Consumer(sub, { format, - latency: this.source.sync.buffer, + latency: this.sync.output.buffer, }); effect.cleanup(() => consumer.close()); @@ -202,7 +213,7 @@ export class Decoder { effect.run((inner) => { const network = inner.get(consumer.buffered); const decode = inner.get(this.#decodeBuffered); - this.#buffered.update(() => Container.mergeBufferedRanges(network, decode)); + this.#output.buffered.update(() => Container.mergeBufferedRanges(network, decode)); }); effect.spawn(async () => { @@ -248,9 +259,9 @@ export class Decoder { // Mark that we received this frame right now. const timestamp = Time.Milli.fromMicro(frame.timestamp as Time.Micro); - this.source.sync.received(timestamp, "audio"); + this.sync.received(timestamp, "audio"); - this.#stats.update((stats) => ({ + this.#output.stats.update((stats) => ({ bytesReceived: (stats?.bytesReceived ?? 0) + frame.data.byteLength, })); @@ -281,7 +292,7 @@ export class Decoder { const consumer = new Container.Consumer(sub, { format: new Container.Cmaf.Format(init), - latency: this.source.sync.buffer, + latency: this.sync.output.buffer, }); effect.cleanup(() => consumer.close()); @@ -289,7 +300,7 @@ export class Decoder { effect.run((inner) => { const network = inner.get(consumer.buffered); const decode = inner.get(this.#decodeBuffered); - this.#buffered.update(() => Container.mergeBufferedRanges(network, decode)); + this.#output.buffered.update(() => Container.mergeBufferedRanges(network, decode)); }); effect.spawn(async () => { @@ -320,9 +331,9 @@ export class Decoder { if (!frame) continue; const timestamp = Time.Milli.fromMicro(frame.timestamp); - this.source.sync.received(timestamp, "audio"); + this.sync.received(timestamp, "audio"); - this.#stats.update((stats) => ({ + this.#output.stats.update((stats) => ({ bytesReceived: (stats?.bytesReceived ?? 0) + frame.data.byteLength, })); @@ -409,7 +420,7 @@ export class Decoder { } } -async function supported(config: Catalog.AudioConfig): Promise { +export async function decoderSupported(config: Catalog.AudioConfig): Promise { // Opus in CMAF uses raw packets; dOps is not a valid OGG Identification Header. let description: Uint8Array | undefined; if (config.codec !== "opus") { diff --git a/js/watch/src/audio/emitter.ts b/js/watch/src/audio/emitter.ts index fce3c44e5..afd5b9e42 100644 --- a/js/watch/src/audio/emitter.ts +++ b/js/watch/src/audio/emitter.ts @@ -1,66 +1,59 @@ -import { Effect, Signal } from "@moq/signals"; +import { Effect, type Getter, getter, type InputProps, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { Decoder } from "./decoder"; const MIN_GAIN = 0.001; const FADE_TIME = 0.2; -export type EmitterProps = { - volume?: number | Signal; - muted?: boolean | Signal; - paused?: boolean | Signal; +type EmitterInput = { + volume: Getter; + muted: Getter; + + // Similar to muted, but controls whether we download audio at all. + // That way we can be "muted" but also download audio for visualizations. + paused: Getter; +}; + +type EmitterOutput = { + // Whether audio should be downloaded. Wired into the decoder's `enabled` input by the owner. + enabled: Signal; }; +export type EmitterProps = InputProps; + // A helper that emits audio directly to the speakers. export class Emitter { source: Decoder; - volume: Signal; - muted: Signal; - // Similar to muted, but controls whether we download audio at all. - // That way we can be "muted" but also download audio for visualizations. - paused: Signal; + readonly input: Readonlys; - #signals = new Effect(); + readonly #output: EmitterOutput = { + enabled: new Signal(false), + }; + readonly output = readonlys(this.#output); - // The volume to use when unmuted. - #unmuteVolume = 0.5; + #signals = new Effect(); // The gain node used to adjust the volume. #gain = new Signal(undefined); constructor(source: Decoder, props?: EmitterProps) { this.source = source; - this.volume = Signal.from(props?.volume ?? 0.5); - this.muted = Signal.from(props?.muted ?? false); - this.paused = Signal.from(props?.paused ?? props?.muted ?? false); - - // Set the volume to 0 when muted. - this.#signals.run((effect) => { - const muted = effect.get(this.muted); - if (muted) { - this.#unmuteVolume = this.volume.peek() || 0.5; - this.volume.set(0); - } else { - this.volume.set(this.#unmuteVolume); - } - }); - - this.#signals.run((effect) => { - const enabled = !effect.get(this.paused) && !effect.get(this.muted); - this.source.enabled.set(enabled); - }); + this.input = { + volume: getter(props?.volume ?? 0.5), + muted: getter(props?.muted ?? false), + paused: getter(props?.paused ?? props?.muted ?? false), + }; - // Set unmute when the volume is non-zero. this.#signals.run((effect) => { - const volume = effect.get(this.volume); - this.muted.set(volume === 0); + const enabled = !effect.get(this.input.paused) && !effect.get(this.input.muted); + this.#output.enabled.set(enabled); }); this.#signals.run((effect) => { - const root = effect.get(this.source.root); + const root = effect.get(this.source.output.root); if (!root) return; - const gain = new GainNode(root.context, { gain: effect.get(this.volume) }); + const gain = new GainNode(root.context, { gain: effect.get(this.input.volume) }); root.connect(gain); effect.set(this.#gain, gain); @@ -68,7 +61,7 @@ export class Emitter { effect.run((inner) => { // We only connect/disconnect when enabled to save power. // Otherwise the worklet keeps running in the background returning 0s. - const enabled = inner.get(this.source.enabled); + const enabled = inner.get(this.#output.enabled); if (!enabled) return; gain.connect(root.context.destination); // speakers @@ -83,7 +76,7 @@ export class Emitter { // Cancel any scheduled transitions on change. effect.cleanup(() => gain.gain.cancelScheduledValues(gain.context.currentTime)); - const volume = effect.get(this.volume); + const volume = effect.get(this.input.volume); if (volume < MIN_GAIN) { gain.gain.exponentialRampToValueAtTime(MIN_GAIN, gain.context.currentTime + FADE_TIME); gain.gain.setValueAtTime(0, gain.context.currentTime + FADE_TIME + 0.01); diff --git a/js/watch/src/audio/mse.ts b/js/watch/src/audio/mse.ts index c63517e1c..f30cb6005 100644 --- a/js/watch/src/audio/mse.ts +++ b/js/watch/src/audio/mse.ts @@ -1,65 +1,76 @@ import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; import * as Moq from "@moq/net"; -import { Effect, type Getter, Signal } from "@moq/signals"; +import { Effect, type Getter, getter, type InputProps, type Readonlys, readonlys, Signal } from "@moq/signals"; import { type BufferedRanges, timeRangesToArray } from "../backend"; import { base64ToBytes } from "../base64"; import type { Muxer } from "../mse"; +import type { Sync } from "../sync"; import type { Backend, Stats } from "./backend"; import type { Source } from "./source"; -export type MseProps = { - volume?: number | Signal; - muted?: boolean | Signal; +type MseInput = { + volume: Getter; + muted: Getter; }; +type MseOutput = { + stats: Signal; + buffered: Signal; + + // MSE plays through the