openmeet-terminal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +2253 -0
  2. package/package.json +47 -0
package/dist/index.js ADDED
@@ -0,0 +1,2253 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.tsx
4
+ import { execSync as execSync3, spawnSync } from "node:child_process";
5
+ import { platform as platform2 } from "node:os";
6
+ import { parseArgs } from "node:util";
7
+ import { render } from "ink";
8
+
9
+ // src/app.tsx
10
+ import { Box as Box9 } from "ink";
11
+ import { useEffect as useEffect6, useState as useState7 } from "react";
12
+
13
+ // src/components/device-picker.tsx
14
+ import { Box, Text, useInput } from "ink";
15
+ import SelectInput from "ink-select-input";
16
+ import { useEffect, useRef, useState } from "react";
17
+
18
+ // src/lib/audio-test.ts
19
+ import { spawn } from "node:child_process";
20
+ var SAMPLE_RATE = 48e3;
21
+ var FRAME_SIZE = 480;
22
+ var BYTES_PER_FRAME = FRAME_SIZE * 2;
23
+ function buildEnv(extra) {
24
+ if (Object.keys(extra).length === 0) return void 0;
25
+ return { ...process.env, ...extra };
26
+ }
27
+ function computeRMS(samples) {
28
+ let sum = 0;
29
+ for (let i = 0; i < samples.length; i++) {
30
+ sum += samples[i] * samples[i];
31
+ }
32
+ return Math.sqrt(sum / samples.length);
33
+ }
34
+ var MicTester = class {
35
+ process = null;
36
+ onLevel = null;
37
+ setLevelCallback(cb) {
38
+ this.onLevel = cb;
39
+ }
40
+ start(envs) {
41
+ this.stop();
42
+ const cmd = envs.recDeviceName ? "sox" : "rec";
43
+ const args = envs.recDeviceName ? [
44
+ "-q",
45
+ "-t",
46
+ "coreaudio",
47
+ envs.recDeviceName,
48
+ "-t",
49
+ "raw",
50
+ "-b",
51
+ "16",
52
+ "-e",
53
+ "signed-integer",
54
+ "-c",
55
+ "1",
56
+ "-r",
57
+ String(SAMPLE_RATE),
58
+ "-"
59
+ ] : ["-q", "-t", "raw", "-b", "16", "-e", "signed-integer", "-c", "1", "-r", String(SAMPLE_RATE), "-"];
60
+ this.process = spawn(cmd, args, {
61
+ env: buildEnv(envs.recExtra),
62
+ stdio: ["ignore", "pipe", "pipe"]
63
+ });
64
+ let buffer = Buffer.alloc(0);
65
+ this.process.stdout?.on("data", (chunk) => {
66
+ buffer = Buffer.concat([buffer, chunk]);
67
+ while (buffer.length >= BYTES_PER_FRAME) {
68
+ const frameBuffer = buffer.subarray(0, BYTES_PER_FRAME);
69
+ buffer = buffer.subarray(BYTES_PER_FRAME);
70
+ const samples = new Int16Array(FRAME_SIZE);
71
+ for (let i = 0; i < FRAME_SIZE; i++) {
72
+ samples[i] = frameBuffer.readInt16LE(i * 2);
73
+ }
74
+ this.onLevel?.(computeRMS(samples));
75
+ }
76
+ });
77
+ this.process.on("error", () => {
78
+ });
79
+ this.process.on("close", () => {
80
+ this.process = null;
81
+ });
82
+ }
83
+ stop() {
84
+ if (this.process) {
85
+ this.process.kill();
86
+ this.process = null;
87
+ }
88
+ }
89
+ };
90
+ function playTestTone(envs) {
91
+ const cmd = envs.playDeviceName ? "sox" : "play";
92
+ const args = envs.playDeviceName ? [
93
+ "-q",
94
+ "-n",
95
+ "-t",
96
+ "coreaudio",
97
+ envs.playDeviceName,
98
+ "synth",
99
+ "0.5",
100
+ "sine",
101
+ "880",
102
+ "fade",
103
+ "h",
104
+ "0.05",
105
+ "0.5",
106
+ "0.05"
107
+ ] : ["-q", "-n", "synth", "0.5", "sine", "880", "fade", "h", "0.05", "0.5", "0.05"];
108
+ const proc = spawn(cmd, args, {
109
+ env: buildEnv(envs.playExtra),
110
+ stdio: ["ignore", "ignore", "pipe"]
111
+ });
112
+ proc.on("error", () => {
113
+ });
114
+ }
115
+
116
+ // src/lib/devices.ts
117
+ import { execSync } from "node:child_process";
118
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
119
+ import { homedir, platform } from "node:os";
120
+ import { dirname, join } from "node:path";
121
+ var CONFIG_DIR = join(homedir(), ".config", "openmeet");
122
+ var INPUT_DEVICE_FILE = join(CONFIG_DIR, "audio-input");
123
+ var OUTPUT_DEVICE_FILE = join(CONFIG_DIR, "audio-output");
124
+ async function listAudioDevices() {
125
+ const os = platform();
126
+ if (os === "darwin") {
127
+ return listMacOSDevices();
128
+ }
129
+ if (os === "linux") {
130
+ return listLinuxDevices();
131
+ }
132
+ return { inputs: [], outputs: [] };
133
+ }
134
+ function listMacOSDevices() {
135
+ const inputs = [];
136
+ const outputs = [];
137
+ try {
138
+ const json = execSync("system_profiler SPAudioDataType -json", { encoding: "utf-8" });
139
+ const data = JSON.parse(json);
140
+ const sections = data.SPAudioDataType ?? [];
141
+ for (const section of sections) {
142
+ const devices = section._items ?? [];
143
+ for (const device of devices) {
144
+ const name = device._name;
145
+ if (!name) continue;
146
+ if (device.coreaudio_device_input) {
147
+ inputs.push({ id: name, name, type: "input" });
148
+ }
149
+ if (device.coreaudio_device_output) {
150
+ outputs.push({ id: name, name, type: "output" });
151
+ }
152
+ }
153
+ }
154
+ } catch {
155
+ }
156
+ return { inputs, outputs };
157
+ }
158
+ function listLinuxDevices() {
159
+ const inputs = [];
160
+ const outputs = [];
161
+ try {
162
+ const sourcesRaw = execSync("pactl list sources short", { encoding: "utf-8" });
163
+ for (const line of sourcesRaw.trim().split("\n")) {
164
+ const parts = line.split(" ");
165
+ if (parts.length >= 2) {
166
+ const name = parts[1];
167
+ inputs.push({ id: name, name, type: "input" });
168
+ }
169
+ }
170
+ } catch {
171
+ }
172
+ try {
173
+ const sinksRaw = execSync("pactl list sinks short", { encoding: "utf-8" });
174
+ for (const line of sinksRaw.trim().split("\n")) {
175
+ const parts = line.split(" ");
176
+ if (parts.length >= 2) {
177
+ const name = parts[1];
178
+ outputs.push({ id: name, name, type: "output" });
179
+ }
180
+ }
181
+ } catch {
182
+ }
183
+ return { inputs, outputs };
184
+ }
185
+ function getSavedInputDevice() {
186
+ try {
187
+ const val = readFileSync(INPUT_DEVICE_FILE, "utf-8").trim();
188
+ return val || null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+ function getSavedOutputDevice() {
194
+ try {
195
+ const val = readFileSync(OUTPUT_DEVICE_FILE, "utf-8").trim();
196
+ return val || null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+ function saveDevicePreferences(input, output) {
202
+ try {
203
+ mkdirSync(dirname(INPUT_DEVICE_FILE), { recursive: true });
204
+ writeFileSync(INPUT_DEVICE_FILE, input?.id ?? "", "utf-8");
205
+ writeFileSync(OUTPUT_DEVICE_FILE, output?.id ?? "", "utf-8");
206
+ } catch {
207
+ }
208
+ }
209
+ function getDeviceEnv(input, output) {
210
+ const os = platform();
211
+ const recExtra = {};
212
+ const playExtra = {};
213
+ let recDeviceName;
214
+ let playDeviceName;
215
+ if (os === "darwin") {
216
+ if (input) recDeviceName = input.name;
217
+ if (output) playDeviceName = output.name;
218
+ } else if (os === "linux") {
219
+ if (input) recExtra.PULSE_SOURCE = input.id;
220
+ if (output) playExtra.PULSE_SINK = output.id;
221
+ }
222
+ return { recExtra, playExtra, recDeviceName, playDeviceName };
223
+ }
224
+
225
+ // src/components/device-picker.tsx
226
+ import { jsx, jsxs } from "react/jsx-runtime";
227
+ var BAR_WIDTH = 30;
228
+ var MAX_RMS = 8e3;
229
+ function renderBar(level) {
230
+ const normalized = Math.min(level / MAX_RMS, 1);
231
+ const filled = Math.round(normalized * BAR_WIDTH);
232
+ return "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH - filled);
233
+ }
234
+ function barColor(level) {
235
+ const normalized = Math.min(level / MAX_RMS, 1);
236
+ if (normalized > 0.75) return "red";
237
+ if (normalized > 0.4) return "yellow";
238
+ return "green";
239
+ }
240
+ function DevicePicker({ inputs, outputs, loading, savedInputId, savedOutputId, onConfirm }) {
241
+ const [step, setStep] = useState("input");
242
+ const [selectedInput, setSelectedInput] = useState();
243
+ const [selectedOutput, setSelectedOutput] = useState();
244
+ const [micLevel, setMicLevel] = useState(0);
245
+ const testerRef = useRef(null);
246
+ const smoothedRef = useRef(0);
247
+ useEffect(() => {
248
+ if (step !== "test") {
249
+ testerRef.current?.stop();
250
+ testerRef.current = null;
251
+ smoothedRef.current = 0;
252
+ setMicLevel(0);
253
+ return;
254
+ }
255
+ const envs = getDeviceEnv(selectedInput, selectedOutput);
256
+ const tester = new MicTester();
257
+ testerRef.current = tester;
258
+ let lastUpdate = 0;
259
+ tester.setLevelCallback((rms) => {
260
+ smoothedRef.current = smoothedRef.current * 0.7 + rms * 0.3;
261
+ const now = Date.now();
262
+ if (now - lastUpdate > 80) {
263
+ lastUpdate = now;
264
+ setMicLevel(smoothedRef.current);
265
+ }
266
+ });
267
+ tester.start(envs);
268
+ return () => {
269
+ tester.stop();
270
+ };
271
+ }, [step, selectedInput, selectedOutput]);
272
+ useInput((input, key) => {
273
+ if (loading) return;
274
+ if (inputs.length === 0 && outputs.length === 0) {
275
+ if (key.return) onConfirm();
276
+ return;
277
+ }
278
+ if (step === "test") {
279
+ if (input === "t") {
280
+ const envs = getDeviceEnv(selectedInput, selectedOutput);
281
+ playTestTone(envs);
282
+ }
283
+ if (key.return) {
284
+ onConfirm(selectedInput, selectedOutput);
285
+ }
286
+ if (key.escape) {
287
+ setStep("input");
288
+ }
289
+ }
290
+ });
291
+ if (loading) {
292
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [
293
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "blue", children: "Audio Setup" }),
294
+ /* @__PURE__ */ jsx(Text, { children: "Loading audio devices..." })
295
+ ] });
296
+ }
297
+ if (inputs.length === 0 && outputs.length === 0) {
298
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [
299
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "blue", children: "Audio Setup" }),
300
+ /* @__PURE__ */ jsx(Text, {}),
301
+ /* @__PURE__ */ jsx(Text, { children: "No specific audio devices found." }),
302
+ /* @__PURE__ */ jsx(Text, { children: "Using system default devices." }),
303
+ /* @__PURE__ */ jsx(Text, {}),
304
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press [Enter] to continue" })
305
+ ] });
306
+ }
307
+ const inputItems = [
308
+ { label: "System Default", value: "__default__" },
309
+ ...inputs.map((d) => ({ label: d.name, value: d.id }))
310
+ ];
311
+ const outputItems = [
312
+ { label: "System Default", value: "__default__" },
313
+ ...outputs.map((d) => ({ label: d.name, value: d.id }))
314
+ ];
315
+ const savedInputIndex = savedInputId ? inputItems.findIndex((item) => item.value === savedInputId) : 0;
316
+ const savedOutputIndex = savedOutputId ? outputItems.findIndex((item) => item.value === savedOutputId) : 0;
317
+ if (step === "input") {
318
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
319
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "blue", children: "Audio Setup" }),
320
+ /* @__PURE__ */ jsx(Box, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(200) }) }),
321
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "Input (Microphone):" }),
322
+ /* @__PURE__ */ jsx(
323
+ SelectInput,
324
+ {
325
+ items: inputItems,
326
+ initialIndex: savedInputIndex >= 0 ? savedInputIndex : 0,
327
+ onSelect: (item) => {
328
+ const device = inputs.find((d) => d.id === item.value);
329
+ setSelectedInput(device);
330
+ if (outputs.length === 0) {
331
+ setSelectedOutput(void 0);
332
+ setStep("test");
333
+ } else {
334
+ setStep("output");
335
+ }
336
+ }
337
+ }
338
+ ),
339
+ /* @__PURE__ */ jsx(Text, {}),
340
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "[\u2191\u2193] navigate [Enter] select" })
341
+ ] });
342
+ }
343
+ if (step === "output") {
344
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
345
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "blue", children: "Audio Setup" }),
346
+ /* @__PURE__ */ jsx(Box, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(200) }) }),
347
+ /* @__PURE__ */ jsxs(Text, { children: [
348
+ "Input: ",
349
+ /* @__PURE__ */ jsx(Text, { bold: true, children: selectedInput?.name ?? "System Default" })
350
+ ] }),
351
+ /* @__PURE__ */ jsx(Text, {}),
352
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "Output (Speakers):" }),
353
+ /* @__PURE__ */ jsx(
354
+ SelectInput,
355
+ {
356
+ items: outputItems,
357
+ initialIndex: savedOutputIndex >= 0 ? savedOutputIndex : 0,
358
+ onSelect: (item) => {
359
+ const device = outputs.find((d) => d.id === item.value);
360
+ setSelectedOutput(device);
361
+ setStep("test");
362
+ }
363
+ }
364
+ ),
365
+ /* @__PURE__ */ jsx(Text, {}),
366
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "[\u2191\u2193] navigate [Enter] select" })
367
+ ] });
368
+ }
369
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
370
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "blue", children: "Audio Test" }),
371
+ /* @__PURE__ */ jsx(Box, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(200) }) }),
372
+ /* @__PURE__ */ jsxs(Text, { children: [
373
+ "Input: ",
374
+ /* @__PURE__ */ jsx(Text, { bold: true, children: selectedInput?.name ?? "System Default" })
375
+ ] }),
376
+ /* @__PURE__ */ jsxs(Text, { children: [
377
+ "Output: ",
378
+ /* @__PURE__ */ jsx(Text, { bold: true, children: selectedOutput?.name ?? "System Default" })
379
+ ] }),
380
+ /* @__PURE__ */ jsx(Text, {}),
381
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "Mic level:" }),
382
+ /* @__PURE__ */ jsx(Text, { children: /* @__PURE__ */ jsx(Text, { color: barColor(micLevel), children: renderBar(micLevel) }) }),
383
+ /* @__PURE__ */ jsx(Text, {}),
384
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "[t] play test tone [Enter] confirm [Esc] re-select" })
385
+ ] });
386
+ }
387
+
388
+ // src/components/home-screen.tsx
389
+ import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
390
+ import TextInput from "ink-text-input";
391
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
392
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
393
+ function HomeScreen({ emoji: emoji2, loading, error, onCreateRoom, onJoinRoom, onQuit }) {
394
+ const [mode, setMode] = useState2("menu");
395
+ const [joinCode, setJoinCode] = useState2("");
396
+ const [escPressed, setEscPressed] = useState2(false);
397
+ const escTimerRef = useRef2(null);
398
+ useEffect2(() => {
399
+ if (escPressed) {
400
+ escTimerRef.current = setTimeout(() => setEscPressed(false), 2e3);
401
+ return () => {
402
+ if (escTimerRef.current) clearTimeout(escTimerRef.current);
403
+ };
404
+ }
405
+ }, [escPressed]);
406
+ useInput2((input, key) => {
407
+ if (mode === "join" && key.escape) {
408
+ setMode("menu");
409
+ setJoinCode("");
410
+ return;
411
+ }
412
+ if (mode === "menu" && key.escape) {
413
+ if (escPressed) {
414
+ onQuit();
415
+ } else {
416
+ setEscPressed(true);
417
+ }
418
+ return;
419
+ }
420
+ if (mode === "menu" && !loading) {
421
+ if (input === "c") onCreateRoom();
422
+ if (input === "j") setMode("join");
423
+ }
424
+ });
425
+ if (mode === "join") {
426
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [
427
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "blue", children: "Join Room" }),
428
+ /* @__PURE__ */ jsx2(Box2, { height: 1 }),
429
+ /* @__PURE__ */ jsxs2(Box2, { children: [
430
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: "Room code: " }),
431
+ /* @__PURE__ */ jsx2(
432
+ TextInput,
433
+ {
434
+ value: joinCode,
435
+ onChange: setJoinCode,
436
+ placeholder: "Enter room code",
437
+ onSubmit: (value) => {
438
+ if (value.trim()) onJoinRoom(value.trim());
439
+ }
440
+ }
441
+ )
442
+ ] }),
443
+ /* @__PURE__ */ jsx2(Box2, { height: 1 }),
444
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "[Enter] join [Esc] back" })
445
+ ] });
446
+ }
447
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [
448
+ /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "blue", children: [
449
+ "\u{1F3A5}",
450
+ " OpenMeet Terminal"
451
+ ] }),
452
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Lightweight video conferencing" }),
453
+ /* @__PURE__ */ jsx2(Box2, { height: 1 }),
454
+ /* @__PURE__ */ jsxs2(Text2, { children: [
455
+ "You are ",
456
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: emoji2 })
457
+ ] }),
458
+ /* @__PURE__ */ jsx2(Box2, { height: 1 }),
459
+ loading ? /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: "Creating room..." }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
460
+ /* @__PURE__ */ jsxs2(Text2, { children: [
461
+ "[",
462
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: "c" }),
463
+ "] Create Room"
464
+ ] }),
465
+ /* @__PURE__ */ jsxs2(Text2, { children: [
466
+ "[",
467
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: "j" }),
468
+ "] Join Room"
469
+ ] })
470
+ ] }),
471
+ error && /* @__PURE__ */ jsxs2(Fragment, { children: [
472
+ /* @__PURE__ */ jsx2(Box2, { height: 1 }),
473
+ /* @__PURE__ */ jsx2(Text2, { color: "red", children: error })
474
+ ] }),
475
+ /* @__PURE__ */ jsx2(Box2, { height: 1 }),
476
+ escPressed ? /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: "Press Esc again to quit" }) : /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "[Esc] quit" })
477
+ ] });
478
+ }
479
+
480
+ // src/components/room-view.tsx
481
+ import { Box as Box8, Text as Text8, useInput as useInput3 } from "ink";
482
+ import SelectInput2 from "ink-select-input";
483
+ import { useEffect as useEffect5, useRef as useRef4, useState as useState6 } from "react";
484
+
485
+ // src/hooks/use-room.ts
486
+ import { useCallback, useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
487
+
488
+ // src/lib/audio.ts
489
+ import { execSync as execSync2, spawn as spawn2 } from "node:child_process";
490
+ import { createWriteStream, unlinkSync } from "node:fs";
491
+ import { tmpdir } from "node:os";
492
+ import { join as join2 } from "node:path";
493
+ import wrtc from "@roamhq/wrtc";
494
+ var { RTCAudioSink } = wrtc.nonstandard;
495
+ var SAMPLE_RATE2 = 48e3;
496
+ var FRAME_SIZE2 = 480;
497
+ var BYTES_PER_FRAME2 = FRAME_SIZE2 * 2;
498
+ var RMS_THRESHOLD = 800;
499
+ var SPEAKING_HOLD_MS = 300;
500
+ function computeRMS2(samples) {
501
+ let sum = 0;
502
+ for (let i = 0; i < samples.length; i++) {
503
+ sum += samples[i] * samples[i];
504
+ }
505
+ return Math.sqrt(sum / samples.length);
506
+ }
507
+ function buildEnv2(extra) {
508
+ if (Object.keys(extra).length === 0) return void 0;
509
+ return { ...process.env, ...extra };
510
+ }
511
+ var AudioManager = class {
512
+ audioSource;
513
+ recExtra;
514
+ playExtra;
515
+ recDeviceName;
516
+ playDeviceName;
517
+ recProcess = null;
518
+ peers = /* @__PURE__ */ new Map();
519
+ _isMuted = false;
520
+ capturing = false;
521
+ onSpeaking = null;
522
+ speakingTimers = /* @__PURE__ */ new Map();
523
+ speakingStates = /* @__PURE__ */ new Map();
524
+ volumes = /* @__PURE__ */ new Map();
525
+ audioLevels = /* @__PURE__ */ new Map();
526
+ constructor(audioSource, envs) {
527
+ this.audioSource = audioSource;
528
+ this.recExtra = envs.recExtra;
529
+ this.playExtra = envs.playExtra;
530
+ this.recDeviceName = envs.recDeviceName;
531
+ this.playDeviceName = envs.playDeviceName;
532
+ }
533
+ setSpeakingCallback(cb) {
534
+ this.onSpeaking = cb;
535
+ }
536
+ updateSpeaking(id, rms) {
537
+ const isSpeaking = rms > RMS_THRESHOLD;
538
+ const wasSpeaking = this.speakingStates.get(id) ?? false;
539
+ if (isSpeaking) {
540
+ const timer = this.speakingTimers.get(id);
541
+ if (timer) {
542
+ clearTimeout(timer);
543
+ this.speakingTimers.delete(id);
544
+ }
545
+ if (!wasSpeaking) {
546
+ this.speakingStates.set(id, true);
547
+ this.onSpeaking?.(id, true);
548
+ }
549
+ } else if (wasSpeaking && !this.speakingTimers.has(id)) {
550
+ const timer = setTimeout(() => {
551
+ this.speakingTimers.delete(id);
552
+ this.speakingStates.set(id, false);
553
+ this.onSpeaking?.(id, false);
554
+ }, SPEAKING_HOLD_MS);
555
+ this.speakingTimers.set(id, timer);
556
+ }
557
+ }
558
+ get isMuted() {
559
+ return this._isMuted;
560
+ }
561
+ setVolume(peerId, volume) {
562
+ this.volumes.set(peerId, Math.max(0, Math.min(1, volume)));
563
+ }
564
+ getVolume(peerId) {
565
+ return this.volumes.get(peerId) ?? 1;
566
+ }
567
+ getAudioLevel(peerId) {
568
+ return this.audioLevels.get(peerId) ?? 0;
569
+ }
570
+ getAllAudioLevels() {
571
+ const levels = {};
572
+ for (const [peerId, level] of this.audioLevels) {
573
+ levels[peerId] = level;
574
+ }
575
+ return levels;
576
+ }
577
+ startCapture() {
578
+ if (this.capturing) return;
579
+ this.capturing = true;
580
+ const cmd = this.recDeviceName ? "sox" : "rec";
581
+ const args = this.recDeviceName ? [
582
+ "-q",
583
+ "-t",
584
+ "coreaudio",
585
+ this.recDeviceName,
586
+ "-t",
587
+ "raw",
588
+ "-b",
589
+ "16",
590
+ "-e",
591
+ "signed-integer",
592
+ "-c",
593
+ "1",
594
+ "-r",
595
+ String(SAMPLE_RATE2),
596
+ "-"
597
+ ] : ["-q", "-t", "raw", "-b", "16", "-e", "signed-integer", "-c", "1", "-r", String(SAMPLE_RATE2), "-"];
598
+ this.recProcess = spawn2(cmd, args, {
599
+ env: buildEnv2(this.recExtra),
600
+ stdio: ["ignore", "pipe", "ignore"]
601
+ });
602
+ let buffer = Buffer.alloc(0);
603
+ this.recProcess.stdout?.on("data", (chunk) => {
604
+ buffer = Buffer.concat([buffer, chunk]);
605
+ while (buffer.length >= BYTES_PER_FRAME2) {
606
+ const frameBuffer = buffer.subarray(0, BYTES_PER_FRAME2);
607
+ buffer = buffer.subarray(BYTES_PER_FRAME2);
608
+ const realSamples = new Int16Array(FRAME_SIZE2);
609
+ for (let i = 0; i < FRAME_SIZE2; i++) {
610
+ realSamples[i] = frameBuffer.readInt16LE(i * 2);
611
+ }
612
+ const localRms = computeRMS2(realSamples);
613
+ this.updateSpeaking("__local__", localRms);
614
+ this.audioLevels.set("__local__", localRms);
615
+ const samples = this._isMuted ? new Int16Array(FRAME_SIZE2) : realSamples;
616
+ this.audioSource.onData({
617
+ samples,
618
+ sampleRate: SAMPLE_RATE2,
619
+ bitsPerSample: 16,
620
+ channelCount: 1,
621
+ numberOfFrames: FRAME_SIZE2
622
+ });
623
+ }
624
+ });
625
+ this.recProcess.on("error", () => {
626
+ this.capturing = false;
627
+ });
628
+ this.recProcess.on("close", () => {
629
+ this.capturing = false;
630
+ });
631
+ }
632
+ stopCapture() {
633
+ if (this.recProcess) {
634
+ this.recProcess.kill();
635
+ this.recProcess = null;
636
+ }
637
+ this.capturing = false;
638
+ }
639
+ mute() {
640
+ this._isMuted = true;
641
+ }
642
+ unmute() {
643
+ this._isMuted = false;
644
+ }
645
+ toggleMute() {
646
+ this._isMuted = !this._isMuted;
647
+ return this._isMuted;
648
+ }
649
+ spawnPlay(peerId, peer, channels, rate) {
650
+ const fifoPath = join2(tmpdir(), `openmeet-audio-${peerId}-${Date.now()}`);
651
+ try {
652
+ execSync2(`mkfifo "${fifoPath}"`);
653
+ } catch {
654
+ return;
655
+ }
656
+ peer.fifoPath = fifoPath;
657
+ const cmd = this.playDeviceName ? "sox" : "play";
658
+ const args = this.playDeviceName ? [
659
+ "-q",
660
+ "-t",
661
+ "raw",
662
+ "-b",
663
+ "16",
664
+ "-e",
665
+ "signed-integer",
666
+ "-c",
667
+ channels,
668
+ "-r",
669
+ rate,
670
+ fifoPath,
671
+ "-t",
672
+ "coreaudio",
673
+ this.playDeviceName
674
+ ] : ["-q", "-t", "raw", "-b", "16", "-e", "signed-integer", "-c", channels, "-r", rate, fifoPath];
675
+ const playProcess = spawn2(cmd, args, {
676
+ env: buildEnv2(this.playExtra),
677
+ stdio: ["ignore", "ignore", "ignore"]
678
+ });
679
+ peer.playProcess = playProcess;
680
+ peer.fifoStream = createWriteStream(fifoPath);
681
+ peer.fifoStream.on("error", () => {
682
+ });
683
+ playProcess.on("error", () => {
684
+ });
685
+ playProcess.on("close", () => {
686
+ if (peer.fifoPath) {
687
+ try {
688
+ unlinkSync(peer.fifoPath);
689
+ } catch {
690
+ }
691
+ peer.fifoPath = null;
692
+ }
693
+ });
694
+ }
695
+ wireSink(peerId, peer) {
696
+ let spawned = false;
697
+ peer.sink.ondata = (data) => {
698
+ const samplesCopy = new Int16Array(data.samples.length);
699
+ samplesCopy.set(data.samples);
700
+ const rms = computeRMS2(samplesCopy);
701
+ this.updateSpeaking(peerId, rms);
702
+ this.audioLevels.set(peerId, rms);
703
+ const vol = this.volumes.get(peerId) ?? 1;
704
+ if (vol !== 1) {
705
+ for (let i = 0; i < samplesCopy.length; i++) {
706
+ samplesCopy[i] = Math.max(-32768, Math.min(32767, Math.round(samplesCopy[i] * vol)));
707
+ }
708
+ }
709
+ const buf = Buffer.from(samplesCopy.buffer, samplesCopy.byteOffset, samplesCopy.byteLength);
710
+ if (!spawned) {
711
+ spawned = true;
712
+ this.spawnPlay(peerId, peer, "1", String(SAMPLE_RATE2));
713
+ }
714
+ if (peer.fifoStream?.writable) {
715
+ peer.fifoStream.write(buf);
716
+ }
717
+ };
718
+ }
719
+ addRemotePeer(peerId, track) {
720
+ this.removeRemotePeer(peerId);
721
+ try {
722
+ const sink = new RTCAudioSink(track);
723
+ const peer = { sink, playProcess: null, fifoPath: null, fifoStream: null, track };
724
+ this.peers.set(peerId, peer);
725
+ this.wireSink(peerId, peer);
726
+ } catch {
727
+ }
728
+ }
729
+ removeRemotePeer(peerId) {
730
+ const peer = this.peers.get(peerId);
731
+ if (peer) {
732
+ peer.sink.stop();
733
+ if (peer.fifoStream) {
734
+ peer.fifoStream.end();
735
+ peer.fifoStream = null;
736
+ }
737
+ if (peer.playProcess) {
738
+ peer.playProcess.kill();
739
+ }
740
+ if (peer.fifoPath) {
741
+ try {
742
+ unlinkSync(peer.fifoPath);
743
+ } catch {
744
+ }
745
+ peer.fifoPath = null;
746
+ }
747
+ this.peers.delete(peerId);
748
+ }
749
+ const timer = this.speakingTimers.get(peerId);
750
+ if (timer) clearTimeout(timer);
751
+ this.speakingTimers.delete(peerId);
752
+ this.speakingStates.delete(peerId);
753
+ this.volumes.delete(peerId);
754
+ this.audioLevels.delete(peerId);
755
+ }
756
+ updateDevices(envs) {
757
+ this.recExtra = envs.recExtra;
758
+ this.playExtra = envs.playExtra;
759
+ this.recDeviceName = envs.recDeviceName;
760
+ this.playDeviceName = envs.playDeviceName;
761
+ if (this.capturing) {
762
+ this.stopCapture();
763
+ this.startCapture();
764
+ }
765
+ for (const [peerId, peer] of this.peers) {
766
+ if (peer.fifoStream) {
767
+ peer.fifoStream.end();
768
+ peer.fifoStream = null;
769
+ }
770
+ if (peer.playProcess) {
771
+ peer.playProcess.kill();
772
+ peer.playProcess = null;
773
+ }
774
+ if (peer.fifoPath) {
775
+ try {
776
+ unlinkSync(peer.fifoPath);
777
+ } catch {
778
+ }
779
+ peer.fifoPath = null;
780
+ }
781
+ peer.sink.stop();
782
+ peer.sink = new RTCAudioSink(peer.track);
783
+ this.wireSink(peerId, peer);
784
+ }
785
+ }
786
+ shutdown() {
787
+ this.stopCapture();
788
+ for (const peerId of [...this.peers.keys()]) {
789
+ this.removeRemotePeer(peerId);
790
+ }
791
+ const localTimer = this.speakingTimers.get("__local__");
792
+ if (localTimer) clearTimeout(localTimer);
793
+ this.speakingTimers.clear();
794
+ this.speakingStates.clear();
795
+ this.volumes.clear();
796
+ this.audioLevels.clear();
797
+ }
798
+ };
799
+
800
+ // src/lib/webrtc.ts
801
+ import wrtc2 from "@roamhq/wrtc";
802
+
803
+ // src/lib/sdp.ts
804
+ function boostOpusQuality(sdp) {
805
+ return sdp.replace(/a=fmtp:(\d+) (.+)/g, (match, pt, params) => {
806
+ if (params.includes("minptime")) {
807
+ let modified = params;
808
+ if (!modified.includes("maxaveragebitrate")) {
809
+ modified += ";maxaveragebitrate=128000";
810
+ }
811
+ return `a=fmtp:${pt} ${modified}`;
812
+ }
813
+ return match;
814
+ });
815
+ }
816
+
817
+ // src/lib/webrtc.ts
818
+ var { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } = wrtc2;
819
+ var ICE_SERVERS = {
820
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }, { urls: "stun:stun1.l.google.com:19302" }]
821
+ };
822
+ function createAudioSource() {
823
+ const { RTCAudioSource } = wrtc2.nonstandard;
824
+ const source = new RTCAudioSource();
825
+ const track = source.createTrack();
826
+ return { source, track };
827
+ }
828
+ var PeerConnectionManager = class _PeerConnectionManager {
829
+ connections = /* @__PURE__ */ new Map();
830
+ audioTrack;
831
+ sendSignal;
832
+ myId;
833
+ onRemoteAudioTrack;
834
+ onPeerDisconnected;
835
+ makingOffer = /* @__PURE__ */ new Set();
836
+ constructor(options) {
837
+ this.myId = options.myId;
838
+ this.audioTrack = options.audioTrack;
839
+ this.sendSignal = options.sendSignal;
840
+ this.onRemoteAudioTrack = options.onRemoteAudioTrack;
841
+ this.onPeerDisconnected = options.onPeerDisconnected;
842
+ }
843
+ setMyId(id) {
844
+ this.myId = id;
845
+ }
846
+ /** Create a bare peer connection with event handlers but NO transceivers. */
847
+ setupPeerConnection(peerId) {
848
+ if (this.connections.has(peerId)) {
849
+ this.connections.get(peerId).close();
850
+ }
851
+ const pc = new RTCPeerConnection(ICE_SERVERS);
852
+ this.connections.set(peerId, pc);
853
+ pc.ontrack = (event) => {
854
+ if (event.track.kind === "audio") {
855
+ this.onRemoteAudioTrack(peerId, event.track);
856
+ }
857
+ };
858
+ pc.onicecandidate = (event) => {
859
+ if (event.candidate) {
860
+ this.sendSignal({
861
+ type: "ice-candidate",
862
+ fromId: this.myId,
863
+ toId: peerId,
864
+ candidate: event.candidate.toJSON ? event.candidate.toJSON() : event.candidate
865
+ });
866
+ }
867
+ };
868
+ pc.onconnectionstatechange = () => {
869
+ if (pc.connectionState === "failed") {
870
+ this.removeConnection(peerId);
871
+ }
872
+ };
873
+ return pc;
874
+ }
875
+ /** Create a peer connection WITH 3 transceivers (offerer path). */
876
+ createOffererConnection(peerId) {
877
+ const pc = this.setupPeerConnection(peerId);
878
+ pc.addTransceiver("audio", {
879
+ direction: "sendrecv",
880
+ sendEncodings: [{ priority: "high", networkPriority: "high" }]
881
+ });
882
+ if (this.audioTrack) {
883
+ pc.getTransceivers()[0].sender.replaceTrack(this.audioTrack);
884
+ }
885
+ pc.addTransceiver("video", {
886
+ direction: "sendrecv",
887
+ sendEncodings: [{ priority: "low", networkPriority: "low", maxFramerate: 30 }]
888
+ });
889
+ pc.addTransceiver("video", {
890
+ direction: "recvonly"
891
+ });
892
+ return pc;
893
+ }
894
+ /** Extract a plain { type, sdp } object for safe JSON serialization. */
895
+ static extractSdp(desc) {
896
+ return { type: desc.type, sdp: desc.sdp };
897
+ }
898
+ async createConnection(peerId) {
899
+ try {
900
+ const pc = this.createOffererConnection(peerId);
901
+ const offer = await pc.createOffer();
902
+ await pc.setLocalDescription({
903
+ ...offer,
904
+ sdp: boostOpusQuality(offer.sdp ?? "")
905
+ });
906
+ this.sendSignal({
907
+ type: "offer",
908
+ fromId: this.myId,
909
+ toId: peerId,
910
+ sdp: _PeerConnectionManager.extractSdp(pc.localDescription)
911
+ });
912
+ } catch {
913
+ }
914
+ }
915
+ async handleOffer(peerId, sdp) {
916
+ let pc = this.connections.get(peerId);
917
+ if (pc && pc.signalingState === "have-local-offer") {
918
+ pc.close();
919
+ this.connections.delete(peerId);
920
+ pc = void 0;
921
+ }
922
+ if (!pc) {
923
+ pc = this.setupPeerConnection(peerId);
924
+ if (this.audioTrack) {
925
+ pc.addTrack(this.audioTrack);
926
+ }
927
+ }
928
+ try {
929
+ await pc.setRemoteDescription(new RTCSessionDescription(sdp));
930
+ const answer = await pc.createAnswer();
931
+ let modifiedSdp = answer.sdp ?? "";
932
+ const mSections = modifiedSdp.split(/(?=m=)/);
933
+ const audioIdx = mSections.findIndex((s) => s.startsWith("m=audio"));
934
+ if (audioIdx >= 0 && !mSections[audioIdx].includes("a=sendrecv")) {
935
+ mSections[audioIdx] = mSections[audioIdx].replace(/a=recvonly|a=inactive/, "a=sendrecv");
936
+ modifiedSdp = mSections.join("");
937
+ }
938
+ modifiedSdp = boostOpusQuality(modifiedSdp);
939
+ await pc.setLocalDescription({ ...answer, sdp: modifiedSdp });
940
+ this.sendSignal({
941
+ type: "answer",
942
+ fromId: this.myId,
943
+ toId: peerId,
944
+ sdp: _PeerConnectionManager.extractSdp(pc.localDescription)
945
+ });
946
+ } catch {
947
+ }
948
+ }
949
+ async handleAnswer(peerId, sdp) {
950
+ const pc = this.connections.get(peerId);
951
+ if (!pc) return;
952
+ try {
953
+ await pc.setRemoteDescription(new RTCSessionDescription(sdp));
954
+ } catch {
955
+ }
956
+ }
957
+ async handleIceCandidate(peerId, candidate) {
958
+ const pc = this.connections.get(peerId);
959
+ if (!pc) return;
960
+ try {
961
+ await pc.addIceCandidate(new RTCIceCandidate(candidate));
962
+ } catch {
963
+ }
964
+ }
965
+ removeConnection(peerId) {
966
+ const pc = this.connections.get(peerId);
967
+ if (pc) {
968
+ pc.close();
969
+ this.connections.delete(peerId);
970
+ this.makingOffer.delete(peerId);
971
+ this.onPeerDisconnected(peerId);
972
+ }
973
+ }
974
+ closeAll() {
975
+ for (const peerId of [...this.connections.keys()]) {
976
+ this.removeConnection(peerId);
977
+ }
978
+ }
979
+ getConnection(peerId) {
980
+ return this.connections.get(peerId);
981
+ }
982
+ getAllPeerIds() {
983
+ return [...this.connections.keys()];
984
+ }
985
+ };
986
+
987
+ // src/lib/websocket.ts
988
+ import WS from "ws";
989
+ var WebSocketClient = class {
990
+ ws = null;
991
+ handlers = /* @__PURE__ */ new Set();
992
+ connectionHandlers = /* @__PURE__ */ new Set();
993
+ reconnectAttempts = 0;
994
+ maxReconnectAttempts = 10;
995
+ reconnectTimer = null;
996
+ url;
997
+ _connected = false;
998
+ disposed = false;
999
+ constructor(url) {
1000
+ this.url = url;
1001
+ }
1002
+ get connected() {
1003
+ return this._connected;
1004
+ }
1005
+ connect() {
1006
+ this.ws = new WS(this.url);
1007
+ this.ws.on("open", () => {
1008
+ this._connected = true;
1009
+ this.reconnectAttempts = 0;
1010
+ for (const handler of this.connectionHandlers) {
1011
+ handler(true);
1012
+ }
1013
+ });
1014
+ this.ws.on("message", (data) => {
1015
+ try {
1016
+ const message = JSON.parse(data.toString());
1017
+ for (const handler of this.handlers) {
1018
+ handler(message);
1019
+ }
1020
+ } catch {
1021
+ }
1022
+ });
1023
+ this.ws.on("close", () => {
1024
+ if (this.disposed) return;
1025
+ this._connected = false;
1026
+ for (const handler of this.connectionHandlers) {
1027
+ handler(false);
1028
+ }
1029
+ this.scheduleReconnect();
1030
+ });
1031
+ this.ws.on("error", () => {
1032
+ if (this.disposed) return;
1033
+ });
1034
+ }
1035
+ send(message) {
1036
+ if (this.ws?.readyState === WS.OPEN) {
1037
+ this.ws.send(JSON.stringify(message));
1038
+ }
1039
+ }
1040
+ subscribe(handler) {
1041
+ this.handlers.add(handler);
1042
+ return () => this.handlers.delete(handler);
1043
+ }
1044
+ onConnectionChange(handler) {
1045
+ this.connectionHandlers.add(handler);
1046
+ return () => this.connectionHandlers.delete(handler);
1047
+ }
1048
+ disconnect() {
1049
+ this.disposed = true;
1050
+ if (this.reconnectTimer) {
1051
+ clearTimeout(this.reconnectTimer);
1052
+ this.reconnectTimer = null;
1053
+ }
1054
+ this.ws?.close();
1055
+ this.ws = null;
1056
+ this._connected = false;
1057
+ }
1058
+ scheduleReconnect() {
1059
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
1060
+ const delay = Math.min(1e3 * 2 ** this.reconnectAttempts, 1e4);
1061
+ this.reconnectTimer = setTimeout(() => {
1062
+ this.reconnectAttempts++;
1063
+ this.connect();
1064
+ }, delay);
1065
+ }
1066
+ }
1067
+ };
1068
+
1069
+ // src/hooks/use-room.ts
1070
+ function useRoom(options) {
1071
+ const { serverUrl, roomId, username, deviceEnvs } = options;
1072
+ const [connected, setConnected] = useState3(false);
1073
+ const [joined, setJoined] = useState3(false);
1074
+ const [myId, setMyId] = useState3(null);
1075
+ const [participants, setParticipants] = useState3([]);
1076
+ const [chatMessages, setChatMessages] = useState3([]);
1077
+ const [remoteMuteStates, setRemoteMuteStates] = useState3({});
1078
+ const [remoteScreenShareStates, setRemoteScreenShareStates] = useState3({});
1079
+ const [speakingStates, setSpeakingStates] = useState3({});
1080
+ const [audioLevels, setAudioLevels] = useState3({});
1081
+ const [peerVolumes, setPeerVolumes] = useState3({});
1082
+ const [connectionStats, setConnectionStats] = useState3(null);
1083
+ const [roomEvents, setRoomEvents] = useState3([]);
1084
+ const [joinedAt, setJoinedAt] = useState3(null);
1085
+ const [isMuted, setIsMuted] = useState3(false);
1086
+ const [error, setError] = useState3(null);
1087
+ const addEvent = useCallback((message, type) => {
1088
+ setRoomEvents((prev) => [...prev, { timestamp: Date.now(), message, type }]);
1089
+ }, []);
1090
+ const resolveName = useCallback((peerId) => {
1091
+ return participantsRef.current.find((p) => p.id === peerId)?.username ?? peerId.slice(0, 6);
1092
+ }, []);
1093
+ const wsRef = useRef3(null);
1094
+ const peerManagerRef = useRef3(null);
1095
+ const audioManagerRef = useRef3(null);
1096
+ const myIdRef = useRef3(null);
1097
+ const joinedRef = useRef3(false);
1098
+ const participantsRef = useRef3([]);
1099
+ const remoteMuteRef = useRef3({});
1100
+ const remoteScreenRef = useRef3({});
1101
+ const send = useCallback((msg) => {
1102
+ wsRef.current?.send(msg);
1103
+ }, []);
1104
+ const sendMessage = useCallback(
1105
+ (content) => {
1106
+ if (!content.trim()) return;
1107
+ send({
1108
+ type: "chat-message",
1109
+ id: "",
1110
+ roomId,
1111
+ username,
1112
+ content: content.trim(),
1113
+ contentType: "text",
1114
+ timestamp: 0
1115
+ });
1116
+ },
1117
+ [send, roomId, username]
1118
+ );
1119
+ const toggleMute = useCallback(() => {
1120
+ const audioManager = audioManagerRef.current;
1121
+ if (audioManager) {
1122
+ const newMuted = audioManager.toggleMute();
1123
+ setIsMuted(newMuted);
1124
+ if (myIdRef.current && joinedRef.current) {
1125
+ send({
1126
+ type: "mute-state",
1127
+ fromId: myIdRef.current,
1128
+ isAudioMuted: newMuted,
1129
+ isVideoMuted: true
1130
+ });
1131
+ }
1132
+ }
1133
+ }, [send]);
1134
+ const setPeerVolume = useCallback((peerId, volume) => {
1135
+ const clamped = Math.max(0, Math.min(1, volume));
1136
+ setPeerVolumes((prev) => ({ ...prev, [peerId]: clamped }));
1137
+ audioManagerRef.current?.setVolume(peerId, clamped);
1138
+ }, []);
1139
+ const updateDevices = useCallback((envs) => {
1140
+ audioManagerRef.current?.updateDevices(envs);
1141
+ }, []);
1142
+ const leave = useCallback(() => {
1143
+ audioManagerRef.current?.shutdown();
1144
+ peerManagerRef.current?.closeAll();
1145
+ wsRef.current?.disconnect();
1146
+ }, []);
1147
+ useEffect3(() => {
1148
+ const ws = new WebSocketClient(serverUrl);
1149
+ wsRef.current = ws;
1150
+ const { source, track } = createAudioSource();
1151
+ const audioManager = new AudioManager(source, deviceEnvs);
1152
+ audioManager.setSpeakingCallback((id, speaking) => {
1153
+ setSpeakingStates((prev) => ({ ...prev, [id]: speaking }));
1154
+ });
1155
+ audioManagerRef.current = audioManager;
1156
+ const peerManager = new PeerConnectionManager({
1157
+ myId: "",
1158
+ audioTrack: track,
1159
+ sendSignal: (msg) => ws.send(msg),
1160
+ onRemoteAudioTrack: (peerId, remoteTrack) => {
1161
+ audioManager.addRemotePeer(peerId, remoteTrack);
1162
+ },
1163
+ onPeerDisconnected: (peerId) => {
1164
+ audioManager.removeRemotePeer(peerId);
1165
+ }
1166
+ });
1167
+ peerManagerRef.current = peerManager;
1168
+ const unsubMessage = ws.subscribe((msg) => {
1169
+ switch (msg.type) {
1170
+ case "room-joined": {
1171
+ setJoined(true);
1172
+ joinedRef.current = true;
1173
+ setMyId(msg.yourId);
1174
+ myIdRef.current = msg.yourId;
1175
+ peerManager.setMyId(msg.yourId);
1176
+ setParticipants(msg.participants);
1177
+ participantsRef.current = msg.participants;
1178
+ setJoinedAt(Date.now());
1179
+ addEvent("You joined the room", "info");
1180
+ for (const p of msg.participants) {
1181
+ addEvent(`${p.username} is in the room`, "info");
1182
+ }
1183
+ audioManager.startCapture();
1184
+ for (const p of msg.participants) {
1185
+ peerManager.createConnection(p.id);
1186
+ }
1187
+ ws.send({
1188
+ type: "mute-state",
1189
+ fromId: msg.yourId,
1190
+ isAudioMuted: false,
1191
+ isVideoMuted: true
1192
+ });
1193
+ ws.send({
1194
+ type: "screen-share-state",
1195
+ fromId: msg.yourId,
1196
+ isScreenSharing: false
1197
+ });
1198
+ break;
1199
+ }
1200
+ case "participant-joined": {
1201
+ setParticipants((prev) => {
1202
+ const next = [...prev, msg.participant];
1203
+ participantsRef.current = next;
1204
+ return next;
1205
+ });
1206
+ addEvent(`${msg.participant.username} joined`, "join");
1207
+ break;
1208
+ }
1209
+ case "participant-left": {
1210
+ const leaving = participantsRef.current.find((p) => p.id === msg.participantId);
1211
+ if (leaving) addEvent(`${leaving.username} left`, "leave");
1212
+ setParticipants((prev) => {
1213
+ const next = prev.filter((p) => p.id !== msg.participantId);
1214
+ participantsRef.current = next;
1215
+ return next;
1216
+ });
1217
+ peerManager.removeConnection(msg.participantId);
1218
+ delete remoteMuteRef.current[msg.participantId];
1219
+ setRemoteMuteStates((prev) => {
1220
+ const next = { ...prev };
1221
+ delete next[msg.participantId];
1222
+ return next;
1223
+ });
1224
+ delete remoteScreenRef.current[msg.participantId];
1225
+ setRemoteScreenShareStates((prev) => {
1226
+ const next = { ...prev };
1227
+ delete next[msg.participantId];
1228
+ return next;
1229
+ });
1230
+ break;
1231
+ }
1232
+ case "offer": {
1233
+ peerManager.handleOffer(msg.fromId, msg.sdp);
1234
+ break;
1235
+ }
1236
+ case "answer": {
1237
+ peerManager.handleAnswer(msg.fromId, msg.sdp);
1238
+ break;
1239
+ }
1240
+ case "ice-candidate": {
1241
+ peerManager.handleIceCandidate(msg.fromId, msg.candidate);
1242
+ break;
1243
+ }
1244
+ case "mute-state": {
1245
+ const wasMuted = remoteMuteRef.current[msg.fromId];
1246
+ if (wasMuted !== void 0 && wasMuted !== msg.isAudioMuted) {
1247
+ addEvent(`${resolveName(msg.fromId)} ${msg.isAudioMuted ? "muted" : "unmuted"}`, "mute");
1248
+ }
1249
+ remoteMuteRef.current[msg.fromId] = msg.isAudioMuted;
1250
+ setRemoteMuteStates((prev) => ({ ...prev, [msg.fromId]: msg.isAudioMuted }));
1251
+ break;
1252
+ }
1253
+ case "screen-share-state": {
1254
+ const wasSharing = remoteScreenRef.current[msg.fromId];
1255
+ if (wasSharing !== void 0 && wasSharing !== msg.isScreenSharing) {
1256
+ addEvent(
1257
+ `${resolveName(msg.fromId)} ${msg.isScreenSharing ? "started" : "stopped"} screen sharing`,
1258
+ "screen"
1259
+ );
1260
+ }
1261
+ remoteScreenRef.current[msg.fromId] = msg.isScreenSharing;
1262
+ setRemoteScreenShareStates((prev) => ({ ...prev, [msg.fromId]: msg.isScreenSharing }));
1263
+ break;
1264
+ }
1265
+ case "chat-broadcast": {
1266
+ setChatMessages((prev) => [...prev, msg.message]);
1267
+ break;
1268
+ }
1269
+ case "error": {
1270
+ setError(msg.message);
1271
+ break;
1272
+ }
1273
+ }
1274
+ });
1275
+ const unsubConnection = ws.onConnectionChange((isConnected) => {
1276
+ setConnected(isConnected);
1277
+ if (isConnected && !joinedRef.current) {
1278
+ ws.send({ type: "join-room", roomId, username });
1279
+ }
1280
+ if (!isConnected) {
1281
+ setJoined(false);
1282
+ joinedRef.current = false;
1283
+ peerManager.closeAll();
1284
+ }
1285
+ });
1286
+ ws.connect();
1287
+ return () => {
1288
+ unsubMessage();
1289
+ unsubConnection();
1290
+ audioManager.shutdown();
1291
+ peerManager.closeAll();
1292
+ ws.disconnect();
1293
+ };
1294
+ }, [serverUrl, roomId, username, deviceEnvs, addEvent, resolveName]);
1295
+ useEffect3(() => {
1296
+ if (myId && joined) {
1297
+ send({
1298
+ type: "mute-state",
1299
+ fromId: myId,
1300
+ isAudioMuted: isMuted,
1301
+ isVideoMuted: true
1302
+ });
1303
+ send({
1304
+ type: "screen-share-state",
1305
+ fromId: myId,
1306
+ isScreenSharing: false
1307
+ });
1308
+ }
1309
+ }, [participants.length, myId, joined, send]);
1310
+ useEffect3(() => {
1311
+ const interval = setInterval(() => {
1312
+ const am = audioManagerRef.current;
1313
+ if (!am) return;
1314
+ setAudioLevels(am.getAllAudioLevels());
1315
+ }, 100);
1316
+ return () => clearInterval(interval);
1317
+ }, []);
1318
+ useEffect3(() => {
1319
+ let prev = null;
1320
+ const poll = async () => {
1321
+ const pm = peerManagerRef.current;
1322
+ if (!pm) return;
1323
+ const peerIds = pm.getAllPeerIds();
1324
+ if (peerIds.length === 0) {
1325
+ setConnectionStats(null);
1326
+ prev = null;
1327
+ return;
1328
+ }
1329
+ let totalAudioBytesSent = 0;
1330
+ let totalAudioBytesRecv = 0;
1331
+ let totalPacketsRecv = 0;
1332
+ let totalPacketsLost = 0;
1333
+ let rttSum = 0;
1334
+ let rttCount = 0;
1335
+ for (const peerId of peerIds) {
1336
+ const pc = pm.getConnection(peerId);
1337
+ if (!pc || typeof pc.getStats !== "function") continue;
1338
+ try {
1339
+ const report = await pc.getStats();
1340
+ const stats = report.values ? [...report.values()] : [];
1341
+ for (const stat of stats) {
1342
+ if (stat.type === "outbound-rtp" && stat.kind === "audio") {
1343
+ totalAudioBytesSent += stat.bytesSent ?? 0;
1344
+ }
1345
+ if (stat.type === "inbound-rtp" && stat.kind === "audio") {
1346
+ totalAudioBytesRecv += stat.bytesReceived ?? 0;
1347
+ totalPacketsRecv += stat.packetsReceived ?? 0;
1348
+ totalPacketsLost += stat.packetsLost ?? 0;
1349
+ }
1350
+ if (stat.type === "candidate-pair" && stat.state === "succeeded") {
1351
+ if (stat.currentRoundTripTime != null) {
1352
+ rttSum += stat.currentRoundTripTime * 1e3;
1353
+ rttCount++;
1354
+ }
1355
+ }
1356
+ if (stat.type === "remote-inbound-rtp" && stat.roundTripTime != null) {
1357
+ rttSum += stat.roundTripTime * 1e3;
1358
+ rttCount++;
1359
+ }
1360
+ }
1361
+ } catch {
1362
+ }
1363
+ }
1364
+ if (prev) {
1365
+ const timeDelta = (Date.now() - prev.timestamp) / 1e3;
1366
+ if (timeDelta > 0) {
1367
+ const sendBitrate = (totalAudioBytesSent - prev.audioBytesSent) * 8 / timeDelta / 1e3;
1368
+ const recvBitrate = (totalAudioBytesRecv - prev.audioBytesRecv) * 8 / timeDelta / 1e3;
1369
+ const newPacketsRecv = totalPacketsRecv - prev.packetsRecv;
1370
+ const newPacketsLost = totalPacketsLost - prev.packetsLost;
1371
+ const totalNew = newPacketsRecv + newPacketsLost;
1372
+ setConnectionStats({
1373
+ sendBitrateKbps: Math.max(0, Math.round(sendBitrate)),
1374
+ recvBitrateKbps: Math.max(0, Math.round(recvBitrate)),
1375
+ rttMs: rttCount > 0 ? Math.round(rttSum / rttCount) : 0,
1376
+ packetLossPercent: totalNew > 0 ? Math.round(newPacketsLost / totalNew * 1e3) / 10 : 0
1377
+ });
1378
+ }
1379
+ }
1380
+ prev = {
1381
+ audioBytesSent: totalAudioBytesSent,
1382
+ audioBytesRecv: totalAudioBytesRecv,
1383
+ packetsRecv: totalPacketsRecv,
1384
+ packetsLost: totalPacketsLost,
1385
+ timestamp: Date.now()
1386
+ };
1387
+ };
1388
+ const interval = setInterval(poll, 2e3);
1389
+ return () => clearInterval(interval);
1390
+ }, []);
1391
+ return {
1392
+ connected,
1393
+ joined,
1394
+ myId,
1395
+ participants,
1396
+ chatMessages,
1397
+ remoteMuteStates,
1398
+ remoteScreenShareStates,
1399
+ speakingStates,
1400
+ audioLevels,
1401
+ peerVolumes,
1402
+ connectionStats,
1403
+ roomEvents,
1404
+ joinedAt,
1405
+ isMuted,
1406
+ error,
1407
+ sendMessage,
1408
+ toggleMute,
1409
+ setPeerVolume,
1410
+ updateDevices,
1411
+ leave
1412
+ };
1413
+ }
1414
+
1415
+ // src/components/chat-input.tsx
1416
+ import { Box as Box3, Text as Text3 } from "ink";
1417
+ import TextInput2 from "ink-text-input";
1418
+ import { useState as useState4 } from "react";
1419
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1420
+ function ChatInput({ focused, onSend }) {
1421
+ const [value, setValue] = useState4("");
1422
+ return /* @__PURE__ */ jsxs3(Box3, { paddingX: 1, children: [
1423
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: focused ? "green" : "gray", children: "> " }),
1424
+ focused ? /* @__PURE__ */ jsx3(
1425
+ TextInput2,
1426
+ {
1427
+ value,
1428
+ onChange: setValue,
1429
+ onSubmit: (val) => {
1430
+ if (val.trim()) {
1431
+ onSend(val);
1432
+ setValue("");
1433
+ }
1434
+ },
1435
+ placeholder: "Type message..."
1436
+ }
1437
+ ) : /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Press [Tab] to type a message" })
1438
+ ] });
1439
+ }
1440
+
1441
+ // src/components/chat-log.tsx
1442
+ import { Box as Box4, Text as Text4 } from "ink";
1443
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1444
+ var COLORS = ["red", "green", "yellow", "blue", "magenta", "cyan"];
1445
+ function usernameColor(name) {
1446
+ let hash = 0;
1447
+ for (const char of name) {
1448
+ hash = (hash << 5) - hash + char.charCodeAt(0) | 0;
1449
+ }
1450
+ return COLORS[Math.abs(hash) % COLORS.length];
1451
+ }
1452
+ function formatTime(timestamp) {
1453
+ const date = new Date(timestamp);
1454
+ return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
1455
+ }
1456
+ function ChatLog({ messages }) {
1457
+ const visible = messages.slice(-20);
1458
+ return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", paddingX: 1, flexGrow: 1, justifyContent: "flex-end", children: visible.length === 0 ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "No messages yet" }) : visible.map((msg, i) => /* @__PURE__ */ jsxs4(Box4, { children: [
1459
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1460
+ "[",
1461
+ formatTime(msg.timestamp),
1462
+ "] "
1463
+ ] }),
1464
+ /* @__PURE__ */ jsx4(Text4, { color: usernameColor(msg.username), bold: true, children: msg.username }),
1465
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1466
+ ": ",
1467
+ msg.content
1468
+ ] })
1469
+ ] }, msg.id || i)) });
1470
+ }
1471
+
1472
+ // src/components/participant-list.tsx
1473
+ import { Box as Box5, Text as Text5 } from "ink";
1474
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1475
+ var BAR_COUNT = 20;
1476
+ var MAX_RMS2 = 8e3;
1477
+ function vuColor(level, volume) {
1478
+ const normalized = Math.min(level / MAX_RMS2, 1) * volume;
1479
+ if (normalized > 0.75) return "red";
1480
+ if (normalized > 0.4) return "yellow";
1481
+ return "green";
1482
+ }
1483
+ function VuMeter({ level, volume = 1 }) {
1484
+ const activeBars = Math.round(volume * BAR_COUNT);
1485
+ const normalized = Math.min(level / MAX_RMS2, 1);
1486
+ const filled = Math.round(normalized * activeBars);
1487
+ const emptyActive = activeBars - filled;
1488
+ const inactive = BAR_COUNT - activeBars;
1489
+ const color = vuColor(level, volume);
1490
+ return /* @__PURE__ */ jsxs5(Text5, { children: [
1491
+ /* @__PURE__ */ jsx5(Text5, { color, children: "\u2588".repeat(filled) }),
1492
+ /* @__PURE__ */ jsx5(Text5, { color: "green", children: "\u2591".repeat(emptyActive) }),
1493
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2591".repeat(inactive) })
1494
+ ] });
1495
+ }
1496
+ function ParticipantList({
1497
+ participants,
1498
+ username,
1499
+ isMuted,
1500
+ remoteMuteStates,
1501
+ remoteScreenShareStates,
1502
+ speakingStates,
1503
+ audioLevels,
1504
+ peerVolumes,
1505
+ selectedPeerIdx
1506
+ }) {
1507
+ const localSpeaking = speakingStates.__local__ && !isMuted;
1508
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", paddingX: 1, children: [
1509
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Participants:" }),
1510
+ /* @__PURE__ */ jsxs5(Box5, { paddingLeft: 1, justifyContent: "space-between", children: [
1511
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1512
+ /* @__PURE__ */ jsx5(Text5, { color: localSpeaking ? "green" : void 0, children: localSpeaking ? "\u25CF " : "\u25CB " }),
1513
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1514
+ username,
1515
+ " (you)"
1516
+ ] }),
1517
+ isMuted && /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: " [muted]" })
1518
+ ] }),
1519
+ /* @__PURE__ */ jsx5(VuMeter, { level: audioLevels.__local__ ?? 0 })
1520
+ ] }),
1521
+ participants.map((p, idx) => {
1522
+ const speaking = speakingStates[p.id] && !remoteMuteStates[p.id];
1523
+ const isSelected = idx === selectedPeerIdx;
1524
+ const level = audioLevels[p.id] ?? 0;
1525
+ const vol = peerVolumes[p.id] ?? 1;
1526
+ return /* @__PURE__ */ jsxs5(Box5, { paddingLeft: 1, justifyContent: "space-between", children: [
1527
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1528
+ /* @__PURE__ */ jsx5(Text5, { color: speaking ? "green" : void 0, children: speaking ? "\u25CF " : "\u25CB " }),
1529
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: isSelected ? "> " : " " }),
1530
+ /* @__PURE__ */ jsx5(Text5, { children: p.username }),
1531
+ remoteMuteStates[p.id] && /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: " [muted]" }),
1532
+ remoteScreenShareStates[p.id] && /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: " [scr]" })
1533
+ ] }),
1534
+ /* @__PURE__ */ jsxs5(Box5, { children: [
1535
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1536
+ "vol:",
1537
+ Math.round(vol * 100).toString().padStart(3),
1538
+ "%",
1539
+ " "
1540
+ ] }),
1541
+ /* @__PURE__ */ jsx5(VuMeter, { level, volume: vol })
1542
+ ] })
1543
+ ] }, p.id);
1544
+ })
1545
+ ] });
1546
+ }
1547
+
1548
+ // src/components/room-log.tsx
1549
+ import { Box as Box6, Text as Text6 } from "ink";
1550
+ import { useEffect as useEffect4, useState as useState5 } from "react";
1551
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1552
+ var EVENT_COLORS = {
1553
+ join: "green",
1554
+ leave: "red",
1555
+ screen: "cyan",
1556
+ mute: "yellow",
1557
+ info: "blue"
1558
+ };
1559
+ var EVENT_ICONS = {
1560
+ join: "+",
1561
+ leave: "-",
1562
+ screen: "\u25A3",
1563
+ mute: "\u266A",
1564
+ info: "\xB7"
1565
+ };
1566
+ function formatTime2(timestamp) {
1567
+ const d = new Date(timestamp);
1568
+ return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`;
1569
+ }
1570
+ function formatElapsed(ms) {
1571
+ const totalSec = Math.floor(ms / 1e3);
1572
+ const h = Math.floor(totalSec / 3600);
1573
+ const m = Math.floor(totalSec % 3600 / 60);
1574
+ const s = totalSec % 60;
1575
+ if (h > 0) return `${h}h${m.toString().padStart(2, "0")}m`;
1576
+ if (m > 0) return `${m}m${s.toString().padStart(2, "0")}s`;
1577
+ return `${s}s`;
1578
+ }
1579
+ function RoomLog({ events, joinedAt }) {
1580
+ const [elapsed, setElapsed] = useState5("");
1581
+ useEffect4(() => {
1582
+ if (!joinedAt) return;
1583
+ const update = () => setElapsed(formatElapsed(Date.now() - joinedAt));
1584
+ update();
1585
+ const interval = setInterval(update, 1e3);
1586
+ return () => clearInterval(interval);
1587
+ }, [joinedAt]);
1588
+ const visible = events.slice(-30);
1589
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [
1590
+ /* @__PURE__ */ jsxs6(Box6, { justifyContent: "space-between", children: [
1591
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Room Log" }),
1592
+ elapsed && /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
1593
+ "in room: ",
1594
+ elapsed
1595
+ ] })
1596
+ ] }),
1597
+ /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", flexGrow: 1, justifyContent: "flex-end", children: visible.length === 0 ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "No events yet" }) : visible.map((event) => /* @__PURE__ */ jsxs6(Box6, { children: [
1598
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
1599
+ "[",
1600
+ formatTime2(event.timestamp),
1601
+ "] "
1602
+ ] }),
1603
+ /* @__PURE__ */ jsxs6(Text6, { color: EVENT_COLORS[event.type], children: [
1604
+ EVENT_ICONS[event.type],
1605
+ " ",
1606
+ event.message
1607
+ ] })
1608
+ ] }, `${event.timestamp}-${event.message}`)) })
1609
+ ] });
1610
+ }
1611
+
1612
+ // src/components/status-bar.tsx
1613
+ import { Box as Box7, Text as Text7 } from "ink";
1614
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1615
+ function StatusBar({ isMuted, connected }) {
1616
+ return /* @__PURE__ */ jsxs7(Box7, { paddingX: 1, justifyContent: "space-between", children: [
1617
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
1618
+ "[Esc] Leave [Tab] Chat [m] ",
1619
+ isMuted ? "unmute" : "mute",
1620
+ " [d] Devices [\u2191\u2193] Select [[-]/[+]] Vol"
1621
+ ] }),
1622
+ /* @__PURE__ */ jsx7(Text7, { color: connected ? "green" : "red", children: connected ? "\u25CF Connected" : "\u25CB Disconnected" })
1623
+ ] });
1624
+ }
1625
+
1626
+ // src/components/room-view.tsx
1627
+ import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
1628
+ var BAR_WIDTH2 = 30;
1629
+ var MAX_RMS3 = 8e3;
1630
+ function renderBar2(level) {
1631
+ const normalized = Math.min(level / MAX_RMS3, 1);
1632
+ const filled = Math.round(normalized * BAR_WIDTH2);
1633
+ return "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH2 - filled);
1634
+ }
1635
+ function barColor2(level) {
1636
+ const normalized = Math.min(level / MAX_RMS3, 1);
1637
+ if (normalized > 0.75) return "red";
1638
+ if (normalized > 0.4) return "yellow";
1639
+ return "green";
1640
+ }
1641
+ function RoomView({ serverUrl, roomId, username, deviceEnvs, onBack }) {
1642
+ const room = useRoom({ serverUrl, roomId, username, deviceEnvs });
1643
+ const [inputFocused, setInputFocused] = useState6(true);
1644
+ const [deviceStep, setDeviceStep] = useState6(null);
1645
+ const [devices, setDevices] = useState6({
1646
+ inputs: [],
1647
+ outputs: []
1648
+ });
1649
+ const [selectedInput, setSelectedInput] = useState6();
1650
+ const [selectedOutput, setSelectedOutput] = useState6();
1651
+ const [micLevel, setMicLevel] = useState6(0);
1652
+ const [selectedPeerIdx, setSelectedPeerIdx] = useState6(0);
1653
+ const testerRef = useRef4(null);
1654
+ const smoothedRef = useRef4(0);
1655
+ useEffect5(() => {
1656
+ if (deviceStep === "loading") {
1657
+ listAudioDevices().then((d) => {
1658
+ setDevices(d);
1659
+ setDeviceStep(d.inputs.length > 0 ? "input" : d.outputs.length > 0 ? "output" : null);
1660
+ });
1661
+ }
1662
+ }, [deviceStep]);
1663
+ useEffect5(() => {
1664
+ if (deviceStep !== "test") {
1665
+ testerRef.current?.stop();
1666
+ testerRef.current = null;
1667
+ smoothedRef.current = 0;
1668
+ setMicLevel(0);
1669
+ return;
1670
+ }
1671
+ const envs = getDeviceEnv(selectedInput, selectedOutput);
1672
+ const tester = new MicTester();
1673
+ testerRef.current = tester;
1674
+ let lastUpdate = 0;
1675
+ tester.setLevelCallback((rms) => {
1676
+ smoothedRef.current = smoothedRef.current * 0.7 + rms * 0.3;
1677
+ const now = Date.now();
1678
+ if (now - lastUpdate > 80) {
1679
+ lastUpdate = now;
1680
+ setMicLevel(smoothedRef.current);
1681
+ }
1682
+ });
1683
+ tester.start(envs);
1684
+ return () => {
1685
+ tester.stop();
1686
+ };
1687
+ }, [deviceStep, selectedInput, selectedOutput]);
1688
+ const applyDevices = (newInput, newOutput) => {
1689
+ saveDevicePreferences(newInput, newOutput);
1690
+ const newEnvs = getDeviceEnv(newInput, newOutput);
1691
+ room.updateDevices(newEnvs);
1692
+ setDeviceStep(null);
1693
+ };
1694
+ useInput3((input, key) => {
1695
+ if (deviceStep && deviceStep !== "loading") {
1696
+ if (deviceStep === "test") {
1697
+ if (input === "t") {
1698
+ const envs = getDeviceEnv(selectedInput, selectedOutput);
1699
+ playTestTone(envs);
1700
+ return;
1701
+ }
1702
+ if (key.return) {
1703
+ applyDevices(selectedInput, selectedOutput);
1704
+ return;
1705
+ }
1706
+ if (key.escape) {
1707
+ setDeviceStep("input");
1708
+ return;
1709
+ }
1710
+ return;
1711
+ }
1712
+ if (key.escape) {
1713
+ setDeviceStep(null);
1714
+ }
1715
+ return;
1716
+ }
1717
+ if (key.escape) {
1718
+ room.leave();
1719
+ onBack();
1720
+ return;
1721
+ }
1722
+ if (key.tab) {
1723
+ setInputFocused((prev) => !prev);
1724
+ return;
1725
+ }
1726
+ if (!inputFocused) {
1727
+ if (input === "m") {
1728
+ room.toggleMute();
1729
+ }
1730
+ if (input === "d") {
1731
+ setDeviceStep("loading");
1732
+ }
1733
+ if (key.upArrow) {
1734
+ setSelectedPeerIdx((prev) => Math.max(0, prev - 1));
1735
+ }
1736
+ if (key.downArrow) {
1737
+ setSelectedPeerIdx((prev) => Math.min(room.participants.length - 1, prev + 1));
1738
+ }
1739
+ if (input === "[" || input === "-") {
1740
+ const peerId = room.participants[selectedPeerIdx]?.id;
1741
+ if (peerId) {
1742
+ const current = room.peerVolumes[peerId] ?? 1;
1743
+ room.setPeerVolume(peerId, Math.round((current - 0.02) * 100) / 100);
1744
+ }
1745
+ }
1746
+ if (input === "]" || input === "=" || input === "+") {
1747
+ const peerId = room.participants[selectedPeerIdx]?.id;
1748
+ if (peerId) {
1749
+ const current = room.peerVolumes[peerId] ?? 1;
1750
+ room.setPeerVolume(peerId, Math.round((current + 0.02) * 100) / 100);
1751
+ }
1752
+ }
1753
+ }
1754
+ });
1755
+ if (deviceStep && deviceStep !== "loading") {
1756
+ if (deviceStep === "test") {
1757
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", flexGrow: 1, paddingX: 1, paddingY: 1, children: [
1758
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "blue", children: "Audio Test" }),
1759
+ /* @__PURE__ */ jsx8(Box8, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(200) }) }),
1760
+ /* @__PURE__ */ jsxs8(Text8, { children: [
1761
+ "Input: ",
1762
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: selectedInput?.name ?? "System Default" })
1763
+ ] }),
1764
+ /* @__PURE__ */ jsxs8(Text8, { children: [
1765
+ "Output: ",
1766
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: selectedOutput?.name ?? "System Default" })
1767
+ ] }),
1768
+ /* @__PURE__ */ jsx8(Text8, {}),
1769
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Mic level:" }),
1770
+ /* @__PURE__ */ jsx8(Text8, { children: /* @__PURE__ */ jsx8(Text8, { color: barColor2(micLevel), children: renderBar2(micLevel) }) }),
1771
+ /* @__PURE__ */ jsx8(Text8, {}),
1772
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "[t] play test tone [Enter] confirm [Esc] re-select" })
1773
+ ] });
1774
+ }
1775
+ const inputItems = [
1776
+ { label: "System Default", value: "__default__" },
1777
+ ...devices.inputs.map((d) => ({ label: d.name, value: d.id }))
1778
+ ];
1779
+ const outputItems = [
1780
+ { label: "System Default", value: "__default__" },
1781
+ ...devices.outputs.map((d) => ({ label: d.name, value: d.id }))
1782
+ ];
1783
+ if (deviceStep === "input") {
1784
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", flexGrow: 1, paddingX: 1, paddingY: 1, children: [
1785
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "blue", children: "Change Audio Device" }),
1786
+ /* @__PURE__ */ jsx8(Box8, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(200) }) }),
1787
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Input (Microphone):" }),
1788
+ /* @__PURE__ */ jsx8(
1789
+ SelectInput2,
1790
+ {
1791
+ items: inputItems,
1792
+ onSelect: (item) => {
1793
+ const device = devices.inputs.find((d) => d.id === item.value);
1794
+ setSelectedInput(device);
1795
+ if (devices.outputs.length === 0) {
1796
+ setSelectedOutput(void 0);
1797
+ setDeviceStep("test");
1798
+ } else {
1799
+ setDeviceStep("output");
1800
+ }
1801
+ }
1802
+ }
1803
+ ),
1804
+ /* @__PURE__ */ jsx8(Text8, {}),
1805
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "[\u2191\u2193] navigate [Enter] select [Esc] cancel" })
1806
+ ] });
1807
+ }
1808
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", flexGrow: 1, paddingX: 1, paddingY: 1, children: [
1809
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "blue", children: "Change Audio Device" }),
1810
+ /* @__PURE__ */ jsx8(Box8, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(200) }) }),
1811
+ /* @__PURE__ */ jsxs8(Text8, { children: [
1812
+ "Input: ",
1813
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: selectedInput?.name ?? "System Default" })
1814
+ ] }),
1815
+ /* @__PURE__ */ jsx8(Text8, {}),
1816
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Output (Speakers):" }),
1817
+ /* @__PURE__ */ jsx8(
1818
+ SelectInput2,
1819
+ {
1820
+ items: outputItems,
1821
+ onSelect: (item) => {
1822
+ const device = devices.outputs.find((d) => d.id === item.value);
1823
+ setSelectedOutput(device);
1824
+ setDeviceStep("test");
1825
+ }
1826
+ }
1827
+ ),
1828
+ /* @__PURE__ */ jsx8(Text8, {}),
1829
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "[\u2191\u2193] navigate [Enter] select [Esc] cancel" })
1830
+ ] });
1831
+ }
1832
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", flexGrow: 1, children: [
1833
+ /* @__PURE__ */ jsxs8(Box8, { paddingX: 1, gap: 1, justifyContent: "space-between", children: [
1834
+ /* @__PURE__ */ jsxs8(Box8, { gap: 1, children: [
1835
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "blue", children: "OpenMeet" }),
1836
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "|" }),
1837
+ /* @__PURE__ */ jsxs8(Text8, { children: [
1838
+ "Room: ",
1839
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: roomId })
1840
+ ] }),
1841
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "|" }),
1842
+ /* @__PURE__ */ jsxs8(Text8, { children: [
1843
+ room.participants.length + 1,
1844
+ "p"
1845
+ ] })
1846
+ ] }),
1847
+ /* @__PURE__ */ jsxs8(Box8, { gap: 1, children: [
1848
+ room.connectionStats ? /* @__PURE__ */ jsxs8(Fragment2, { children: [
1849
+ /* @__PURE__ */ jsxs8(Text8, { color: "green", children: [
1850
+ "\u2191",
1851
+ room.connectionStats.sendBitrateKbps,
1852
+ "k"
1853
+ ] }),
1854
+ /* @__PURE__ */ jsxs8(Text8, { color: "cyan", children: [
1855
+ "\u2193",
1856
+ room.connectionStats.recvBitrateKbps,
1857
+ "k"
1858
+ ] }),
1859
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "|" }),
1860
+ /* @__PURE__ */ jsxs8(
1861
+ Text8,
1862
+ {
1863
+ color: room.connectionStats.rttMs > 150 ? "red" : room.connectionStats.rttMs > 80 ? "yellow" : void 0,
1864
+ children: [
1865
+ "RTT:",
1866
+ room.connectionStats.rttMs,
1867
+ "ms"
1868
+ ]
1869
+ }
1870
+ ),
1871
+ /* @__PURE__ */ jsxs8(
1872
+ Text8,
1873
+ {
1874
+ color: room.connectionStats.packetLossPercent > 5 ? "red" : room.connectionStats.packetLossPercent > 1 ? "yellow" : void 0,
1875
+ children: [
1876
+ "Loss:",
1877
+ room.connectionStats.packetLossPercent,
1878
+ "%"
1879
+ ]
1880
+ }
1881
+ ),
1882
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "|" })
1883
+ ] }) : null,
1884
+ /* @__PURE__ */ jsx8(Text8, { color: room.connected ? "green" : "red", children: "\u25CF" })
1885
+ ] })
1886
+ ] }),
1887
+ /* @__PURE__ */ jsx8(Box8, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(200) }) }),
1888
+ deviceStep === "loading" && /* @__PURE__ */ jsx8(Box8, { paddingX: 1, children: /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "Loading audio devices..." }) }),
1889
+ /* @__PURE__ */ jsx8(
1890
+ ParticipantList,
1891
+ {
1892
+ participants: room.participants,
1893
+ myId: room.myId,
1894
+ username,
1895
+ isMuted: room.isMuted,
1896
+ remoteMuteStates: room.remoteMuteStates,
1897
+ remoteScreenShareStates: room.remoteScreenShareStates,
1898
+ speakingStates: room.speakingStates,
1899
+ audioLevels: room.audioLevels,
1900
+ peerVolumes: room.peerVolumes,
1901
+ selectedPeerIdx
1902
+ }
1903
+ ),
1904
+ /* @__PURE__ */ jsx8(Box8, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(200) }) }),
1905
+ /* @__PURE__ */ jsxs8(Box8, { flexGrow: 1, flexBasis: 0, overflow: "hidden", children: [
1906
+ /* @__PURE__ */ jsx8(Box8, { flexDirection: "column", flexGrow: 1, flexBasis: "50%", children: /* @__PURE__ */ jsx8(ChatLog, { messages: room.chatMessages }) }),
1907
+ /* @__PURE__ */ jsx8(
1908
+ Box8,
1909
+ {
1910
+ flexDirection: "column",
1911
+ flexGrow: 1,
1912
+ flexBasis: "50%",
1913
+ borderStyle: "single",
1914
+ borderRight: false,
1915
+ borderTop: false,
1916
+ borderBottom: false,
1917
+ borderDimColor: true,
1918
+ children: /* @__PURE__ */ jsx8(RoomLog, { events: room.roomEvents, joinedAt: room.joinedAt })
1919
+ }
1920
+ )
1921
+ ] }),
1922
+ /* @__PURE__ */ jsx8(Box8, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(200) }) }),
1923
+ /* @__PURE__ */ jsx8(ChatInput, { focused: inputFocused, onSend: room.sendMessage }),
1924
+ /* @__PURE__ */ jsx8(Box8, { height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(200) }) }),
1925
+ /* @__PURE__ */ jsx8(StatusBar, { isMuted: room.isMuted, connected: room.connected }),
1926
+ room.error && /* @__PURE__ */ jsx8(Box8, { paddingX: 1, children: /* @__PURE__ */ jsxs8(Text8, { color: "red", children: [
1927
+ "Error: ",
1928
+ room.error
1929
+ ] }) })
1930
+ ] });
1931
+ }
1932
+
1933
+ // src/app.tsx
1934
+ import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
1935
+ function wsToHttpUrl(wsUrl) {
1936
+ const url = new URL(wsUrl);
1937
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
1938
+ url.pathname = "";
1939
+ return url.origin;
1940
+ }
1941
+ function FullScreen({ children }) {
1942
+ const [size, setSize] = useState7({
1943
+ columns: process.stdout.columns || 80,
1944
+ rows: process.stdout.rows || 24
1945
+ });
1946
+ useEffect6(() => {
1947
+ const onResize = () => {
1948
+ setSize({
1949
+ columns: process.stdout.columns || 80,
1950
+ rows: process.stdout.rows || 24
1951
+ });
1952
+ };
1953
+ process.stdout.on("resize", onResize);
1954
+ return () => {
1955
+ process.stdout.off("resize", onResize);
1956
+ };
1957
+ }, []);
1958
+ return /* @__PURE__ */ jsx9(
1959
+ Box9,
1960
+ {
1961
+ width: size.columns,
1962
+ height: size.rows,
1963
+ borderStyle: "round",
1964
+ borderColor: "blue",
1965
+ flexDirection: "column",
1966
+ overflow: "hidden",
1967
+ children
1968
+ }
1969
+ );
1970
+ }
1971
+ function App({ serverUrl, emoji: emoji2, initialRoom, inputDevice, outputDevice }) {
1972
+ const [screen, setScreen] = useState7(initialRoom ? "devices" : "home");
1973
+ const [roomId, setRoomId] = useState7(initialRoom ?? "");
1974
+ const [devices, setDevices] = useState7({
1975
+ inputs: [],
1976
+ outputs: []
1977
+ });
1978
+ const [devicesLoaded, setDevicesLoaded] = useState7(false);
1979
+ const [deviceEnvs, setDeviceEnvs] = useState7({ recExtra: {}, playExtra: {} });
1980
+ const [creating, setCreating] = useState7(false);
1981
+ const [homeError, setHomeError] = useState7(null);
1982
+ const [savedInputId] = useState7(() => getSavedInputDevice());
1983
+ const [savedOutputId] = useState7(() => getSavedOutputDevice());
1984
+ useEffect6(() => {
1985
+ listAudioDevices().then((d) => {
1986
+ setDevices(d);
1987
+ setDevicesLoaded(true);
1988
+ });
1989
+ }, []);
1990
+ useEffect6(() => {
1991
+ if (screen !== "devices" || !devicesLoaded) return;
1992
+ if (inputDevice && outputDevice) {
1993
+ const input = devices.inputs.find((d) => d.name === inputDevice);
1994
+ const output = devices.outputs.find((d) => d.name === outputDevice);
1995
+ setDeviceEnvs(getDeviceEnv(input, output));
1996
+ setScreen("room");
1997
+ return;
1998
+ }
1999
+ if (savedInputId !== null || savedOutputId !== null) {
2000
+ const inputStillExists = !savedInputId || devices.inputs.some((d) => d.id === savedInputId);
2001
+ const outputStillExists = !savedOutputId || devices.outputs.some((d) => d.id === savedOutputId);
2002
+ if (inputStillExists && outputStillExists) {
2003
+ const input = savedInputId ? devices.inputs.find((d) => d.id === savedInputId) : void 0;
2004
+ const output = savedOutputId ? devices.outputs.find((d) => d.id === savedOutputId) : void 0;
2005
+ setDeviceEnvs(getDeviceEnv(input, output));
2006
+ setScreen("room");
2007
+ }
2008
+ }
2009
+ }, [screen, inputDevice, outputDevice, devices, devicesLoaded, savedInputId, savedOutputId]);
2010
+ const handleCreateRoom = async () => {
2011
+ setCreating(true);
2012
+ setHomeError(null);
2013
+ try {
2014
+ const httpUrl = wsToHttpUrl(serverUrl);
2015
+ const res = await fetch(`${httpUrl}/api/rooms`, {
2016
+ method: "POST",
2017
+ headers: { "Content-Type": "application/json" },
2018
+ body: JSON.stringify({ name: "Room" })
2019
+ });
2020
+ if (res.ok) {
2021
+ const room = await res.json();
2022
+ setRoomId(room.id);
2023
+ setScreen("devices");
2024
+ } else {
2025
+ setHomeError("Failed to create room");
2026
+ }
2027
+ } catch {
2028
+ setHomeError("Cannot reach server");
2029
+ } finally {
2030
+ setCreating(false);
2031
+ }
2032
+ };
2033
+ const handleJoinRoom = (id) => {
2034
+ setRoomId(id);
2035
+ setScreen("devices");
2036
+ };
2037
+ return /* @__PURE__ */ jsxs9(FullScreen, { children: [
2038
+ screen === "home" && /* @__PURE__ */ jsx9(
2039
+ HomeScreen,
2040
+ {
2041
+ emoji: emoji2,
2042
+ loading: creating,
2043
+ error: homeError,
2044
+ onCreateRoom: handleCreateRoom,
2045
+ onJoinRoom: handleJoinRoom,
2046
+ onQuit: () => process.exit(0)
2047
+ }
2048
+ ),
2049
+ screen === "devices" && /* @__PURE__ */ jsx9(
2050
+ DevicePicker,
2051
+ {
2052
+ inputs: devices.inputs,
2053
+ outputs: devices.outputs,
2054
+ loading: !devicesLoaded,
2055
+ savedInputId,
2056
+ savedOutputId,
2057
+ onConfirm: (input, output) => {
2058
+ saveDevicePreferences(input, output);
2059
+ setDeviceEnvs(getDeviceEnv(input, output));
2060
+ setScreen("room");
2061
+ }
2062
+ }
2063
+ ),
2064
+ screen === "room" && /* @__PURE__ */ jsx9(
2065
+ RoomView,
2066
+ {
2067
+ serverUrl,
2068
+ roomId,
2069
+ username: emoji2,
2070
+ deviceEnvs,
2071
+ onBack: () => setScreen("home")
2072
+ }
2073
+ )
2074
+ ] });
2075
+ }
2076
+
2077
+ // src/lib/emoji.ts
2078
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2079
+ import { homedir as homedir2 } from "node:os";
2080
+ import { dirname as dirname2, join as join3 } from "node:path";
2081
+ var EMOJI_FILE = join3(homedir2(), ".config", "openmeet", "emoji");
2082
+ var EMOJI_POOL = [
2083
+ // Animals
2084
+ "\u{1F436}",
2085
+ "\u{1F431}",
2086
+ "\u{1F43B}",
2087
+ "\u{1F43C}",
2088
+ "\u{1F428}",
2089
+ "\u{1F435}",
2090
+ "\u{1F981}",
2091
+ "\u{1F984}",
2092
+ "\u{1F98A}",
2093
+ "\u{1F989}",
2094
+ "\u{1F427}",
2095
+ "\u{1F40A}",
2096
+ "\u{1F422}",
2097
+ "\u{1F41D}",
2098
+ "\u{1F419}",
2099
+ "\u{1F433}",
2100
+ "\u{1F418}",
2101
+ "\u{1F992}",
2102
+ "\u{1F98E}",
2103
+ "\u{1F40C}",
2104
+ "\u{1F99C}",
2105
+ "\u{1F9A9}",
2106
+ "\u{1F40B}",
2107
+ "\u{1F99A}",
2108
+ // Fruits
2109
+ "\u{1F34E}",
2110
+ "\u{1F34A}",
2111
+ "\u{1F34B}",
2112
+ "\u{1F349}",
2113
+ "\u{1F353}",
2114
+ "\u{1F351}",
2115
+ "\u{1F352}",
2116
+ "\u{1F347}",
2117
+ "\u{1F34D}",
2118
+ "\u{1F95D}",
2119
+ "\u{1F951}",
2120
+ "\u{1FAD0}",
2121
+ "\u{1F965}",
2122
+ "\u{1F346}",
2123
+ "\u{1F955}",
2124
+ // Funny faces
2125
+ "\u{1F92A}",
2126
+ "\u{1F913}",
2127
+ "\u{1F978}",
2128
+ "\u{1F920}",
2129
+ "\u{1F47B}",
2130
+ "\u{1F916}",
2131
+ "\u{1F47D}",
2132
+ "\u{1F9B8}",
2133
+ "\u{1F9DB}",
2134
+ "\u{1F9D9}",
2135
+ "\u{1F9DA}",
2136
+ "\u{1F9DC}",
2137
+ "\u{1F9DE}",
2138
+ "\u{1F383}",
2139
+ "\u{1F47E}",
2140
+ "\u{1F479}"
2141
+ ];
2142
+ function getOrCreateEmoji() {
2143
+ try {
2144
+ const stored = readFileSync2(EMOJI_FILE, "utf-8").trim();
2145
+ if (stored) return stored;
2146
+ } catch {
2147
+ }
2148
+ const emoji2 = EMOJI_POOL[Math.floor(Math.random() * EMOJI_POOL.length)];
2149
+ try {
2150
+ mkdirSync2(dirname2(EMOJI_FILE), { recursive: true });
2151
+ writeFileSync2(EMOJI_FILE, emoji2, "utf-8");
2152
+ } catch {
2153
+ }
2154
+ return emoji2;
2155
+ }
2156
+
2157
+ // src/index.tsx
2158
+ import { jsx as jsx10 } from "react/jsx-runtime";
2159
+ function checkSox() {
2160
+ try {
2161
+ execSync3("which rec", { stdio: "ignore" });
2162
+ execSync3("which play", { stdio: "ignore" });
2163
+ return true;
2164
+ } catch {
2165
+ return false;
2166
+ }
2167
+ }
2168
+ function checkMicPermission() {
2169
+ if (platform2() !== "darwin") return "unknown";
2170
+ try {
2171
+ const result = spawnSync(
2172
+ "rec",
2173
+ ["-q", "-t", "raw", "-b", "16", "-e", "signed-integer", "-c", "1", "-r", "48000", "-", "trim", "0", "0.1"],
2174
+ {
2175
+ timeout: 5e3,
2176
+ stdio: ["ignore", "pipe", "pipe"]
2177
+ }
2178
+ );
2179
+ if (result.error) return "unknown";
2180
+ if (result.stdout && result.stdout.length > 0) return "granted";
2181
+ const stderr = result.stderr?.toString() ?? "";
2182
+ if (stderr.includes("permission") || stderr.includes("not authorized") || result.status !== 0) {
2183
+ return "denied";
2184
+ }
2185
+ return "unknown";
2186
+ } catch {
2187
+ return "unknown";
2188
+ }
2189
+ }
2190
+ var { values } = parseArgs({
2191
+ options: {
2192
+ server: { type: "string", default: "ws://localhost:3001/ws" },
2193
+ room: { type: "string" },
2194
+ "input-device": { type: "string" },
2195
+ "output-device": { type: "string" },
2196
+ help: { type: "boolean", short: "h" }
2197
+ }
2198
+ });
2199
+ if (values.help) {
2200
+ process.stdout.write(`Usage: openmeet [options]
2201
+
2202
+ --server <url> WebSocket URL (default: ws://localhost:3001/ws)
2203
+ --room <id> Room ID to join
2204
+ --input-device <name> Input device name (skip device picker)
2205
+ --output-device <name> Output device name (skip device picker)
2206
+ -h, --help Show help
2207
+ `);
2208
+ process.exit(0);
2209
+ }
2210
+ if (!checkSox()) {
2211
+ process.stderr.write(`Error: sox is required but not found on PATH.
2212
+
2213
+ Install sox:
2214
+ macOS: brew install sox
2215
+ Ubuntu: sudo apt install sox
2216
+ Fedora: sudo dnf install sox
2217
+ `);
2218
+ process.exit(1);
2219
+ }
2220
+ var micStatus = checkMicPermission();
2221
+ if (micStatus === "denied") {
2222
+ process.stderr.write(`Error: Microphone access denied.
2223
+
2224
+ Your terminal app needs microphone permission on macOS:
2225
+ 1. Open System Settings > Privacy & Security > Microphone
2226
+ 2. Enable the toggle for your terminal app (Terminal, iTerm2, Warp, etc.)
2227
+ 3. Restart the terminal and try again
2228
+ `);
2229
+ process.exit(1);
2230
+ }
2231
+ var emoji = getOrCreateEmoji();
2232
+ console.log = () => {
2233
+ };
2234
+ console.error = () => {
2235
+ };
2236
+ console.warn = () => {
2237
+ };
2238
+ process.stdout.write("\x1B[?1049h");
2239
+ process.on("exit", () => {
2240
+ process.stdout.write("\x1B[?1049l");
2241
+ });
2242
+ render(
2243
+ /* @__PURE__ */ jsx10(
2244
+ App,
2245
+ {
2246
+ serverUrl: values.server ?? "ws://localhost:3001/ws",
2247
+ emoji,
2248
+ initialRoom: values.room,
2249
+ inputDevice: values["input-device"],
2250
+ outputDevice: values["output-device"]
2251
+ }
2252
+ )
2253
+ );