diff --git a/.gitignore b/.gitignore
index c9a1414..eb55ce3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,11 @@ electron-dist/
Pair-linux-source.zip
.DS_Store
nul
+*.log
+build*.log
+app_ids.txt
+html_ids.txt
+addon/build/*
+!addon/build/Release/
+addon/build/Release/*
+!addon/build/Release/pair-capture.node
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.node.recipe b/addon/build/Release/obj/pair-capture/pair-capture.node.recipe
deleted file mode 100644
index 1da9fe3..0000000
--- a/addon/build/Release/obj/pair-capture/pair-capture.node.recipe
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
- C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.node
-
-
-
-
-
-
\ No newline at end of file
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.obj b/addon/build/Release/obj/pair-capture/pair-capture.obj
deleted file mode 100644
index 8b9dee8..0000000
Binary files a/addon/build/Release/obj/pair-capture/pair-capture.obj and /dev/null differ
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.command.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.command.1.tlog
deleted file mode 100644
index 6b17fcb..0000000
Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.command.1.tlog and /dev/null differ
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.read.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.read.1.tlog
deleted file mode 100644
index 44df7e6..0000000
Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.read.1.tlog and /dev/null differ
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.write.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.write.1.tlog
deleted file mode 100644
index a2929a6..0000000
Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.write.1.tlog and /dev/null differ
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/Cl.items.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/Cl.items.tlog
deleted file mode 100644
index af7e258..0000000
--- a/addon/build/Release/obj/pair-capture/pair-capture.tlog/Cl.items.tlog
+++ /dev/null
@@ -1,2 +0,0 @@
-C:\Users\benja\Documents\discordult\addon\pair-capture.cc;C:\Users\benja\Documents\discordult\addon\build\Release\obj\pair-capture\pair-capture.obj
-C:\Users\benja\AppData\Roaming\npm\node_modules\node-gyp\src\win_delay_load_hook.cc;C:\Users\benja\Documents\discordult\addon\build\Release\obj\pair-capture\win_delay_load_hook.obj
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.command.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.command.1.tlog
deleted file mode 100644
index 53021a0..0000000
Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.command.1.tlog and /dev/null differ
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.read.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.read.1.tlog
deleted file mode 100644
index 51effdb..0000000
Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.read.1.tlog and /dev/null differ
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.secondary.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.secondary.1.tlog
deleted file mode 100644
index a7925f3..0000000
--- a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.secondary.1.tlog
+++ /dev/null
@@ -1,5 +0,0 @@
-^C:\USERS\BENJA\DOCUMENTS\DISCORDULT\ADDON\BUILD\RELEASE\OBJ\PAIR-CAPTURE\PAIR-CAPTURE.OBJ|C:\USERS\BENJA\DOCUMENTS\DISCORDULT\ADDON\BUILD\RELEASE\OBJ\PAIR-CAPTURE\WIN_DELAY_LOAD_HOOK.OBJ
-C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.LIB
-C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.EXP
-C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.IPDB
-C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.IOBJ
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.write.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.write.1.tlog
deleted file mode 100644
index 441a3af..0000000
Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.write.1.tlog and /dev/null differ
diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/pair-capture.lastbuildstate b/addon/build/Release/obj/pair-capture/pair-capture.tlog/pair-capture.lastbuildstate
deleted file mode 100644
index 595281b..0000000
--- a/addon/build/Release/obj/pair-capture/pair-capture.tlog/pair-capture.lastbuildstate
+++ /dev/null
@@ -1,2 +0,0 @@
-PlatformToolSet=v145:VCToolArchitecture=Native64Bit:VCToolsVersion=14.51.36231:VCServicingVersionCrtHeaders=14.51.36244:VCServicingVersionCompilers=14.51.36244:TargetPlatformVersion=10.0.26100.0:
-Release|x64|C:\Users\benja\Documents\discordult\addon\build\|
diff --git a/addon/build/Release/obj/pair-capture/win_delay_load_hook.obj b/addon/build/Release/obj/pair-capture/win_delay_load_hook.obj
deleted file mode 100644
index ce4d2a7..0000000
Binary files a/addon/build/Release/obj/pair-capture/win_delay_load_hook.obj and /dev/null differ
diff --git a/addon/build/Release/pair-capture.exp b/addon/build/Release/pair-capture.exp
deleted file mode 100644
index 6e5ec02..0000000
Binary files a/addon/build/Release/pair-capture.exp and /dev/null differ
diff --git a/addon/build/Release/pair-capture.iobj b/addon/build/Release/pair-capture.iobj
deleted file mode 100644
index 4046f1b..0000000
Binary files a/addon/build/Release/pair-capture.iobj and /dev/null differ
diff --git a/addon/build/Release/pair-capture.ipdb b/addon/build/Release/pair-capture.ipdb
deleted file mode 100644
index 8ff3681..0000000
Binary files a/addon/build/Release/pair-capture.ipdb and /dev/null differ
diff --git a/addon/build/Release/pair-capture.lib b/addon/build/Release/pair-capture.lib
deleted file mode 100644
index 09b7ecc..0000000
Binary files a/addon/build/Release/pair-capture.lib and /dev/null differ
diff --git a/addon/build/Release/pair-capture.node b/addon/build/Release/pair-capture.node
index c0052b5..b1a6024 100644
Binary files a/addon/build/Release/pair-capture.node and b/addon/build/Release/pair-capture.node differ
diff --git a/addon/build/Release/pair-capture.pdb b/addon/build/Release/pair-capture.pdb
deleted file mode 100644
index 44fd82f..0000000
Binary files a/addon/build/Release/pair-capture.pdb and /dev/null differ
diff --git a/addon/build/binding.sln b/addon/build/binding.sln
deleted file mode 100644
index 4871138..0000000
--- a/addon/build/binding.sln
+++ /dev/null
@@ -1,19 +0,0 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 2015
-Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "pair-capture", "pair-capture.vcxproj", "{139377FD-1DA0-C185-31F2-879258A3A79A}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Release|x64 = Release|x64
- Debug|x64 = Debug|x64
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {139377FD-1DA0-C185-31F2-879258A3A79A}.Release|x64.ActiveCfg = Release|x64
- {139377FD-1DA0-C185-31F2-879258A3A79A}.Release|x64.Build.0 = Release|x64
- {139377FD-1DA0-C185-31F2-879258A3A79A}.Debug|x64.ActiveCfg = Debug|x64
- {139377FD-1DA0-C185-31F2-879258A3A79A}.Debug|x64.Build.0 = Debug|x64
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
-EndGlobal
diff --git a/addon/build/config.gypi b/addon/build/config.gypi
deleted file mode 100644
index 3c0ee31..0000000
--- a/addon/build/config.gypi
+++ /dev/null
@@ -1,517 +0,0 @@
-# Do not edit. File was generated by node-gyp's "configure" step
-{
- "target_defaults": {
- "cflags": [],
- "configurations": {
- "Debug": {
- "v8_enable_v8_checks": 0,
- "variables": {}
- },
- "Release": {
- "v8_enable_v8_checks": 1,
- "variables": {}
- }
- },
- "default_configuration": "Release",
- "defines": [],
- "include_dirs": [],
- "libraries": [],
- "msbuild_toolset": "v170",
- "msvs_windows_target_platform_version": "10.0.26100.0"
- },
- "variables": {
- "arm_fpu": "neon",
- "asan": 0,
- "build_v8_with_gn": "false",
- "built_with_electron": 1,
- "control_flow_guard": "false",
- "coverage": "false",
- "dcheck_always_on": 0,
- "debug_nghttp2": "false",
- "debug_node": "false",
- "enable_lto": "false",
- "enable_pgo_generate": "false",
- "enable_pgo_use": "false",
- "error_on_warn": "false",
- "force_dynamic_crt": 0,
- "host_arch": "x64",
- "icu_data_in": "..\\..\\deps\\icu-tmp\\icudt77l.dat",
- "icu_endianness": "l",
- "icu_gyp_path": "tools/icu/icu-generic.gyp",
- "icu_path": "deps/icu-small",
- "icu_small": "false",
- "icu_ver_major": "77",
- "libdir": "lib",
- "llvm_version": "0.0",
- "napi_build_version": "10",
- "node_builtin_shareable_builtins": [
- "deps/cjs-module-lexer/lexer.js",
- "deps/cjs-module-lexer/dist/lexer.js",
- "deps/undici/undici.js",
- "deps/amaro/dist/index.js"
- ],
- "node_byteorder": "little",
- "node_cctest_sources": [
- "src/node_snapshot_stub.cc",
- "test/cctest/inspector/test_node_protocol.cc",
- "test/cctest/node_test_fixture.cc",
- "test/cctest/test_aliased_buffer.cc",
- "test/cctest/test_base64.cc",
- "test/cctest/test_base_object_ptr.cc",
- "test/cctest/test_cppgc.cc",
- "test/cctest/test_crypto_clienthello.cc",
- "test/cctest/test_dataqueue.cc",
- "test/cctest/test_environment.cc",
- "test/cctest/test_inspector_socket.cc",
- "test/cctest/test_inspector_socket_server.cc",
- "test/cctest/test_json_utils.cc",
- "test/cctest/test_linked_binding.cc",
- "test/cctest/test_node_api.cc",
- "test/cctest/test_node_crypto.cc",
- "test/cctest/test_node_crypto_env.cc",
- "test/cctest/test_node_postmortem_metadata.cc",
- "test/cctest/test_node_task_runner.cc",
- "test/cctest/test_path.cc",
- "test/cctest/test_per_process.cc",
- "test/cctest/test_platform.cc",
- "test/cctest/test_quic_cid.cc",
- "test/cctest/test_quic_error.cc",
- "test/cctest/test_quic_tokens.cc",
- "test/cctest/test_report.cc",
- "test/cctest/test_sockaddr.cc",
- "test/cctest/test_traced_value.cc",
- "test/cctest/test_util.cc",
- "test/cctest/node_test_fixture.h"
- ],
- "node_debug_lib": "false",
- "node_enable_d8": "false",
- "node_enable_v8_vtunejit": "false",
- "node_enable_v8windbg": "false",
- "node_fipsinstall": "false",
- "node_install_corepack": "true",
- "node_install_npm": "true",
- "node_library_files": [
- "lib/_http_agent.js",
- "lib/_http_client.js",
- "lib/_http_common.js",
- "lib/_http_incoming.js",
- "lib/_http_outgoing.js",
- "lib/_http_server.js",
- "lib/_stream_duplex.js",
- "lib/_stream_passthrough.js",
- "lib/_stream_readable.js",
- "lib/_stream_transform.js",
- "lib/_stream_wrap.js",
- "lib/_stream_writable.js",
- "lib/_tls_common.js",
- "lib/_tls_wrap.js",
- "lib/assert.js",
- "lib/assert/strict.js",
- "lib/async_hooks.js",
- "lib/buffer.js",
- "lib/child_process.js",
- "lib/cluster.js",
- "lib/console.js",
- "lib/constants.js",
- "lib/crypto.js",
- "lib/dgram.js",
- "lib/diagnostics_channel.js",
- "lib/dns.js",
- "lib/dns/promises.js",
- "lib/domain.js",
- "lib/events.js",
- "lib/fs.js",
- "lib/fs/promises.js",
- "lib/http.js",
- "lib/http2.js",
- "lib/https.js",
- "lib/inspector.js",
- "lib/inspector/promises.js",
- "lib/internal/abort_controller.js",
- "lib/internal/assert.js",
- "lib/internal/assert/assertion_error.js",
- "lib/internal/assert/calltracker.js",
- "lib/internal/assert/myers_diff.js",
- "lib/internal/assert/utils.js",
- "lib/internal/async_context_frame.js",
- "lib/internal/async_hooks.js",
- "lib/internal/async_local_storage/async_context_frame.js",
- "lib/internal/async_local_storage/async_hooks.js",
- "lib/internal/blob.js",
- "lib/internal/blocklist.js",
- "lib/internal/bootstrap/node.js",
- "lib/internal/bootstrap/realm.js",
- "lib/internal/bootstrap/shadow_realm.js",
- "lib/internal/bootstrap/switches/does_not_own_process_state.js",
- "lib/internal/bootstrap/switches/does_own_process_state.js",
- "lib/internal/bootstrap/switches/is_main_thread.js",
- "lib/internal/bootstrap/switches/is_not_main_thread.js",
- "lib/internal/bootstrap/web/exposed-wildcard.js",
- "lib/internal/bootstrap/web/exposed-window-or-worker.js",
- "lib/internal/buffer.js",
- "lib/internal/child_process.js",
- "lib/internal/child_process/serialization.js",
- "lib/internal/cli_table.js",
- "lib/internal/cluster/child.js",
- "lib/internal/cluster/primary.js",
- "lib/internal/cluster/round_robin_handle.js",
- "lib/internal/cluster/shared_handle.js",
- "lib/internal/cluster/utils.js",
- "lib/internal/cluster/worker.js",
- "lib/internal/console/constructor.js",
- "lib/internal/console/global.js",
- "lib/internal/constants.js",
- "lib/internal/crypto/aes.js",
- "lib/internal/crypto/certificate.js",
- "lib/internal/crypto/cfrg.js",
- "lib/internal/crypto/cipher.js",
- "lib/internal/crypto/diffiehellman.js",
- "lib/internal/crypto/ec.js",
- "lib/internal/crypto/hash.js",
- "lib/internal/crypto/hashnames.js",
- "lib/internal/crypto/hkdf.js",
- "lib/internal/crypto/keygen.js",
- "lib/internal/crypto/keys.js",
- "lib/internal/crypto/mac.js",
- "lib/internal/crypto/pbkdf2.js",
- "lib/internal/crypto/random.js",
- "lib/internal/crypto/rsa.js",
- "lib/internal/crypto/scrypt.js",
- "lib/internal/crypto/sig.js",
- "lib/internal/crypto/util.js",
- "lib/internal/crypto/webcrypto.js",
- "lib/internal/crypto/webidl.js",
- "lib/internal/crypto/x509.js",
- "lib/internal/data_url.js",
- "lib/internal/debugger/inspect.js",
- "lib/internal/debugger/inspect_client.js",
- "lib/internal/debugger/inspect_repl.js",
- "lib/internal/dgram.js",
- "lib/internal/dns/callback_resolver.js",
- "lib/internal/dns/promises.js",
- "lib/internal/dns/utils.js",
- "lib/internal/encoding.js",
- "lib/internal/error_serdes.js",
- "lib/internal/errors.js",
- "lib/internal/errors/error_source.js",
- "lib/internal/event_target.js",
- "lib/internal/events/abort_listener.js",
- "lib/internal/events/symbols.js",
- "lib/internal/file.js",
- "lib/internal/fixed_queue.js",
- "lib/internal/freelist.js",
- "lib/internal/freeze_intrinsics.js",
- "lib/internal/fs/cp/cp-sync.js",
- "lib/internal/fs/cp/cp.js",
- "lib/internal/fs/dir.js",
- "lib/internal/fs/glob.js",
- "lib/internal/fs/promises.js",
- "lib/internal/fs/read/context.js",
- "lib/internal/fs/recursive_watch.js",
- "lib/internal/fs/rimraf.js",
- "lib/internal/fs/streams.js",
- "lib/internal/fs/sync_write_stream.js",
- "lib/internal/fs/utils.js",
- "lib/internal/fs/watchers.js",
- "lib/internal/heap_utils.js",
- "lib/internal/histogram.js",
- "lib/internal/http.js",
- "lib/internal/http2/compat.js",
- "lib/internal/http2/core.js",
- "lib/internal/http2/util.js",
- "lib/internal/inspector/network.js",
- "lib/internal/inspector/network_http.js",
- "lib/internal/inspector/network_http2.js",
- "lib/internal/inspector/network_resources.js",
- "lib/internal/inspector/network_undici.js",
- "lib/internal/inspector_async_hook.js",
- "lib/internal/inspector_network_tracking.js",
- "lib/internal/js_stream_socket.js",
- "lib/internal/legacy/processbinding.js",
- "lib/internal/linkedlist.js",
- "lib/internal/main/check_syntax.js",
- "lib/internal/main/embedding.js",
- "lib/internal/main/eval_stdin.js",
- "lib/internal/main/eval_string.js",
- "lib/internal/main/inspect.js",
- "lib/internal/main/mksnapshot.js",
- "lib/internal/main/print_help.js",
- "lib/internal/main/prof_process.js",
- "lib/internal/main/repl.js",
- "lib/internal/main/run_main_module.js",
- "lib/internal/main/test_runner.js",
- "lib/internal/main/watch_mode.js",
- "lib/internal/main/worker_thread.js",
- "lib/internal/mime.js",
- "lib/internal/modules/cjs/loader.js",
- "lib/internal/modules/customization_hooks.js",
- "lib/internal/modules/esm/assert.js",
- "lib/internal/modules/esm/create_dynamic_module.js",
- "lib/internal/modules/esm/formats.js",
- "lib/internal/modules/esm/get_format.js",
- "lib/internal/modules/esm/hooks.js",
- "lib/internal/modules/esm/initialize_import_meta.js",
- "lib/internal/modules/esm/load.js",
- "lib/internal/modules/esm/loader.js",
- "lib/internal/modules/esm/module_job.js",
- "lib/internal/modules/esm/module_map.js",
- "lib/internal/modules/esm/resolve.js",
- "lib/internal/modules/esm/shared_constants.js",
- "lib/internal/modules/esm/translators.js",
- "lib/internal/modules/esm/utils.js",
- "lib/internal/modules/esm/worker.js",
- "lib/internal/modules/helpers.js",
- "lib/internal/modules/package_json_reader.js",
- "lib/internal/modules/run_main.js",
- "lib/internal/modules/typescript.js",
- "lib/internal/navigator.js",
- "lib/internal/net.js",
- "lib/internal/options.js",
- "lib/internal/per_context/domexception.js",
- "lib/internal/per_context/messageport.js",
- "lib/internal/per_context/primordials.js",
- "lib/internal/perf/event_loop_delay.js",
- "lib/internal/perf/event_loop_utilization.js",
- "lib/internal/perf/nodetiming.js",
- "lib/internal/perf/observe.js",
- "lib/internal/perf/performance.js",
- "lib/internal/perf/performance_entry.js",
- "lib/internal/perf/resource_timing.js",
- "lib/internal/perf/timerify.js",
- "lib/internal/perf/usertiming.js",
- "lib/internal/perf/utils.js",
- "lib/internal/priority_queue.js",
- "lib/internal/process/execution.js",
- "lib/internal/process/finalization.js",
- "lib/internal/process/per_thread.js",
- "lib/internal/process/permission.js",
- "lib/internal/process/pre_execution.js",
- "lib/internal/process/promises.js",
- "lib/internal/process/report.js",
- "lib/internal/process/signal.js",
- "lib/internal/process/task_queues.js",
- "lib/internal/process/warning.js",
- "lib/internal/process/worker_thread_only.js",
- "lib/internal/promise_hooks.js",
- "lib/internal/querystring.js",
- "lib/internal/quic/quic.js",
- "lib/internal/quic/state.js",
- "lib/internal/quic/stats.js",
- "lib/internal/quic/symbols.js",
- "lib/internal/readline/callbacks.js",
- "lib/internal/readline/emitKeypressEvents.js",
- "lib/internal/readline/interface.js",
- "lib/internal/readline/promises.js",
- "lib/internal/readline/utils.js",
- "lib/internal/repl.js",
- "lib/internal/repl/await.js",
- "lib/internal/repl/history.js",
- "lib/internal/repl/utils.js",
- "lib/internal/socket_list.js",
- "lib/internal/socketaddress.js",
- "lib/internal/source_map/prepare_stack_trace.js",
- "lib/internal/source_map/source_map.js",
- "lib/internal/source_map/source_map_cache.js",
- "lib/internal/source_map/source_map_cache_map.js",
- "lib/internal/stream_base_commons.js",
- "lib/internal/streams/add-abort-signal.js",
- "lib/internal/streams/compose.js",
- "lib/internal/streams/destroy.js",
- "lib/internal/streams/duplex.js",
- "lib/internal/streams/duplexify.js",
- "lib/internal/streams/duplexpair.js",
- "lib/internal/streams/end-of-stream.js",
- "lib/internal/streams/from.js",
- "lib/internal/streams/lazy_transform.js",
- "lib/internal/streams/legacy.js",
- "lib/internal/streams/operators.js",
- "lib/internal/streams/passthrough.js",
- "lib/internal/streams/pipeline.js",
- "lib/internal/streams/readable.js",
- "lib/internal/streams/state.js",
- "lib/internal/streams/transform.js",
- "lib/internal/streams/utils.js",
- "lib/internal/streams/writable.js",
- "lib/internal/test/binding.js",
- "lib/internal/test/transfer.js",
- "lib/internal/test_runner/assert.js",
- "lib/internal/test_runner/coverage.js",
- "lib/internal/test_runner/harness.js",
- "lib/internal/test_runner/mock/loader.js",
- "lib/internal/test_runner/mock/mock.js",
- "lib/internal/test_runner/mock/mock_timers.js",
- "lib/internal/test_runner/reporter/dot.js",
- "lib/internal/test_runner/reporter/junit.js",
- "lib/internal/test_runner/reporter/lcov.js",
- "lib/internal/test_runner/reporter/spec.js",
- "lib/internal/test_runner/reporter/tap.js",
- "lib/internal/test_runner/reporter/utils.js",
- "lib/internal/test_runner/reporter/v8-serializer.js",
- "lib/internal/test_runner/runner.js",
- "lib/internal/test_runner/snapshot.js",
- "lib/internal/test_runner/test.js",
- "lib/internal/test_runner/tests_stream.js",
- "lib/internal/test_runner/utils.js",
- "lib/internal/timers.js",
- "lib/internal/tls/secure-context.js",
- "lib/internal/tls/secure-pair.js",
- "lib/internal/trace_events_async_hooks.js",
- "lib/internal/tty.js",
- "lib/internal/url.js",
- "lib/internal/util.js",
- "lib/internal/util/colors.js",
- "lib/internal/util/comparisons.js",
- "lib/internal/util/debuglog.js",
- "lib/internal/util/diff.js",
- "lib/internal/util/inspect.js",
- "lib/internal/util/inspector.js",
- "lib/internal/util/parse_args/parse_args.js",
- "lib/internal/util/parse_args/utils.js",
- "lib/internal/util/trace_sigint.js",
- "lib/internal/util/types.js",
- "lib/internal/v8/startup_snapshot.js",
- "lib/internal/v8_prof_polyfill.js",
- "lib/internal/v8_prof_processor.js",
- "lib/internal/validators.js",
- "lib/internal/vm.js",
- "lib/internal/vm/module.js",
- "lib/internal/wasm_web_api.js",
- "lib/internal/watch_mode/files_watcher.js",
- "lib/internal/watchdog.js",
- "lib/internal/webidl.js",
- "lib/internal/webstorage.js",
- "lib/internal/webstreams/adapters.js",
- "lib/internal/webstreams/compression.js",
- "lib/internal/webstreams/encoding.js",
- "lib/internal/webstreams/queuingstrategies.js",
- "lib/internal/webstreams/readablestream.js",
- "lib/internal/webstreams/transfer.js",
- "lib/internal/webstreams/transformstream.js",
- "lib/internal/webstreams/util.js",
- "lib/internal/webstreams/writablestream.js",
- "lib/internal/worker.js",
- "lib/internal/worker/clone_dom_exception.js",
- "lib/internal/worker/io.js",
- "lib/internal/worker/js_transferable.js",
- "lib/internal/worker/messaging.js",
- "lib/module.js",
- "lib/net.js",
- "lib/os.js",
- "lib/path.js",
- "lib/path/posix.js",
- "lib/path/win32.js",
- "lib/perf_hooks.js",
- "lib/process.js",
- "lib/punycode.js",
- "lib/querystring.js",
- "lib/readline.js",
- "lib/readline/promises.js",
- "lib/repl.js",
- "lib/sea.js",
- "lib/sqlite.js",
- "lib/stream.js",
- "lib/stream/consumers.js",
- "lib/stream/promises.js",
- "lib/stream/web.js",
- "lib/string_decoder.js",
- "lib/sys.js",
- "lib/test.js",
- "lib/test/reporters.js",
- "lib/timers.js",
- "lib/timers/promises.js",
- "lib/tls.js",
- "lib/trace_events.js",
- "lib/tty.js",
- "lib/url.js",
- "lib/util.js",
- "lib/util/types.js",
- "lib/v8.js",
- "lib/vm.js",
- "lib/wasi.js",
- "lib/worker_threads.js",
- "lib/zlib.js"
- ],
- "node_module_version": 136,
- "node_no_browser_globals": "false",
- "node_prefix": "\\usr\\local",
- "node_release_urlbase": "",
- "node_shared": "false",
- "node_shared_ada": "false",
- "node_shared_brotli": "false",
- "node_shared_cares": "false",
- "node_shared_http_parser": "false",
- "node_shared_libuv": "false",
- "node_shared_nghttp2": "false",
- "node_shared_nghttp3": "false",
- "node_shared_ngtcp2": "false",
- "node_shared_openssl": "false",
- "node_shared_simdjson": "false",
- "node_shared_simdutf": "false",
- "node_shared_sqlite": "false",
- "node_shared_uvwasi": "false",
- "node_shared_zlib": "false",
- "node_shared_zstd": "false",
- "node_tag": "",
- "node_target_type": "executable",
- "node_use_amaro": "true",
- "node_use_bundled_v8": "true",
- "node_use_node_code_cache": "false",
- "node_use_node_snapshot": "false",
- "node_use_openssl": "true",
- "node_use_sqlite": "true",
- "node_use_v8_platform": "true",
- "node_with_ltcg": "true",
- "node_without_node_options": "false",
- "node_write_snapshot_as_array_literals": "true",
- "openssl_is_fips": "false",
- "openssl_no_asm": 1,
- "openssl_quic": "false",
- "ossfuzz": "false",
- "shlib_suffix": "so.127",
- "single_executable_application": "true",
- "suppress_all_error_on_warn": "false",
- "target_arch": "x64",
- "ubsan": 0,
- "use_ccache_win": 0,
- "use_prefix_to_find_headers": "false",
- "using_electron_config_gypi": 1,
- "v8_enable_31bit_smis_on_64bit_arch": 1,
- "v8_enable_extensible_ro_snapshot": 0,
- "v8_enable_external_code_space": 1,
- "v8_enable_gdbjit": 0,
- "v8_enable_hugepage": 0,
- "v8_enable_i18n_support": 1,
- "v8_enable_inspector": 1,
- "v8_enable_javascript_promise_hooks": 1,
- "v8_enable_lite_mode": 0,
- "v8_enable_maglev": 0,
- "v8_enable_object_print": 1,
- "v8_enable_pointer_compression": 1,
- "v8_enable_pointer_compression_shared_cage": 1,
- "v8_enable_sandbox": 1,
- "v8_enable_shared_ro_heap": 0,
- "v8_enable_webassembly": 1,
- "v8_optimized_debug": 1,
- "v8_promise_internal_field_count": 1,
- "v8_random_seed": 0,
- "v8_trace_maps": 0,
- "v8_use_siphash": 1,
- "want_separate_host_toolset": 1,
- "nodedir": "C:\\Users\\benja\\AppData\\Local\\node-gyp\\Cache\\37.10.3",
- "python": "C:\\Python314\\python.exe",
- "standalone_static_library": 1,
- "msbuild_path": "C:\\Program Files (x86)\\Microsoft Visual Studio\\18\\BuildTools\\MSBuild\\Current\\Bin\\MSBuild.exe",
- "runtime": "electron",
- "target": "37.10.3",
- "user_agent": "npm/11.13.0 node/v24.16.0 win32 x64 workspaces/false",
- "userconfig": "C:\\Users\\benja\\.npmrc",
- "prefix": "C:\\Users\\benja\\AppData\\Roaming\\npm",
- "npm_version": "11.13.0",
- "node_gyp": "C:\\Program Files\\nodejs\\node_modules\\npm\\node_modules\\node-gyp\\bin\\node-gyp.js",
- "local_prefix": "C:\\Users\\benja\\Documents\\discordult\\addon",
- "init_module": "C:\\Users\\benja\\.npm-init.js",
- "global_prefix": "C:\\Users\\benja\\AppData\\Roaming\\npm",
- "globalconfig": "C:\\Users\\benja\\AppData\\Roaming\\npm\\etc\\npmrc",
- "cache": "C:\\Users\\benja\\AppData\\Local\\npm-cache"
- }
-}
diff --git a/addon/build/pair-capture.vcxproj b/addon/build/pair-capture.vcxproj
deleted file mode 100644
index ffd0e1a..0000000
--- a/addon/build/pair-capture.vcxproj
+++ /dev/null
@@ -1,148 +0,0 @@
-
-
-
-
- Debug
- x64
-
-
- Release
- x64
-
-
-
- {139377FD-1DA0-C185-31F2-879258A3A79A}
- Win32Proj
- pair-capture
- true
- x64
- 10.0.26100.0
-
-
-
- DynamicLibrary
-
-
- v170
-
-
-
-
-
-
-
-
-
- $(ExecutablePath);$(MSBuildProjectDirectory)\..\bin\;$(MSBuildProjectDirectory)\..\bin\
- true
- $(Configuration)\obj\$(ProjectName)\
- false
- true
- $(SolutionDir)$(Configuration)\
- .node
- .node
- .node
- .node
- $(ProjectName)
- $(OutDir)\$(ProjectName).node
-
-
-
- C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\include\node;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\src;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\config;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\openssl\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\uv\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\zlib;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\v8\include;..\..\node_modules\node-addon-api;%(AdditionalIncludeDirectories)
- /Zc:__cplusplus -std:c++20 /Zm2000 %(AdditionalOptions)
- EnableFastChecks
- true
- OldStyle
- 4351;4355;4800;4251;4275;4244;4267;%(DisableSpecificWarnings)
- Sync
- false
- true
- false
- Disabled
- NotUsing
- NODE_GYP_MODULE_NAME=pair-capture;USING_UV_SHARED=1;USING_V8_SHARED=1;V8_DEPRECATION_WARNINGS=1;_GLIBCXX_USE_CXX11_ABI=1;_FILE_OFFSET_BITS=64;ELECTRON_ENSURE_CONFIG_GYPI;USING_ELECTRON_CONFIG_GYPI;V8_COMPRESS_POINTERS;V8_COMPRESS_POINTERS_IN_SHARED_CAGE;V8_31BIT_SMIS_ON_64BIT_ARCH;V8_ENABLE_SANDBOX;V8_EXTERNAL_CODE_SPACE;WIN32;_CRT_SECURE_NO_DEPRECATE;_CRT_NONSTDC_NO_DEPRECATE;_HAS_EXCEPTIONS=0;NOMINMAX;OPENSSL_NO_PINSHARED;OPENSSL_THREADS;OPENSSL_NO_ASM;BUILDING_NODE_EXTENSION;HOST_BINARY="node.exe";DEBUG;_DEBUG;%(PreprocessorDefinitions)
- MultiThreadedDebug
- true
- true
- false
- Level3
- true
-
-
- /LTCG:INCREMENTAL %(AdditionalOptions)
-
-
- kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;uuid.lib;odbc32.lib;delayimp.lib;"C:\\Users\\benja\\AppData\\Local\\node-gyp\\Cache\\37.10.3\\x64\\node.lib";ole32.lib;oleaut32.lib
- /LTCG:INCREMENTAL /ignore:4199 %(AdditionalOptions)
- node.exe;%(DelayLoadDLLs)
- true
- true
- true
- $(OutDir)$(ProjectName).node
- true
- .node
- MachineX64
-
-
- C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\include\node;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\src;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\config;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\openssl\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\uv\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\zlib;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\v8\include;..\..\node_modules\node-addon-api;%(AdditionalIncludeDirectories)
- NODE_GYP_MODULE_NAME=pair-capture;USING_UV_SHARED=1;USING_V8_SHARED=1;V8_DEPRECATION_WARNINGS=1;_GLIBCXX_USE_CXX11_ABI=1;_FILE_OFFSET_BITS=64;ELECTRON_ENSURE_CONFIG_GYPI;USING_ELECTRON_CONFIG_GYPI;V8_COMPRESS_POINTERS;V8_COMPRESS_POINTERS_IN_SHARED_CAGE;V8_31BIT_SMIS_ON_64BIT_ARCH;V8_ENABLE_SANDBOX;V8_EXTERNAL_CODE_SPACE;WIN32;_CRT_SECURE_NO_DEPRECATE;_CRT_NONSTDC_NO_DEPRECATE;_HAS_EXCEPTIONS=0;NOMINMAX;OPENSSL_NO_PINSHARED;OPENSSL_THREADS;OPENSSL_NO_ASM;BUILDING_NODE_EXTENSION;HOST_BINARY="node.exe";DEBUG;_DEBUG;%(PreprocessorDefinitions);%(PreprocessorDefinitions)
-
-
-
-
- C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\include\node;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\src;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\config;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\openssl\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\uv\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\zlib;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\v8\include;..\..\node_modules\node-addon-api;%(AdditionalIncludeDirectories)
- /Zc:__cplusplus -std:c++20 /Zm2000 %(AdditionalOptions)
- true
- OldStyle
- 4351;4355;4800;4251;4275;4244;4267;%(DisableSpecificWarnings)
- Sync
- Speed
- true
- AnySuitable
- true
- true
- true
- Full
- NotUsing
- NODE_GYP_MODULE_NAME=pair-capture;USING_UV_SHARED=1;USING_V8_SHARED=1;V8_DEPRECATION_WARNINGS=1;_GLIBCXX_USE_CXX11_ABI=1;_FILE_OFFSET_BITS=64;ELECTRON_ENSURE_CONFIG_GYPI;USING_ELECTRON_CONFIG_GYPI;V8_COMPRESS_POINTERS;V8_COMPRESS_POINTERS_IN_SHARED_CAGE;V8_31BIT_SMIS_ON_64BIT_ARCH;V8_ENABLE_SANDBOX;V8_EXTERNAL_CODE_SPACE;WIN32;_CRT_SECURE_NO_DEPRECATE;_CRT_NONSTDC_NO_DEPRECATE;_HAS_EXCEPTIONS=0;NOMINMAX;OPENSSL_NO_PINSHARED;OPENSSL_THREADS;OPENSSL_NO_ASM;BUILDING_NODE_EXTENSION;HOST_BINARY="node.exe";%(PreprocessorDefinitions)
- MultiThreaded
- false
- true
- true
- false
- Level3
- true
-
-
- /LTCG:INCREMENTAL %(AdditionalOptions)
-
-
- kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;uuid.lib;odbc32.lib;delayimp.lib;"C:\\Users\\benja\\AppData\\Local\\node-gyp\\Cache\\37.10.3\\x64\\node.lib";ole32.lib;oleaut32.lib
- /LTCG:INCREMENTAL /ignore:4199 %(AdditionalOptions)
- node.exe;%(DelayLoadDLLs)
- true
- true
- true
- $(OutDir)$(ProjectName).node
- true
- .node
- MachineX64
-
-
- C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\include\node;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\src;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\config;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\openssl\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\uv\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\zlib;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\v8\include;..\..\node_modules\node-addon-api;%(AdditionalIncludeDirectories)
- NODE_GYP_MODULE_NAME=pair-capture;USING_UV_SHARED=1;USING_V8_SHARED=1;V8_DEPRECATION_WARNINGS=1;_GLIBCXX_USE_CXX11_ABI=1;_FILE_OFFSET_BITS=64;ELECTRON_ENSURE_CONFIG_GYPI;USING_ELECTRON_CONFIG_GYPI;V8_COMPRESS_POINTERS;V8_COMPRESS_POINTERS_IN_SHARED_CAGE;V8_31BIT_SMIS_ON_64BIT_ARCH;V8_ENABLE_SANDBOX;V8_EXTERNAL_CODE_SPACE;WIN32;_CRT_SECURE_NO_DEPRECATE;_CRT_NONSTDC_NO_DEPRECATE;_HAS_EXCEPTIONS=0;NOMINMAX;OPENSSL_NO_PINSHARED;OPENSSL_THREADS;OPENSSL_NO_ASM;BUILDING_NODE_EXTENSION;HOST_BINARY="node.exe";%(PreprocessorDefinitions);%(PreprocessorDefinitions)
-
-
-
-
-
-
-
- $(IntDir)\pair-capture.obj
-
-
-
-
-
-
-
diff --git a/addon/build/pair-capture.vcxproj.filters b/addon/build/pair-capture.vcxproj.filters
deleted file mode 100644
index 0f239b0..0000000
--- a/addon/build/pair-capture.vcxproj.filters
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
- {739DB09A-CC57-A953-A6CF-F64FA08E4FA7}
-
-
- {7B735499-E5DD-1C2B-6C26-70023832A1CF}
-
-
- {E9F714C1-DA89-54E2-60CF-39FEB20BF756}
-
-
- {7F0EA4FD-B064-B82B-EA7E-4436B0159B62}
-
-
- {87566BA0-CA33-1144-65F5-087C5F9D6C20}
-
-
- {D7E4E273-4F57-58B8-AAC7-5AC8BF4576BB}
-
-
- {56DF7A98-063D-FB9D-485C-089023B4C16A}
-
-
- {77348C0E-2034-7791-74D5-63C077DF5A3B}
-
-
- {8CDEE807-BC53-E450-C8B8-4DEBB66742D4}
-
-
- {739DB09A-CC57-A953-A6CF-F64FA08E4FA7}
-
-
-
-
- ..
-
-
- C:\Users\benja\Documents\discordult\node_modules\node-gyp\src
-
-
- ..
-
-
-
diff --git a/addon/pair-capture.cc b/addon/pair-capture.cc
index b2680da..b3d289c 100644
--- a/addon/pair-capture.cc
+++ b/addon/pair-capture.cc
@@ -5,18 +5,18 @@
#include
#include
#include
-#include
-#include
#include
#include
-#include
#include
#include
#include
+#include
-// Windows process loopback lets us capture the system mix while excluding
-// Pair's process tree. This is the same class of capture Discord uses to keep
-// its own voice playback out of a stream. It needs Windows 10 build 20348+.
+// Windows process loopback captures the system mix while excluding Pair's
+// process tree, so call playback is not shared with the screen. This needs
+// Windows 10 build 20348+. Older Windows is not given an endpoint-loopback
+// fallback: that mix can still contain Pair playback (call voice and remote
+// screen audio), so the app shares video only when process exclusion fails.
struct ActivationState {
HANDLE event=nullptr;
HRESULT result=E_FAIL;
@@ -51,16 +51,7 @@ class ActivationHandler final : public IActivateAudioInterfaceCompletionHandler,
class Capture {
public:
- std::vector refRing;
- std::mutex refMutex;
- uint64_t refWritten=0;
- float estimatedGain=0.5f;
- int bestDelay=2048;
bool runningFlag=false;
- static constexpr int RING_SIZE=96000;
-
- IMMDeviceEnumerator* enumerator=nullptr;
- IMMDevice* device=nullptr;
IAudioClient* audioClient=nullptr;
IAudioCaptureClient* captureClient=nullptr;
WAVEFORMATEX* mixFormat=nullptr;
@@ -70,7 +61,7 @@ class Capture {
std::thread captureThread;
Napi::ThreadSafeFunction dataCb,errCb;
- Capture():refRing(RING_SIZE,0.0f){}
+ Capture()=default;
~Capture(){stop();cleanup();}
void start(Napi::Function dataCbFn,Napi::Function errCbFn){
@@ -97,13 +88,8 @@ class Capture {
if(errCb){errCb.Release();errCb=nullptr;}
}
- void pushRef(float* data,size_t frames){
- std::lock_guard lk(refMutex);
- for(size_t i=0;inChannels));
o.Set("bitsPerSample",Napi::Number::New(env,(double)mixFormat->wBitsPerSample));
o.Set("sampleType",mixFormat->wFormatTag==WAVE_FORMAT_IEEE_FLOAT?Napi::String::New(env,"float"):Napi::String::New(env,"pcm"));
+ o.Set("mode",Napi::String::New(env,"process"));
+ o.Set("available",Napi::Boolean::New(env,true));
return o;
}
private:
- HRESULT initWasapi(){
- HRESULT hr=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
- if(SUCCEEDED(hr))comInitialized=true;
- else if(hr!=RPC_E_CHANGED_MODE)return hr;
+ HRESULT finishClientInit(){
+ HRESULT hr=audioClient->GetBufferSize(&bufFrames);
+ if(FAILED(hr))return hr;
+ captureEvent=CreateEvent(nullptr,FALSE,FALSE,nullptr);
+ if(!captureEvent)return E_FAIL;
+ hr=audioClient->SetEventHandle(captureEvent);
+ if(FAILED(hr))return hr;
+ return audioClient->GetService(__uuidof(IAudioCaptureClient),(void**)&captureClient);
+ }
- // Capture every Windows render stream except Pair and its child processes.
- // Unlike endpoint loopback, this does not need an audio-device selection
- // and therefore cannot feed the remote caller's Pair audio back to them.
+ HRESULT initProcessLoopback(){
ActivationState state;
state.event=CreateEvent(nullptr,FALSE,FALSE,nullptr);
if(!state.event)return HRESULT_FROM_WIN32(GetLastError());
@@ -134,13 +125,10 @@ class Capture {
PROPVARIANT prop={};
prop.vt=VT_BLOB;prop.blob.cbSize=sizeof(params);prop.blob.pBlobData=(BYTE*)¶ms;
auto* handler=new ActivationHandler(&state);
- // Resolve dynamically: older SDK link libraries do not always export this
- // newer API even though supported Windows releases do. That lets one addon
- // run on both current and older Windows without a loader failure.
HMODULE audioApi=LoadLibraryW(L"mmdevapi.dll");
auto activate=audioApi?reinterpret_cast(GetProcAddress(audioApi,"ActivateAudioInterfaceAsync")):nullptr;
IActivateAudioInterfaceAsyncOperation* operation=nullptr;
- hr=activate?activate(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK,__uuidof(IAudioClient),&prop,handler,&operation):HRESULT_FROM_WIN32(ERROR_PROC_NOT_FOUND);
+ HRESULT hr=activate?activate(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK,__uuidof(IAudioClient),&prop,handler,&operation):HRESULT_FROM_WIN32(ERROR_PROC_NOT_FOUND);
if(FAILED(hr)){if(audioApi)FreeLibrary(audioApi);handler->Release();CloseHandle(state.event);return hr;}
WaitForSingleObject(state.event,INFINITE);
if(operation)operation->Release();
@@ -148,9 +136,6 @@ class Capture {
handler->Release();CloseHandle(state.event);
if(FAILED(state.result))return state.result;
audioClient=state.client;
-
- // Request a predictable PCM format. Windows converts the process mix for
- // us, which keeps the Node bridge's real-time samples simple and stable.
auto* requested=(WAVEFORMATEX*)CoTaskMemAlloc(sizeof(WAVEFORMATEX));
if(!requested)return E_OUTOFMEMORY;
ZeroMemory(requested,sizeof(WAVEFORMATEX));
@@ -159,18 +144,19 @@ class Capture {
mixFormat=requested;
hr=audioClient->Initialize(AUDCLNT_SHAREMODE_SHARED,AUDCLNT_STREAMFLAGS_LOOPBACK|AUDCLNT_STREAMFLAGS_EVENTCALLBACK|AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,0,0,mixFormat,nullptr);
if(FAILED(hr))return hr;
- hr=audioClient->GetBufferSize(&bufFrames);
- if(FAILED(hr))return hr;
- captureEvent=CreateEvent(nullptr,FALSE,FALSE,nullptr);
- if(!captureEvent)return E_FAIL;
- hr=audioClient->SetEventHandle(captureEvent);
- if(FAILED(hr))return hr;
- hr=audioClient->GetService(__uuidof(IAudioCaptureClient),(void**)&captureClient);
- return hr;
+ return finishClientInit();
+ }
+
+ HRESULT initWasapi(){
+ HRESULT hr=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
+ if(SUCCEEDED(hr))comInitialized=true;
+ else if(hr!=RPC_E_CHANGED_MODE)return hr;
+
+ // Process exclusion only. Endpoint loopback is intentionally unavailable:
+ // it cannot guarantee Pair playback stays out of the share mix.
+ return initProcessLoopback();
}
- int diagCounter=0;
- int totalProcessed=0;
void loop(){
HRESULT hr=audioClient->Start();
if(FAILED(hr)){emitErr("start failed");return;}
@@ -190,76 +176,31 @@ class Capture {
audioClient->Stop();
}
- void updateDelay(float* captured,int frames,int sr){
- if(refWritten<(uint64_t)(sr*0.05))return;
- static int counter=0;
- if(++counter%30!=0)return;
-
- int maxD=std::min((int)(sr*0.15),RING_SIZE-frames-256);
- int minD=(int)(sr*0.01);
- if(minD<256)minD=256;
- if(maxD<=minD)return;
-
- float bestCorr=0;int bestD=bestDelay;
- int n=std::min(frames,256);
- for(int d=minD;d1e-10f&&c/denom>bestCorr){bestCorr=c/denom;bestD=d;}
- }
- if(bestCorr>0.05f)bestDelay=(bestDelay*3+bestD)/4;
- }
-
- void diag(){
- diagCounter++;
- if(diagCounter%100!=0)return;
- char m[128];
- {
- std::lock_guard lk(refMutex);
- sprintf(m,"[AEC] diag: delay=%d gain=%.4f refWritten=%llu total=%d",bestDelay,estimatedGain,(unsigned long long)refWritten,totalProcessed);
- }
- errCb.NonBlockingCall([m](Napi::Env e,Napi::Function cb){cb.Call({Napi::String::New(e,m)});});
- }
-
void process(BYTE* data,UINT32 frames){
- totalProcessed+=frames;
const int ch=mixFormat&&mixFormat->nChannels>0?mixFormat->nChannels:2;
const int outCh=2;
- // Process-loopback already excludes Pair's process tree, so call playback is
- // not in this mix. Keep a full stereo pass-through for music/game audio
- // instead of collapsing to mono or running a soft canceller that can
- // smear desktop sound.
float* buf=(float*)calloc((size_t)frames*(size_t)outCh,sizeof(float));
if(!buf)return;
- if(mixFormat->wFormatTag==WAVE_FORMAT_IEEE_FLOAT){
- float* f=(float*)data;
- for(UINT32 i=0;i1?f[i*ch+1]:L;
- buf[i*outCh+0]=L;
- buf[i*outCh+1]=R;
+
+ auto sampleAt=[&](UINT32 i,int c)->float{
+ if(mixFormat->wFormatTag==WAVE_FORMAT_IEEE_FLOAT){
+ float* f=(float*)data;
+ return f[i*ch+std::min(c,ch-1)];
}
- }else if(mixFormat->wBitsPerSample==16){
- INT16* ps=(INT16*)data;
- for(UINT32 i=0;i1?ps[i*ch+1]/32768.0f:L;
- buf[i*outCh+0]=L;
- buf[i*outCh+1]=R;
+ if(mixFormat->wBitsPerSample==16){
+ INT16* ps=(INT16*)data;
+ return ps[i*ch+std::min(c,ch-1)]/32768.0f;
}
- }else{
INT32* pl=(INT32*)data;
- for(UINT32 i=0;i1?pl[i*ch+1]/2147483648.0f:L;
- buf[i*outCh+0]=L;
- buf[i*outCh+1]=R;
- }
+ return pl[i*ch+std::min(c,ch-1)]/2147483648.0f;
+ };
+
+ // Process-loopback exclusion already omits Pair's process tree.
+ for(UINT32 i=0;i1?sampleAt(i,1):L;
+ buf[i*outCh+0]=L;
+ buf[i*outCh+1]=R;
}
UINT32 fCopy=frames;
@@ -284,9 +225,8 @@ class Capture {
if(captureClient)captureClient->Release();
if(audioClient)audioClient->Release();
if(mixFormat)CoTaskMemFree(mixFormat);
- if(device)device->Release();
- if(enumerator)enumerator->Release();
if(comInitialized)CoUninitialize();
+ captureEvent=nullptr;captureClient=nullptr;audioClient=nullptr;mixFormat=nullptr;comInitialized=false;
}
};
diff --git a/app.js b/app.js
index 3bc5810..36e9e3d 100644
--- a/app.js
+++ b/app.js
@@ -297,7 +297,7 @@ function buildGifPicker(){
return wrap;
}
function giphyFetch(endpoint,type,query,offset){
- const apiKey=window._giphyKey||'LtCRMfaqI1JFzONkJJFRJ8ktT3EdOoTL';
+ const apiKey=window.pairEnv?.giphyKey||window._giphyKey||'';if(!apiKey)return Promise.resolve({data:[]});
const base=type==='stickers'?'stickers':'gifs';
const off=offset?`&offset=${offset}`:'';
if(giphyFetch._cooldown>Date.now())return Promise.resolve({data:[]});
@@ -305,14 +305,14 @@ function giphyFetch(endpoint,type,query,offset){
return fetch(url,{credentials:'omit'}).then(r=>{if(r.status===429){giphyFetch._cooldown=Date.now()+10000;console.warn('giphy 429, cooling 10s');return{data:[]}}if(!r.ok)throw new Error('GIPHY '+r.status);return r.json()}).catch(()=>({data:[]}));
}
function klipyFetch(type,query,offset){
- const key='wDEDuoSRgy4oajhdMGJ7gtS2cFBB3DtWULsUYodKIRhcXvHreSPr6eNM3nm0oWc1';
+ const key=window.pairEnv?.klipyKey||'';if(!key)return Promise.resolve({results:[]});
const params=new URLSearchParams({key,limit:'24',contentfilter:'off',locale:'en',media_filter:'gif,tinygif,webm,tinywebm'});
if(type==='stickers')params.set('searchfilter','sticker');
if(offset)params.set('page',Math.floor(offset/24)+1);
const url=query?`https://api.klipy.com/v2/search?${params}&q=${encodeURIComponent(query)}`:`https://api.klipy.com/v2/featured?${params}`;
return fetch(url,{headers:{Accept:'application/json'},credentials:'omit'}).then(r=>{if(!r.ok){console.warn('klipy err',r.status);return{results:[]}}return r.json()}).catch(e=>{console.warn('klipy fail',e.message);return{results:[]}});
}
-function klipyShare(id){try{fetch(`https://api.klipy.com/v1/registershare?key=wDEDuoSRgy4oajhdMGJ7gtS2cFBB3DtWULsUYodKIRhcXvHreSPr6eNM3nm0oWc1&id=${id}`)}catch{}}
+function klipyShare(id){const key=window.pairEnv?.klipyKey;if(!key)return;try{fetch(`https://api.klipy.com/v1/registershare?key=${encodeURIComponent(key)}&id=${id}`)}catch{}}
function giphyAnalytics(giphyId,type){
try{fetch('https://api.giphy.com/v1/analytics/action/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action_type:'SENT',action_object_type:type==='stickers'?'sticker':'gif',action_object_id:giphyId})})}catch{}
}
@@ -431,8 +431,8 @@ function fileBus(){return (streamWs&&streamWs.readyState===WebSocket.OPEN)?strea
// Direct pairing is STUN-only by default. In the desktop app, validated custom
// TURN settings are supplied by the preload bridge; browser builds retain the
// safe default rather than depending on a Node `process` global.
-const DEFAULT_ICE_SERVERS=[{urls:'stun:stun.l.google.com:19302'},{urls:'stun:stun1.l.google.com:19302'}];
-const ICE_SERVERS=Array.isArray(window.pairEnv?.iceServers)&&window.pairEnv.iceServers.length?window.pairEnv.iceServers:DEFAULT_ICE_SERVERS;
+const DEFAULT_ICE_SERVERS=(window.pairHelpers&&window.pairHelpers.DEFAULT_ICE_SERVERS)||[{urls:'stun:stun.l.google.com:19302'},{urls:'stun:stun1.l.google.com:19302'}];
+const ICE_SERVERS=(window.pairHelpers?window.pairHelpers.mergeIceServers(DEFAULT_ICE_SERVERS,window.pairEnv&&window.pairEnv.iceServers):DEFAULT_ICE_SERVERS);
function setupPeer(){
// Close previous pc and associated resources if reconnecting (e.g. peer-left → peer-ready).
// Null pc first so the old pc's onconnectionstatechange handler bails (sees !pc).
@@ -445,7 +445,54 @@ function setupPeer(){
try{oldPc.close()}catch{}
}
pc=new RTCPeerConnection({iceServers:ICE_SERVERS});pc.onicecandidate=()=>{}; let wasEverConnected=false;
- pc.onconnectionstatechange=()=>{if(!pc)return;if(pc.connectionState==='connected'){screenBtn.disabled=false;screenPreset.disabled=false;if(pc._connectTimer){clearTimeout(pc._connectTimer);pc._connectTimer=connectTimer=null} if(!wasEverConnected){wasEverConnected=true;if(reconnectCall){reconnectCall=false;if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null}callActive=false;startCall()}}else{setStatus('Connected directly',true);friendLeftNotified=false}}if(['failed','disconnected','closed'].includes(pc.connectionState)){screenBtn.disabled=true;screenPreset.disabled=true;if(pc._connectTimer){clearTimeout(pc._connectTimer);pc._connectTimer=connectTimer=null}setParticipant(participantFriend,false);setStatus(pc.connectionState);if(!friendLeftNotified){friendLeftNotified=true;playSound('leave')}};if(pc.connectionState==='connecting'){pairHint.textContent='Negotiating peer connection (ICE '+ (pc.iceConnectionState||'') +')…';armConnectTimeout()}};pc.oniceconnectionstatechange=()=>{if(pc.iceConnectionState==='failed'){pairHint.textContent='Peer connection failed (ICE '+(pc.iceConnectionState||'')+'). NAT/network blocks a direct link and the TURN relay could not be reached. Both must be on v1.0.0+, and your network must allow the TURN relay.'}else if(pc.iceConnectionState==='checking'||pc.iceConnectionState==='connected'){pairHint.textContent='Negotiating peer connection (ICE '+(pc.iceConnectionState||'')+' )…'}};pc.ondatachannel=e=>{if(e.channel.label==='chat')chat=e.channel;else files=e.channel;wire()};
+ let iceRestartTimer=null,iceRestartAttempts=0;
+ pc.onconnectionstatechange=()=>{
+ if(!pc)return;
+ if(pc.connectionState==='connected'){
+ screenBtn.disabled=false;screenPreset.disabled=false;
+ if(pc._connectTimer){clearTimeout(pc._connectTimer);pc._connectTimer=connectTimer=null}
+ if(iceRestartTimer){clearTimeout(iceRestartTimer);iceRestartTimer=null}
+ iceRestartAttempts=0;
+ if(!wasEverConnected){
+ wasEverConnected=true;
+ if(reconnectCall){reconnectCall=false;if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null}callActive=false;startCall()}
+ }else{setStatus('Connected directly',true);friendLeftNotified=false}
+ }
+ if(pc.connectionState==='disconnected'){
+ pairHint.textContent='Connection interrupted — trying to recover…';
+ if(!iceRestartTimer&&iceRestartAttempts<2){
+ iceRestartTimer=setTimeout(async()=>{
+ iceRestartTimer=null;
+ if(!pc||pc.connectionState==='connected'||pc.connectionState==='closed')return;
+ iceRestartAttempts++;
+ try{
+ const offer=await pc.createOffer({iceRestart:true});
+ if(!pc||pc.connectionState==='closed')return;
+ await pc.setLocalDescription({type:'offer',sdp:patchSdp(offer.sdp)});
+ await waitIce();
+ if(signaling)signaling.send(JSON.stringify({type:'signal',payload:{kind:'reneg-offer',sdp:pc.localDescription.sdp}}));
+ else if(chat?.readyState==='open')send({t:'reneg-offer',sdp:pc.localDescription.sdp});
+ logCallEvent('Recovering connection…');
+ }catch(e){if(window.pairHelpers)window.pairHelpers.debugLog('ice restart failed',e)}
+ },1500);
+ }
+ }
+ if(pc.connectionState==='failed'||pc.connectionState==='closed'){
+ screenBtn.disabled=true;screenPreset.disabled=true;
+ if(pc._connectTimer){clearTimeout(pc._connectTimer);pc._connectTimer=connectTimer=null}
+ if(iceRestartTimer){clearTimeout(iceRestartTimer);iceRestartTimer=null}
+ setParticipant(participantFriend,false);setStatus(pc.connectionState);
+ if(!friendLeftNotified){friendLeftNotified=true;playSound('leave')}
+ if(pc.connectionState==='failed')pairHint.textContent='Peer connection failed. Check network/NAT, or set PAIR_TURN for a relay.';
+ }
+ if(pc.connectionState==='connecting'){pairHint.textContent='Negotiating peer connection (ICE '+ (pc.iceConnectionState||'') +')…';armConnectTimeout()}
+ };
+ pc.oniceconnectionstatechange=()=>{
+ if(!pc)return;
+ if(pc.iceConnectionState==='failed'){pairHint.textContent='Peer connection failed (ICE failed). NAT/network may be blocking the link; configure PAIR_TURN if needed.'}
+ else if(pc.iceConnectionState==='checking'||pc.iceConnectionState==='connected'){pairHint.textContent='Negotiating peer connection (ICE '+(pc.iceConnectionState||'')+' )…'}
+ };
+ pc.ondatachannel=e=>{if(e.channel.label==='chat')chat=e.channel;else files=e.channel;wire()};
// If WebRTC can't establish within ~25s (e.g. TURN unreachable / blocked
// network), surface a clear message instead of hanging on "Connecting…" forever.
let connectTimer=null;pc._connectTimer=null;function armConnectTimeout(){if(connectTimer||pc.connectionState==='connected')return;connectTimer=setTimeout(()=>{pc._connectTimer=null;if(pc&&pc.connectionState!=='connected'&&pc.connectionState!=='failed'&&pc.connectionState!=='closed'){pairHint.textContent='Still connecting… if this persists, one of you is behind a strict NAT/firewall that blocks the peer connection. Try a different network or add a TURN server.'}},25000);pc._connectTimer=connectTimer}
@@ -466,9 +513,10 @@ function setupPeer(){
// Keep a reference so we can close the AudioContext on disconnect
pc._silentAudioCtx=silentCtx;
}catch(e){console.warn('Silent audio track failed, using addTransceiver:',e);try{audioTransceiver=pc.addTransceiver('audio',{direction:'sendrecv'})}catch(e2){console.warn('addTransceiver also failed:',e2);audioTransceiver=null}}
+ if(window.pairHelpers)window.pairHelpers.debugLog('setupPeer transceivers',pc.getTransceivers().length);
logCallEvent('Diag: setupPeer transceivers='+pc.getTransceivers().length+' audioTr='+(audioTransceiver?'ok:mid='+audioTransceiver.mid:'null'));
let gestureGuard=false,screenGestureGuard=false;
- pc.ontrack=e=>{logCallEvent('Diag: ontrack kind='+e.track.kind);try{const stream=e.streams[0]||new MediaStream([e.track]);
+ pc.ontrack=e=>{if(window.pairHelpers)window.pairHelpers.debugLog('ontrack',e.track.kind);logCallEvent('Diag: ontrack kind='+e.track.kind);try{const stream=e.streams[0]||new MediaStream([e.track]);
// Keep remoteScreenExpected true until screen-end so audio that arrives after
// the video track still routes to the screen element instead of the voice sink.
const screenStreamId=remoteScreen.srcObject?.id;
@@ -496,18 +544,20 @@ function setupPeer(){
}
if(e.track.kind==='audio'){logCallEvent('Audio track received from friend');if(remoteAudio.srcObject){try{remoteAudio.srcObject.getAudioTracks().forEach(t=>t.onended=null)}catch{}}if(remoteAudio.srcObject&&remoteAudio.srcObject!==stream){try{remoteAudio.srcObject.addTrack(e.track)}catch{}}else remoteAudio.srcObject=stream;e.track.onended=()=>{if(!friendLeftNotified){friendLeftNotified=true;playSound('leave')}logCallEvent('Friend left the call');callStatus.textContent='Friend left the call';callStatus.className='call-status'};if(!callActive){setRemoteCallAudio(false);return}setRemoteCallAudio(true);if(!gestureGuard){gestureGuard=true;document.addEventListener('pointerdown',()=>setRemoteCallAudio(callActive),{once:true});document.addEventListener('keydown',()=>setRemoteCallAudio(callActive),{once:true})}}else if(e.track.kind==='video'){remoteScreen.hidden=false;try{remoteScreen.srcObject=stream;remoteScreen.play()}catch{};e.track.onended=()=>{if(remoteScreen.srcObject===stream){remoteScreen.srcObject=null;remoteScreen.hidden=true}}}}catch{}};
}
-async function waitIce(){if(pc.iceGatheringState==='complete')return;await new Promise(resolve=>{let done=false;const finish=()=>{if(done)return;done=true;pc?.removeEventListener('icegatheringstatechange',f);clearTimeout(timeout);resolve()};const f=()=>{if(pc?.iceGatheringState==='complete')finish()};const timeout=setTimeout(finish,5000);pc.addEventListener('icegatheringstatechange',f)})}
-function patchOpusSdp(sdp){return sdp.replace(/a=fmtp:111[^\r\n]*/g,m=>{if(!m.includes('maxaveragebitrate'))m+='; maxaveragebitrate=256000';else m=m.replace(/maxaveragebitrate=\d+/,'maxaveragebitrate=256000');if(!m.includes('maxplaybackrate'))m+='; maxplaybackrate=48000';if(!m.includes('useinbandfec'))m+='; useinbandfec=1';if(!m.includes('usedtx'))m+='; usedtx=0';if(!m.includes('stereo'))m+='; stereo=1';else m=m.replace(/stereo=[01]/,'stereo=1');if(!m.includes('sprop-stereo'))m+='; sprop-stereo=1';else m=m.replace(/sprop-stereo=[01]/,'sprop-stereo=1');return m})}
+async function waitIce(){if(pc.iceGatheringState==='complete')return;await new Promise(resolve=>{let done=false;const finish=()=>{if(done)return;done=true;pc?.removeEventListener('icegatheringstatechange',f);clearTimeout(timeout);resolve()};const f=()=>{if(pc?.iceGatheringState==='complete')finish()};const timeout=setTimeout(finish,15000);pc.addEventListener('icegatheringstatechange',f)})}
+function patchOpusSdp(sdp){return window.pairHelpers?window.pairHelpers.patchOpusSdp(sdp,96000):sdp}
// Sender parameters are the primary limiter. This SDP fallback keeps browsers
// that ignore those parameters from silently collapsing a high-motion share to
-// the old 16 Mbps ceiling.
+// an overly low ceiling on older clients.
function patchVideoSdp(sdp){
+ const kbps=Math.round(Math.max(2,Math.min(120,screenBitrateMbps||12))*1000);
+ if(window.pairHelpers)return window.pairHelpers.patchVideoSdp(sdp,kbps);
sdp=sdp.replace(/\r\n/g,'\n');
return sdp.replace(/^m=video .*\n(?:[^m].*\n)*/gm,m=>{
let section=m;
section=section.replace(/\nb=AS:\d+/g,'');
section=section.replace(/\na=x-google-(?:min|max)-bitrate:\d+/g,'');
- return section+'a=x-google-max-bitrate:140000\n';
+ return section+'a=x-google-max-bitrate:'+kbps+'\n';
});
}
function patchSdp(sdp){return patchVideoSdp(patchOpusSdp(sdp))}
@@ -662,7 +712,7 @@ enableLocalTestControls();
async function ss(key){if(window.pairSettings){try{return await window.pairSettings.get(key)}catch{}}try{return localStorage.getItem('pair.'+key)}catch{}}
async function ssSet(key,val){if(window.pairSettings){try{await window.pairSettings.set(key,val);return}catch{}}try{if(val==null)localStorage.removeItem('pair.'+key);else localStorage.setItem('pair.'+key,val)}catch{}}
-let screenCursor='always',screenContentHint='motion',screenBitrateMbps=20,screenCodec='auto',shareResolution=1080,shareFrameRate=30;
+let screenCursor='always',screenContentHint='motion',screenBitrateMbps=12,screenCodec='auto',shareResolution=720,shareFrameRate=30;
function openSettingsTab(name){document.querySelectorAll('.settings-tab').forEach(tab=>{const active=tab.dataset.settingsTab===name;tab.classList.toggle('active',active);tab.setAttribute('aria-selected',String(active))});document.querySelectorAll('.settings-page').forEach(page=>{const active=page.dataset.settingsPage===name;page.classList.toggle('active',active);page.hidden=!active})}
function addScreenShareSettings(){
const tab=document.createElement('button');tab.type='button';tab.className='settings-tab';tab.dataset.settingsTab='screen';tab.setAttribute('role','tab');tab.setAttribute('aria-selected','false');tab.textContent='Screen sharing';
@@ -672,7 +722,7 @@ function addScreenShareSettings(){
const bitrate=$('#screenBitrateSetting'),bitrateValue=$('#screenBitrateValue'),codec=$('#screenCodecSetting'),contentHint=$('#screenContentHintSetting'),cursor=$('#screenCursorSetting');
const updateBitrate=()=>{screenBitrateMbps=Math.max(2,Math.min(120,Number(bitrate.value)||20));bitrate.style.setProperty('--range-fill',((screenBitrateMbps-2)/118*100)+'%');bitrateValue.textContent=screenBitrateMbps+' Mbps';ssSet('screenBitrate',String(screenBitrateMbps))};
bitrate.oninput=updateBitrate;enableRangeDrag(bitrate);codec.onchange=()=>{screenCodec=['auto','H264','H265','VP9','AV1','VP8'].includes(codec.value)?codec.value:'auto';ssSet('screenCodec',screenCodec)};contentHint.onchange=()=>{screenContentHint=contentHint.value==='detail'?'detail':'motion';ssSet('screenContentHint',screenContentHint)};cursor.onchange=()=>{screenCursor=['always','motion','never'].includes(cursor.value)?cursor.value:'always';ssSet('screenCursor',screenCursor)};
- return async()=>{const b=Number(await ss('screenBitrate'));screenBitrateMbps=Number.isFinite(b)&&b>=2&&b<=120?b:20;bitrate.value=String(screenBitrateMbps);bitrate.style.setProperty('--range-fill',((screenBitrateMbps-2)/118*100)+'%');bitrateValue.textContent=screenBitrateMbps+' Mbps';const v=await ss('screenCodec');screenCodec=['auto','H264','H265','VP9','AV1','VP8'].includes(v)?v:'auto';codec.value=screenCodec;const c=await ss('screenCursor');screenCursor=['always','motion','never'].includes(c)?c:'always';cursor.value=screenCursor;const h=await ss('screenContentHint');screenContentHint=h==='detail'?'detail':'motion';contentHint.value=screenContentHint};
+ return async()=>{const b=Number(await ss('screenBitrate'));screenBitrateMbps=Number.isFinite(b)&&b>=2&&b<=120?b:12;bitrate.value=String(screenBitrateMbps);bitrate.style.setProperty('--range-fill',((screenBitrateMbps-2)/118*100)+'%');bitrateValue.textContent=screenBitrateMbps+' Mbps';const v=await ss('screenCodec');screenCodec=['auto','H264','H265','VP9','AV1','VP8'].includes(v)?v:'auto';codec.value=screenCodec;const c=await ss('screenCursor');screenCursor=['always','motion','never'].includes(c)?c:'always';cursor.value=screenCursor;const h=await ss('screenContentHint');screenContentHint=h==='detail'?'detail':'motion';contentHint.value=screenContentHint};
}
const restoreScreenShareSettings=addScreenShareSettings();
document.querySelectorAll('.settings-tab').forEach(tab=>tab.onclick=()=>openSettingsTab(tab.dataset.settingsTab));
@@ -745,10 +795,22 @@ async function automaticPair(kind){
signaling.onerror=()=>pairHint.textContent='Could not reach the signaling server. Check the address and firewall.';
signaling.onmessage=async event=>{try{const message=JSON.parse(event.data);
if(message.type==='full'){pairHint.textContent='That room already has two people.';return}
+ if(message.type==='peer-left'){
+ logCallEvent('Friend left the room');
+ pairHint.textContent='Friend left the room. Waiting for them to rejoin…';
+ setParticipant(participantFriend,false);
+ if(screenActive||screenStarting||screenStream)stopScreenShare(true);
+ try{if(pc){pc.close()}}catch{}
+ if(pc&&pc._silentAudioCtx)try{pc._silentAudioCtx.close()}catch{}
+ pc=chat=files=null;audioTransceiver=null;sharedKey=null;
+ try{remoteAudio.srcObject=null}catch{};try{remoteScreen.srcObject=null}catch{};remoteScreen.hidden=true;
+ setStatus('Waiting for friend');
+ return;
+ }
if(message.type==='peer-ready'&&role==='host'){
reconnectCall=callActive;setupPeer();const kp=await keyPair();if(!pc)return;pc._kp=kp;setupChannels();
const offer=await pc.createOffer();if(!pc)return;await pc.setLocalDescription({type:'offer',sdp:patchSdp(offer.sdp)});if(!pc)return;await waitIce();if(!signaling)return;
- logCallEvent('Diag: offer has m=audio=' + (pc.localDescription.sdp.includes('m=audio')?'yes':'NO'));
+
signaling.send(JSON.stringify({type:'signal',payload:{kind:'offer',sdp:pc.localDescription.sdp,pub:await exportPub(kp.publicKey)}}));
openStreamRelay(address,room);pairHint.textContent='Offer sent. Connecting…';
// If the friend never answers (wrong role, different room, or an old build
@@ -769,20 +831,14 @@ async function automaticPair(kind){
// sender. Also update audioTransceiver to the MATCHED transceiver (with
// non-null mid) so startCall uses it — the one from addTransceiver in
// setupPeer has mid=null and would send on an un-negotiated path.
- pc.getTransceivers().filter(t=>t.receiver.track?.kind==='audio').forEach(t=>{try{if(t.direction!=='sendrecv'){t.setDirection('sendrecv');logCallEvent('Diag: set audioTr direction to sendrecv (was '+t.direction+')')}}catch(e){logCallEvent('Diag: setDirection error: '+e.message)}});
+ pc.getTransceivers().filter(t=>t.receiver.track?.kind==='audio').forEach(t=>{try{if(t.direction!=='sendrecv')t.setDirection('sendrecv')}catch{}});
const matched=pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio'&&t.mid);if(matched)audioTransceiver=matched;
- logCallEvent('Diag: before createAnswer transceivers='+pc.getTransceivers().length+' audioTr='+(pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio')?'ok:dir='+(pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio').direction):'null'));
const a=await pc.createAnswer();if(!pc)return;await pc.setLocalDescription({type:'answer',sdp:patchSdp(a.sdp)});if(!pc)return;await waitIce();if(!signaling)return;
- logCallEvent('Diag: answer has m=audio=' + (pc.localDescription.sdp.includes('m=audio')?'yes':'NO'));
signaling.send(JSON.stringify({type:'signal',payload:{kind:'answer',sdp:pc.localDescription.sdp,pub:await exportPub(kp.publicKey)}}));
openStreamRelay(address,room);pairHint.textContent='Answer sent. Connecting…'
}else if(remote.kind==='answer'&&role==='host'){
- logCallEvent('Diag: before setRD(answer) transceivers='+pc.getTransceivers().length+' audioTr='+(pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio')?'ok:dir='+(pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio').direction):'null'));
await pc.setRemoteDescription({type:'answer',sdp:remote.sdp});if(!pc)return;if(!await derive(pc._kp,remote.pub)){disconnectRoom();pairHint.textContent='Security code was not confirmed.';return}
- logCallEvent('Diag: after setRD(answer)');
- const matched=pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio'&&t.mid);if(matched)audioTransceiver=matched;
- const cd=matched?matched.currentDirection:'none';
- logCallEvent('Diag: audio currentDir='+cd);
+ const matchedHost=pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio'&&t.mid);if(matchedHost)audioTransceiver=matchedHost;
// If the friend's answer didn't include an audio sender, startCall will
// add a transceiver and renegotiate instead of relying on the unmatched one.
openStreamRelay(address,room);pairHint.textContent='Secure connection established.'
@@ -795,7 +851,7 @@ async function automaticPair(kind){
if(renegPending&&role==='join'){renegotiating++;renegPending=false}
try{if(!pc)return;await pc.setRemoteDescription({type:'offer',sdp:remote.sdp});if(!pc)return;const a=await pc.createAnswer();if(!pc)return;await pc.setLocalDescription({type:'answer',sdp:patchSdp(a.sdp)});if(!pc)return;await waitIce();if(signaling)signaling.send(JSON.stringify({type:'signal',payload:{kind:'reneg-answer',sdp:pc.localDescription.sdp}}))}catch(e){console.warn('reneg-offer error',e)}
}else if(remote.kind==='reneg-answer'){
- try{if(!pc)return;await pc.setRemoteDescription({type:'answer',sdp:remote.sdp})}catch(e){console.warn('reneg-answer error',e)}
+ try{if(!pc)return;await pc.setRemoteDescription({type:'answer',sdp:remote.sdp});clearRenegPending()}catch(e){console.warn('reneg-answer error',e)}
}
}
}catch(e){console.warn('signaling message error',e);pairHint.textContent='Connection setup failed: '+(e&&e.message||e)}};
@@ -872,11 +928,11 @@ async function startCall(){
// Resolve to the transceiver that owns it so sender.sender is correct.
const resolvedTr=tr&&tr.mid===undefined&&!tr.sender?allTransceivers.find(t=>t.sender===tr)||tr:tr;
const sender=resolvedTr?resolvedTr.sender:null;
- logCallEvent('Diag: startCall transceivers='+allTransceivers.length+' audioTr='+(tr?'ok:mid='+tr.mid+' dir='+tr.direction:'null')+' sender='+(sender?'ok':'null'));
+
if(!sender){try{send({t:'call-end'})}catch{};endCall(true);callStatus.textContent='No audio sender available';callStatus.className='call-status';return}
try{await sender.replaceTrack(track)}catch(e){try{send({t:'call-end'})}catch{};endCall(true);callStatus.textContent='Failed to attach mic: '+(e?.message||e);callStatus.className='call-status';return}
// Configure Opus for maximum quality — 510 kbps (spec limit), 48 kHz, FEC
- try{const p=sender.getParameters();if(p){if(!p.encodings||!p.encodings.length)p.encodings=[{}];p.encodings[0].maxBitrate=256000;if(p.codecs)p.codecs.forEach(c=>{if(c.mimeType.toLowerCase()==='audio/opus'){c.maxptime=120;c.ptime=20;if(c.parameters){c.parameters.maxaveragebitrate=256000;c.parameters.maxplaybackrate=48000;c.parameters.useinbandfec=1;c.parameters.usedtx=0;c.parameters.cbr=1;c.parameters.stereo=1;c.parameters['sprop-stereo']=1;c.parameters.spropmaxcapturerate=48000}}});await sender.setParameters(p)}}catch(e){console.warn('opus params:',e)}
+ try{const p=sender.getParameters();if(p){if(!p.encodings||!p.encodings.length)p.encodings=[{}];p.encodings[0].maxBitrate=96000;if(p.codecs)p.codecs.forEach(c=>{if(c.mimeType.toLowerCase()==='audio/opus'){c.maxptime=120;c.ptime=20;if(c.parameters){c.parameters.maxaveragebitrate=96000;c.parameters.maxplaybackrate=48000;c.parameters.useinbandfec=1;c.parameters.usedtx=1;c.parameters.cbr=0;c.parameters.stereo=1;c.parameters['sprop-stereo']=1;c.parameters.spropmaxcapturerate=48000}}});await sender.setParameters(p)}}catch(e){console.warn('opus params:',e)}
// endCall may have run while we were awaiting getUserMedia or replaceTrack
// (e.g. user clicked Stop Voice or the connection dropped). The generation
// counter callGen is incremented by every endCall call. If it changed, bail.
@@ -936,11 +992,8 @@ let renegotiating=0;
// we resolve it by role. The joiner defers (answers the host's offer instead of
// insisting on its own); the host wins. role is deterministic across peers.
let renegPending=false;
-// A screen share has two negotiations: video immediately, then audio once the
-// clean capture route is ready. Do not create the latter offer while the
-// former is still awaiting its answer. Chrome rejects createOffer in
-// have-local-offer, which made the audio sender local-only and resulted in a
-// silent share.
+// Do not createOffer while a previous renegotiation is still have-local-offer;
+// Chrome rejects that and can leave a new sender local-only (silent share).
function waitForStablePeer(target=pc,timeout=10000){
if(!target||target.signalingState==='closed')return Promise.resolve(false);
if(target.signalingState==='stable')return Promise.resolve(true);
@@ -955,7 +1008,7 @@ function waitForStablePeer(target=pc,timeout=10000){
});
}
async function renegotiate(){
- if(!pc||(!signaling&&chat?.readyState!=='open'))return;
+ if(!pc||(!signaling&&chat?.readyState!=='open'))return false;
const target=pc;
if(!await waitForStablePeer(target)){
console.warn('renegotiate skipped: peer did not return to stable');
@@ -974,84 +1027,123 @@ async function renegotiate(){
if(signaling)signaling.send(JSON.stringify({type:'signal',payload:{kind:'reneg-offer',sdp:pc.localDescription.sdp}}));
else if(chat?.readyState==='open')send({t:'reneg-offer',sdp:pc.localDescription.sdp});
else{renegPending=false;return false}
+ // Keep renegPending true until the remote answer arrives (or this gen is
+ // superseded) so glare handling can still detect an in-flight offer.
return true;
- }catch(e){console.warn('renegotiate error',e);return false}
- finally{if(myId===renegotiating)renegPending=false}
+ }catch(e){console.warn('renegotiate error',e);renegPending=false;return false}
}
+function clearRenegPending(){renegPending=false}
// Capture desktop sound for screen share while keeping Pair voice out of that
-// mix. Windows uses process-loopback that excludes Pair's process tree; Linux
-// uses a dedicated PipeWire sink. The voice call stays on its own WebRTC track.
+// mix. Windows uses process-loopback exclusion only; older Windows without that
+// API shares video only rather than falling back to endpoint loopback.
+// Prefer AudioWorklet over ScriptProcessor.
+async function loadShareAudioWorklet(ctx){
+ const candidates=[];
+ try{candidates.push(new URL('share-audio-worklet.js',window.location.href).href)}catch{}
+ candidates.push('share-audio-worklet.js');
+ for(const url of candidates){
+ try{await ctx.audioWorklet.addModule(url);return true}catch{}
+ try{
+ const res=await fetch(url,{cache:'force-cache'});
+ if(!res.ok)continue;
+ const text=await res.text();
+ const blobUrl=URL.createObjectURL(new Blob([text],{type:'application/javascript'}));
+ try{await ctx.audioWorklet.addModule(blobUrl);return true}
+ finally{try{URL.revokeObjectURL(blobUrl)}catch{}}
+ }catch{}
+ }
+ return false;
+}
+
async function setupNativeScreenCapture(){
if(!window.pairCapture){
console.warn('[AUDIO] isolated desktop capture unavailable; refusing full-mix loopback');
+ logCallEvent('Computer sound unavailable on this system');
return null;
}
- let ctx,dest,addonData=false,aecTimedOut=false;
+ let ctx,dest,addonData=false,captureTimedOut=false,workletNode;
try{
ctx=new AudioContext({sampleRate:48000});
if(ctx.state==='suspended'){try{await ctx.resume()}catch{}}
dest=ctx.createMediaStreamDestination();dest.channelCount=2;
- // Interleaved stereo ring: [L,R,L,R,...]
- const RS=96000;const cleanBuf=new Float32Array(RS*2);
- let wp=0,avail=0;
- const unsubClean=window.pairCapture.onCleanAudio((buf,frames)=>{
- if(aecTimedOut)return;
+ const feed=(arr,frames)=>{
+ if(captureTimedOut)return;
addonData=true;
- const arr=new Float32Array(buf);
- // Prefer interleaved stereo from the native addon. Older builds emit mono
- // (one float per frame); lift that to L/R so the share track stays stereo.
const stereo=arr.length>=frames*2;
+ const interleaved=new Float32Array(frames*2);
+ for(let i=0;i{
for(let i=0;ifeed(new Float32Array(buf),frames));
+ const unsubError=window.pairCapture.onError(msg=>{
+ if(window.pairHelpers)window.pairHelpers.debugLog('capture error',msg);
});
- const unsubError=window.pairCapture.onError(msg=>console.warn('[AUDIO] capture error:',msg));
window.pairCapture.start();
- // Attach as soon as the first clean packets arrive so share audio starts
- // with the video instead of waiting on a fixed multi-second delay.
+
+ let usedWorklet=false;
+ if(await loadShareAudioWorklet(ctx)){
+ workletNode=new AudioWorkletNode(ctx,'pair-share-audio',{numberOfInputs:0,numberOfOutputs:1,outputChannelCount:[2]});
+ workletNode.connect(dest);
+ usedWorklet=true;
+ }else{
+ if(window.pairHelpers)window.pairHelpers.debugLog('worklet unavailable; using script processor');
+ const op=ctx.createScriptProcessor(1024,0,2);
+ op.onaudioprocess=e=>{
+ const L=e.outputBuffer.getChannelData(0);
+ const R=e.outputBuffer.getChannelData(1);
+ if(availsetTimeout(r,40));
if(!addonData){
console.warn('[AUDIO] isolated desktop capture produced no samples; sharing video only');
- aecTimedOut=true;
+ logCallEvent('Computer sound unavailable — sharing video only');
+ captureTimedOut=true;
if(unsubClean)unsubClean();if(unsubError)unsubError();
window.pairCapture.stop();
+ try{if(workletNode)workletNode.disconnect()}catch{}
+ try{if(dest._pairScript)dest._pairScript.disconnect()}catch{}
if(ctx)try{ctx.close()}catch{}
return null;
}
- const B=1024;
- const op=ctx.createScriptProcessor(B,0,2);
- op.onaudioprocess=e=>{
- const L=e.outputBuffer.getChannelData(0);
- const R=e.outputBuffer.getChannelData(1);
- if(avail{if(unsubClean)unsubClean();if(unsubError)unsubError();window.pairCapture.stop();try{op.disconnect()}catch{}};
+ screenCaptureCleanup=()=>{
+ if(unsubClean)unsubClean();if(unsubError)unsubError();
+ window.pairCapture.stop();
+ try{if(workletNode)workletNode.disconnect()}catch{}
+ try{if(dest._pairScript)dest._pairScript.disconnect()}catch{}
+ };
+ if(window.pairHelpers)window.pairHelpers.debugLog('share audio ready','process',usedWorklet?'worklet':'script');
return t||null;
}catch(e){
console.warn('[AUDIO] isolated desktop capture failed:',e?.message||e);
+ logCallEvent('Computer sound failed to start');
if(ctx)try{ctx.close()}catch{}
try{window.pairCapture.stop()}catch{}
return null;
@@ -1063,19 +1155,45 @@ function cleanupNativeScreenCapture(){
if(screenOutDest){screenOutDest=null}
if(screenOutCtx){try{screenOutCtx.close()}catch{};screenOutCtx=null}
}
-async function linuxShareAudioTrack(){
- const share=await window.pairEnv?.startLinuxShareAudio?.();if(!share)return null;
+
+async function linuxShareAudioTrack(expectedGen){
+ const aborted=()=>expectedGen!=null&&expectedGen!==screenGen;
+ let sinkStarted=false;
+ const discardSink=()=>{if(!sinkStarted)return;sinkStarted=false;try{window.pairEnv?.stopLinuxShareAudio?.()}catch{}};
+ // Electron can redact input labels until getUserMedia has been granted once.
+ // Probe and stop before creating the PipeWire share sink so enumerateDevices
+ // can see “Pair Share Audio” on a fresh profile or share-without-call.
+ try{
+ const permissionProbe=await navigator.mediaDevices.getUserMedia({audio:true});
+ permissionProbe.getTracks().forEach(t=>t.stop());
+ }catch(e){console.warn('[AUDIO] unable to reveal PipeWire inputs:',e?.message||e)}
+ if(aborted())return null;
+ const share=await window.pairEnv?.startLinuxShareAudio?.();
+ if(!share)return null;
+ sinkStarted=true;
+ if(aborted()){discardSink();return null}
const label=typeof share==='string'?share:share.label||'Pair Share Audio';
// PipeWire may expose this as “Monitor of Pair Share Audio”, so match both
// the friendly label and the stable Pair/Share words rather than one exact
// desktop-specific spelling.
- // Electron can redact input labels until getUserMedia has been granted once.
- // Open and immediately stop the default input only to establish that grant;
- // it is never added to the share. Without this, the Pair Share monitor is
- // present but impossible to select on a first share or a fresh profile.
- try{const permissionProbe=await navigator.mediaDevices.getUserMedia({audio:true});permissionProbe.getTracks().forEach(t=>t.stop())}catch(e){console.warn('[AUDIO] unable to reveal PipeWire inputs:',e?.message||e)}
- let inputs=0,lastError='';for(let attempt=0;attempt<80;attempt++){const devices=await navigator.mediaDevices.enumerateDevices();inputs=devices.filter(d=>d.kind==='audioinput').length;const device=devices.find(d=>d.kind==='audioinput'&&(d.label.includes(label)||(/pair/i.test(d.label)&&/share/i.test(d.label))));if(device){try{const stream=await navigator.mediaDevices.getUserMedia({audio:{deviceId:{exact:device.deviceId},channelCount:{ideal:2},sampleRate:{ideal:48000},echoCancellation:false,noiseSuppression:false,autoGainControl:false}});const track=stream.getAudioTracks()[0]||null;if(track){track.enabled=true;return track}}catch(e){lastError=String(e?.name||e?.message||'capture rejected');console.warn('[AUDIO] Pair Share Audio device failed:',e)}}await new Promise(resolve=>setTimeout(resolve,150))}
- screenAudioDebug=' · monitor unavailable ('+inputs+' inputs'+(lastError?', '+lastError:'')+')';console.warn('[AUDIO] Pair Share Audio monitor was not exposed by PipeWire');window.pairEnv?.stopLinuxShareAudio?.();return null;
+ for(let attempt=0;attempt<24;attempt++){
+ if(aborted()){discardSink();return null}
+ const devices=await navigator.mediaDevices.enumerateDevices();
+ if(aborted()){discardSink();return null}
+ const device=devices.find(d=>d.kind==='audioinput'&&(d.label.includes(label)||(/pair/i.test(d.label)&&/share/i.test(d.label))));
+ if(device){
+ try{
+ const stream=await navigator.mediaDevices.getUserMedia({audio:{deviceId:{exact:device.deviceId},echoCancellation:false,noiseSuppression:false,autoGainControl:false}});
+ if(aborted()){stream.getTracks().forEach(t=>t.stop());discardSink();return null}
+ const track=stream.getAudioTracks()[0]||null;
+ if(track){track.enabled=true;return track}
+ }catch(e){console.warn('[AUDIO] Pair Share Audio device failed:',e)}
+ }
+ await new Promise(resolve=>setTimeout(resolve,150));
+ }
+ console.warn('[AUDIO] Pair Share Audio monitor was not exposed by PipeWire');
+ discardSink();
+ return null;
}
function startScreenStats(sender){
if(screenStatsTimer)clearInterval(screenStatsTimer);screenStatsLast=null;
@@ -1121,84 +1239,103 @@ async function startScreenShare(){
// Desktop capture defaults to text/detail on some Chromium builds. Motion
// tells the encoder to preserve changing game/action content instead.
try{track.contentHint=screenContentHint}catch{}
- // Add the video track
+ // Add the video track, prepare computer sound (if enabled), then renegotiate
+ // once so share audio and video land together instead of racing two offers.
let sender;
try{sender=pc.addTrack(track,stream);screenSenders=[sender]}catch{stream.getTracks().forEach(t=>t.stop());return}
- // Audio attachment can take a moment while native capture warms up. Start
- // video first, then renegotiate again only if a clean audio track is ready.
- const attachShareAudio=async()=>{
- if(!screenAudioOn)return;
+ let audioReady=false;
+ if(screenAudioOn){
+ screenStatus.textContent='Sharing… preparing sound';
let audioTrack=null;
if(window.pairEnv?.platform==='linux'){
- audioTrack=await linuxShareAudioTrack();
+ audioTrack=await linuxShareAudioTrack(gen);
}else{
- // Drop any Chromium loopback that may have been granted unexpectedly.
try{stream.getAudioTracks().forEach(t=>{try{t.stop()}catch{};try{stream.removeTrack(t)}catch{}})}catch{}
try{audioTrack=await setupNativeScreenCapture()}catch(e){
console.warn('[AUDIO] clean capture failed:',e?.message||e);
audioTrack=null;
}
}
- if(!audioTrack){
- console.warn('[AUDIO] computer sound unavailable without echo risk; sharing video only');
- if(gen===screenGen&&screenActive){
- screenAudioDebug=' · sound unavailable';screenStatus.textContent=(screenStatus.textContent||'Sharing')+screenAudioDebug;
- logCallEvent('Computer sound unavailable — sharing video only');
- }
- return;
- }
const discardShareAudio=()=>{
try{audioTrack?.stop()}catch{}
- try{stream.removeTrack(audioTrack)}catch{}
+ try{if(audioTrack)stream.removeTrack(audioTrack)}catch{}
// A canceled/failed Linux attach must restore the desktop default sink
// immediately; otherwise a late async result can leave audio routed to
// an orphaned Pair Share sink after the user has already stopped.
if(window.pairEnv?.platform==='linux')try{window.pairEnv.stopLinuxShareAudio?.()}catch{}
else cleanupNativeScreenCapture();
};
- if(gen!==screenGen||!screenActive||!pc){discardShareAudio();return}
- try{
- audioTrack.enabled=true;
- try{audioTrack.contentHint='music'}catch{}
- try{stream.addTrack(audioTrack)}catch{}
- const audioSender=pc.addTrack(audioTrack,stream);
- screenSenders.push(audioSender);
- try{
- const p=audioSender.getParameters();
- if(p){
- if(!p.encodings||!p.encodings.length)p.encodings=[{}];
- // Prefer higher bitrate stereo for game/music desktop sound; voice
- // remains on the separate call track at its own Opus settings.
- p.encodings[0].maxBitrate=192000;
- await audioSender.setParameters(p);
- }
- }catch{}
- if(!await renegotiate())throw new Error('audio negotiation did not start');
- if(gen!==screenGen||!screenActive||!pc)throw new Error('screen share ended during audio negotiation');
- logCallEvent('Computer sound sharing started');screenAudioDebug=' · sound live';screenStatus.textContent='Sharing'+screenAudioDebug;
- }catch(e){
- console.warn('[AUDIO] addTrack failed:',e);if(gen===screenGen&&screenActive){screenAudioDebug=' · sound failed';screenStatus.textContent='Sharing'+screenAudioDebug}
- const sender=screenSenders.find(s=>s.track===audioTrack);
- if(sender){try{pc?.removeTrack(sender)}catch{};screenSenders=screenSenders.filter(s=>s!==sender)}
+ if(gen!==screenGen||!pc){
discardShareAudio();
+ screenSenders.forEach(s=>{try{pc.removeTrack(s)}catch{}});screenSenders=[];
+ stream.getTracks().forEach(t=>t.stop());
+ return;
}
- };
+ if(audioTrack){
+ try{
+ audioTrack.enabled=true;
+ try{audioTrack.contentHint='music'}catch{}
+ try{stream.addTrack(audioTrack)}catch{}
+ const audioSender=pc.addTrack(audioTrack,stream);
+ screenSenders.push(audioSender);
+ try{
+ const p=audioSender.getParameters();
+ if(p){
+ if(!p.encodings||!p.encodings.length)p.encodings=[{}];
+ p.encodings[0].maxBitrate=192000;
+ await audioSender.setParameters(p);
+ }
+ }catch{}
+ audioReady=true;
+ }catch(e){
+ console.warn('[AUDIO] addTrack failed:',e);
+ const senderGone=screenSenders.find(s=>s.track===audioTrack);
+ if(senderGone){try{pc?.removeTrack(senderGone)}catch{};screenSenders=screenSenders.filter(s=>s!==senderGone)}
+ discardShareAudio();
+ }
+ }else{
+ console.warn('[AUDIO] computer sound unavailable without echo risk; sharing video only');
+ logCallEvent('Computer sound unavailable — sharing video only');
+ }
+ }
// Prefer codecs that are normally hardware accelerated. AV1 is excellent at
// low bitrates but its software encoder is a frequent source of high CPU and
// seconds of latency during desktop capture, so it stays a last fallback.
- try{const tr=pc.getTransceivers().find(t=>t.sender===sender);if(tr){const caps=RTCRtpSender.getCapabilities('video');if(caps){const names=screenCodec==='auto'?['AV1','H265','H264','VP9','VP8']:[screenCodec,'H264','VP9','VP8'];const cs=names.map(name=>caps.codecs.find(c=>c.mimeType===`video/${name}`)).filter(Boolean);if(cs.length)tr.setCodecPreferences(cs)}}}catch(e){console.warn('[VIDEO] codec pref err:',e)}
- try{const p=sender.getParameters();if(p){if(!p.encodings||!p.encodings.length)p.encodings=[{}];const maxBitrate=Math.round(Math.max(2,Math.min(120,screenBitrateMbps))*1000000);p.encodings[0].maxBitrate=maxBitrate;p.encodings[0].maxFramerate=fps;p.degradationPreference=screenContentHint==='detail'?'maintain-resolution':'maintain-framerate';await sender.setParameters(p);console.log('[VIDEO] bitrate='+(maxBitrate/1e6)+'Mbps '+fps+'fps '+p.degradationPreference)}else console.warn('[VIDEO] no params')}catch(e){console.warn('[VIDEO] setParams err:',e)}
- if(gen!==screenGen||!pc){screenSenders.forEach(s=>{try{pc.removeTrack(s)}catch{}});screenSenders=[];stream.getTracks().forEach(t=>t.stop());return}
+ try{const tr=pc.getTransceivers().find(t=>t.sender===sender);if(tr){const caps=RTCRtpSender.getCapabilities('video');if(caps){const names=window.pairHelpers?window.pairHelpers.preferredVideoCodecs(screenCodec):(screenCodec==='auto'?['H264','VP9','VP8','H265','AV1']:[screenCodec,'H264','VP9','VP8']);const cs=names.map(name=>caps.codecs.find(c=>c.mimeType===`video/${name}`)).filter(Boolean);if(cs.length)tr.setCodecPreferences(cs)}}}catch(e){console.warn('[VIDEO] codec pref err:',e)}
+ try{const p=sender.getParameters();if(p){if(!p.encodings||!p.encodings.length)p.encodings=[{}];const preset=screenPreset.value;const maxBitrate=Math.round(Math.max(2,Math.min(120,screenBitrateMbps))*1000000);p.encodings[0].maxBitrate=maxBitrate;p.encodings[0].maxFramerate=SCREEN_PRESETS[preset]?.frameRate?.max||30;p.degradationPreference=screenContentHint==='detail'?'maintain-resolution':'maintain-framerate';await sender.setParameters(p);if(window.pairHelpers)window.pairHelpers.debugLog('video bitrate',maxBitrate,p.degradationPreference)}else console.warn('[VIDEO] no params')}catch(e){console.warn('[VIDEO] setParams err:',e)}
+ if(gen!==screenGen||!pc){
+ screenSenders.forEach(s=>{try{pc.removeTrack(s)}catch{}});screenSenders=[];
+ stream.getTracks().forEach(t=>t.stop());
+ if(window.pairEnv?.platform==='linux')try{window.pairEnv.stopLinuxShareAudio?.()}catch{}
+ cleanupNativeScreenCapture();
+ return;
+ }
screenActive=true;screenAudioDebug=screenAudioOn?' · starting sound capture':' · sound off';
screenPreview.muted=true;
screenPreview.srcObject=stream;screenPreview.hidden=false;try{screenPreview.play()}catch{}
- screenBtn.textContent='Stop sharing';screenBtn.title='Stop screen sharing';screenStatus.textContent='Sharing';
+ screenBtn.textContent='Stop sharing';screenBtn.title='Stop screen sharing';
startScreenStats(sender);
try{send({t:'screen-start'})}catch{};
- logCallEvent('You started screen sharing');
track.onended=()=>{if(screenActive)stopScreenShare()};
- await renegotiate();if(gen!==screenGen)return;void attachShareAudio();
- }catch(e){screenStatus.textContent='Share failed';if(e.name!=='NotAllowedError')logCallEvent('Screen share error')}
+ // Wait until signaling is stable inside renegotiate before createOffer.
+ // Only claim computer-sound success when that renegotiation actually starts.
+ const renegOk=await renegotiate();
+ if(gen!==screenGen)return;
+ const soundLive=audioReady&&renegOk;
+ screenStatus.textContent=soundLive?'Sharing':'Sharing · video only';
+ if(audioReady&&!renegOk){
+ console.warn('[AUDIO] share audio renegotiation did not start; sharing video only');
+ logCallEvent('You started screen sharing');
+ logCallEvent('Computer sound negotiation failed — sharing video only');
+ }else{
+ logCallEvent(soundLive?'You started screen sharing with computer sound':'You started screen sharing');
+ }
+ }catch(e){
+ screenStatus.textContent='Share failed';
+ if(window.pairEnv?.platform==='linux')try{window.pairEnv.stopLinuxShareAudio?.()}catch{}
+ cleanupNativeScreenCapture();
+ if(e.name!=='NotAllowedError')logCallEvent('Screen share error');
+ }
}finally{if(gen===screenGen)screenStarting=false}
}
async function stopScreenShare(fromEnd){
diff --git a/app_ids.txt b/app_ids.txt
deleted file mode 100644
index e69de29..0000000
diff --git a/build.log b/build.log
deleted file mode 100644
index a31d7ff..0000000
Binary files a/build.log and /dev/null differ
diff --git a/build10.log b/build10.log
deleted file mode 100644
index 5ce66c2..0000000
Binary files a/build10.log and /dev/null differ
diff --git a/build11.log b/build11.log
deleted file mode 100644
index 1699fe2..0000000
Binary files a/build11.log and /dev/null differ
diff --git a/build12.log b/build12.log
deleted file mode 100644
index a7a4c46..0000000
Binary files a/build12.log and /dev/null differ
diff --git a/build13.log b/build13.log
deleted file mode 100644
index 2a3fb56..0000000
Binary files a/build13.log and /dev/null differ
diff --git a/build2.log b/build2.log
deleted file mode 100644
index 9223005..0000000
Binary files a/build2.log and /dev/null differ
diff --git a/build3.log b/build3.log
deleted file mode 100644
index 89c45ea..0000000
Binary files a/build3.log and /dev/null differ
diff --git a/build4.log b/build4.log
deleted file mode 100644
index 8930566..0000000
Binary files a/build4.log and /dev/null differ
diff --git a/build5.log b/build5.log
deleted file mode 100644
index 30bd06f..0000000
Binary files a/build5.log and /dev/null differ
diff --git a/build6.log b/build6.log
deleted file mode 100644
index 1ad2866..0000000
Binary files a/build6.log and /dev/null differ
diff --git a/build7.log b/build7.log
deleted file mode 100644
index ef5f93d..0000000
Binary files a/build7.log and /dev/null differ
diff --git a/build8.log b/build8.log
deleted file mode 100644
index f872765..0000000
Binary files a/build8.log and /dev/null differ
diff --git a/build9.log b/build9.log
deleted file mode 100644
index 5ce66c2..0000000
Binary files a/build9.log and /dev/null differ
diff --git a/html_ids.txt b/html_ids.txt
deleted file mode 100644
index 6b4b910..0000000
Binary files a/html_ids.txt and /dev/null differ
diff --git a/index.html b/index.html
index 382ffa8..1129d12 100644
--- a/index.html
+++ b/index.html
@@ -136,6 +136,7 @@ Pair.
+