Skip to content

Conversation

@kixelated
Copy link
Collaborator

@kixelated kixelated commented Aug 29, 2025

Summary by CodeRabbit

  • New Features

    • Unified Source model added with Camera, Microphone, Screen and Device sources plus a new Source export for publishing and device selection.
  • Improvements

    • Many props now accept reactive signals for live updates (enable, mute, volume, name, captions, preview, location, etc.).
    • Publish UI and audio/video pipelines now driven by sources (source replaces device), simplifying status and controls.
  • Chores

    • Removed automated Claude review workflows; demo/documentation examples updated to reflect source-based API.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 29, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Widespread API changes make many props accept Signals via a new Signal.from; publish pipeline switches from media/device to source-based APIs (Device/Camera/Microphone/Screen); audio/video encoding and catalog wiring updated to use sources; various cleanups and two CI workflows removed.

Changes

Cohort / File(s) Summary of changes
Signals core
js/signals/src/index.ts
Added Signal.from<T>(...), Effect.reload(), renamed Effect.eventListener(...)event(...), and added spawn error logging.
DOM helpers
js/signals/src/dom.ts
Removed events from CreateOptions and deleted automatic event listener wiring in element creation.
Source modules (new)
js/hang/src/publish/source/index.ts, .../device.ts, .../camera.ts, .../microphone.ts, .../screen.ts
New reactive Device manager and Camera/Microphone/Screen components exposing enabled/constraints/stream Signals and lifecycle methods; re-exported via source/index.ts.
Publish exports
js/hang/src/publish/index.ts
Added export * as Source from "./source".
Publish audio
js/hang/src/publish/audio/index.ts, .../captions.ts, .../speaking.ts
Replaced media/constraints with source (AudioStreamTrack/Source); many props now accept Signals; encoder/stream logic refactored to use source; added worker/worklet cleanup (nulling handlers).
Publish video
js/hang/src/publish/video/index.ts, .../polyfill.ts, .../detection.ts
Introduced VideoStreamTrack/Source alias; VideoProps now uses source and Signalized flags; encoding pipeline refactored to consume source and TrackProducer; polyfill signature updated; detection gating simplified and accepts Signals.
Publish element & UI
js/hang/src/publish/element.ts, js/hang-demo/src/publish.html
Replaced public device with source; UI rewritten for Source options (microphone/camera/screen/nothing); demo updated to remove boolean audio/video attributes and show new wiring.
Broadcast (publish/watch)
js/hang/src/publish/broadcast.ts, js/hang/src/watch/broadcast.ts
BroadcastProps fields (enabled, name, user, reload) accept Signals; constructors use Signal.from(...); publish side removed public device type/property; catalog publishing preserved.
Meet / Preview / Connection
js/hang/src/connection.ts, js/hang/src/meet/element.ts, js/hang/src/meet/room.ts, js/hang/src/preview/*
Numerous props (e.g., ConnectionProps.url, RoomProps.name, preview/member enabled) now accept Signals; constructors switched to Signal.from(...); local-video wiring moved from .media.source.
Publish/Watch: other props
multiple js/hang/src/{publish,watch} files
Many props (location/current/handle, preview/info, chat/message/typing, audio/speaking/emitter, video/renderer/detection, etc.) broadened to accept Signals; constructors unified to Signal.from(...); small cleanup additions (worklet/worker message nulling).
Signals DOM API & misc
js/signals/src/dom.ts, js/moq-clock/src/main.ts
Removed element event option; added a // @ts-ignore directive above a Deno import in moq-clock.
CI workflows removed
.github/workflows/claude-code-review.yml, .github/workflows/claude-dispatch.yml
Deleted two Claude-related GitHub Actions workflows.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant HangPublish
  participant DeviceMgr as DeviceManager
  participant Source as Source.Camera/Screen/Microphone
  participant Broadcast
  participant PubAudio as Publish.Audio
  participant PubVideo as Publish.Video

  User->>HangPublish: set source("camera"|"screen"|undefined)
  HangPublish->>DeviceMgr: select device (if applicable)
  DeviceMgr-->>HangPublish: selected MediaDeviceInfo
  HangPublish->>Source: instantiate Camera/Microphone/Screen
  note over Source: getUserMedia / getDisplayMedia → tracks
  Source-->>HangPublish: stream { video?, audio? }
  HangPublish->>Broadcast: set video.source / audio.source (Signals)
  Broadcast->>PubAudio: audio.source (reactive)
  Broadcast->>PubVideo: video.source (reactive)

  par Audio path
    PubAudio->>PubAudio: read enabled + source
    PubAudio->>PubAudio: encoder configured from source.getSettings()
    PubAudio->>Broadcast: publish audio groups
  and Video path
    PubVideo->>PubVideo: process frames via VideoTrackProcessor(source)
    PubVideo->>Broadcast: publish video groups
  end
Loading
sequenceDiagram
  autonumber
  participant App
  participant Component
  participant Signals as Signal/Effect

  App->>Component: prop (value or Signal<T>)
  Component->>Signals: Signal.from(prop)
  Signals-->>Component: Signal<T> (identity if already Signal)
  Component->>Signals: Effect.event(...) usage
  Component->>Signals: Effect.reload() (reschedule)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

I twitch my whiskers at fresh code streams,
Signals hum and stitch reactive dreams.
Camera, mic, and screen awake—
Sources feed the frames we make.
Hop, hop, hooray—new wires in place! 🐇✨


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between bf2d4bb and ecacd91.

📒 Files selected for processing (1)
  • js/hang/src/publish/source/device.ts (1 hunks)
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch device-manager

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (16)
js/hang/src/publish/audio/captions.ts (1)

10-15: Remove or wire the transcribe prop
transcribe is declared in CaptionsProps but never read in the implementation. Either:

  • Drop the transcribe?: boolean line from js/hang/src/publish/audio/captions.ts (and update the demo in js/hang-demo/src/publish.html)
  • Or add a runtime check (e.g. if (!props.transcribe) return;) where transcription is triggered.
js/hang/src/connection.ts (1)

83-96: Based on inspection of the Moq library’s Connection implementations, closed() is defined only in the root js/moq/src/connection.ts and wraps the underlying WebTransport.closed promise, which always resolves on normal close (never rejects) and only rejects on internal promise errors (not on normal disconnect). As such, clean closures currently bypass your retry logic. Keeping the existing catch-only retry path is insufficient.

Trigger retry on clean closes
After the Promise.race, detect when connection.closed() wins without error and schedule the backoff retry in the same block as the error path, without retrying on effect cancellation:

-               await Promise.race([cancel, connection.closed()]);
+               const canceled = await Promise.race([
+                   cancel.then(() => true),
+                   connection.closed().then(() => false),
+               ]);
+               if (!canceled && this.reload) {
+                   this.status.set("disconnected");
+                   const tick = this.#tick.peek() + 1;
+                   effect.timer(() => this.#tick.set(prev => Math.max(prev, tick)), this.#delay);
+                   this.#delay = Math.min(this.#delay * 2, this.maxDelay);
+               }

This ensures clean closes re-trigger the effect while still skipping retry when the effect is canceled.

js/hang/src/publish/audio/index.ts (4)

117-121: Avoid passing undefined sampleRate into AudioContext

Only set sampleRate if present to prevent odd UA behavior.

-const context = new AudioContext({
-  latencyHint: "interactive",
-  sampleRate: settings.sampleRate,
-});
+const contextOptions: AudioContextOptions = { latencyHint: "interactive" };
+if (settings.sampleRate) contextOptions.sampleRate = settings.sampleRate;
+const context = new AudioContext(contextOptions);

148-156: Clear #worklet and #gain on teardown to prevent stale references

Without clearing, #runEncoder/#runGain may see a closed node.

 effect.spawn(async () => {
@@
   effect.set(this.#worklet, worklet);
@@
   effect.set(this.#gain, gain);
 });
+// Ensure downstream effects stop when this effect tears down
+effect.cleanup(() => {
+  worklet.port.onmessage = null as unknown as (ev: MessageEvent) => void;
+  effect.set(this.#gain, undefined);
+  effect.set(this.#worklet, undefined);
+});

239-247: AudioDataInit includes no “transfer” field; this won’t type-check

Also guard for empty/mismatched channel arrays.

-const frame = new AudioData({
+if (channels.length === 0 || channels.length !== settings.channelCount) {
+  // Mismatch between capture and configured channel count; drop frame.
+  return;
+}
+const frame = new AudioData({
   format: "f32-planar",
   sampleRate: worklet.context.sampleRate,
   numberOfFrames: channels[0].length,
   numberOfChannels: channels.length,
   timestamp: (1_000_000 * data.timestamp) / worklet.context.sampleRate,
   data: joined,
-  transfer: [joined.buffer],
 });

218-228: Flush encoder before closing to avoid dropping final frames

Effect cleanups are sync—spawn a flush.

-effect.cleanup(() => encoder.close());
+effect.cleanup(() => {
+  // Best-effort flush then close
+  Promise.resolve(encoder.flush()).catch(() => {}).finally(() => encoder.close());
+});
js/hang/src/publish/chat/typing.ts (1)

29-40: Unset catalog on cleanup to avoid stale availability

When disabled, catalog should go undefined so watchers don’t think the track is still present.

 		this.#signals.effect((effect) => {
 			const enabled = effect.get(this.enabled);
 			if (!enabled) return;

 			broadcast.insertTrack(this.#track.consume());
 			effect.cleanup(() => broadcast.removeTrack(this.#track.name));

 			this.catalog.set({
 				name: this.#track.name,
 				priority: u8(this.#track.priority),
 			});
+			effect.cleanup(() => this.catalog.set(undefined));
 		});
js/hang/src/publish/video/polyfill.ts (1)

27-33: Stop the stream when the track ends to avoid leaks

Currently the loop will keep enqueuing frames after the track stops. Close the stream on track end and guard in the pull loop.

-	return new ReadableStream<VideoFrame>({
-		async start() {
+	return new ReadableStream<VideoFrame>({
+		async start(controller) {
 			video = document.createElement("video") as HTMLVideoElement;
 			video.srcObject = new MediaStream([track]);
+			track.addEventListener("ended", () => controller.close(), { once: true });
 			await Promise.all([
 				video.play(),
 				new Promise((r) => {
 					video.onloadedmetadata = r;
 				}),
 			]);
@@
-		async pull(controller) {
+		async pull(controller) {
 			while (true) {
+				// If the track is no longer live, close the stream.
+				if (track.readyState !== "live") {
+					controller.close();
+					return;
+				}
 				const now = performance.now();
 				if (now - last < 1000 / frameRate) {
 					await new Promise((r) => requestAnimationFrame(r));
 					continue;
 				}

Also applies to: 49-61

js/hang/src/watch/video/detection.ts (1)

31-35: Enabled toggle doesn’t gate subscription; can leak CPU and stay subscribed

Second effect does not check enabled, and the first effect never clears #catalog. Once enabled, detection may continue reading after disable. Gate the second effect on enabled and clear #catalog on cleanup.

 		this.#signals.effect((effect) => {
-			if (!effect.get(this.enabled)) return;
-			this.#catalog.set(effect.get(catalog)?.detection);
+			if (!effect.get(this.enabled)) return;
+			this.#catalog.set(effect.get(catalog)?.detection);
+			effect.cleanup(() => this.#catalog.set(undefined));
 		});
@@
-		this.#signals.effect((effect) => {
+		this.#signals.effect((effect) => {
+			if (!effect.get(this.enabled)) return;
 			const catalog = effect.get(this.#catalog);
 			if (!catalog) return;
 
 			const broadcast = effect.get(this.broadcast);
 			if (!broadcast) return;

Also applies to: 36-57

js/hang/src/publish/chat/message.ts (1)

29-37: Catalog not cleared when disabled.

After disabling, catalog retains the last value, advertising a non-existent track.

     this.#signals.effect((effect) => {
       const enabled = effect.get(this.enabled);
       if (!enabled) return;

       broadcast.insertTrack(this.#track.consume());
       effect.cleanup(() => broadcast.removeTrack(this.#track.name));

-      this.catalog.set({ name: this.#track.name, priority: u8(this.#track.priority) });
+      this.catalog.set({ name: this.#track.name, priority: u8(this.#track.priority) });
+      effect.cleanup(() => this.catalog.set(undefined));
     });
js/hang/src/publish/video/detection.ts (1)

49-51: Catalog should clear on cleanup.

Avoid exposing a stale detection catalog after disable.

     this.#catalog.set({
       track: { name: this.#track.name, priority: Catalog.u8(this.#track.priority) },
     });
+    effect.cleanup(() => this.#catalog.set(undefined));
js/hang/src/watch/video/renderer.ts (1)

30-36: Scheduler doesn’t react to canvas/paused/enabled changes; can throw when canvas becomes undefined.
Because #schedule uses peek and isn’t subscribed to dependencies, rAF may remain scheduled after props change. If canvas is removed mid-frame, #render throws. Also, rAF keeps running even when source.enabled is false.

Apply these diffs to make scheduling reactive and robust:

@@
 		this.#signals.effect((effect) => {
 			const canvas = effect.get(this.canvas);
 			this.#ctx.set(canvas?.getContext("2d") ?? undefined);
 		});
 
-		this.#signals.effect(this.#schedule.bind(this));
+		// (Re)schedule on relevant changes.
+		this.#signals.effect((effect) => {
+			effect.get(this.canvas);
+			effect.get(this.paused);
+			effect.get(this.source.enabled);
+			this.#schedule();
+		});
 		this.#signals.effect(this.#runEnabled.bind(this));
@@
 	#schedule() {
 		const ctx = this.#ctx.peek();
 		const paused = this.paused.peek();
+		const enabled = this.source.enabled.peek();
 
-		if (ctx && !paused) {
+		if (ctx && !paused && enabled) {
 			if (!this.#animate) {
 				this.#animate = requestAnimationFrame(this.#render.bind(this));
 			}
 		} else {
 			if (this.#animate) {
 				cancelAnimationFrame(this.#animate);
 				this.#animate = undefined;
 			}
 		}
 	}
@@
 	#render() {
 		// Schedule the next render.
 		this.#animate = undefined;
 		this.#schedule();
 
 		const ctx = this.#ctx.peek();
-		if (!ctx) {
-			throw new Error("scheduled without a canvas");
-		}
+		if (!ctx || !this.source.enabled.peek()) {
+			// Canvas detached or source disabled; nothing to draw.
+			return;
+		}

Also applies to: 88-104, 105-113

js/hang/src/meet/room.ts (1)

108-111: Avoid tearing down locals when announcements stream ends or filter changes.
In finally you call this.close(), which also clears locals and fires callbacks. On name/filter changes this is surprising.

Apply this diff to only clear remotes/active here and leave locals intact:

-		} finally {
-			this.close();
-		}
+		} finally {
+			// Only clear remotes/active; keep locals.
+			for (const [n, watch] of this.remotes) {
+				watch.close();
+				this.#onRemote?.(n, undefined);
+			}
+			for (const n of this.active.keys()) {
+				this.#onActive?.(n, undefined);
+			}
+			this.remotes = new Map();
+			this.active = new Map();
+		}

If you prefer, extract this into a private helper for readability.

js/hang/src/watch/video/index.ts (1)

100-101: Clear frames on disable/teardown of the decoding effect to prevent stale redraws.
When enabled flips to false, the last frame persists and the renderer can keep scheduling. Clear buffered frames in the effect cleanup.

Apply:

-		effect.cleanup(() => decoder.close());
+		effect.cleanup(() => {
+			decoder.close();
+			this.frame.set((prev) => {
+				prev?.close();
+				return undefined;
+			});
+			this.#next?.close();
+			this.#next = undefined;
+		});
js/hang/src/watch/broadcast.ts (1)

119-130: Avoid stale #broadcast handle on cleanup.
When the effect re-runs (name/enabled/active changes), #broadcast keeps pointing at the old (closed) consumer. Restore it to undefined on cleanup.

Apply:

-    const broadcast = conn.consume(name);
-    effect.cleanup(() => broadcast.close());
-    effect.set(this.#broadcast, broadcast);
+    const broadcast = conn.consume(name);
+    effect.set(this.#broadcast, broadcast, undefined);
+    effect.cleanup(() => broadcast.close());
js/hang/src/publish/video/index.ts (1)

171-196: Guard against missing frameRate and zero dimensions.
getSettings().frameRate can be undefined on some browsers; width/height could be 0 briefly. Prevent NaN/INF bitrate.

   static async #bestEncoderConfig(settings: VideoTrackSettings, frame: VideoFrame): Promise<VideoEncoderConfig> {
-    const width = frame.codedWidth;
-    const height = frame.codedHeight;
-    const framerate = settings.frameRate;
+    const width = Math.max(1, frame.codedWidth);
+    const height = Math.max(1, frame.codedHeight);
+    const framerate = settings.frameRate ?? 30;
@@
-    const framerateFactor = 30.0 + (framerate - 30) / 2;
+    const framerateFactor = 30.0 + (framerate - 30) / 2;
🧹 Nitpick comments (24)
js/moq-clock/src/main.ts (1)

3-4: Prefer ts-expect-error over ts-ignore (safer suppression).

ts-expect-error will fail the build if the error disappears, keeping the suppression honest.

Apply:

-// @ts-ignore Deno import.
+// @ts-expect-error External TypeScript can't resolve Deno's jsr: scheme.
js/signals/src/index.ts (2)

27-33: Signal.from is a solid ergonomics win.

Minor: instanceof can fail across duplicated bundles; if this ever happens, consider duck-typing (peek/subscribe) fallback.


398-406: Fix log message to match the renamed API (event vs eventListener).

Prevents confusion during debugging.

Apply:

-				console.warn("Effect.eventListener called when closed, ignoring");
+				console.warn("Effect.event called when closed, ignoring");
js/hang/src/publish/audio/index.ts (2)

104-110: Remove noisy console logs or gate behind a debug flag

These will spam in production and re-fire on every reactive tick.

- console.log("running audio source", enabled);
+ // debug: console.log("running audio source", enabled);
@@
- console.log("running audio source", source);
+ // debug: console.log("running audio source", source);

18-22: Unused AudioConstraints type

It appears unused after the source migration—remove to reduce surface area.

-export type AudioConstraints = Omit<
-  MediaTrackConstraints,
-  "aspectRatio" | "backgroundBlur" | "displaySurface" | "facingMode" | "frameRate" | "height" | "width"
->;
+// (removed: constraints moved to Source-based API)
js/hang/src/meet/element.ts (1)

112-114: Rename callback param to reflect new semantics (track, not media)

Purely readability; reduces confusion post-migration.

-const cleanup = broadcast.video.source.subscribe((media) => {
-  video.srcObject = media ? new MediaStream([media]) : null;
+const cleanup = broadcast.video.source.subscribe((track) => {
+  video.srcObject = track ? new MediaStream([track]) : null;
 });
js/hang/src/watch/chat/typing.ts (1)

31-34: Optional: clear catalog on disable to avoid stale pointers

Harmless either way; adds symmetry with active reset.

 this.#signals.effect((effect) => {
   if (!effect.get(this.enabled)) return;
   this.#catalog.set(effect.get(catalog)?.chat?.typing);
+  effect.cleanup(() => this.#catalog.set(undefined));
 });
js/hang/src/watch/chat/message.ts (2)

62-64: Fix dequal-avoidance mismatch

Comment says to avoid the dequal check but code doesn’t. Set via function.

-				// Use a function to avoid the dequal check.
-				this.#latest.set(frame);
+				// Use a function to avoid the dequal check.
+				this.#latest.set(() => frame);

33-36: Optional: clear cached catalog on disable to avoid stale reads

Prevents consumers from seeing a non-existent track after disabling.

 		this.#signals.effect((effect) => {
 			if (!effect.get(this.enabled)) return;
 			this.#catalog.set(effect.get(catalog)?.chat?.message);
+			effect.cleanup(() => this.#catalog.set(undefined));
 		});
js/hang/src/watch/audio/index.ts (2)

83-85: Comment contradicts code path

We only create AudioContext when enabled; update comment.

-		// NOTE: We still create an AudioContext even when muted.
-		// This way we can process the audio for visualizations.
+		// NOTE: We only create an AudioContext when enabled.
+		// If visualizations while muted are desired, move the gating to the decoder only.

153-155: Unify cancellation check (minor)

Prefer strict undefined to match other modules and avoid future falsey pitfalls.

-					if (!frame) break;
+					if (frame === undefined) break;
js/hang/src/watch/audio/speaking.ts (1)

47-55: Harden read loop against subscription errors.

Wrap the read loop to avoid unhandled rejections from sub.readBool() and ensure state resets on error.

-    effect.spawn(async (cancel) => {
-      for (;;) {
-        const speaking = await Promise.race([sub.readBool(), cancel]);
-        if (speaking === undefined) break;
-
-        this.#active.set(speaking);
-      }
-    });
+    effect.spawn(async (cancel) => {
+      try {
+        for (;;) {
+          const speaking = await Promise.race([sub.readBool(), cancel]);
+          if (speaking === undefined) break;
+          this.#active.set(speaking);
+        }
+      } catch {
+        // Optional: surface via a logger if available.
+      } finally {
+        this.#active.set(undefined);
+      }
+    });
js/hang/src/watch/audio/captions.ts (1)

47-55: Guard against read errors and ensure state reset.

Mirror the speaking.ts suggestion to avoid leaking stale text and to prevent unhandled rejections.

-    effect.spawn(async (cancel) => {
-      for (;;) {
-        const frame = await Promise.race([sub.readString(), cancel]);
-        if (frame === undefined) break; // don't treat "" as EOS
-        this.#text.set(frame);
-      }
-    });
-    effect.cleanup(() => this.#text.set(undefined));
+    effect.spawn(async (cancel) => {
+      try {
+        for (;;) {
+          const frame = await Promise.race([sub.readString(), cancel]);
+          if (frame === undefined) break; // don't treat "" as EOS
+          this.#text.set(frame);
+        }
+      } catch {
+        // Optional: log
+      } finally {
+        this.#text.set(undefined);
+      }
+    });
js/hang/src/publish/video/detection.ts (2)

86-89: Avoid double-closing the track.

Track is already closed via signals.cleanup; closing again here risks double-close errors if not idempotent.

   close() {
     this.signals.close();
-    this.#track.close();
   }

61-81: Optional: cancel in-flight detect on disable.

Consider checking an effect-bound cancellation flag before/after api.detect to minimize wasted work on rapid toggles.

js/hang/src/publish/broadcast.ts (1)

11-23: reload prop appears unused in publisher.
It’s defined in BroadcastProps but not referenced. Drop it or wire it; otherwise it’s confusing.

js/hang/src/publish/source/camera.ts (2)

49-50: Use debug-level logging.
Console noise in hot paths; prefer console.debug.

-    console.log("requesting camera", device);
+    console.debug("requesting camera", device);

20-22: flip is unused here.
You expose flip but never read it. If video flipping is handled downstream, consider removing from Camera or wire it into constraints/metadata.

Also applies to: 30-30

js/hang/src/publish/element.ts (3)

166-171: Avoid double-driving Screen.enabled.
You both bind screen.enabled via an effect (Lines 143-147) and set it here. Prefer the binding effect only to prevent oscillation.

-    if (this.#audio instanceof Source.Screen) {
-      // Enable the screenshare capture if either audio or video are enabled.
-      this.#audio.enabled.set(audio || !!this.#video?.enabled.peek());
-    } else {
+    if (!(this.#audio instanceof Source.Screen)) {
       this.#audio?.enabled.set(audio);
     }
@@
-    if (this.#video instanceof Source.Screen) {
-      // Only disable the screenshare capture if both audio and video are disabled.
-      this.#video.enabled.set(video || !!this.#audio?.enabled.peek());
-    } else {
+    if (!(this.#video instanceof Source.Screen)) {
       this.#video?.enabled.set(video);
     }

Also applies to: 181-186


291-318: UI still labeled “Device:” while the API is source-based.
Optional rename to “Source:” for clarity.


323-349: Status logic: “Connected” branch is effectively unreachable.
The prior branches cover all audio/video combinations; consider removing or reordering if you intend to show “Connected” without media.

js/hang/src/publish/source/microphone.ts (3)

39-45: Add a debug breadcrumb when requesting the mic

Parity with camera source helps trace selection.

 	#runStream(effect: Effect): void {
 		const enabled = effect.get(this.enabled);
 		if (!enabled) return;
 
 		const device = effect.get(this.device);
 		if (!device) return;
 
+		console.debug("requesting microphone", device);

75-81: Add a permission-request debug log (parity with camera)

This improves diagnosability around first-time permission prompts.

 		if (!devices.length) {
-			// Request permissions and try again.
+			// Request permissions and try again.
+			console.debug("requesting permissions");

109-116: Broaden default device heuristics for cross-platform labels

Fallback to common mic labels improves default picks on Windows/macOS/mobile when alias/groupId is missing.

-		if (!defaultDevice) {
-			// Look for default or communications device
-			defaultDevice = devices.find((d) => {
-				const label = d.label.toLowerCase();
-				return label.includes("default") || label.includes("communications");
-			});
-		}
+		if (!defaultDevice) {
+			// Look for common built-ins or system-preferred devices
+			defaultDevice = devices.find((d) => {
+				const label = d.label.toLowerCase();
+				return (
+					label.includes("default") ||
+					label.includes("communications") ||
+					label.includes("built-in") ||
+					label.includes("internal") ||
+					label.includes("headset") ||
+					label.includes("usb") ||
+					label.includes("bluetooth")
+				);
+			});
+		}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 97233e4 and 0ed4392.

📒 Files selected for processing (36)
  • js/hang/src/connection.ts (2 hunks)
  • js/hang/src/meet/element.ts (1 hunks)
  • js/hang/src/meet/room.ts (2 hunks)
  • js/hang/src/preview/member.ts (2 hunks)
  • js/hang/src/preview/room.ts (2 hunks)
  • js/hang/src/publish/audio/captions.ts (3 hunks)
  • js/hang/src/publish/audio/index.ts (8 hunks)
  • js/hang/src/publish/audio/speaking.ts (3 hunks)
  • js/hang/src/publish/broadcast.ts (5 hunks)
  • js/hang/src/publish/chat/message.ts (2 hunks)
  • js/hang/src/publish/chat/typing.ts (2 hunks)
  • js/hang/src/publish/element.ts (10 hunks)
  • js/hang/src/publish/index.ts (1 hunks)
  • js/hang/src/publish/location.ts (3 hunks)
  • js/hang/src/publish/preview.ts (2 hunks)
  • js/hang/src/publish/source/camera.ts (1 hunks)
  • js/hang/src/publish/source/index.ts (1 hunks)
  • js/hang/src/publish/source/microphone.ts (1 hunks)
  • js/hang/src/publish/source/screen.ts (1 hunks)
  • js/hang/src/publish/video/detection.ts (3 hunks)
  • js/hang/src/publish/video/index.ts (8 hunks)
  • js/hang/src/publish/video/polyfill.ts (1 hunks)
  • js/hang/src/watch/audio/captions.ts (2 hunks)
  • js/hang/src/watch/audio/emitter.ts (2 hunks)
  • js/hang/src/watch/audio/index.ts (2 hunks)
  • js/hang/src/watch/audio/speaking.ts (2 hunks)
  • js/hang/src/watch/broadcast.ts (2 hunks)
  • js/hang/src/watch/chat/message.ts (2 hunks)
  • js/hang/src/watch/chat/typing.ts (2 hunks)
  • js/hang/src/watch/location.ts (3 hunks)
  • js/hang/src/watch/preview.ts (2 hunks)
  • js/hang/src/watch/video/detection.ts (2 hunks)
  • js/hang/src/watch/video/index.ts (2 hunks)
  • js/hang/src/watch/video/renderer.ts (2 hunks)
  • js/moq-clock/src/main.ts (1 hunks)
  • js/signals/src/index.ts (4 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
{rs,js}/hang/**

📄 CodeRabbit inference engine (CLAUDE.md)

All media logic belongs in the hang layer (rs/hang and js/hang)

Files:

  • js/hang/src/meet/element.ts
  • js/hang/src/publish/source/index.ts
  • js/hang/src/connection.ts
  • js/hang/src/watch/location.ts
  • js/hang/src/watch/audio/captions.ts
  • js/hang/src/publish/index.ts
  • js/hang/src/watch/preview.ts
  • js/hang/src/publish/video/polyfill.ts
  • js/hang/src/publish/chat/message.ts
  • js/hang/src/publish/location.ts
  • js/hang/src/watch/chat/message.ts
  • js/hang/src/watch/audio/index.ts
  • js/hang/src/meet/room.ts
  • js/hang/src/watch/audio/emitter.ts
  • js/hang/src/watch/video/renderer.ts
  • js/hang/src/publish/preview.ts
  • js/hang/src/watch/video/detection.ts
  • js/hang/src/publish/source/screen.ts
  • js/hang/src/preview/member.ts
  • js/hang/src/preview/room.ts
  • js/hang/src/publish/source/camera.ts
  • js/hang/src/publish/chat/typing.ts
  • js/hang/src/publish/audio/captions.ts
  • js/hang/src/watch/chat/typing.ts
  • js/hang/src/publish/audio/speaking.ts
  • js/hang/src/watch/broadcast.ts
  • js/hang/src/watch/video/index.ts
  • js/hang/src/publish/source/microphone.ts
  • js/hang/src/watch/audio/speaking.ts
  • js/hang/src/publish/broadcast.ts
  • js/hang/src/publish/video/index.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/publish/video/detection.ts
  • js/hang/src/publish/element.ts
js/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place JS/TS packages under the js/ directory

Files:

  • js/hang/src/meet/element.ts
  • js/hang/src/publish/source/index.ts
  • js/hang/src/connection.ts
  • js/hang/src/watch/location.ts
  • js/hang/src/watch/audio/captions.ts
  • js/hang/src/publish/index.ts
  • js/moq-clock/src/main.ts
  • js/hang/src/watch/preview.ts
  • js/hang/src/publish/video/polyfill.ts
  • js/hang/src/publish/chat/message.ts
  • js/hang/src/publish/location.ts
  • js/hang/src/watch/chat/message.ts
  • js/hang/src/watch/audio/index.ts
  • js/hang/src/meet/room.ts
  • js/hang/src/watch/audio/emitter.ts
  • js/hang/src/watch/video/renderer.ts
  • js/hang/src/publish/preview.ts
  • js/hang/src/watch/video/detection.ts
  • js/hang/src/publish/source/screen.ts
  • js/hang/src/preview/member.ts
  • js/hang/src/preview/room.ts
  • js/hang/src/publish/source/camera.ts
  • js/signals/src/index.ts
  • js/hang/src/publish/chat/typing.ts
  • js/hang/src/publish/audio/captions.ts
  • js/hang/src/watch/chat/typing.ts
  • js/hang/src/publish/audio/speaking.ts
  • js/hang/src/watch/broadcast.ts
  • js/hang/src/watch/video/index.ts
  • js/hang/src/publish/source/microphone.ts
  • js/hang/src/watch/audio/speaking.ts
  • js/hang/src/publish/broadcast.ts
  • js/hang/src/publish/video/index.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/publish/video/detection.ts
  • js/hang/src/publish/element.ts
🧬 Code graph analysis (31)
js/hang/src/connection.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/watch/location.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/watch/audio/captions.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/index.ts (2)
js/hang/src/publish/audio/index.ts (1)
  • Source (16-16)
js/hang/src/publish/video/index.ts (1)
  • Source (13-13)
js/hang/src/watch/preview.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/video/polyfill.ts (1)
js/hang/src/publish/video/index.ts (1)
  • VideoStreamTrack (19-23)
js/hang/src/publish/chat/message.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/location.ts (2)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/catalog/location.ts (1)
  • Position (42-42)
js/hang/src/watch/chat/message.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/watch/audio/index.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/meet/room.ts (2)
js/moq/src/path.ts (1)
  • Valid (31-31)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/watch/audio/emitter.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/watch/video/renderer.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/preview.ts (2)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/preview/info.ts (1)
  • Info (13-13)
js/hang/src/watch/video/detection.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/source/screen.ts (6)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • Effect (88-447)
  • effect (303-313)
js/hang/src/publish/video/index.ts (4)
  • VideoConstraints (37-43)
  • VideoStreamTrack (19-23)
  • effect (88-168)
  • effect (334-369)
js/hang/src/publish/audio/index.ts (6)
  • AudioConstraints (18-21)
  • AudioStreamTrack (24-28)
  • effect (102-156)
  • effect (158-171)
  • effect (173-252)
  • effect (254-272)
js/hang/src/publish/source/camera.ts (3)
  • effect (42-72)
  • effect (74-134)
  • effect (136-154)
js/hang/src/publish/source/microphone.ts (3)
  • effect (39-65)
  • effect (67-125)
  • effect (127-145)
js/hang/src/publish/element.ts (4)
  • video (174-176)
  • video (178-187)
  • audio (159-161)
  • audio (163-172)
js/hang/src/preview/member.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/preview/room.ts (2)
js/moq/src/path.ts (1)
  • Valid (31-31)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/source/camera.ts (3)
js/signals/src/index.ts (4)
  • Signal (19-83)
  • Getter (10-13)
  • Effect (88-447)
  • effect (303-313)
js/hang/src/publish/video/index.ts (4)
  • VideoConstraints (37-43)
  • VideoStreamTrack (19-23)
  • effect (88-168)
  • effect (334-369)
js/hang/src/publish/source/microphone.ts (3)
  • effect (39-65)
  • effect (67-125)
  • effect (127-145)
js/hang/src/publish/chat/typing.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/audio/captions.ts (3)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • effect (303-313)
  • Effect (88-447)
js/hang/src/publish/audio/index.ts (5)
  • Audio (59-279)
  • effect (102-156)
  • effect (158-171)
  • effect (173-252)
  • effect (254-272)
js/hang/src/publish/audio/speaking.ts (1)
  • effect (31-111)
js/hang/src/watch/chat/typing.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/audio/speaking.ts (4)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • effect (303-313)
  • Effect (88-447)
js/hang/src/publish/audio/captions.ts (1)
  • effect (40-131)
js/hang/src/publish/audio/index.ts (4)
  • effect (102-156)
  • effect (158-171)
  • effect (173-252)
  • effect (254-272)
js/hang/src/publish/element.ts (2)
  • source (107-109)
  • source (111-157)
js/hang/src/watch/broadcast.ts (2)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/moq/src/path.ts (1)
  • Valid (31-31)
js/hang/src/watch/video/index.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/source/microphone.ts (3)
js/signals/src/index.ts (4)
  • Signal (19-83)
  • Getter (10-13)
  • Effect (88-447)
  • effect (303-313)
js/hang/src/publish/audio/index.ts (6)
  • AudioConstraints (18-21)
  • AudioStreamTrack (24-28)
  • effect (102-156)
  • effect (158-171)
  • effect (173-252)
  • effect (254-272)
js/hang/src/publish/source/camera.ts (3)
  • effect (42-72)
  • effect (74-134)
  • effect (136-154)
js/hang/src/watch/audio/speaking.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/broadcast.ts (8)
js/hang/src/watch/broadcast.ts (1)
  • BroadcastProps (12-29)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/audio/index.ts (2)
  • AudioProps (45-57)
  • Audio (59-279)
js/hang/src/watch/audio/index.ts (2)
  • AudioProps (13-25)
  • Audio (29-212)
js/hang/src/watch/video/index.ts (2)
  • VideoProps (11-14)
  • Video (17-145)
js/hang/src/publish/video/index.ts (2)
  • VideoProps (45-50)
  • Video (52-381)
js/hang/src/publish/location.ts (2)
  • LocationProps (6-15)
  • Location (17-77)
js/hang/src/publish/preview.ts (1)
  • Preview (10-48)
js/hang/src/publish/video/index.ts (6)
js/hang/src/publish/audio/index.ts (5)
  • Source (16-16)
  • effect (102-156)
  • effect (158-171)
  • effect (173-252)
  • effect (254-272)
js/signals/src/index.ts (4)
  • Signal (19-83)
  • Getter (10-13)
  • effect (303-313)
  • Effect (88-447)
js/hang/src/publish/source/camera.ts (3)
  • effect (42-72)
  • effect (74-134)
  • effect (136-154)
js/hang/src/publish/source/microphone.ts (3)
  • effect (39-65)
  • effect (67-125)
  • effect (127-145)
js/hang/src/publish/element.ts (2)
  • source (107-109)
  • source (111-157)
js/hang/src/publish/video/polyfill.ts (1)
  • VideoTrackProcessor (6-63)
js/hang/src/publish/video/detection.ts (1)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/element.ts (7)
js/signals/src/index.ts (1)
  • Signal (19-83)
js/hang/src/publish/audio/index.ts (5)
  • Source (16-16)
  • effect (102-156)
  • effect (158-171)
  • effect (173-252)
  • effect (254-272)
js/hang/src/publish/video/index.ts (3)
  • Source (13-13)
  • effect (88-168)
  • effect (334-369)
js/hang/src/publish/source/camera.ts (4)
  • Camera (11-159)
  • effect (42-72)
  • effect (74-134)
  • effect (136-154)
js/hang/src/publish/source/screen.ts (2)
  • Screen (11-76)
  • effect (28-71)
js/hang/src/publish/source/microphone.ts (4)
  • Microphone (10-150)
  • effect (39-65)
  • effect (67-125)
  • effect (127-145)
js/hang/src/meet/element.ts (4)
  • name (89-118)
  • name (120-153)
  • name (174-176)
  • name (178-180)
🔇 Additional comments (66)
js/hang/src/publish/preview.ts (2)

6-8: Prop types expansion looks good.

Allowing boolean | Signal and Info | Signal<Info | undefined> aligns with the new reactive API.


20-22: Correct use of Signal.from for normalization.

This ensures callers can pass either raw values or Signals without extra wrappers.

js/hang/src/publish/audio/captions.ts (2)

35-36: Good: normalized enabled via Signal.from.

Keeps the public surface flexible and consistent with other modules.


41-46: Switch to audio.source and local enabled gate looks correct.

Correctly short-circuits when disabled or no track; MediaStreamAudioSourceNode creation from the track is right.

Also applies to: 90-93

js/signals/src/index.ts (2)

231-234: spawn now swallows rejections with a log — good.

Prevents the scheduler from hanging on rejected promises while preserving diagnostics.


409-412: reload() helper is a nice addition.

Explicit rescheduling without poking a signal is useful.

js/hang/src/connection.ts (2)

6-7: URL prop now accepts Signal — good.

Matches the reactive props pattern used elsewhere.


39-44: Correct normalization with Signal.from.

Keeps this.url reactive without forcing callers to wrap.

js/hang/src/preview/member.ts (2)

7-8: Prop now accepts Signal: LGTM

The widened type is consistent with the new signal-driven API.


19-20: Use Signal.from for enabled: LGTM

This preserves existing defaults and reuses provided signals when present.

js/hang/src/publish/audio/index.ts (2)

16-16: Source alias introduced: LGTM

Alias keeps the public surface aligned with video’s Source.


86-95: Signal.from adoption and new source prop: LGTM

Constructor wiring matches the new Source-based API.

js/hang/src/publish/index.ts (1)

6-6: Expose Source namespace: LGTM

Keeps the new Camera/Microphone/Screen sources discoverable from publish/.

js/hang/src/watch/chat/typing.ts (1)

8-9: Signal.from for enabled: LGTM

Matches the broader signalable-props pattern.

Also applies to: 28-29

js/hang/src/watch/chat/message.ts (2)

8-9: API: Prop now accepts Signal — looks good

Broadens inputs without breaking existing boolean callers.


30-31: Use Signal.from for normalization — good

Keeps the field a Signal while allowing reactive callers.

js/hang/src/publish/chat/typing.ts (2)

7-8: API: Prop now accepts Signal<boolean) — good

Consistent with the broader Signal.from migration.


26-27: Normalization via Signal.from — good

Keeps internal type stable.

js/hang/src/watch/audio/index.ts (2)

15-16: API: enabled accepts Signal — good

Non-breaking and enables reactive toggling.


62-63: Signal.from usage — good

Keeps internal contract as Signal.

js/hang/src/publish/video/polyfill.ts (1)

1-1: Type rename to VideoStreamTrack — looks correct

Matches the new interface and keeps runtime unchanged.

Also applies to: 6-6

js/hang/src/watch/video/detection.ts (2)

9-10: API: enabled accepts Signal — good

Keeps call sites flexible without breaking booleans.


28-29: Normalization via Signal.from — good

Consistent with other modules.

js/hang/src/watch/audio/speaking.ts (2)

6-6: Prop typing widening matches Signal.from usage.

Accepting boolean | Signal is consistent with the new reactive API.


28-28: Good normalization with Signal.from.

This keeps external API flexible while ensuring an internal Signal.

js/hang/src/publish/chat/message.ts (2)

7-7: Prop typing widening is consistent.

boolean | Signal aligns with the codebase-wide pattern.


26-26: Signal.from usage is appropriate.

Normalization avoids boilerplate for callers passing Signals.

js/hang/src/publish/source/index.ts (1)

1-3: Source re-export verified. Top-level js/hang/src/publish/index.ts includes export * as Source from "./source".

js/hang/src/watch/audio/captions.ts (2)

6-6: Prop typing widening matches new API.


28-28: Signal.from is the right call.

js/hang/src/publish/video/detection.ts (1)

25-37: Minor: track cleanup placement is good.

Track producer lifecycle is tied to the effect; insert/remove are balanced.

js/hang/src/watch/preview.ts (2)

8-9: API: enabled now accepts boolean | Signal — good widening with backward compatibility.
No behavior regressions spotted; callers can pass either a literal or a Signal.


24-24: Use Signal.from for normalization — correct.
Keeps external Signals unwrapped and preserves defaults.

js/hang/src/meet/room.ts (1)

8-9: API: name now accepts Path.Valid | Signal — good normalization via Signal.from.
Constructor default remains Moq.Path.empty(); behavior matches previous semantics.

Also applies to: 39-40

js/hang/src/watch/video/index.ts (1)

12-13: API: enabled now accepts boolean | Signal — good; constructor uses Signal.from correctly.
No gating regressions observed.

Also applies to: 46-47

js/hang/src/preview/room.ts (2)

8-10: API: name/enabled now accept Signal — correct use of Signal.from with sensible defaults.
Looks consistent with the broader migration.

Also applies to: 24-26


45-49: Confirm announced(name) supports undefined.
You pass possibly undefined name to conn.announced(name). If the API doesn’t accept undefined as “all”, guard accordingly.

If needed:

-		const name = effect.get(this.name);
-		const announced = conn.announced(name);
+		const name = effect.get(this.name);
+		const announced = conn.announced(name ?? undefined /* all broadcasts */);

Or default to Moq.Path.empty() if that’s the intended “room root”.

js/hang/src/watch/location.ts (3)

7-7: LGTM! Signal-based prop normalization correctly implemented.

The change from enabled?: boolean to enabled?: boolean | Signal<boolean> aligns with the PR's migration to reactive inputs.


29-29: LGTM! Proper Signal.from initialization.

The change from new Signal(...) to Signal.from(...) correctly handles both plain boolean values and existing Signals, maintaining backward compatibility while enabling reactive inputs.


120-120: LGTM! Consistent Signal.from usage in LocationPeer.

The LocationPeer constructor now properly normalizes the handle parameter using Signal.from, consistent with the Location class changes.

js/hang/src/publish/audio/speaking.ts (4)

10-10: LGTM! Reactive prop signature correctly updated.

The SpeakingProps.enabled type change to boolean | Signal<boolean> follows the established pattern across the codebase.


27-27: LGTM! Proper Signal normalization.

Using Signal.from() correctly handles both plain boolean values and existing Signal instances.


32-36: LGTM! Clean extraction of reactive values and source-based media handling.

The code properly extracts the enabled state and migrates from this.audio.media to this.audio.source, aligning with the PR's shift to source-driven media pipeline.


81-81: LGTM! MediaStream constructed from new source variable.

The MediaStream construction now uses the source variable extracted from this.audio.source, completing the migration from media-based to source-based audio handling.

js/hang/src/publish/location.ts (3)

8-14: LGTM! Comprehensive reactive prop interface.

All three LocationProps fields now properly support both plain values and Signal types:

  • enabled: boolean | Signal<boolean>
  • current: Catalog.Position | Signal<Catalog.Position | undefined>
  • handle: string | Signal<string | undefined>

This provides full reactive capability while maintaining backward compatibility.


36-38: LGTM! Consistent Signal.from initialization pattern.

All three properties are properly initialized using Signal.from(), ensuring correct handling of both plain values and existing Signal instances with appropriate defaults.


92-92: LGTM! LocationPeer handle initialization updated.

The LocationPeer constructor now uses Signal.from() for handle initialization, maintaining consistency with the Location class changes.

js/hang/src/watch/audio/emitter.ts (2)

8-10: LGTM! Complete reactive prop support for AudioEmitter.

All AudioEmitterProps now support both plain values and Signal types:

  • volume?: number | Signal<number>
  • muted?: boolean | Signal<boolean>
  • paused?: boolean | Signal<boolean>

This enables full reactive control over audio playback parameters.


33-35: LGTM! Proper Signal.from initialization with logical defaults.

All three properties are correctly initialized:

  • volume: defaults to 0.5
  • muted: defaults to false
  • paused: defaults to muted state when not explicitly set

The initialization pattern correctly uses Signal.from() to handle both plain values and Signal inputs.

js/hang/src/publish/source/screen.ts (5)

5-9: LGTM! Comprehensive reactive ScreenProps interface.

The ScreenProps interface properly supports Signal inputs for all properties:

  • enabled: boolean | Signal<boolean>
  • video: VideoConstraints | boolean | Signal<VideoConstraints | boolean | undefined>
  • audio: AudioConstraints | boolean | Signal<AudioConstraints | boolean | undefined>

This follows the established pattern across other Source classes.


21-23: LGTM! Proper Signal normalization with appropriate defaults.

All properties are correctly initialized using Signal.from():

  • enabled: defaults to false when not provided
  • video and audio: accept undefined when not provided

This maintains consistency with other Source implementations.


36-43: Flag browser compatibility concerns with CaptureController.

The Chrome-only CaptureController usage should be documented or guarded more explicitly for cross-browser compatibility.

Consider adding a more explicit browser compatibility check or documentation about Chrome-only features:

		// TODO Expose these to the application.
-		// @ts-expect-error Chrome only
		let controller: CaptureController | undefined;
-		// @ts-expect-error Chrome only
-		if (typeof self.CaptureController !== "undefined") {
-			// @ts-expect-error Chrome only
+		// Note: CaptureController is Chrome-only, gracefully degrade on other browsers
+		if (typeof globalThis.CaptureController !== "undefined") {
			controller = new CaptureController();
			controller.setFocusBehavior("no-focus-change");
		}

46-61: LGTM! Proper async media capture with cancellation.

The screen capture implementation correctly:

  • Uses Promise.race() with cancel signal for proper cleanup
  • Handles getDisplayMedia failure gracefully
  • Passes video/audio constraints from reactive signals
  • Uses Chrome-specific options for enhanced UX

64-69: LGTM! Proper track extraction and cleanup.

The implementation correctly:

  • Extracts video and audio tracks with proper typing
  • Registers cleanup handlers to stop tracks on effect cleanup
  • Sets the stream signal with proper structure
js/hang/src/watch/broadcast.ts (3)

15-21: Prop types accept Signals — good alignment with the new reactive API.
The widened types for enabled/name/reload are consistent with the rest of hang and with Signal.from usage.


62-64: Constructor now uses Signal.from — good.
Prevents double-wrapping and keeps reactive props flexible.


109-116: Confirm announcement name filtering.
You ignore updates where update.name !== "". Is this suffix check intentional for your naming scheme? If announcements can legitimately carry non-empty suffixes, this will suppress state changes.

js/hang/src/publish/broadcast.ts (1)

51-53: Constructor Signal.from usage — good.
Consistent with watch-side API and other publish modules.

Also applies to: 59-59

js/hang/src/publish/source/camera.ts (1)

136-154: Default device selection flow — solid.
Validates selected device availability and falls back to a sensible default.

js/hang/src/publish/video/index.ts (3)

13-23: Typed VideoStreamTrack alias and Source = VideoStreamTrack — good.
Matches polyfill signature and simplifies interop with Source.* modules.


46-50: Prop and state refactor to source-driven video — good.
Signal-based enabled/flip/source and a fixed #track align with the new architecture.

Also applies to: 56-59, 63-64, 80-83


371-380: Closing #track — good.
Ensures producer resources are released.

js/hang/src/publish/source/microphone.ts (4)

29-37: Solid effect wiring for device reactivity

Good separation: devicechange listener, device enumeration, selection, and stream acquisition in distinct effects. Aligns with hang-layer media logic guideline.


10-23: API surface looks good; signals are exposed read-only where appropriate

devices via Getter avoids external mutation; enabled/device/constraints accept raw or Signal via Signal.from. Nice.


147-149: Graceful teardown

close() delegates to Effect.close(); prior cleanups (track.stop, listeners) will run. LGTM.


46-50: Could you share lines 40–60 of js/hang/src/publish/source/microphone.ts so I can confirm the merge order of deviceId vs. spread constraints?

Comment on lines +24 to 28
export interface AudioStreamTrack extends MediaStreamTrack {
kind: "audio";
clone(): AudioTrack;
clone(): AudioStreamTrack;
getSettings(): AudioTrackSettings;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

getSettings typing is too strong; sampleRate can be undefined in practice

Your code already guards sampleRate later; the interface should reflect that to avoid unsoundness.

Apply:

-export interface AudioStreamTrack extends MediaStreamTrack {
+export interface AudioStreamTrack extends MediaStreamTrack {
   kind: "audio";
-  clone(): AudioStreamTrack;
-  getSettings(): AudioTrackSettings;
+  clone(): AudioStreamTrack;
+  getSettings(): AudioTrackSettings;
 }
@@
-export interface AudioTrackSettings {
+export interface AudioTrackSettings {
   deviceId: string;
   groupId: string;
   autoGainControl: boolean;
   channelCount: number;
   echoCancellation: boolean;
   noiseSuppression: boolean;
-  sampleRate: number;
+  sampleRate?: number;
   sampleSize: number;
 }

Also applies to: 32-42

🤖 Prompt for AI Agents
In js/hang/src/publish/audio/index.ts around lines 24-28 (and similarly lines
32-42), the current AudioStreamTrack.getSettings typing requires sampleRate but
in practice sampleRate can be undefined; update the
AudioTrackSettings/getSettings return type so sampleRate is optional or typed as
number | undefined and propagate that change to the other similar
interfaces/occurrences noted (lines 32-42); then keep the runtime guards as-is
where code already checks for undefined.

Comment on lines +194 to 209
let group: Moq.GroupProducer = this.#track.appendGroup();
effect.cleanup(() => group.close());

let groupTimestamp = 0;

const encoder = new AudioEncoder({
output: (frame) => {
if (frame.type !== "key") {
throw new Error("only key frames are supported");
}

if (!this.#group || frame.timestamp - this.#groupTimestamp >= 1000 * this.maxLatency) {
this.#group?.close();
this.#group = this.#track.appendGroup();
this.#groupTimestamp = frame.timestamp;
if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
group.close();
group = this.#track.appendGroup();
groupTimestamp = frame.timestamp;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

First-frame grouping can close an empty group if timestamps start > 0

Initialize from the first encoded frame to avoid an immediate rotate.

-let group: Moq.GroupProducer = this.#track.appendGroup();
+let group: Moq.GroupProducer = this.#track.appendGroup();
 effect.cleanup(() => group.close());
 
-let groupTimestamp = 0;
+let groupTimestamp: number | undefined = undefined;
@@
- if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
+ if (groupTimestamp === undefined) {
+   groupTimestamp = frame.timestamp;
+ } else if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
   group.close();
   group = this.#track.appendGroup();
   groupTimestamp = frame.timestamp;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let group: Moq.GroupProducer = this.#track.appendGroup();
effect.cleanup(() => group.close());
let groupTimestamp = 0;
const encoder = new AudioEncoder({
output: (frame) => {
if (frame.type !== "key") {
throw new Error("only key frames are supported");
}
if (!this.#group || frame.timestamp - this.#groupTimestamp >= 1000 * this.maxLatency) {
this.#group?.close();
this.#group = this.#track.appendGroup();
this.#groupTimestamp = frame.timestamp;
if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
group.close();
group = this.#track.appendGroup();
groupTimestamp = frame.timestamp;
}
let group: Moq.GroupProducer = this.#track.appendGroup();
effect.cleanup(() => group.close());
let groupTimestamp: number | undefined = undefined;
const encoder = new AudioEncoder({
output: (frame) => {
if (frame.type !== "key") {
throw new Error("only key frames are supported");
}
if (groupTimestamp === undefined) {
// First encoded frame: set the baseline timestamp without rotating
groupTimestamp = frame.timestamp;
} else if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
// Latency exceeded: close and reopen the group
group.close();
group = this.#track.appendGroup();
groupTimestamp = frame.timestamp;
}
// …rest of output handling…
},
});
🤖 Prompt for AI Agents
In js/hang/src/publish/audio/index.ts around lines 194 to 209, the grouping
logic can immediately close an empty group when the first encoded frame has
timestamp > 0; change initialization so groupTimestamp is set from the timestamp
of the first processed frame (or only perform the timestamp-based rotate after
the first frame has been appended), i.e., defer the initial groupTimestamp
assignment until the encoder output handler sees the first frame and then set
groupTimestamp = frame.timestamp before any rotate check, ensuring you do not
close a newly created but still-empty group.

this.device = new Signal(props?.device);
this.user = Signal.from(props?.user);

this.#broadcast.insertTrack(this.#catalog.consume());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Track insertion without symmetric removal in close().
You insert the catalog track once and never remove/close it when the publisher closes.

Apply:

   this.#broadcast.insertTrack(this.#catalog.consume());

And in close():

   close() {
     this.signals.close();
     this.audio.close();
     this.video.close();
     this.location.close();
     this.chat.close();
+    this.preview.close();
+    // Remove and close the catalog track.
+    this.#broadcast.removeTrack(this.#catalog.name);
+    this.#catalog.close();
+    // Optionally close the broadcast producer to release resources.
+    this.#broadcast.close();
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.#broadcast.insertTrack(this.#catalog.consume());
close() {
this.signals.close();
this.audio.close();
this.video.close();
this.location.close();
this.chat.close();
this.preview.close();
// Remove and close the catalog track.
this.#broadcast.removeTrack(this.#catalog.name);
this.#catalog.close();
// Optionally close the broadcast producer to release resources.
this.#broadcast.close();
}
🤖 Prompt for AI Agents
js/hang/src/publish/broadcast.ts around line 61, you insert a catalog track via
this.#broadcast.insertTrack(this.#catalog.consume()) but never remove or close
it on publisher shutdown; instead capture the consumed track in a field (e.g.
this.#publishedTrack = this.#catalog.consume()), pass that to insertTrack, and
in close() perform the symmetric cleanup: check for existence, call
this.#broadcast.removeTrack(this.#publishedTrack) (or the broadcast API’s
equivalent), and then close or release the track (e.g.
this.#publishedTrack.close() or this.#catalog.release(this.#publishedTrack))
with null/ready checks and idempotence so repeated close() calls are safe.

Comment on lines 114 to 116
this.#video?.close();
this.#audio?.close();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Clear broadcast sources when switching/clearing source.
When closing previous Source.* instances, also clear broadcast.video.source and broadcast.audio.source to avoid stale, stopped tracks keeping the publisher “enabled”.

     this.#video?.close();
     this.#audio?.close();
+    // Clear published sources immediately; effects below will repopulate.
+    this.broadcast.video.source.set(undefined);
+    this.broadcast.audio.source.set(undefined);
@@
     this.#source.set(source);

Also applies to: 156-156

🤖 Prompt for AI Agents
In js/hang/src/publish/element.ts around lines 114-116 (and also at line 156),
the code closes previous this.#video and this.#audio but does not clear
broadcast.video.source and broadcast.audio.source, which can leave stopped
tracks attached and keep the publisher "enabled"; after calling
this.#video?.close() and this.#audio?.close(), set broadcast.video.source = null
and broadcast.audio.source = null (guarding for existence of broadcast and its
video/audio subobjects) so any stale/closed tracks are removed; apply the same
change at the other location noted (line 156).

Comment on lines +119 to +129
video.signals.effect((effect) => {
const stream = effect.get(video.stream);
effect.set(this.broadcast.video.source, stream);
});

const audio = new Source.Microphone({ enabled: this.broadcast.audio.enabled });
audio.signals.effect((effect) => {
const stream = effect.get(audio.stream);
effect.set(this.broadcast.audio.source, stream);
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make Effect.set reversible to avoid stale values on cleanup.
Without a cleanup value, broadcast..source remains set after Source. is closed.

-      video.signals.effect((effect) => {
+      video.signals.effect((effect) => {
         const stream = effect.get(video.stream);
-        effect.set(this.broadcast.video.source, stream);
+        effect.set(this.broadcast.video.source, stream, undefined);
       });
@@
-      audio.signals.effect((effect) => {
+      audio.signals.effect((effect) => {
         const stream = effect.get(audio.stream);
-        effect.set(this.broadcast.audio.source, stream);
+        effect.set(this.broadcast.audio.source, stream, undefined);
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
video.signals.effect((effect) => {
const stream = effect.get(video.stream);
effect.set(this.broadcast.video.source, stream);
});
const audio = new Source.Microphone({ enabled: this.broadcast.audio.enabled });
audio.signals.effect((effect) => {
const stream = effect.get(audio.stream);
effect.set(this.broadcast.audio.source, stream);
});
video.signals.effect((effect) => {
const stream = effect.get(video.stream);
effect.set(this.broadcast.video.source, stream, undefined);
});
const audio = new Source.Microphone({ enabled: this.broadcast.audio.enabled });
audio.signals.effect((effect) => {
const stream = effect.get(audio.stream);
effect.set(this.broadcast.audio.source, stream, undefined);
});
🤖 Prompt for AI Agents
In js/hang/src/publish/element.ts around lines 119-129, the Effect.set calls for
video and audio currently assign the stream but do not provide a
cleanup/restoration value, leaving broadcast.video.source and
broadcast.audio.source stale after the Source is closed; update both effect.set
usages to make them reversible by passing a cleanup value or callback that
restores the previous value (or sets undefined) when the effect is
disposed/stream closed so the broadcast.*.source is cleared on cleanup.

Comment on lines 52 to 65
effect.spawn(async (cancel) => {
const stream = await Promise.race([
navigator.mediaDevices.getUserMedia({ audio: finalConstraints }).catch(() => undefined),
cancel,
]);
if (!stream) return;

const track = stream.getAudioTracks()[0] as AudioStreamTrack | undefined;
if (!track) return;

effect.cleanup(() => track.stop());
effect.set(this.stream, track, undefined);
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix hot-mic/resource leak when cancel wins getUserMedia race

getUserMedia cannot be aborted. If cancel resolves first, the eventual stream (when it resolves) is never stopped, briefly leaving the mic active. Stop late-resolving streams after cancel.

-		effect.spawn(async (cancel) => {
-			const stream = await Promise.race([
-				navigator.mediaDevices.getUserMedia({ audio: finalConstraints }).catch(() => undefined),
-				cancel,
-			]);
-			if (!stream) return;
-
-			const track = stream.getAudioTracks()[0] as AudioStreamTrack | undefined;
-			if (!track) return;
-
-			effect.cleanup(() => track.stop());
-			effect.set(this.stream, track, undefined);
-		});
+		effect.spawn(async (cancel) => {
+			const req = navigator.mediaDevices
+				.getUserMedia({ audio: finalConstraints })
+				.catch(() => undefined as MediaStream | undefined);
+			const CANCELLED = {};
+			const result = await Promise.race([req, cancel.then(() => CANCELLED)]);
+			if (result === CANCELLED) {
+				// Ensure any late-resolving stream is immediately stopped.
+				req.then((s) => s?.getTracks().forEach((t) => t.stop())).catch(() => {});
+				return;
+			}
+			const stream = result as MediaStream | undefined;
+			if (!stream) return;
+
+			const track = stream.getAudioTracks()[0] as AudioStreamTrack | undefined;
+			if (!track) return;
+
+			effect.cleanup(() => track.stop());
+			effect.set(this.stream, track, undefined);
+		});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
effect.spawn(async (cancel) => {
const stream = await Promise.race([
navigator.mediaDevices.getUserMedia({ audio: finalConstraints }).catch(() => undefined),
cancel,
]);
if (!stream) return;
const track = stream.getAudioTracks()[0] as AudioStreamTrack | undefined;
if (!track) return;
effect.cleanup(() => track.stop());
effect.set(this.stream, track, undefined);
});
}
effect.spawn(async (cancel) => {
const req = navigator.mediaDevices
.getUserMedia({ audio: finalConstraints })
.catch(() => undefined as MediaStream | undefined);
const CANCELLED = {};
const result = await Promise.race([req, cancel.then(() => CANCELLED)]);
if (result === CANCELLED) {
// Ensure any late-resolving stream is immediately stopped.
req.then((s) => s?.getTracks().forEach((t) => t.stop())).catch(() => {});
return;
}
const stream = result as MediaStream | undefined;
if (!stream) return;
const track = stream.getAudioTracks()[0] as AudioStreamTrack | undefined;
if (!track) return;
effect.cleanup(() => track.stop());
effect.set(this.stream, track, undefined);
});

Comment on lines 41 to 44
#run(effect: Effect): void {
if (!effect.get(this.enabled)) return;
if (!effect.get(this.video.enabled)) return;
const enabled = effect.get(this.enabled);
if (!enabled) return;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Regression: removal of video.enabled gating can stall detection.

If video is disabled (no frames), process() returns early and no next timeout is scheduled; with no dependency on video.enabled, the effect won’t re-run, so detection may never resume when video re-enables.

Two-part fix:

  1. Gate on both detection and video enabled.
  2. Always reschedule when no frame is available.
-  #run(effect: Effect): void {
-    const enabled = effect.get(this.enabled);
-    if (!enabled) return;
+  #run(effect: Effect): void {
+    const enabled = effect.get(this.enabled);
+    // Also depend on video.enabled so the effect re-runs when video toggles.
+    const videoEnabled = (this.video as any).enabled ? effect.get((this.video as any).enabled) : true;
+    if (!enabled || !videoEnabled) return;
-    const process = async () => {
-      const frame = this.video.frame.peek();
-      if (!frame) return;
+    const process = async () => {
+      const frame = this.video.frame.peek();
+      if (!frame) {
+        // Poll again later until frames are available.
+        timeout = setTimeout(process, this.#interval);
+        return;
+      }

If Video.enabled is not a Signal in your current type, keep the reschedule change and omit the gating; otherwise, include both.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#run(effect: Effect): void {
if (!effect.get(this.enabled)) return;
if (!effect.get(this.video.enabled)) return;
const enabled = effect.get(this.enabled);
if (!enabled) return;
#run(effect: Effect): void {
const enabled = effect.get(this.enabled);
// Also depend on video.enabled so the effect re-runs when video toggles.
const videoEnabled = (this.video as any).enabled
? effect.get((this.video as any).enabled)
: true;
if (!enabled || !videoEnabled) return;
let timeout: ReturnType<typeof setTimeout>;
const process = async () => {
const frame = this.video.frame.peek();
if (!frame) {
// Poll again later until frames are available.
timeout = setTimeout(process, this.#interval);
return;
}
// …existing detection logic…
timeout = setTimeout(process, this.#interval);
};
process();
}
🤖 Prompt for AI Agents
In js/hang/src/publish/video/detection.ts around lines 41 to 44, the effect
currently only gates on detection.enabled and returns early without scheduling
when video frames are absent; this can stall detection when video is later
re-enabled. Update the effect to check both detection.enabled and video.enabled
(if video.enabled is a Signal in your types) before returning, and ensure that
when no frame is available you still schedule the next timeout/reschedule call
so the effect will re-run; if video.enabled is not a Signal in your types, skip
the extra gate but still always reschedule when no frame is found.

Comment on lines 88 to 101
#runEncoder(effect: Effect): void {
if (!effect.get(this.enabled)) return;
const enabled = effect.get(this.enabled);
if (!enabled) return;

const media = effect.get(this.media);
if (!media) return;
const source = effect.get(this.source);
if (!source) return;

const track = effect.get(this.#track);
if (!track) return;
// Insert the track into the broadcast.
this.broadcast.insertTrack(this.#track.consume());
effect.cleanup(() => this.broadcast.removeTrack(this.#track.name));

const settings = media.getSettings() as VideoTrackSettings;
const processor = VideoTrackProcessor(media);
const settings = source.getSettings() as VideoTrackSettings;
const processor = VideoTrackProcessor(source);
const reader = processor.getReader();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Ensure frame Signal is cleared on effect cleanup.
Close any last held VideoFrame when the encoder effect tears down to avoid retaining a frame until Video.close() is called.

     const reader = processor.getReader();
     effect.cleanup(() => reader.cancel());
+    effect.cleanup(() =>
+      this.frame.set((prev) => {
+        prev?.close();
+        return undefined;
+      }),
+    );

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In js/hang/src/publish/video/index.ts around lines 88 to 101, the encoder effect
currently creates a VideoTrackProcessor and reader but never closes any
last-held VideoFrame on teardown; update the effect to track the most recently
acquired VideoFrame (e.g., lastFrame variable) and in effect.cleanup() check if
lastFrame is set, call lastFrame.close(), and null it so the frame Signal is
cleared; also ensure any reader/processor resources are released as appropriate
(e.g., cancel/release reader) so no VideoFrame is retained until Video.close().

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
js/signals/src/dom.ts (2)

75-96: Bug: removing a DocumentFragment throws; track child nodes for array renders

parent.appendChild(node) with node as a DocumentFragment moves its children; the fragment itself is never a child of parent. The cleanup then tries to removeChild(node), which will throw. Capture the fragment’s children and remove those instead.

Apply this diff:

-  } else if (Array.isArray(e)) {
-    node = document.createDocumentFragment();
-    for (const child of e) {
-      render(effect, node, child);
-    }
+  } else if (Array.isArray(e)) {
+    const fragment = document.createDocumentFragment();
+    for (const child of e) {
+      render(effect, fragment, child);
+    }
+    const nodes = Array.from(fragment.childNodes);
+    parent.appendChild(fragment);
+    effect.cleanup(() => {
+      for (const n of nodes) {
+        if (n.parentNode === parent) parent.removeChild(n);
+      }
+    });
+    return;
   }
@@
-  parent.appendChild(node);
-  effect.cleanup(() => {
-    try {
-      parent.removeChild(node);
-    } catch (e) {
-      console.log("cleanup failed", parent, node);
-      throw e;
-    }
-  });
+  parent.appendChild(node);
+  effect.cleanup(() => {
+    if (node.parentNode === parent) parent.removeChild(node);
+  });

3-10: Provide effect-scoped event-listener helper to replace removed events option

There are no remaining call-sites using the dropped events property (verified via ripgrep), but its removal also drops support for capture, passive, once and prevents multiple listeners. To restore that functionality, add a lightweight helper in js/signals/src/dom.ts:

export function on(
  effect: Effect,
  target: EventTarget,
  type: string,
  listener: EventListenerOrEventListenerObject,
  options?: AddEventListenerOptions | boolean
) {
  target.addEventListener(type, listener, options);
  effect.cleanup(() => target.removeEventListener(type, listener, options));
}
js/hang/src/publish/audio/index.ts (1)

115-121: Don’t pass an undefined sampleRate to AudioContext options.

Create options conditionally to avoid invalid init objects and align with optional typing.

-    const context = new AudioContext({
-      latencyHint: "interactive",
-      sampleRate: settings.sampleRate,
-    });
+    const context = new AudioContext({
+      latencyHint: "interactive",
+      ...(settings.sampleRate ? { sampleRate: settings.sampleRate } : {}),
+    });
js/hang-demo/src/publish.html (1)

70-76: Docs/API mismatch: attribute and JS API should use source (not device).

The PR moved to a source-centric API. Update the snippet to match.

-  Either modify HTML attributes like <code class="language-html">&lt;hang-publish device="camera" /&gt;</code>
-  or access the element's Javascript API:
+  Either modify HTML attributes like <code class="language-html">&lt;hang-publish source="camera" /&gt;</code>
+  or access the element's Javascript API:
 <pre><code class="language-typescript">const publish = document.getElementById("publish");
-publish.lib.device = "camera";</code></pre>
+publish.lib.source = "camera";</code></pre>
♻️ Duplicate comments (6)
js/hang/src/publish/audio/index.ts (2)

31-42: Make sampleRate optional in settings (Firefox returns undefined).

Type is too strong; keeps forcing non-existent values.

-// This is a fork of the MediaTrackSettings interface with properties required for audio or vidfeo.
+// This is a fork of the MediaTrackSettings interface with properties required for audio or video.
 export interface AudioTrackSettings {
   deviceId: string;
   groupId: string;

   autoGainControl: boolean;
   channelCount: number;
   echoCancellation: boolean;
   noiseSuppression: boolean;
-  sampleRate: number;
+  sampleRate?: number;
   sampleSize: number;
 }

192-207: Avoid closing an empty first group when timestamps start > 0.

Initialize groupTimestamp from the first frame.

-    let group: Moq.GroupProducer = this.#track.appendGroup();
+    let group: Moq.GroupProducer = this.#track.appendGroup();
     effect.cleanup(() => group.close());

-    let groupTimestamp = 0;
+    let groupTimestamp: number | undefined = undefined;
@@
-        if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
+        if (groupTimestamp === undefined) {
+          groupTimestamp = frame.timestamp;
+        } else if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
           group.close();
           group = this.#track.appendGroup();
           groupTimestamp = frame.timestamp;
         }
js/hang/src/publish/source/microphone.ts (1)

43-55: Fix hot-mic/resource leak when cancel wins getUserMedia race.

-    effect.spawn(async (cancel) => {
-      const stream = await Promise.race([
-        navigator.mediaDevices.getUserMedia({ audio: finalConstraints }).catch(() => undefined),
-        cancel,
-      ]);
-      if (!stream) return;
-
-      const track = stream.getAudioTracks()[0] as AudioStreamTrack | undefined;
-      if (!track) return;
-
-      effect.cleanup(() => track.stop());
-      effect.set(this.stream, track, undefined);
-    });
+    effect.spawn(async (cancel) => {
+      const req = navigator.mediaDevices
+        .getUserMedia({ audio: finalConstraints })
+        .catch(() => undefined as MediaStream | undefined);
+      const CANCELLED = Symbol("cancelled");
+      const result = await Promise.race([req, cancel.then(() => CANCELLED)]);
+      if (result === CANCELLED) {
+        req.then((s) => s?.getTracks().forEach((t) => t.stop())).catch(() => {});
+        return;
+      }
+      const stream = result as MediaStream | undefined;
+      if (!stream) return;
+
+      const track = stream.getAudioTracks()[0] as AudioStreamTrack | undefined;
+      if (!track) {
+        stream.getTracks().forEach((t) => t.stop());
+        return;
+      }
+
+      effect.cleanup(() => track.stop());
+      effect.set(this.stream, track, undefined);
+    });
js/hang/src/publish/element.ts (3)

114-116: Clear broadcast.*.source when switching/clearing to avoid stale, stopped tracks.
Closing wrappers doesn’t clear broadcast.video.source/audio.source, leaving publisher “enabled” and UI states wrong until new sources arrive.

     this.#audio.peek()?.close();
     this.#video.peek()?.close();
+    // Clear published sources immediately; effects below will repopulate.
+    this.broadcast.video.source.set(undefined);
+    this.broadcast.audio.source.set(undefined);
@@
   } else {
-      this.#video.set(undefined);
-      this.#audio.set(undefined);
+      this.#video.set(undefined);
+      this.#audio.set(undefined);
+      // Also clear published sources when deselecting.
+      this.broadcast.video.source.set(undefined);
+      this.broadcast.audio.source.set(undefined);
   }

Also applies to: 152-154


119-129: Make Effect.set reversible for camera/mic streams.
Without a cleanup value, broadcast.*.source can remain set after the Source is closed.

-      video.signals.effect((effect) => {
+      video.signals.effect((effect) => {
         const stream = effect.get(video.stream);
-        effect.set(this.broadcast.video.source, stream);
+        effect.set(this.broadcast.video.source, stream, undefined);
       });
@@
-      audio.signals.effect((effect) => {
+      audio.signals.effect((effect) => {
         const stream = effect.get(audio.stream);
-        effect.set(this.broadcast.audio.source, stream);
+        effect.set(this.broadcast.audio.source, stream, undefined);
       });

135-141: Screen wiring should clear on cleanup; don’t early-return.
Returning early prevents clearing sources when the stream ends; also add cleanup value.

-      screen.signals.effect((effect) => {
+      screen.signals.effect((effect) => {
         const stream = effect.get(screen.stream);
-        if (!stream) return;
-
-        effect.set(this.broadcast.video.source, stream.video);
-        effect.set(this.broadcast.audio.source, stream.audio);
+        effect.set(this.broadcast.video.source, stream?.video, undefined);
+        effect.set(this.broadcast.audio.source, stream?.audio, undefined);
       });
🧹 Nitpick comments (7)
js/signals/src/dom.ts (1)

47-56: Simplify children append; no need to branch or manually create Text nodes

append accepts both Node and string. This reduces code and DOM allocations.

-  // Append children
-  if (children) {
-    children.forEach((child) => {
-      if (typeof child === "string") {
-        element.appendChild(document.createTextNode(child));
-      } else {
-        element.appendChild(child);
-      }
-    });
-  }
+  // Append children
+  if (children.length) element.append(...children);
js/hang/src/publish/audio/index.ts (1)

227-233: Clean up onmessage handler to prevent leaks if the worklet outlives the effect.

-    worklet.port.onmessage = ({ data }: { data: Capture.AudioFrame }) => {
+    worklet.port.onmessage = ({ data }: { data: Capture.AudioFrame }) => {
       const channels = data.channels.slice(0, settings.channelCount);
       ...
       encoder.encode(frame);
       frame.close();
     };
+    effect.cleanup(() => {
+      worklet.port.onmessage = null;
+    });
js/hang/src/publish/source/device.ts (1)

103-106: Nit: typo in comment (“preferrence”).

-    // NOTE: The default device might change, and with no (valid) preferrence, we should switch to it.
+    // NOTE: The default device might change, and with no (valid) preference, we should switch to it.
js/hang/src/publish/source/camera.ts (1)

16-18: flip is unused. Implement or drop to avoid dead API.

Options:

  • Wire into downstream preview via CSS transform (outside this module), or
  • Add a MediaStreamTrackProcessor/Generator path to apply mirroring, or
  • Remove flip from this class for now.
js/hang/src/publish/element.ts (3)

8-8: Also observe a “source” attribute (keep “device” for back-compat).
Allows declarative use via while preserving existing “device”.

-const OBSERVED = ["url", "name", "device", "audio", "video", "controls", "captions"] as const;
+const OBSERVED = ["url", "name", "device", "source", "audio", "video", "controls", "captions"] as const;
@@
-    } else if (name === "device") {
-      if (newValue === "camera" || newValue === "screen" || newValue === null) {
-        this.source = newValue ?? undefined;
-      } else {
-        throw new Error(`Invalid device: ${newValue}`);
-      }
+    } else if (name === "device" || name === "source") {
+      if (newValue === "camera" || newValue === "screen" || newValue === null) {
+        this.source = newValue ?? undefined;
+      } else {
+        throw new Error(`Invalid ${name}: ${newValue}`);
+      }

Also applies to: 71-77


277-281: Rename UI label to “Source:” (source-centric API).
Minor clarity tweak matching the new model.

-      "Device:",
+      "Source:",

499-521: Tidy unreachable “connected” branch in status.
Given preceding conditions, the final else if (status === "connected") never runs.

-      } else if (status === "connected") {
-        container.textContent = "🟢 Connected";
-      }
+      }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0ed4392 and 2559ac3.

📒 Files selected for processing (8)
  • js/hang-demo/src/publish.html (1 hunks)
  • js/hang/src/publish/audio/index.ts (8 hunks)
  • js/hang/src/publish/element.ts (7 hunks)
  • js/hang/src/publish/source/camera.ts (1 hunks)
  • js/hang/src/publish/source/device.ts (1 hunks)
  • js/hang/src/publish/source/index.ts (1 hunks)
  • js/hang/src/publish/source/microphone.ts (1 hunks)
  • js/signals/src/dom.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • js/hang/src/publish/source/index.ts
🧰 Additional context used
📓 Path-based instructions (2)
js/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place JS/TS packages under the js/ directory

Files:

  • js/signals/src/dom.ts
  • js/hang-demo/src/publish.html
  • js/hang/src/publish/source/microphone.ts
  • js/hang/src/publish/source/device.ts
  • js/hang/src/publish/element.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/publish/source/camera.ts
{rs,js}/hang/**

📄 CodeRabbit inference engine (CLAUDE.md)

All media logic belongs in the hang layer (rs/hang and js/hang)

Files:

  • js/hang/src/publish/source/microphone.ts
  • js/hang/src/publish/source/device.ts
  • js/hang/src/publish/element.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/publish/source/camera.ts
🧬 Code graph analysis (5)
js/hang/src/publish/source/microphone.ts (4)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • Effect (88-447)
  • effect (303-313)
js/hang/src/publish/source/device.ts (4)
  • DeviceProps (3-5)
  • Device (7-111)
  • effect (40-85)
  • effect (87-106)
js/hang/src/publish/audio/index.ts (6)
  • AudioConstraints (18-21)
  • AudioStreamTrack (24-28)
  • effect (102-154)
  • effect (156-169)
  • effect (171-250)
  • effect (252-270)
js/hang/src/publish/source/camera.ts (1)
  • effect (31-61)
js/hang/src/publish/source/device.ts (3)
js/signals/src/index.ts (4)
  • Signal (19-83)
  • Getter (10-13)
  • Effect (88-447)
  • effect (303-313)
js/hang/src/publish/source/camera.ts (1)
  • effect (31-61)
js/hang/src/publish/source/microphone.ts (1)
  • effect (30-56)
js/hang/src/publish/element.ts (9)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • effect (303-313)
  • Effect (88-447)
js/hang/src/publish/audio/index.ts (5)
  • Source (16-16)
  • effect (102-154)
  • effect (156-169)
  • effect (171-250)
  • effect (252-270)
js/hang/src/publish/video/index.ts (3)
  • Source (13-13)
  • effect (88-168)
  • effect (334-369)
js/hang/src/publish/source/camera.ts (2)
  • Camera (12-67)
  • effect (31-61)
js/hang/src/publish/source/screen.ts (2)
  • Screen (11-76)
  • effect (28-71)
js/hang/src/publish/source/microphone.ts (2)
  • Microphone (11-62)
  • effect (30-56)
js/hang/src/publish/preview.ts (1)
  • preview (41-43)
js/hang/src/meet/element.ts (4)
  • name (89-118)
  • name (120-153)
  • name (174-176)
  • name (178-180)
js/hang/src/watch/element.ts (6)
  • name (140-142)
  • name (144-146)
  • parent (276-294)
  • parent (296-349)
  • parent (351-378)
  • parent (380-400)
js/hang/src/publish/audio/index.ts (8)
js/hang/src/publish/video/index.ts (4)
  • Source (13-13)
  • effect (88-168)
  • effect (334-369)
  • settings (171-297)
js/signals/src/index.ts (2)
  • Signal (19-83)
  • effect (303-313)
js/hang/src/publish/audio/speaking.ts (2)
  • Speaking (14-117)
  • effect (31-111)
js/hang/src/publish/audio/captions.ts (2)
  • Captions (17-136)
  • effect (40-131)
js/hang/src/publish/element.ts (2)
  • source (107-109)
  • source (111-157)
js/hang/src/publish/source/microphone.ts (1)
  • effect (30-56)
js/hang/src/publish/source/screen.ts (1)
  • effect (28-71)
js/moq/src/group.ts (1)
  • GroupProducer (8-71)
js/hang/src/publish/source/camera.ts (3)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • Effect (88-447)
  • effect (303-313)
js/hang/src/publish/video/index.ts (4)
  • VideoConstraints (37-43)
  • VideoStreamTrack (19-23)
  • effect (88-168)
  • effect (334-369)
js/hang/src/publish/source/microphone.ts (1)
  • effect (30-56)
🔇 Additional comments (7)
js/hang/src/publish/element.ts (7)

5-6: LGTM: Source-centric imports.


11-12: LGTM: Local SourceType alias.


21-24: LGTM: Internal state signals for active source wrappers.


35-41: LGTM: Auto-enable publish when any track exists.


46-58: LGTM: Preview binds to video source with proper cleanup.


64-66: LGTM: Early-return on unchanged attributes.


143-147: LGTM: Bind screen.enabled to audio||video with explicit cleanup.

Comment on lines +25 to 28
<hang-publish url="%VITE_RELAY_URL%" name="me" controls captions>
<!-- It's optional to provide a video element to preview the outgoing media. -->
<video style="max-width: 100%; height: auto; border-radius: 4px; margin: 0 auto;" muted autoplay></video>
</hang-publish>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Example code below references #publish but the element lacks an id.

Add id="publish" so document.getElementById("publish") works.

-  <hang-publish url="%VITE_RELAY_URL%" name="me" controls captions>
+  <hang-publish id="publish" url="%VITE_RELAY_URL%" name="me" controls captions>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<hang-publish url="%VITE_RELAY_URL%" name="me" controls captions>
<!-- It's optional to provide a video element to preview the outgoing media. -->
<video style="max-width: 100%; height: auto; border-radius: 4px; margin: 0 auto;" muted autoplay></video>
</hang-publish>
<hang-publish id="publish" url="%VITE_RELAY_URL%" name="me" controls captions>
<!-- It's optional to provide a video element to preview the outgoing media. -->
<video style="max-width: 100%; height: auto; border-radius: 4px; margin: 0 auto;" muted autoplay></video>
</hang-publish>
🤖 Prompt for AI Agents
In js/hang-demo/src/publish.html around lines 25 to 28, the <hang-publish>
element is referenced by document.getElementById("publish") but lacks an id
attribute; add id="publish" to the <hang-publish> tag so the DOM lookup
succeeds, ensuring any existing code that calls
document.getElementById("publish") will correctly find the element.

Comment on lines +46 to +49
if (!devices.length) {
console.warn(`no ${this.kind} devices found`);
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid stale state when no devices are found.

Currently returns early without clearing lists; downstream may keep using removed devices.

-    if (!devices.length) {
-      console.warn(`no ${this.kind} devices found`);
-      return;
-    }
+    if (!devices.length) {
+      console.warn(`no ${this.kind} devices found`);
+      effect.set(this.#devices, [], []);
+      effect.set(this.#default, undefined, undefined);
+      return;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!devices.length) {
console.warn(`no ${this.kind} devices found`);
return;
}
if (!devices.length) {
console.warn(`no ${this.kind} devices found`);
effect.set(this.#devices, [], []);
effect.set(this.#default, undefined, undefined);
return;
}
🤖 Prompt for AI Agents
In js/hang/src/publish/source/device.ts around lines 46 to 49, the code returns
early when no devices are found but leaves internal state intact causing
downstream code to use stale device lists; fix by clearing the relevant stored
state before returning (e.g., reset this.devices to an empty array, clear any
device maps/sets, nullify selected device indices or related UI state, and
emit/update any change notifications), then return as before.

And remove Claude because it's not very good at PRs.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
js/hang/src/publish/audio/index.ts (2)

146-153: Make #worklet and #gain signals reversible on teardown.

Prevent stale references by reverting to undefined on cleanup.

-      effect.set(this.#worklet, worklet);
+      effect.set(this.#worklet, worklet, undefined);
@@
-      effect.set(this.#gain, gain);
+      effect.set(this.#gain, gain, undefined);

273-277: Close Speaking on teardown to release its worker and track.

Audio.close() should also call this.speaking.close().

   close() {
     this.#signals.close();
     this.captions.close();
+    this.speaking.close();
     this.#track.close();
   }
♻️ Duplicate comments (6)
js/hang/src/publish/source/device.ts (1)

46-49: Clear state when no devices are found to prevent stale selections.

If enumeration returns zero devices, available/default remain from a previous run. Reset both before returning.

-    if (!devices.length) {
-      console.warn(`no ${this.kind} devices found`);
-      return;
-    }
+    if (!devices.length) {
+      console.warn(`no ${this.kind} devices found`);
+      effect.set(this.#devices, [], []);
+      effect.set(this.#default, undefined, undefined);
+      return;
+    }
js/hang/src/publish/audio/index.ts (2)

24-42: Relax AudioTrackSettings and getSettings typing; sampleRate can be undefined.

Firefox (and sometimes others) omit sampleRate. The code already guards at runtime; types should reflect that to avoid unsoundness.

 export interface AudioStreamTrack extends MediaStreamTrack {
   kind: "audio";
-  clone(): AudioStreamTrack;
-  getSettings(): AudioTrackSettings;
+  clone(): AudioStreamTrack;
+  getSettings(): AudioTrackSettings;
 }
@@
 // MediaTrackSettings can represent both audio and video, which means a LOT of possibly undefined properties.
-// This is a fork of the MediaTrackSettings interface with properties required for audio or vidfeo.
+// This is a fork of the MediaTrackSettings interface with properties required for audio or video.
 export interface AudioTrackSettings {
   deviceId: string;
   groupId: string;
   autoGainControl: boolean;
   channelCount: number;
   echoCancellation: boolean;
   noiseSuppression: boolean;
-  sampleRate: number;
+  sampleRate?: number;
   sampleSize: number;
 }

192-207: Fix first-frame group rotation to avoid closing an empty group.

Initialize timestamp from the first frame before applying the rotate threshold.

-    let group: Moq.GroupProducer = this.#track.appendGroup();
-    effect.cleanup(() => group.close());
-
-    let groupTimestamp = 0;
+    let group: Moq.GroupProducer = this.#track.appendGroup();
+    effect.cleanup(() => group.close());
+
+    let groupTimestamp: number | undefined = undefined;
@@
-        if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
+        if (groupTimestamp === undefined) {
+          groupTimestamp = frame.timestamp;
+        } else if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
           group.close();
           group = this.#track.appendGroup();
           groupTimestamp = frame.timestamp;
         }
js/hang/src/publish/element.ts (3)

123-125: Clear broadcast sources when switching to a new source.

After closing the previous Source.* instances, immediately clear broadcast.video.source and broadcast.audio.source to avoid stale tracks keeping the publisher “enabled.”

     this.#audio.peek()?.close();
     this.#video.peek()?.close();
+    // Clear published sources immediately; effects below will repopulate.
+    this.broadcast.video.source.set(undefined);
+    this.broadcast.audio.source.set(undefined);

128-137: Make camera wiring reversible on cleanup.

Ensure broadcast.*.source reverts when the effect disposes.

-      video.signals.effect((effect) => {
+      video.signals.effect((effect) => {
         const stream = effect.get(video.stream);
-        effect.set(this.broadcast.video.source, stream);
+        effect.set(this.broadcast.video.source, stream, undefined);
       });
@@
-      audio.signals.effect((effect) => {
+      audio.signals.effect((effect) => {
         const stream = effect.get(audio.stream);
-        effect.set(this.broadcast.audio.source, stream);
+        effect.set(this.broadcast.audio.source, stream, undefined);
       });

145-151: Screen wiring: avoid early return and revert on cleanup.

Don’t return early when stream is absent; always set sources (to values or undefined) so teardown clears them.

-      screen.signals.effect((effect) => {
-        const stream = effect.get(screen.stream);
-        if (!stream) return;
-
-        effect.set(this.broadcast.video.source, stream.video);
-        effect.set(this.broadcast.audio.source, stream.audio);
-      });
+      screen.signals.effect((effect) => {
+        const stream = effect.get(screen.stream);
+        effect.set(this.broadcast.video.source, stream?.video, undefined);
+        effect.set(this.broadcast.audio.source, stream?.audio, undefined);
+      });
🧹 Nitpick comments (6)
js/hang/src/publish/source/device.ts (1)

80-81: Gate noisy debug logs (optional).

Consider gating console.debug behind a DEV flag to avoid log spam in production.

js/hang/src/publish/audio/captions.ts (2)

50-57: Revert catalog on cleanup to avoid stale UI/state.

this.catalog stays set after teardown. Make it reversible.

-    effect.set(this.catalog, catalog);
+    effect.set(this.catalog, catalog, undefined);

70-78: Fix log prefix for clarity.

This is captions, not VAD. Adjust the error message.

-        console.error("VAD worker error:", data.message);
+        console.error("Captions worker error:", data.message);
js/hang/src/publish/audio/index.ts (2)

115-123: Avoid passing undefined sampleRate to AudioContext options.

Construct options conditionally to prevent an undefined numeric field.

-    const context = new AudioContext({
-      latencyHint: "interactive",
-      sampleRate: settings.sampleRate,
-    });
+    const ctxOpts: AudioContextOptions = { latencyHint: "interactive" };
+    if (settings.sampleRate !== undefined) ctxOpts.sampleRate = settings.sampleRate;
+    const context = new AudioContext(ctxOpts);

31-31: Typo in comment (“vidfeo”).

Minor comment fix.

-// This is a fork of the MediaTrackSettings interface with properties required for audio or vidfeo.
+// This is a fork of the MediaTrackSettings interface with properties required for audio or video.
js/hang/src/publish/element.ts (1)

521-521: UI copy: say “Select Source” (not “Device”).

Aligns with the new source-based model.

-        container.textContent = "🟡 Select Device";
+        container.textContent = "🟡 Select Source";
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2559ac3 and bd9a80a.

📒 Files selected for processing (11)
  • .github/workflows/claude-code-review.yml (0 hunks)
  • .github/workflows/claude-dispatch.yml (0 hunks)
  • js/hang-demo/src/publish.html (2 hunks)
  • js/hang/src/publish/audio/captions.ts (4 hunks)
  • js/hang/src/publish/audio/index.ts (9 hunks)
  • js/hang/src/publish/audio/speaking.ts (4 hunks)
  • js/hang/src/publish/element.ts (7 hunks)
  • js/hang/src/publish/source/camera.ts (1 hunks)
  • js/hang/src/publish/source/device.ts (1 hunks)
  • js/hang/src/publish/source/microphone.ts (1 hunks)
  • js/hang/src/watch/audio/index.ts (3 hunks)
💤 Files with no reviewable changes (2)
  • .github/workflows/claude-dispatch.yml
  • .github/workflows/claude-code-review.yml
🚧 Files skipped from review as they are similar to previous changes (4)
  • js/hang/src/watch/audio/index.ts
  • js/hang/src/publish/source/camera.ts
  • js/hang-demo/src/publish.html
  • js/hang/src/publish/audio/speaking.ts
🧰 Additional context used
📓 Path-based instructions (2)
{rs,js}/hang/**

📄 CodeRabbit inference engine (CLAUDE.md)

All media logic belongs in the hang layer (rs/hang and js/hang)

Files:

  • js/hang/src/publish/element.ts
  • js/hang/src/publish/audio/captions.ts
  • js/hang/src/publish/source/microphone.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/publish/source/device.ts
js/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place JS/TS packages under the js/ directory

Files:

  • js/hang/src/publish/element.ts
  • js/hang/src/publish/audio/captions.ts
  • js/hang/src/publish/source/microphone.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/publish/source/device.ts
🧬 Code graph analysis (5)
js/hang/src/publish/element.ts (7)
js/signals/src/index.ts (2)
  • Signal (19-83)
  • Effect (88-447)
js/hang/src/publish/audio/index.ts (5)
  • Source (16-16)
  • effect (102-154)
  • effect (156-169)
  • effect (171-251)
  • effect (253-271)
js/hang/src/publish/video/index.ts (3)
  • Source (13-13)
  • effect (88-168)
  • effect (334-369)
js/hang/src/publish/source/camera.ts (2)
  • Camera (11-68)
  • effect (28-62)
js/hang/src/publish/source/screen.ts (2)
  • Screen (11-76)
  • effect (28-71)
js/hang/src/publish/source/microphone.ts (2)
  • Microphone (11-66)
  • effect (30-60)
js/hang/src/meet/element.ts (4)
  • name (89-118)
  • name (120-153)
  • name (174-176)
  • name (178-180)
js/hang/src/publish/audio/captions.ts (2)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • effect (303-313)
  • Effect (88-447)
js/hang/src/publish/audio/index.ts (5)
  • Audio (59-278)
  • effect (102-154)
  • effect (156-169)
  • effect (171-251)
  • effect (253-271)
js/hang/src/publish/source/microphone.ts (4)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • Effect (88-447)
  • effect (303-313)
js/hang/src/publish/source/device.ts (4)
  • DeviceProps (3-5)
  • Device (7-111)
  • effect (40-85)
  • effect (87-106)
js/hang/src/publish/audio/index.ts (6)
  • AudioConstraints (18-21)
  • AudioStreamTrack (24-28)
  • effect (102-154)
  • effect (156-169)
  • effect (171-251)
  • effect (253-271)
js/hang/src/publish/source/camera.ts (1)
  • effect (28-62)
js/hang/src/publish/audio/index.ts (7)
js/hang/src/publish/video/index.ts (3)
  • Source (13-13)
  • effect (88-168)
  • effect (334-369)
js/signals/src/index.ts (2)
  • Signal (19-83)
  • effect (303-313)
js/hang/src/publish/audio/speaking.ts (2)
  • Speaking (14-118)
  • effect (31-112)
js/hang/src/publish/audio/captions.ts (2)
  • Captions (17-137)
  • effect (40-132)
js/hang/src/publish/source/microphone.ts (1)
  • effect (30-60)
js/hang/src/publish/source/screen.ts (1)
  • effect (28-71)
js/moq/src/group.ts (1)
  • GroupProducer (8-71)
js/hang/src/publish/source/device.ts (4)
js/signals/src/index.ts (4)
  • Signal (19-83)
  • Getter (10-13)
  • Effect (88-447)
  • effect (303-313)
js/hang/src/publish/source/camera.ts (1)
  • effect (28-62)
js/hang/src/publish/source/microphone.ts (1)
  • effect (30-60)
js/hang/src/publish/element.ts (2)
  • device (108-110)
  • device (112-114)
🔇 Additional comments (1)
js/hang/src/publish/source/microphone.ts (1)

43-59: getUserMedia race is handled; hot-mic mitigated.

Pre-registering cleanup on the pending media promise ensures late streams are stopped when the effect cancels. LGTM.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/hang/src/publish/audio/index.ts (1)

180-191: Possible runtime crash if channelCount is missing; derive from worklet as a reliable fallback

Firefox can omit channelCount; configuring AudioEncoder with undefined channels will throw. Use worklet.channelCount (always present) as the primary source; also base bitrate on the resolved channel count.

-const config = {
-  // TODO get codec and description from decoderConfig
-  codec: "opus",
-  // Firefox doesn't provide the sampleRate in the settings.
-  sampleRate: u53(settings.sampleRate ?? worklet?.context.sampleRate),
-  numberOfChannels: u53(settings.channelCount),
-  // TODO configurable
-  bitrate: u53(settings.channelCount * 32_000),
-};
+const resolvedChannels = worklet.channelCount ?? settings.channelCount ?? 1;
+const resolvedRate = settings.sampleRate ?? worklet.context.sampleRate;
+const config = {
+  // TODO get codec and description from decoderConfig
+  codec: "opus",
+  sampleRate: u53(resolvedRate),
+  numberOfChannels: u53(resolvedChannels),
+  // TODO configurable
+  bitrate: u53(resolvedChannels * 32_000),
+};

Additionally, ensure the frame path slices using the same resolvedChannels:

-worklet.port.onmessage = ({ data }: { data: Capture.AudioFrame }) => {
-  const channels = data.channels.slice(0, settings.channelCount);
+worklet.port.onmessage = ({ data }: { data: Capture.AudioFrame }) => {
+  const limit = worklet.channelCount ?? settings.channelCount ?? data.channels.length;
+  const channels = data.channels.slice(0, limit);
   const joinedLength = channels.reduce((a, b) => a + b.length, 0);
   // ...
   const frame = new AudioData({
     format: "f32-planar",
-    sampleRate: worklet.context.sampleRate,
-    numberOfFrames: channels[0].length,
-    numberOfChannels: channels.length,
+    sampleRate: worklet.context.sampleRate,
+    numberOfFrames: channels[0].length,
+    numberOfChannels: channels.length,
     timestamp: (1_000_000 * data.timestamp) / worklet.context.sampleRate,
     data: joined,
     transfer: [joined.buffer],
   });
♻️ Duplicate comments (2)
js/hang/src/publish/audio/index.ts (2)

192-207: Avoid closing an empty first group when timestamps start > 0

Initialize groupTimestamp from the first encoded frame before rotating.

-let group: Moq.GroupProducer = this.#track.appendGroup();
+let group: Moq.GroupProducer = this.#track.appendGroup();
 effect.cleanup(() => group.close());
 
-let groupTimestamp = 0;
+let groupTimestamp: number | undefined = undefined;
@@
- if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
+ if (groupTimestamp === undefined) {
+   groupTimestamp = frame.timestamp;
+ } else if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
   group.close();
   group = this.#track.appendGroup();
   groupTimestamp = frame.timestamp;
 }

24-28: getSettings typing is too strict; sampleRate can be undefined in browsers (e.g., Firefox)

Make sampleRate optional in AudioTrackSettings and keep the runtime fallback you already use later.

 export interface AudioTrackSettings {
   deviceId: string;
   groupId: string;

   autoGainControl: boolean;
   channelCount: number;
   echoCancellation: boolean;
   noiseSuppression: boolean;
-  sampleRate: number;
+  sampleRate?: number;
   sampleSize: number;
 }

Nit: fix the comment typo “vidfeo” → “video”.

-// This is a fork of the MediaTrackSettings interface with properties required for audio or vidfeo.
+// This is a fork of the MediaTrackSettings interface with properties required for audio or video.
🧹 Nitpick comments (1)
js/hang/src/publish/audio/index.ts (1)

209-214: Propagate encoder errors to the track, not just the current group

Align with video: aborting the track helps downstream react correctly.

 error: (err) => {
   group.abort(err);
+  this.#track.abort(err);
 },
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between bd9a80a and 2755918.

📒 Files selected for processing (5)
  • js/hang/src/publish/audio/index.ts (9 hunks)
  • js/hang/src/publish/source/camera.ts (1 hunks)
  • js/hang/src/publish/source/device.ts (1 hunks)
  • js/hang/src/publish/source/microphone.ts (1 hunks)
  • js/hang/src/watch/audio/index.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • js/hang/src/watch/audio/index.ts
  • js/hang/src/publish/source/microphone.ts
  • js/hang/src/publish/source/device.ts
  • js/hang/src/publish/source/camera.ts
🧰 Additional context used
📓 Path-based instructions (2)
{rs,js}/hang/**

📄 CodeRabbit inference engine (CLAUDE.md)

All media logic belongs in the hang layer (rs/hang and js/hang)

Files:

  • js/hang/src/publish/audio/index.ts
js/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place JS/TS packages under the js/ directory

Files:

  • js/hang/src/publish/audio/index.ts
🧬 Code graph analysis (1)
js/hang/src/publish/audio/index.ts (7)
js/hang/src/publish/video/index.ts (4)
  • Source (13-13)
  • effect (88-168)
  • effect (334-369)
  • settings (171-297)
js/signals/src/index.ts (2)
  • Signal (19-83)
  • effect (303-313)
js/hang/src/publish/audio/speaking.ts (2)
  • Speaking (14-118)
  • effect (31-112)
js/hang/src/publish/audio/captions.ts (2)
  • Captions (17-137)
  • effect (40-132)
js/hang/src/publish/source/microphone.ts (1)
  • effect (30-63)
js/hang/src/publish/source/screen.ts (1)
  • effect (28-71)
js/moq/src/group.ts (1)
  • GroupProducer (8-71)
🔇 Additional comments (5)
js/hang/src/publish/audio/index.ts (5)

16-17: Source alias parity looks good

Matches the video module’s Source typing.


46-51: Props now accept Signals—LGTM

The Signal.from surface is consistent and backwards-friendly defaults are sensible.


69-70: Exposing source as Signal is appropriate

Enables reactive rewiring across the pipeline.


88-94: Constructor Signal wrapping—LGTM

Good consolidation via Signal.from; sane defaults.


250-252: Cleanup handler—LGTM

Clearing onmessage avoids leaks.

Comment on lines 102 to 114
#runSource(effect: Effect): void {
const enabled = effect.get(this.enabled);
const media = effect.get(this.media);
if (!enabled || !media) return;
if (!enabled) return;

const settings = media.getSettings();
if (!settings) {
throw new Error("track has no settings");
}
const source = effect.get(this.source);
if (!source) return;

// Insert the track into the broadcast.
this.broadcast.insertTrack(this.#track.consume());
effect.cleanup(() => this.broadcast.removeTrack(this.#track.name));

const settings = source.getSettings();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Track insertion in #runSource diverges from video; confirm intent + minor AudioContext nit

  • In video, insert happens in #runEncoder after config is known. Here it’s in #runSource. If intentional, fine; otherwise consider moving for parity so the track only appears when the encoder is ready.

  • Optional: only set sampleRate on AudioContext when defined to avoid passing an explicit undefined.

-const context = new AudioContext({
-  latencyHint: "interactive",
-  sampleRate: settings.sampleRate,
-});
+const ctxOpts: AudioContextOptions = { latencyHint: "interactive" };
+if (settings.sampleRate !== undefined) ctxOpts.sampleRate = settings.sampleRate;
+const context = new AudioContext(ctxOpts);
  • Recommend explicit channel behavior on the capture worklet node (prevents implicit up/downmixing).
 const worklet = new AudioWorkletNode(context, "capture", {
   numberOfInputs: 1,
   numberOfOutputs: 0,
-  channelCount: settings.channelCount,
+  channelCount: settings.channelCount,
+  channelCountMode: "explicit",
+  channelInterpretation: "discrete",
 });

Also applies to: 121-123


🏁 Script executed:

#!/bin/bash
# Ensure the file exists
echo "File exists:"
ls -l js/hang/src/publish/audio/index.ts

# Show the runSource method context
echo -e "\n--- runSource context ---"
rg -n "runSource" -C5 js/hang/src/publish/audio/index.ts

# Show the runEncoder method context
echo -e "\n--- runEncoder context ---"
rg -n "runEncoder" -C5 js/hang/src/publish/audio/index.ts

# Show AudioContext instantiation
echo -e "\n--- AudioContext instantiation ---"
rg -n "new AudioContext" -C5 js/hang/src/publish/audio/index.ts

# Show AudioWorkletNode instantiation
echo -e "\n--- AudioWorkletNode instantiation ---"
rg -n "new AudioWorkletNode" -C5 js/hang/src/publish/audio/index.ts

Length of output: 2859


🏁 Script executed:

#!/bin/bash
# Show full #runEncoder implementation to verify track insertion timing
sed -n '165,260p' js/hang/src/publish/audio/index.ts

Length of output: 2771


Confirm track insertion timing & refine AudioContext/WorkletNode options

  • Confirm if inserting the track in #runSource (line 102) instead of in #runEncoder is intentional—video shows insertion after encoder configuration.
  • Optional: only set sampleRate on AudioContext when defined to avoid passing undefined:
-const context = new AudioContext({
-  latencyHint: "interactive",
-  sampleRate: settings.sampleRate,
-});
+const ctxOpts: AudioContextOptions = { latencyHint: "interactive" };
+if (settings.sampleRate !== undefined) ctxOpts.sampleRate = settings.sampleRate;
+const context = new AudioContext(ctxOpts);
  • Essential: explicitly set channelCountMode and channelInterpretation on the capture worklet to prevent implicit mixing:
 const worklet = new AudioWorkletNode(context, "capture", {
   numberOfInputs: 1,
   numberOfOutputs: 0,
-  channelCount: settings.channelCount,
+  channelCount: settings.channelCount,
+  channelCountMode: "explicit",
+  channelInterpretation: "discrete",
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#runSource(effect: Effect): void {
const enabled = effect.get(this.enabled);
const media = effect.get(this.media);
if (!enabled || !media) return;
if (!enabled) return;
const settings = media.getSettings();
if (!settings) {
throw new Error("track has no settings");
}
const source = effect.get(this.source);
if (!source) return;
// Insert the track into the broadcast.
this.broadcast.insertTrack(this.#track.consume());
effect.cleanup(() => this.broadcast.removeTrack(this.#track.name));
const settings = source.getSettings();
// … previous lines …
const settings = source.getSettings();
// Build AudioContext options, only adding sampleRate if defined
const ctxOpts: AudioContextOptions = { latencyHint: "interactive" };
if (settings.sampleRate !== undefined) {
ctxOpts.sampleRate = settings.sampleRate;
}
const context = new AudioContext(ctxOpts);
// Set up the capture worklet with explicit channel handling
- const worklet = new AudioWorkletNode(context, "capture", {
- numberOfInputs: 1,
- numberOfOutputs: 0,
- channelCount: settings.channelCount,
const worklet = new AudioWorkletNode(context, "capture", {
numberOfInputs: 1,
numberOfOutputs: 0,
channelCount: settings.channelCount,
channelCountMode: "explicit",
channelInterpretation: "discrete",
});
// … following lines …
🤖 Prompt for AI Agents
In js/hang/src/publish/audio/index.ts around lines 102–114, confirm whether
insertTrack should occur here in #runSource or after encoder setup in
#runEncoder (move insertTrack to #runEncoder to match video timing if insertion
should happen post-encoder). When constructing the AudioContext only pass
sampleRate if it is defined (avoid passing undefined). Also explicitly set the
capture WorkletNode's channelCountMode to "explicit" and channelInterpretation
to "discrete" to prevent implicit channel mixing.

@kixelated kixelated enabled auto-merge (squash) August 29, 2025 20:35
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/hang/src/publish/audio/index.ts (1)

113-121: Avoid passing undefined sampleRate and prevent implicit channel mixing.

-    const context = new AudioContext({
-      latencyHint: "interactive",
-      sampleRate: settings.sampleRate,
-    });
+    const ctxOpts: AudioContextOptions = { latencyHint: "interactive" };
+    if (settings.sampleRate !== undefined) ctxOpts.sampleRate = settings.sampleRate;
+    const context = new AudioContext(ctxOpts);
@@
-      const worklet = new AudioWorkletNode(context, "capture", {
+      const worklet = new AudioWorkletNode(context, "capture", {
         numberOfInputs: 1,
         numberOfOutputs: 0,
         channelCount: settings.channelCount,
+        channelCountMode: "explicit",
+        channelInterpretation: "discrete",
       });
- 
-      effect.set(this.#worklet, worklet);
+ 
+      effect.set(this.#worklet, worklet, undefined);
@@
-      // Only set the gain after the worklet is registered.
-      effect.set(this.#gain, gain);
+      // Only set the gain after the worklet is registered.
+      effect.set(this.#gain, gain, undefined);

Also applies to: 133-141

♻️ Duplicate comments (5)
js/hang/src/publish/source/device.ts (1)

46-49: Clear state when no devices are found to prevent stale selections.

Returning early leaves available/default/selected unchanged. Reset them before returning.

-    if (!devices.length) {
-      console.warn(`no ${this.kind} devices found`);
-      return;
-    }
+    if (!devices.length) {
+      console.warn(`no ${this.kind} devices found`);
+      effect.set(this.#devices, [], []);
+      effect.set(this.#default, undefined, undefined);
+      effect.set(this.#selected, undefined, undefined);
+      return;
+    }
js/hang/src/publish/element.ts (3)

123-125: Clear published sources when switching/clearing to avoid stale tracks.

   this.#audio.peek()?.close();
   this.#video.peek()?.close();
+  this.broadcast.video.source.set(undefined);
+  this.broadcast.audio.source.set(undefined);
@@
 } else {
   this.#video.set(undefined);
   this.#audio.set(undefined);
+  this.broadcast.video.source.set(undefined);
+  this.broadcast.audio.source.set(undefined);
 }

Also applies to: 161-163


128-137: Make Effect.set reversible for camera wiring.

Without cleanup values, stale streams persist after teardown.

-        const stream = effect.get(video.stream);
-        effect.set(this.broadcast.video.source, stream);
+        const stream = effect.get(video.stream);
+        effect.set(this.broadcast.video.source, stream, undefined);
@@
-        const stream = effect.get(audio.stream);
-        effect.set(this.broadcast.audio.source, stream);
+        const stream = effect.get(audio.stream);
+        effect.set(this.broadcast.audio.source, stream, undefined);

144-151: Screen wiring should revert on cleanup and when stream disappears.

-        const stream = effect.get(screen.stream);
-        if (!stream) return;
-
-        effect.set(this.broadcast.video.source, stream.video);
-        effect.set(this.broadcast.audio.source, stream.audio);
+        const stream = effect.get(screen.stream);
+        effect.set(this.broadcast.video.source, stream?.video, undefined);
+        effect.set(this.broadcast.audio.source, stream?.audio, undefined);
js/hang/src/publish/audio/index.ts (1)

185-199: First-frame grouping rotates immediately if timestamps start > 0.

Initialize from the first encoded frame to avoid closing an empty group.

-    let group: Moq.GroupProducer = this.#track.appendGroup();
-    effect.cleanup(() => group.close());
-
-    let groupTimestamp = 0;
+    let group: Moq.GroupProducer = this.#track.appendGroup();
+    effect.cleanup(() => group.close());
+
+    let groupTimestamp: number | undefined = undefined;
@@
-        if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
+        if (groupTimestamp === undefined) {
+          groupTimestamp = frame.timestamp;
+        } else if (frame.timestamp - groupTimestamp >= 1000 * this.maxLatency) {
           group.close();
           group = this.#track.appendGroup();
           groupTimestamp = frame.timestamp;
         }
🧹 Nitpick comments (5)
js/hang/src/publish/source/device.ts (1)

63-78: Provide a final fallback default device.

If no alias/label match is found, fall back to the first device to reduce “no selection” cases.

       }
     }
 
     console.debug("all devices", devices);
     console.debug("default device", defaultDevice);
 
+    if (!defaultDevice) {
+      defaultDevice = devices[0];
+    }
js/hang/src/publish/element.ts (2)

76-77: Message should mention “source” too.

-        throw new Error(`Invalid device: ${newValue}`);
+        throw new Error(`Invalid source/device: ${newValue}`);

364-368: Caret direction is inverted (▲ when open, ▼ when closed).

-        caret.innerText = v ? "▼" : "▲";
+        caret.innerText = v ? "▲" : "▼";

Also applies to: 446-450

js/hang/src/publish/audio/index.ts (1)

248-255: Align audio and video track insertion timing
Audio inserts in runCatalog (js/hang/src/publish/audio/index.ts:253-254) while video inserts in runEncoder (js/hang/src/publish/video/index.ts:96-97); confirm this divergence is intentional, and if not, consider unifying the insertion point to prevent audio frames appearing before video.

js/hang/src/publish/audio/speaking.ts (1)

47-48: Make catalog reversible on cleanup.

Prevents stale catalog after teardown.

-    effect.set(this.catalog, catalog);
+    effect.set(this.catalog, catalog, undefined);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2755918 and bf2d4bb.

📒 Files selected for processing (7)
  • js/hang/src/publish/audio/captions.ts (4 hunks)
  • js/hang/src/publish/audio/index.ts (11 hunks)
  • js/hang/src/publish/audio/speaking.ts (4 hunks)
  • js/hang/src/publish/element.ts (8 hunks)
  • js/hang/src/publish/source/device.ts (1 hunks)
  • js/hang/src/publish/video/detection.ts (3 hunks)
  • js/hang/src/watch/audio/index.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • js/hang/src/publish/video/detection.ts
  • js/hang/src/publish/audio/captions.ts
  • js/hang/src/watch/audio/index.ts
🧰 Additional context used
📓 Path-based instructions (2)
{rs,js}/hang/**

📄 CodeRabbit inference engine (CLAUDE.md)

All media logic belongs in the hang layer (rs/hang and js/hang)

Files:

  • js/hang/src/publish/source/device.ts
  • js/hang/src/publish/audio/speaking.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/publish/element.ts
js/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place JS/TS packages under the js/ directory

Files:

  • js/hang/src/publish/source/device.ts
  • js/hang/src/publish/audio/speaking.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/publish/element.ts
🧬 Code graph analysis (4)
js/hang/src/publish/source/device.ts (3)
js/signals/src/index.ts (4)
  • Signal (19-83)
  • Getter (10-13)
  • Effect (88-447)
  • effect (303-313)
js/hang/src/publish/source/microphone.ts (1)
  • effect (30-63)
js/hang/src/publish/source/camera.ts (1)
  • effect (28-65)
js/hang/src/publish/audio/speaking.ts (5)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • effect (303-313)
  • Effect (88-447)
js/hang/src/publish/audio/index.ts (4)
  • effect (104-147)
  • effect (149-162)
  • effect (164-246)
  • effect (248-270)
js/hang/src/publish/audio/captions.ts (1)
  • effect (40-128)
js/hang/src/publish/source/microphone.ts (1)
  • effect (30-63)
js/hang/src/publish/element.ts (2)
  • source (116-118)
  • source (120-166)
js/hang/src/publish/audio/index.ts (8)
js/hang/src/publish/video/index.ts (4)
  • Source (13-13)
  • effect (88-168)
  • effect (334-369)
  • settings (171-297)
js/signals/src/index.ts (3)
  • Signal (19-83)
  • effect (303-313)
  • Effect (88-447)
js/hang/src/publish/audio/speaking.ts (2)
  • Speaking (14-114)
  • effect (31-108)
js/hang/src/publish/audio/captions.ts (2)
  • Captions (17-133)
  • effect (40-128)
js/hang/src/publish/source/microphone.ts (1)
  • effect (30-63)
js/hang/src/publish/source/camera.ts (1)
  • effect (28-65)
js/hang/src/publish/source/screen.ts (1)
  • effect (28-71)
js/moq/src/group.ts (1)
  • GroupProducer (8-71)
js/hang/src/publish/element.ts (6)
js/signals/src/index.ts (2)
  • Signal (19-83)
  • Effect (88-447)
js/hang/src/publish/source/camera.ts (2)
  • Camera (11-71)
  • effect (28-65)
js/hang/src/publish/source/screen.ts (2)
  • Screen (11-76)
  • effect (28-71)
js/hang/src/publish/source/microphone.ts (2)
  • Microphone (11-69)
  • effect (30-63)
js/hang/src/publish/source/device.ts (2)
  • effect (40-85)
  • effect (87-106)
js/hang/src/meet/element.ts (4)
  • name (89-118)
  • name (120-153)
  • name (174-176)
  • name (178-180)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Check
🔇 Additional comments (2)
js/hang/src/publish/audio/index.ts (1)

26-30: Relax sampleRate to optional in AudioTrackSettings
Firefox can omit sampleRate; existing call sites (including AudioContext instantiation and worklet fallback) already tolerate undefined.

js/hang/src/publish/audio/speaking.ts (1)

25-33: LGTM: props-to-signal migration and gating are correct.

@kixelated kixelated disabled auto-merge August 29, 2025 20:46
@kixelated kixelated merged commit e819f50 into main Aug 29, 2025
1 of 2 checks passed
@kixelated kixelated deleted the device-manager branch August 29, 2025 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants