From c0f626362d112d2b2daa2b7cc3c0259573da4529 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 16:08:08 -0600 Subject: [PATCH 01/27] chore: update deps and dedupe --- package.json | 2 +- pnpm-lock.yaml | 187 +++++++++++++++++-------------------------------- 2 files changed, 65 insertions(+), 124 deletions(-) diff --git a/package.json b/package.json index f9e2231c8..de522378b 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,7 @@ "vitest-axe": "1.0.0-pre.5", "zustand": "^5.0.14" }, - "packageManager": "pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "engines": { "node": ">=22.13.0", "pnpm": ">=11.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5f4a21e0..02dce5001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -488,15 +488,6 @@ packages: engines: {node: '>=14.14'} hasBin: true - '@emnapi/core@2.0.0-alpha.3': - resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} - - '@emnapi/runtime@2.0.0-alpha.3': - resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} - - '@emnapi/wasi-threads@2.0.1': - resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -772,13 +763,6 @@ packages: resolution: {integrity: sha512-vdBbYEKw8mzE7y5br0OpfwjBAZIowsrPOIbh8a7KnL+eSQDH6jw4yy11JzAZZW0km6l4Ntmg8fvsZwEzAVTdHA==} hasBin: true - '@napi-rs/wasm-runtime@1.2.2': - resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - '@noble/curves@1.9.7': resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} @@ -887,96 +871,92 @@ packages: engines: {node: ^v12.20.0 || ^14.13.0 || >=16.0.0} hasBin: true - '@rolldown/binding-android-arm64@1.2.1': - resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.1': - resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.1': - resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.1': - resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.1': - resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.1': - resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.1': - resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.1': - resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.1': - resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.1': - resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.1': - resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.1': - resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.2.1': - resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - - '@rolldown/binding-win32-arm64-msvc@1.2.1': - resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.1': - resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1263,9 +1243,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -3458,8 +3435,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4013,8 +3990,8 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} - rolldown@1.2.1: - resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5063,22 +5040,6 @@ snapshots: - supports-color optional: true - '@emnapi/core@2.0.0-alpha.3': - dependencies: - '@emnapi/wasi-threads': 2.0.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@2.0.0-alpha.3': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@2.0.1': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true @@ -5296,13 +5257,6 @@ snapshots: commander: 12.1.0 crypto-js: 4.2.0 - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': - dependencies: - '@emnapi/core': 2.0.0-alpha.3 - '@emnapi/runtime': 2.0.0-alpha.3 - '@tybys/wasm-util': 0.10.3 - optional: true - '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 @@ -5401,53 +5355,46 @@ snapshots: '@reteps/dockerfmt-linux-arm64': 0.5.4 '@reteps/dockerfmt-linux-x64': 0.5.4 - '@rolldown/binding-android-arm64@1.2.1': + '@rolldown/binding-android-arm64@1.2.2': optional: true - '@rolldown/binding-darwin-arm64@1.2.1': + '@rolldown/binding-darwin-arm64@1.2.2': optional: true - '@rolldown/binding-darwin-x64@1.2.1': + '@rolldown/binding-darwin-x64@1.2.2': optional: true - '@rolldown/binding-freebsd-x64@1.2.1': + '@rolldown/binding-freebsd-x64@1.2.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.1': + '@rolldown/binding-linux-arm64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.1': + '@rolldown/binding-linux-arm64-musl@1.2.2': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.1': + '@rolldown/binding-linux-ppc64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.1': + '@rolldown/binding-linux-s390x-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.1': + '@rolldown/binding-linux-x64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-musl@1.2.1': + '@rolldown/binding-linux-x64-musl@1.2.2': optional: true - '@rolldown/binding-openharmony-arm64@1.2.1': + '@rolldown/binding-openharmony-arm64@1.2.2': optional: true - '@rolldown/binding-wasm32-wasi@1.2.1': - dependencies: - '@emnapi/core': 2.0.0-alpha.3 - '@emnapi/runtime': 2.0.0-alpha.3 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.2.1': + '@rolldown/binding-win32-arm64-msvc@1.2.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.1': + '@rolldown/binding-win32-x64-msvc@1.2.2': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -5708,11 +5655,6 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@types/aria-query@5.0.4': {} '@types/cacheable-request@6.0.3': @@ -8264,7 +8206,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.17: {} natural-compare@1.4.0: {} @@ -8523,7 +8465,7 @@ snapshots: postcss@8.5.25: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8805,26 +8747,25 @@ snapshots: sprintf-js: 1.1.3 optional: true - rolldown@1.2.1: + rolldown@1.2.2: dependencies: '@oxc-project/types': 0.142.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.2.1 - '@rolldown/binding-darwin-arm64': 1.2.1 - '@rolldown/binding-darwin-x64': 1.2.1 - '@rolldown/binding-freebsd-x64': 1.2.1 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 - '@rolldown/binding-linux-arm64-gnu': 1.2.1 - '@rolldown/binding-linux-arm64-musl': 1.2.1 - '@rolldown/binding-linux-ppc64-gnu': 1.2.1 - '@rolldown/binding-linux-s390x-gnu': 1.2.1 - '@rolldown/binding-linux-x64-gnu': 1.2.1 - '@rolldown/binding-linux-x64-musl': 1.2.1 - '@rolldown/binding-openharmony-arm64': 1.2.1 - '@rolldown/binding-wasm32-wasi': 1.2.1 - '@rolldown/binding-win32-arm64-msvc': 1.2.1 - '@rolldown/binding-win32-x64-msvc': 1.2.1 + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 run-parallel@1.2.0: dependencies: @@ -9428,7 +9369,7 @@ snapshots: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.25 - rolldown: 1.2.1 + rolldown: 1.2.2 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.5 From 9cda43eef8cd2c7f90a832fdc931f3227c65ed56 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 16:44:33 -0600 Subject: [PATCH 02/27] feat(reticulum): integrate rsLXST voice calls Replace the sidecar voice stub with TelephonyService, Peers/Chat Call UI, and renderer Web Audio PCM; raise pnpm floors for undici, postcss, and ip-address. --- AGENTS.md | 2 +- docs/reticulum-sidecar-ipc.md | 30 +- docs/reticulum.md | 7 +- electron-builder.yml | 2 +- pnpm-lock.yaml | 9 +- pnpm-workspace.yaml | 14 +- reticulum-sidecar/Cargo.lock | 46 ++ reticulum-sidecar/Cargo.toml | 15 +- reticulum-sidecar/src/api/mod.rs | 9 +- reticulum-sidecar/src/api/system.rs | 4 - reticulum-sidecar/src/api/voice.rs | 80 ++ .../src/stack/announce_ws_coalesce.rs | 5 + reticulum-sidecar/src/stack/live.rs | 51 +- reticulum-sidecar/src/stack/mod.rs | 72 +- reticulum-sidecar/src/stack/voice_session.rs | 688 ++++++++++++++++++ scripts/clone-ratspeak-stack.sh | 18 +- scripts/clone-ratspeak-stack.test.mjs | 7 + scripts/update.sh | 5 +- scripts/update.test.mjs | 2 +- src/preload/index.ts | 18 + src/renderer/App.tsx | 4 + src/renderer/components/ChatPanel.tsx | 18 +- .../ReticulumPeerListPanel.test.tsx | 1 + .../components/ReticulumPeerListPanel.tsx | 7 + .../ReticulumVoiceCallButton.test.tsx | 30 + .../reticulum/ReticulumVoiceCallButton.tsx | 34 + .../reticulum/ReticulumVoiceOverlay.test.tsx | 88 +++ .../reticulum/ReticulumVoiceOverlay.tsx | 122 ++++ src/renderer/lib/radio/BaseRadioProvider.ts | 5 + .../lib/radio/protocol-capabilities.test.ts | 4 + .../reticulum/clearReticulumSessionStores.ts | 4 + src/renderer/lib/reticulumVoiceAudio.test.ts | 56 ++ src/renderer/lib/reticulumVoiceAudio.ts | 99 +++ src/renderer/lib/reticulumVoiceSession.ts | 233 ++++++ src/renderer/locales/cs/translation.json | 19 + src/renderer/locales/de/translation.json | 19 + src/renderer/locales/en/translation.json | 19 + src/renderer/locales/es/translation.json | 19 + src/renderer/locales/fr/translation.json | 19 + src/renderer/locales/id/translation.json | 19 + src/renderer/locales/it/translation.json | 19 + src/renderer/locales/ja/translation.json | 19 + src/renderer/locales/ko/translation.json | 19 + src/renderer/locales/nl/translation.json | 19 + src/renderer/locales/pl/translation.json | 19 + src/renderer/locales/pt-BR/translation.json | 19 + src/renderer/locales/ru/translation.json | 19 + src/renderer/locales/tr/translation.json | 19 + src/renderer/locales/uk/translation.json | 19 + src/renderer/locales/zh/translation.json | 19 + src/renderer/runtime/useReticulumRuntime.ts | 24 + .../runtime/useReticulumRuntime.voice.test.ts | 25 + .../stores/reticulumVoiceStore.test.ts | 66 ++ src/renderer/stores/reticulumVoiceStore.ts | 153 ++++ src/renderer/vitest.electronApiMock.ts | 16 + src/shared/electron-api.types.ts | 17 + src/shared/voice-types.test.ts | 37 + src/shared/voice-types.ts | 72 ++ 58 files changed, 2461 insertions(+), 42 deletions(-) create mode 100644 reticulum-sidecar/src/api/voice.rs create mode 100644 reticulum-sidecar/src/stack/voice_session.rs create mode 100644 src/renderer/components/reticulum/ReticulumVoiceCallButton.test.tsx create mode 100644 src/renderer/components/reticulum/ReticulumVoiceCallButton.tsx create mode 100644 src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx create mode 100644 src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx create mode 100644 src/renderer/lib/reticulumVoiceAudio.test.ts create mode 100644 src/renderer/lib/reticulumVoiceAudio.ts create mode 100644 src/renderer/lib/reticulumVoiceSession.ts create mode 100644 src/renderer/runtime/useReticulumRuntime.voice.test.ts create mode 100644 src/renderer/stores/reticulumVoiceStore.test.ts create mode 100644 src/renderer/stores/reticulumVoiceStore.ts create mode 100644 src/shared/voice-types.test.ts create mode 100644 src/shared/voice-types.ts diff --git a/AGENTS.md b/AGENTS.md index 181e6a9bb..26d3cdc9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Adding a cross-boundary feature: **Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. -> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched releases for rsLXST / lrgp-rs / Ratspeak / LXMFace, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF`); overlays must apply or the clone fails. Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). +> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched releases for rsLXST / lrgp-rs / Ratspeak / LXMFace, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF`); overlays must apply or the clone fails. Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). **Pre-commit hook order:** diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 3cc515e3b..ce4a6ada2 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -221,16 +221,22 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi ### System -| Method | Path | Body / notes | Response | -| ------ | ------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GET | `/api/v1/diagnostics` | | Reticulum-native health snapshot — includes `announce_ws` coalesce pressure (`last_window_ingress` / `unique` / `overflow`, `last_storm_at_ms`, `last_flush_at_ms`). Renderer Diagnostics emits `reticulum/announce-bus-pressure` when lag/storm/overflow is recent. | -| POST | `/api/v1/system/factory-reset` | | `{ ok }` — Electron UI must call `electronAPI.reticulum.factoryReset` (generic `proxyPost` blocks this path) | -| GET | `/api/v1/voice/status` | | LXST stub status | -| GET | `/api/v1/games/status` | | LRGP stub status | -| GET | `/api/v1/identities` | | `{ identities: […] }` — slots under `config/identities//` + `active_identity`; working key remains `config/identity`; flat identity migrates to `identities/default/` | -| POST | `/api/v1/identities` | `{ display_name? }` | `{ ok, id, identity }` or `{ ok: false, error }` — `rns-stack` only; stage slot → apply working key → commit pointer last (rollback on failure). Cap 16 slots. Errors: `identity_slot_limit_reached`, `display_name_*`. Emits restart. | -| POST | `/api/v1/identities/switch` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — stash, install target → working, reconcile, then commit pointer. Errors: `identity_slot_not_configured`, `identity_not_found` | -| POST | `/api/v1/identities/delete` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — refuses `cannot_delete_active_identity` / `cannot_delete_last_identity` | +| Method | Path | Body / notes | Response | +| ------ | ------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/v1/diagnostics` | | Reticulum-native health snapshot — includes `announce_ws` coalesce pressure (`last_window_ingress` / `unique` / `overflow`, `last_storm_at_ms`, `last_flush_at_ms`). Renderer Diagnostics emits `reticulum/announce-bus-pressure` when lag/storm/overflow is recent. | +| POST | `/api/v1/system/factory-reset` | | `{ ok }` — Electron UI must call `electronAPI.reticulum.factoryReset` (generic `proxyPost` blocks this path) | +| GET | `/api/v1/voice/status` | | LXST telephony status (`available`, `enabled`, `running`, `microphone_muted`, `active_call`) | +| POST | `/api/v1/voice/call` | `{ identity_hash }` | Place Opus call (`QualityHigh`, ~15s discovery). `identity_hash` is 32-hex identity (not LXMF dest). | +| POST | `/api/v1/voice/answer` | | Answer incoming call | +| POST | `/api/v1/voice/reject` | | Reject ringing call | +| POST | `/api/v1/voice/hangup` | | End active call | +| POST | `/api/v1/voice/mute` | `{ muted }` | Renderer mute flag (sidecar drops PCM ingest) | +| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX | +| GET | `/api/v1/games/status` | | LRGP stub status | +| GET | `/api/v1/identities` | | `{ identities: […] }` — slots under `config/identities//` + `active_identity`; working key remains `config/identity`; flat identity migrates to `identities/default/` | +| POST | `/api/v1/identities` | `{ display_name? }` | `{ ok, id, identity }` or `{ ok: false, error }` — `rns-stack` only; stage slot → apply working key → commit pointer last (rollback on failure). Cap 16 slots. Errors: `identity_slot_limit_reached`, `display_name_*`. Emits restart. | +| POST | `/api/v1/identities/switch` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — stash, install target → working, reconcile, then commit pointer. Errors: `identity_slot_not_configured`, `identity_not_found` | +| POST | `/api/v1/identities/delete` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — refuses `cannot_delete_active_identity` / `cannot_delete_last_identity` | ## WebSocket @@ -240,14 +246,14 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi { "type": "lxmf_message", "payload": { ... } } ``` -Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`. +Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.audio` (PCM `samples_b64`). **Note:** Live `wire_packet` frames are **not** pushed on `/ws` (they starved critical `lxmf_message` events on large meshes). Sniffer/Stats poll `GET /api/v1/packets` while those panels are mounted. PacketTap rows still feed the sidecar packet log and LXMF egress evidence. - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. - **`lxmf_outbound_status`:** authoritative outbound delivery updates. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. -- **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. +- **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`, `lxst.telephony`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. `lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, or `network`; multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`). Never use Meshtastic-style `both` for Reticulum. diff --git a/docs/reticulum.md b/docs/reticulum.md index d92a75efa..a3c75d967 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -397,7 +397,7 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne ## Building the sidecar (development) -`rns-stack` builds need siblings `rsReticulum`, `rsLXMF`, and `rsNomad` (see `scripts/clone-ratspeak-stack.sh`). That script floats **rsReticulum** / **rsLXMF** / **rsNomad** to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF`) and applies mesh-client overlays (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. +`rns-stack` builds need siblings `rsReticulum`, `rsLXMF`, `rsNomad`, and `rsLXST` (see `scripts/clone-ratspeak-stack.sh`). That script floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF`) and applies mesh-client overlays (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and contributors do. @@ -407,7 +407,7 @@ End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and pnpm run reticulum:sidecar:build ``` -When sibling checkouts `../rsReticulum`, `../rsLXMF`, and `../rsNomad` exist, the build script applies required patches and compiles with **`rns-stack,rns-ble,rns-rnode-tcp`** (live path table, BLE, RNode USB/Wi‑Fi, Nomad hosting). Without siblings, Cargo builds the **stub** stack (file-backed API for UI/tests — not for real mesh I/O). +When sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, and `../rsLXST` exist, the build script applies required patches and compiles with **`rns-stack,rns-ble,rns-rnode-tcp`** (live path table, BLE, RNode USB/Wi‑Fi, Nomad hosting, LXST voice). Without siblings, Cargo builds the **stub** stack (file-backed API for UI/tests — not for real mesh I/O). **Electron dev:** **Start stack** auto-runs `cargo build` when the debug binary is missing, when `reticulum-sidecar/src/**/*.rs` or `Cargo.toml` is newer than the binary, or when a stub binary is present but full-stack siblings exist. First compile can take several minutes — pre-build with the command above. @@ -468,7 +468,8 @@ Transfers require a **high-speed** path (TCP/network); LoRa/BLE-only destination - **Clear announces** — path table may refill from the live network on the next refresh - **Topology** — next-hop only; not a full end-to-end trace - **AGPL sidecar** — separate process and license from the MIT Electron shell -- **LXST voice calls** and **LRGP games** — not integrated (status endpoints may exist in sidecar; no UI) +- **LRGP games** — not integrated (status endpoint may exist in sidecar; no UI) +- **LXST voice calls** — integrated via rsLXST `TelephonyService` in the sidecar (`/api/v1/voice/*` + WS `voice.*`). Renderer owns mic/speaker (`getUserMedia` / Web Audio); Call controls live on Peers rows and Chat DM (no separate Voice tab). Live interop with Ratspeak / Python LXST should be verified manually on a real mesh. - **Hardware identity (YubiKey/PIV)** — not wired - **In-app firmware download** — local `.zip` pick only diff --git a/electron-builder.yml b/electron-builder.yml index ea7a95ffe..59a31077d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -45,7 +45,7 @@ mac: helperBundleId: com.mesh-client.app.helper extendInfo: NSBluetoothAlwaysUsageDescription: 'Mesh-client needs Bluetooth to communicate with Meshtastic devices.' - NSMicrophoneUsageDescription: 'Mesh-client needs the microphone to record Reticulum voice clips.' + NSMicrophoneUsageDescription: 'Mesh-client needs the microphone for Reticulum LXST voice calls.' NSCameraUsageDescription: 'Mesh-client needs the camera to scan QR codes for channel and contact sharing.' target: - dmg diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02dce5001..11d182e9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,14 +14,15 @@ overrides: brace-expansion: 5.0.9 builder-util-runtime: 9.7.0 fast-uri: ^3.1.5 + ip-address: ^10.3.1 js-yaml: ^4.3.0 markdown-it@<=14.1.1: '>=14.2.0 <15' - postcss: ^8.5.18 + postcss: ^8.5.25 shell-quote: ^1.9.0 tar: ^7.5.18 tmp: ^0.2.6 - undici: ^7.28.0 - undici-types: ^7.28.0 + undici: ^7.29.0 + undici-types: ^7.29.0 ws: ^8.21.0 patchedDependencies: @@ -251,7 +252,7 @@ importers: specifier: ^0.22.1 version: 0.22.1(supports-color@8.1.1) postcss: - specifier: ^8.5.18 + specifier: ^8.5.25 version: 8.5.25 prettier: specifier: ^3.9.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d6f2c6d7..1708cd524 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,24 +40,30 @@ overrides: # Security floors (Dependabot GHSA-52cp-r559-cp3m / GHSA-395f-4hp3-45gv / # GHSA-w8wr-v893-vjvp / GHSA-3jxr-9vmj-r5cp / GHSA-r28c-9q8g-f849 / # GHSA-p2f4-r6v6-j797 / GHSA-7g7r-gx96-252g / GHSA-mh99-v99m-4gvg / - # GHSA-rgw5-rvv9-x895). + # GHSA-rgw5-rvv9-x895 / GHSA-4cwx-7wf7-3272 / GHSA-8xcm-r25x-g524 / + # GHSA-v3r7-h72x-cjcm / GHSA-jr45-8vmc-qm54 / GHSA-m8rv-5g2x-5cg5 / + # GHSA-fxqj-rqcc-2cmp / GHSA-mwp4-54f8-5fhr). # brace-expansion: keep a single 5.0.9 floor. GHSA-rgw5-rvv9-x895 is a # bypass of the CVE-2026-14257 mitigation and marks >=4.0.0 <5.0.9 vulnerable # (only >=5.0.9 counts as patched). CI audit is blocking. # fast-uri: GHSA-7p8r-x3mc-p8w7 (host confusion via backslash authority); # electron-builder → app-builder-lib → ajv still pulls 3.1.4. + # undici: GHSA-4cwx-7wf7-3272 (+ sibling undici GHSAs) — floor >=7.29.0. + # postcss: GHSA-fxqj-rqcc-2cmp — floor >=8.5.23. + # ip-address: GHSA-mwp4-54f8-5fhr (mqtt → socks) — floor >=10.3.1. app-builder-lib: ^26.15.0 brace-expansion: 5.0.9 builder-util-runtime: 9.7.0 fast-uri: ^3.1.5 + ip-address: ^10.3.1 js-yaml: ^4.3.0 markdown-it@<=14.1.1: '>=14.2.0 <15' - postcss: ^8.5.18 + postcss: ^8.5.25 shell-quote: ^1.9.0 tar: ^7.5.18 tmp: ^0.2.6 - undici: ^7.28.0 - undici-types: ^7.28.0 + undici: ^7.29.0 + undici-types: ^7.29.0 ws: ^8.21.0 patchedDependencies: '@jsr/meshtastic__core@2.6.6': patches/@jsr__meshtastic__core@2.6.6.patch diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock index 3b03aed2f..131d2b86f 100644 --- a/reticulum-sidecar/Cargo.lock +++ b/reticulum-sidecar/Cargo.lock @@ -1352,6 +1352,46 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "lxst-core" +version = "0.1.2" +dependencies = [ + "half", + "opus-rs", + "rmpv", + "thiserror 2.0.18", +] + +[[package]] +name = "lxst-rns" +version = "0.1.2" +dependencies = [ + "bytes", + "lxst-core", + "rns-link", + "rns-transport", + "rns-wire", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "lxst-telephony" +version = "0.1.2" +dependencies = [ + "bytes", + "lxst-core", + "lxst-rns", + "rns-crypto", + "rns-identity", + "rns-link", + "rns-runtime", + "rns-transport", + "rns-wire", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "macaddr" version = "1.0.1" @@ -1411,6 +1451,8 @@ dependencies = [ "hex", "http", "lxmf-core", + "lxst-core", + "lxst-telephony", "nomad-core", "notify", "rand 0.8.6", @@ -1647,6 +1689,10 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opus-rs" +version = "0.1.19" + [[package]] name = "parking_lot" version = "0.12.5" diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml index 1cc47ac9b..5b205364e 100644 --- a/reticulum-sidecar/Cargo.toml +++ b/reticulum-sidecar/Cargo.toml @@ -12,11 +12,14 @@ path = "src/main.rs" [features] default = [] -# Full stack: requires sibling checkouts at ../../rsReticulum and ../../rsLXMF (Ratspeak layout). +# Full stack: requires sibling checkouts at ../../rsReticulum, ../../rsLXMF, +# ../../rsNomad, and ../../rsLXST (Ratspeak layout). rns-stack = [ "dep:rns-runtime", "dep:lxmf-core", "dep:nomad-core", + "dep:lxst-telephony", + "dep:lxst-core", "dep:rns-identity", "dep:rns-wire", "dep:rns-ratkey", @@ -92,6 +95,16 @@ package = "nomad-core" path = "../../rsNomad/crates/nomad-core" optional = true +[dependencies.lxst-telephony] +package = "lxst-telephony" +path = "../../rsLXST/crates/lxst-telephony" +optional = true + +[dependencies.lxst-core] +package = "lxst-core" +path = "../../rsLXST/crates/lxst-core" +optional = true + [dependencies.rns-interface] package = "rns-interface" path = "../../rsReticulum/crates/rns-interface" diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index f53da1483..5fea57953 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -15,6 +15,7 @@ mod rrc; mod status; mod system; pub(crate) mod validate; +mod voice; mod ws; use std::sync::Arc; @@ -244,7 +245,13 @@ pub fn router(stack: Arc) -> Router { .route("/api/v1/stack/restart", post(system::stack_restart)) .route("/api/v1/system/factory-reset", post(system::factory_reset)) .route("/api/v1/diagnostics", get(system::diagnostics)) - .route("/api/v1/voice/status", get(system::voice_status)) + .route("/api/v1/voice/status", get(voice::voice_status)) + .route("/api/v1/voice/call", post(voice::voice_call)) + .route("/api/v1/voice/answer", post(voice::voice_answer)) + .route("/api/v1/voice/reject", post(voice::voice_reject)) + .route("/api/v1/voice/hangup", post(voice::voice_hangup)) + .route("/api/v1/voice/mute", post(voice::voice_mute)) + .route("/api/v1/voice/audio", post(voice::voice_audio)) .route("/api/v1/games/status", get(system::games_status)) .route( "/api/v1/identities", diff --git a/reticulum-sidecar/src/api/system.rs b/reticulum-sidecar/src/api/system.rs index 236d19818..ee9915450 100644 --- a/reticulum-sidecar/src/api/system.rs +++ b/reticulum-sidecar/src/api/system.rs @@ -23,10 +23,6 @@ pub async fn diagnostics(State(stack): State>) -> Json>) -> Json { - Json(stack.voice_status().await) -} - pub async fn games_status(State(stack): State>) -> Json { Json(stack.games_status().await) } diff --git a/reticulum-sidecar/src/api/voice.rs b/reticulum-sidecar/src/api/voice.rs new file mode 100644 index 000000000..92dea784b --- /dev/null +++ b/reticulum-sidecar/src/api/voice.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use serde::Deserialize; + +use crate::api::validate::{MAX_DEST_HASH_CHARS, reject_oversize}; +use crate::stack::StackHandle; + +const MAX_SAMPLES_B64_CHARS: usize = 512 * 1024; + +#[derive(Debug, Deserialize)] +pub struct VoiceCallBody { + pub identity_hash: String, +} + +#[derive(Debug, Deserialize)] +pub struct VoiceMuteBody { + pub muted: bool, +} + +#[derive(Debug, Deserialize)] +pub struct VoiceAudioBody { + #[serde(default)] + pub profile: Option, + #[serde(default = "default_channels")] + pub channels: u8, + pub samples_b64: String, +} + +fn default_channels() -> u8 { + 1 +} + +pub async fn voice_status(State(stack): State>) -> Json { + Json(stack.voice_status().await) +} + +pub async fn voice_call( + State(stack): State>, + Json(body): Json, +) -> Json { + if let Some(err) = reject_oversize("identity_hash", &body.identity_hash, MAX_DEST_HASH_CHARS) { + return Json(serde_json::json!({ "ok": false, "error": err })); + } + Json(stack.voice_call(&body.identity_hash).await) +} + +pub async fn voice_answer(State(stack): State>) -> Json { + Json(stack.voice_answer().await) +} + +pub async fn voice_reject(State(stack): State>) -> Json { + Json(stack.voice_reject().await) +} + +pub async fn voice_hangup(State(stack): State>) -> Json { + Json(stack.voice_hangup().await) +} + +pub async fn voice_mute( + State(stack): State>, + Json(body): Json, +) -> Json { + Json(stack.voice_mute(body.muted).await) +} + +pub async fn voice_audio( + State(stack): State>, + Json(body): Json, +) -> Json { + if let Some(err) = reject_oversize("samples_b64", &body.samples_b64, MAX_SAMPLES_B64_CHARS) { + return Json(serde_json::json!({ "ok": false, "error": err })); + } + Json( + stack + .voice_audio(body.profile, body.channels, &body.samples_b64) + .await, + ) +} diff --git a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs index 7cc5b8dbb..ad3a8cab2 100644 --- a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs +++ b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs @@ -162,6 +162,7 @@ pub fn resolve_announce_aspect(name_hash: &[u8; 10]) -> Option<&'static str> { "lxmf.propagation", "nomadnetwork.node", "rrc.hub", + "lxst.telephony", ] .into_iter() .map(|aspect| (rns_identity::name_hash::name_hash(aspect), aspect)) @@ -457,6 +458,10 @@ mod tests { resolve_announce_aspect(&rns_identity::name_hash::name_hash("rrc.hub")), Some("rrc.hub") ); + assert_eq!( + resolve_announce_aspect(&rns_identity::name_hash::name_hash("lxst.telephony")), + Some("lxst.telephony") + ); assert_eq!(resolve_announce_aspect(&[0u8; 10]), None); assert_eq!(resolve_announce_aspect(&[1u8; 10]), None); } diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 8b7f7ce63..9666769ba 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -72,6 +72,7 @@ use super::via::{ classify_path_interface_name, merge_live_interfaces_with_config, merge_observed_egress_vias, resolve_lxmf_sent_via, }; +use super::voice_session::VoiceSessionManager; use lxmf_outbound::{LxmfOutboundDriver, PathTableRoute}; /// Settle window for PacketTap Tx correlation after LXMF enqueue. @@ -133,6 +134,7 @@ pub struct LiveBridge { rrc_session: Arc, rnsh_session: Arc, rncp_transfer: Arc, + voice_session: Arc, /// Local Nomad page/file host (rsNomad / nomad-core). nomad_server: Arc, /// Shared persisted stack state (Nomad node list, prefs). @@ -446,6 +448,11 @@ impl LiveBridge { storage_dir.clone(), config_dir.clone(), )), + voice_session: Arc::new(VoiceSessionManager::spawn( + handle.transport_tx.clone(), + &identity, + event_tx.clone(), + )), nomad_server: Arc::new(NomadServerHandle::new()), persisted: inner.clone(), #[cfg(feature = "rns-ble")] @@ -2122,6 +2129,41 @@ impl LiveBridge { self.rnsh_session.status_snapshot().await } + pub async fn voice_status(&self) -> serde_json::Value { + self.voice_session.status().await + } + + pub async fn voice_call(&self, identity_hash: &str) -> serde_json::Value { + self.voice_session.call(identity_hash).await + } + + pub async fn voice_answer(&self) -> serde_json::Value { + self.voice_session.answer().await + } + + pub async fn voice_reject(&self) -> serde_json::Value { + self.voice_session.reject().await + } + + pub async fn voice_hangup(&self) -> serde_json::Value { + self.voice_session.hangup().await + } + + pub async fn voice_mute(&self, muted: bool) -> serde_json::Value { + self.voice_session.set_mute(muted).await + } + + pub async fn voice_audio( + &self, + profile: Option, + channels: u8, + samples_b64: &str, + ) -> serde_json::Value { + self.voice_session + .send_audio(profile, channels, samples_b64) + .await + } + pub async fn rncp_send(&self, destination_hash_hex: &str, path: &str) -> serde_json::Value { match self.rncp_transfer.send(destination_hash_hex, path).await { Ok(transfer_id) => serde_json::json!({ "ok": true, "transfer_id": transfer_id }), @@ -2427,6 +2469,7 @@ impl LiveBridge { let outbound = self.outbound.clone(); let event_tx = self.event_tx.clone(); let display_name_cache = self.display_name_cache.clone(); + let voice_session = self.voice_session.clone(); tokio::spawn(async move { let (callback_tx, mut callback_rx) = tokio::sync::mpsc::channel::(256); @@ -2472,12 +2515,18 @@ impl LiveBridge { if coalescer.is_empty() { window_start = Some(tokio::time::Instant::now()); } + let identity_hash_hex = evt.identity_hash.map(hex::encode); + if let Some(ref id_hex) = identity_hash_hex { + voice_session + .remember_identity_for_dest(&dest_hex, id_hex) + .await; + } coalescer.push(AnnounceWsRow { destination_hash: dest_hex, display_name, hops: evt.hops, aspect: resolve_announce_aspect(&evt.name_hash).map(str::to_string), - identity_hash: evt.identity_hash.map(hex::encode), + identity_hash: identity_hash_hex, }); } () = async { diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 74fe7bc81..7c992ecfe 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -53,6 +53,8 @@ mod rnsh_session; mod rrc_link; #[cfg(feature = "rns-stack")] mod rrc_session; +#[cfg(feature = "rns-stack")] +mod voice_session; use std::fs; use std::path::PathBuf; @@ -2498,16 +2500,82 @@ impl StackHandle { config_audit::repair_config(&self.config_dir, &request) } - #[allow(clippy::unused_async)] // async matches StackHandle feature-status API awaited by HTTP handlers pub async fn voice_status(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_status().await; + } serde_json::json!({ "available": cfg!(feature = "rns-stack"), "enabled": false, + "running": false, + "microphone_muted": false, "codec": "opus", - "reason": "LXST voice pipeline pending rsLXST integration" + "reason": if cfg!(feature = "rns-stack") { + "stack not running" + } else { + "rns-stack feature required" + }, + "active_call": null, }) } + pub async fn voice_call(&self, identity_hash: &str) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_call(identity_hash).await; + } + let _ = identity_hash; + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_answer(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_answer().await; + } + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_reject(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_reject().await; + } + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_hangup(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_hangup().await; + } + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_mute(&self, muted: bool) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_mute(muted).await; + } + let _ = muted; + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + + pub async fn voice_audio( + &self, + profile: Option, + channels: u8, + samples_b64: &str, + ) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.voice_audio(profile, channels, samples_b64).await; + } + let _ = (profile, channels, samples_b64); + serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) + } + #[allow(clippy::unused_async)] // async matches StackHandle feature-status API awaited by HTTP handlers pub async fn games_status(&self) -> serde_json::Value { serde_json::json!({ diff --git a/reticulum-sidecar/src/stack/voice_session.rs b/reticulum-sidecar/src/stack/voice_session.rs new file mode 100644 index 000000000..e05d15843 --- /dev/null +++ b/reticulum-sidecar/src/stack/voice_session.rs @@ -0,0 +1,688 @@ +//! LXST voice telephony via rsLXST `TelephonyService`. +//! +//! Registers `lxst.telephony`, bridges control/events to HTTP + WS, and accepts +//! renderer-owned PCM frames (Opus encode/decode stays inside rsLXST). + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use base64::Engine as _; +use lxst_core::{CallRole, Profile, RawAudioFrame, SignallingStatus}; +use lxst_telephony::{ + IdentityHash, TelephonyControl, TelephonyService, TelephonyServiceEvent, TelephonyServiceParts, +}; +use rns_identity::identity::Identity; +use rns_transport::messages::TransportMessage; +use serde_json::json; +use tokio::sync::{RwLock, broadcast, mpsc}; + +use super::live::parse_hash16; + +/// Ratspeak-compatible default profile for outbound calls. +const DEFAULT_CALL_PROFILE: Profile = Profile::QualityHigh; +const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Default)] +struct VoiceState { + running: bool, + microphone_muted: bool, + active_call: Option, + last_error: Option, + /// Maps LXMF / peer destination hashes → identity hashes (from announce cache). + dest_to_identity: HashMap, +} + +struct ManagerShared { + control_tx: Option>, + event_tx: broadcast::Sender, + state: RwLock, + /// When true, PCM ingest is dropped (renderer mute). + muted: AtomicBool, + /// Set when `TelephonyService::registered` failed at stack start. + register_error: Option, +} + +pub struct VoiceSessionManager { + shared: Arc, +} + +impl VoiceSessionManager { + /// Register telephony on the live transport and spawn the service + event bridge. + /// On registration failure returns a disabled manager (stack stays up). + pub fn spawn( + transport_tx: mpsc::Sender, + identity: &Identity, + event_tx: broadcast::Sender, + ) -> Self { + match TelephonyService::registered(transport_tx, identity) { + Ok(TelephonyServiceParts { + service, + control_tx, + mut event_rx, + }) => { + let shared = Arc::new(ManagerShared { + control_tx: Some(control_tx), + event_tx: event_tx.clone(), + state: RwLock::new(VoiceState { + running: true, + ..VoiceState::default() + }), + muted: AtomicBool::new(false), + register_error: None, + }); + + tokio::spawn(service.run()); + + let bridge = Arc::clone(&shared); + tokio::spawn(async move { + while let Some(evt) = event_rx.recv().await { + bridge_service_event(&bridge, evt).await; + } + let mut st = bridge.state.write().await; + st.running = false; + st.active_call = None; + }); + + Self { shared } + } + Err(e) => { + let msg = format!("lxst telephony register: {e}"); + tracing::error!(target: "voice", "{msg}"); + Self { + shared: Arc::new(ManagerShared { + control_tx: None, + event_tx, + state: RwLock::new(VoiceState::default()), + muted: AtomicBool::new(false), + register_error: Some(msg), + }), + } + } + } + } + + pub async fn status(&self) -> serde_json::Value { + let st = self.shared.state.read().await; + if let Some(ref err) = self.shared.register_error { + return json!({ + "available": true, + "enabled": false, + "running": false, + "microphone_muted": false, + "codec": "opus", + "reason": err, + "active_call": null, + "last_error": err, + }); + } + json!({ + "available": true, + "enabled": true, + "running": st.running, + "microphone_muted": st.microphone_muted, + "codec": "opus", + "active_call": st.active_call, + "last_error": st.last_error, + }) + } + + pub async fn call(&self, identity_or_dest_hex: &str) -> serde_json::Value { + let Some(control_tx) = self.shared.control_tx.as_ref() else { + return json!({ "ok": false, "error": "voice not available" }); + }; + let remote = match self.resolve_identity_hash(identity_or_dest_hex).await { + Ok(h) => h, + Err(e) => return json!({ "ok": false, "error": e }), + }; + match control_tx + .send(TelephonyControl::Call { + remote_identity: remote, + profile: Some(DEFAULT_CALL_PROFILE), + discovery_timeout: DEFAULT_DISCOVERY_TIMEOUT, + }) + .await + { + Ok(()) => json!({ "ok": true, "identity_hash": hex::encode(remote) }), + Err(e) => json!({ "ok": false, "error": format!("voice control closed: {e}") }), + } + } + + pub async fn answer(&self) -> serde_json::Value { + self.send_control(TelephonyControl::Answer).await + } + + pub async fn reject(&self) -> serde_json::Value { + self.send_control(TelephonyControl::Hangup { ring_timeout: true }) + .await + } + + pub async fn hangup(&self) -> serde_json::Value { + self.send_control(TelephonyControl::Hangup { + ring_timeout: false, + }) + .await + } + + pub async fn set_mute(&self, muted: bool) -> serde_json::Value { + self.shared.muted.store(muted, Ordering::Relaxed); + let mut st = self.shared.state.write().await; + st.microphone_muted = muted; + json!({ "ok": true, "microphone_muted": muted }) + } + + /// Ingest one PCM frame (base64 little-endian f32 interleaved samples). + pub async fn send_audio( + &self, + profile_wire: Option, + channels: u8, + samples_b64: &str, + ) -> serde_json::Value { + if self.shared.muted.load(Ordering::Relaxed) { + return json!({ "ok": true, "dropped": "muted" }); + } + let samples = match decode_samples_b64(samples_b64) { + Ok(s) => s, + Err(e) => return json!({ "ok": false, "error": e }), + }; + let frame = match RawAudioFrame::new(channels, samples) { + Ok(f) => f, + Err(e) => return json!({ "ok": false, "error": format!("invalid pcm frame: {e}") }), + }; + let profile = match profile_wire.and_then(Profile::from_wire) { + Some(p) => p, + None => DEFAULT_CALL_PROFILE, + }; + let Some(control_tx) = self.shared.control_tx.as_ref() else { + return json!({ "ok": false, "error": "voice not available" }); + }; + match control_tx + .send(TelephonyControl::SendOpusFrames { + profile, + frames: vec![frame], + }) + .await + { + Ok(()) => json!({ "ok": true }), + Err(e) => json!({ "ok": false, "error": format!("voice control closed: {e}") }), + } + } + + /// Cache dest → identity from announce/peer refresh (renderer/sidecar). + pub async fn remember_identity_for_dest(&self, destination_hash: &str, identity_hash: &str) { + let dest = destination_hash.trim().to_lowercase(); + let id = identity_hash.trim().to_lowercase(); + if dest.len() == 32 && id.len() == 32 { + self.shared + .state + .write() + .await + .dest_to_identity + .insert(dest, id); + } + } + + async fn resolve_identity_hash(&self, input: &str) -> Result { + let st = self.shared.state.read().await; + resolve_identity_hash_with_cache(input, &st.dest_to_identity) + } + + async fn send_control(&self, control: TelephonyControl) -> serde_json::Value { + let Some(control_tx) = self.shared.control_tx.as_ref() else { + return json!({ "ok": false, "error": "voice not available" }); + }; + match control_tx.send(control).await { + Ok(()) => json!({ "ok": true }), + Err(e) => json!({ "ok": false, "error": format!("voice control closed: {e}") }), + } + } +} + +async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent) { + match evt { + TelephonyServiceEvent::OutgoingCallPending { remote_identity } => { + emit( + &shared.event_tx, + "voice.update", + &json!({ + "type": "outgoing_pending", + "remote_identity": hex::encode(remote_identity), + }), + ); + } + TelephonyServiceEvent::OutgoingCallStarted { + link_id, + remote_identity, + } => { + emit( + &shared.event_tx, + "voice.update", + &json!({ + "type": "outgoing", + "link_id": hex::encode(link_id), + "remote_identity": hex::encode(remote_identity), + }), + ); + } + TelephonyServiceEvent::OutgoingCallFailed { + remote_identity, + message, + } => { + { + let mut st = shared.state.write().await; + st.active_call = None; + st.last_error = Some(message.clone()); + } + emit( + &shared.event_tx, + "voice.error", + &json!({ + "type": "outgoing_failed", + "remote_identity": hex::encode(remote_identity), + "message": message, + }), + ); + } + TelephonyServiceEvent::IncomingCall { + link_id, + remote_identity, + } => { + let call = json!({ + "link_id": hex::encode(link_id), + "remote_identity": hex::encode(remote_identity), + "role": "incoming", + "status": "ringing", + "answered": false, + }); + { + let mut st = shared.state.write().await; + st.active_call = Some(call.clone()); + st.last_error = None; + } + emit(&shared.event_tx, "voice.incoming", &call); + } + TelephonyServiceEvent::CallTerminated { link_id, reason } => { + { + let mut st = shared.state.write().await; + st.active_call = None; + } + emit( + &shared.event_tx, + "voice.terminated", + &json!({ + "link_id": hex::encode(link_id), + "reason": reason.map(signalling_status_str), + }), + ); + } + TelephonyServiceEvent::Snapshot(snap) => { + let active = snap.active_call.as_ref().map(|c| { + json!({ + "link_id": hex::encode(c.link_id), + "remote_identity": hex::encode(c.remote_identity), + "role": call_role_str(c.role), + "status": signalling_status_str(c.status), + "profile": c.profile.map(Profile::wire_value), + "answered": c.answered, + }) + }); + { + let mut st = shared.state.write().await; + st.active_call = active.clone(); + } + emit( + &shared.event_tx, + "voice.update", + &json!({ + "type": "snapshot", + "external_busy": snap.external_busy, + "pending_link_count": snap.pending_link_count, + "active_call": active, + }), + ); + } + TelephonyServiceEvent::OpusFramesReceived { + link_id, + profile, + frames, + } => { + for frame in frames { + let samples_b64 = encode_f32_le_b64(&frame.samples); + emit( + &shared.event_tx, + "voice.audio", + &json!({ + "link_id": hex::encode(link_id), + "profile": profile.wire_value(), + "channels": frame.channels, + "samples_b64": samples_b64, + }), + ); + } + } + TelephonyServiceEvent::Error { message } => { + { + let mut st = shared.state.write().await; + st.last_error = Some(message.clone()); + } + emit( + &shared.event_tx, + "voice.error", + &json!({ "type": "error", "message": message }), + ); + } + TelephonyServiceEvent::Stopped => { + let mut st = shared.state.write().await; + st.running = false; + st.active_call = None; + } + // Media accounting / stream lifecycle — not required by the UI. + TelephonyServiceEvent::MediaSent { .. } + | TelephonyServiceEvent::MediaReceived { .. } + | TelephonyServiceEvent::OpusTransmitStreamStarted { .. } + | TelephonyServiceEvent::OpusTransmitStreamStopped { .. } + | TelephonyServiceEvent::OpusReceiveStreamStarted { .. } + | TelephonyServiceEvent::OpusReceiveStreamStopped { .. } + | TelephonyServiceEvent::OpusReceiveStreamFrames { .. } + | TelephonyServiceEvent::Drive(_) => {} + } +} + +fn emit(event_tx: &broadcast::Sender, event_type: &str, payload: &serde_json::Value) { + let frame = json!({ "type": event_type, "payload": payload }); + let _ = event_tx.send(frame.to_string()); +} + +fn encode_f32_le_b64(samples: &[f32]) -> String { + let mut bytes = Vec::with_capacity(samples.len() * 4); + for s in samples { + bytes.extend_from_slice(&s.to_le_bytes()); + } + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn signalling_status_str(status: SignallingStatus) -> &'static str { + match status { + SignallingStatus::Busy => "busy", + SignallingStatus::Rejected => "rejected", + SignallingStatus::Calling => "calling", + SignallingStatus::Available => "available", + SignallingStatus::Ringing => "ringing", + SignallingStatus::Connecting => "connecting", + SignallingStatus::Established => "established", + } +} + +fn call_role_str(role: CallRole) -> &'static str { + match role { + CallRole::Incoming => "incoming", + CallRole::Outgoing => "outgoing", + } +} + +/// Pure helper for tests / dial path: prefer cached dest→identity, else parse as identity. +pub fn resolve_identity_hash_with_cache( + input: &str, + dest_to_identity: &HashMap, +) -> Result<[u8; 16], String> { + let trimmed = input.trim().to_lowercase(); + if let Some(id_hex) = dest_to_identity.get(&trimmed) { + return parse_hash16(id_hex); + } + parse_hash16(&trimmed) +} + +/// Decode base64 LE f32 PCM; used by audio ingest validation tests. +pub fn decode_samples_b64(samples_b64: &str) -> Result, String> { + if samples_b64.is_empty() { + return Err("empty samples_b64".into()); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(samples_b64.as_bytes()) + .map_err(|e| format!("invalid base64 samples: {e}"))?; + if bytes.len() % 4 != 0 { + return Err("samples_b64 length must be multiple of 4".into()); + } + let mut samples = Vec::with_capacity(bytes.len() / 4); + for chunk in bytes.chunks_exact(4) { + samples.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + Ok(samples) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_identity_passthrough() { + let hex = "aabbccddeeff00112233445566778899"; + let h = resolve_identity_hash_with_cache(hex, &HashMap::new()).unwrap(); + assert_eq!(hex::encode(h), hex); + } + + #[test] + fn resolve_identity_via_dest_cache() { + let dest = "11112222333344445555666677778888".to_string(); + let id = "aabbccddeeff00112233445566778899".to_string(); + let mut map = HashMap::new(); + map.insert(dest.clone(), id.clone()); + let h = resolve_identity_hash_with_cache(&dest, &map).unwrap(); + assert_eq!(hex::encode(h), id); + } + + #[test] + fn resolve_identity_missing_rejects_short() { + assert!(resolve_identity_hash_with_cache("aabb", &HashMap::new()).is_err()); + } + + #[test] + fn decode_samples_rejects_empty() { + assert!(decode_samples_b64("").is_err()); + } + + #[test] + fn decode_samples_roundtrip_one_sample() { + let b64 = base64::engine::general_purpose::STANDARD.encode(0.5f32.to_le_bytes()); + let samples = decode_samples_b64(&b64).unwrap(); + assert_eq!(samples.len(), 1); + assert!((samples[0] - 0.5).abs() < f32::EPSILON); + } + + #[test] + fn signalling_status_strings_cover_wire_set() { + assert_eq!( + signalling_status_str(SignallingStatus::Established), + "established" + ); + assert_eq!(signalling_status_str(SignallingStatus::Ringing), "ringing"); + assert_eq!(call_role_str(CallRole::Incoming), "incoming"); + } + + #[test] + fn quality_high_frame_size_contract() { + // Keep renderer/sidecar PCM packing aligned with lxst-core Profile::QualityHigh. + assert_eq!(Profile::QualityHigh.channels(), 1); + assert_eq!(Profile::QualityHigh.sample_rate_hz(), 48_000); + assert_eq!(Profile::QualityHigh.sample_frames_per_packet(), 2_880); + } + + #[tokio::test] + async fn status_shape_when_manager_spawns() { + let (transport_tx, mut transport_rx) = mpsc::channel::(4); + let identity = Identity::new(); + let (event_tx, _event_rx) = broadcast::channel::(8); + tokio::spawn(async move { while transport_rx.recv().await.is_some() {} }); + let mgr = VoiceSessionManager::spawn(transport_tx, &identity, event_tx); + let status = mgr.status().await; + assert_eq!(status["available"], true); + assert_eq!(status["codec"], "opus"); + assert!(status["enabled"].is_boolean()); + let _ = mgr.hangup().await; + } + + #[tokio::test] + async fn call_rejects_invalid_hex() { + let (transport_tx, mut transport_rx) = mpsc::channel::(4); + let identity = Identity::new(); + let (event_tx, _event_rx) = broadcast::channel::(8); + tokio::spawn(async move { while transport_rx.recv().await.is_some() {} }); + let mgr = VoiceSessionManager::spawn(transport_tx, &identity, event_tx); + let resp = mgr.call("not-a-hash").await; + assert_eq!(resp["ok"], false); + let err = resp["error"].as_str().unwrap_or(""); + assert!( + err.contains("32 hex") || err.contains("not available"), + "{err}" + ); + } + + #[tokio::test] + async fn send_audio_rejects_empty_and_respects_mute() { + let (transport_tx, mut transport_rx) = mpsc::channel::(4); + let identity = Identity::new(); + let (event_tx, _event_rx) = broadcast::channel::(8); + tokio::spawn(async move { while transport_rx.recv().await.is_some() {} }); + let mgr = VoiceSessionManager::spawn(transport_tx, &identity, event_tx); + let empty = mgr.send_audio(None, 1, "").await; + assert_eq!(empty["ok"], false); + let _ = mgr.set_mute(true).await; + let muted = mgr.send_audio(None, 1, "AAAA").await; + // Muted path only when control channel is live; otherwise "not available". + if muted["dropped"] == "muted" { + assert_eq!(muted["ok"], true); + } else { + assert_eq!(muted["ok"], false); + } + } + + fn disabled_manager(event_tx: broadcast::Sender) -> VoiceSessionManager { + VoiceSessionManager { + shared: Arc::new(ManagerShared { + control_tx: None, + event_tx, + state: RwLock::new(VoiceState::default()), + muted: AtomicBool::new(false), + register_error: Some("voice disabled for test".into()), + }), + } + } + + #[tokio::test] + async fn status_disabled_when_register_failed() { + let (event_tx, _) = broadcast::channel::(4); + let mgr = disabled_manager(event_tx); + let status = mgr.status().await; + assert_eq!(status["available"], true); + assert_eq!(status["enabled"], false); + assert_eq!(status["running"], false); + assert!(status["reason"].as_str().unwrap_or("").contains("disabled")); + } + + #[tokio::test] + async fn answer_reject_hangup_stable_when_unavailable() { + let (event_tx, _) = broadcast::channel::(4); + let mgr = disabled_manager(event_tx); + for resp in [mgr.answer().await, mgr.reject().await, mgr.hangup().await] { + assert_eq!(resp["ok"], false); + assert_eq!(resp["error"], "voice not available"); + } + } + + #[tokio::test] + async fn bridge_emits_incoming_update_terminated_and_error() { + let (event_tx, mut event_rx) = broadcast::channel::(16); + let shared = Arc::new(ManagerShared { + control_tx: None, + event_tx, + state: RwLock::new(VoiceState::default()), + muted: AtomicBool::new(false), + register_error: None, + }); + let link = [0x11u8; 16]; + let remote = [0x22u8; 16]; + + bridge_service_event( + &shared, + TelephonyServiceEvent::IncomingCall { + link_id: link, + remote_identity: remote, + }, + ) + .await; + let incoming = event_rx.try_recv().expect("voice.incoming"); + assert!(incoming.contains("\"type\":\"voice.incoming\"")); + assert!(shared.state.read().await.active_call.is_some()); + + bridge_service_event( + &shared, + TelephonyServiceEvent::OutgoingCallPending { + remote_identity: remote, + }, + ) + .await; + let update = event_rx.try_recv().expect("voice.update"); + assert!(update.contains("\"type\":\"voice.update\"")); + + bridge_service_event( + &shared, + TelephonyServiceEvent::CallTerminated { + link_id: link, + reason: Some(SignallingStatus::Rejected), + }, + ) + .await; + let terminated = event_rx.try_recv().expect("voice.terminated"); + assert!(terminated.contains("\"type\":\"voice.terminated\"")); + assert!(shared.state.read().await.active_call.is_none()); + + bridge_service_event( + &shared, + TelephonyServiceEvent::Error { + message: "boom".into(), + }, + ) + .await; + let err = event_rx.try_recv().expect("voice.error"); + assert!(err.contains("\"type\":\"voice.error\"")); + assert_eq!( + shared.state.read().await.last_error.as_deref(), + Some("boom") + ); + } + + #[tokio::test] + async fn send_audio_accepts_quality_high_frame_size() { + let (control_tx, mut control_rx) = mpsc::channel::(4); + let (event_tx, _) = broadcast::channel::(4); + let mgr = VoiceSessionManager { + shared: Arc::new(ManagerShared { + control_tx: Some(control_tx), + event_tx, + state: RwLock::new(VoiceState { + running: true, + ..VoiceState::default() + }), + muted: AtomicBool::new(false), + register_error: None, + }), + }; + let n = Profile::QualityHigh.sample_frames_per_packet() + * usize::from(Profile::QualityHigh.channels()); + let mut bytes = Vec::with_capacity(n * 4); + for _ in 0..n { + bytes.extend_from_slice(&0f32.to_le_bytes()); + } + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let resp = mgr.send_audio(None, 1, &b64).await; + assert_eq!(resp["ok"], true, "{resp}"); + match control_rx.recv().await { + Some(TelephonyControl::SendOpusFrames { frames, .. }) => { + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].samples.len(), n); + } + other => panic!("expected SendOpusFrames, got {other:?}"), + } + } +} diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index a171b1584..93a3c63dd 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Clone rsReticulum + rsLXMF + rsNomad (float origin/main by default), +# Clone rsReticulum + rsLXMF + rsNomad + rsLXST (float origin/main by default), # then apply mesh-client overlays for rns-stack sidecar builds. set -euo pipefail @@ -12,6 +12,7 @@ source "${SCRIPT_DIR}/lib/ratspeak-overlay-apply-list.sh" RNS_DIR="${WORKSPACE_ROOT}/rsReticulum" LXMF_DIR="${WORKSPACE_ROOT}/rsLXMF" NOMAD_DIR="${WORKSPACE_ROOT}/rsNomad" +LXST_DIR="${WORKSPACE_ROOT}/rsLXST" # So apply-*.sh targets the same siblings as this script (WORKSPACE_ROOT may differ from ..). export RS_RETICULUM_DIR="${RNS_DIR}" @@ -21,6 +22,7 @@ export RS_LXMF_DIR="${LXMF_DIR}" RS_RETICULUM_REF="${RS_RETICULUM_REF:-}" RS_LXMF_REF="${RS_LXMF_REF:-}" RS_NOMAD_REF="${RS_NOMAD_REF:-}" +RS_LXST_REF="${RS_LXST_REF:-}" # Ensure an existing checkout has the correct origin remote and is on the # requested ref (or floated origin/main). If the directory does not exist, clone it first. @@ -92,7 +94,7 @@ ensure_repo() { fi } -echo "Preparing Ratspeak stack (rsReticulum/rsLXMF/rsNomad float origin/main unless RS_*_REF set)..." +echo "Preparing Ratspeak stack (rsReticulum/rsLXMF/rsNomad/rsLXST float origin/main unless RS_*_REF set)..." ensure_repo "${RNS_DIR}" 'https://github.com/ratspeak/rsReticulum.git' \ "${RS_RETICULUM_REF}" 'rsReticulum' @@ -106,9 +108,13 @@ apply_ratspeak_lxmf_overlays "${SCRIPT_DIR}" # Float Colorado-Mesh/rsNomad to origin/main (override via RS_NOMAD_REF above). ensure_repo "${NOMAD_DIR}" 'https://github.com/Colorado-Mesh/rsNomad.git' "${RS_NOMAD_REF}" 'rsNomad' +# rsLXST (LXST telephony) — required for rns-stack voice; float origin/main unless RS_LXST_REF set. +ensure_repo "${LXST_DIR}" 'https://github.com/ratspeak/rsLXST.git' "${RS_LXST_REF}" 'rsLXST' + rns_mode='floated origin/main' lxmf_mode='floated origin/main' nomad_mode='floated origin/main' +lxst_mode='floated origin/main' if [[ -n "${RS_RETICULUM_REF}" ]]; then rns_mode="pinned ${RS_RETICULUM_REF:0:12}" fi @@ -118,9 +124,13 @@ fi if [[ -n "${RS_NOMAD_REF}" ]]; then nomad_mode="pinned ${RS_NOMAD_REF:0:12}" fi +if [[ -n "${RS_LXST_REF}" ]]; then + lxst_mode="pinned ${RS_LXST_REF:0:12}" +fi rns_sha="$(git -C "${RNS_DIR}" rev-parse HEAD)" lxmf_sha="$(git -C "${LXMF_DIR}" rev-parse HEAD)" nomad_sha="$(git -C "${NOMAD_DIR}" rev-parse HEAD)" -echo "Ratspeak stack ready: rsReticulum @ ${rns_sha:0:12} (${rns_mode}), rsLXMF @ ${lxmf_sha:0:12} (${lxmf_mode}), rsNomad @ ${nomad_sha:0:12} (${nomad_mode})" -echo "Ratspeak stack SHAs (full): rsReticulum=${rns_sha} rsLXMF=${lxmf_sha} rsNomad=${nomad_sha}" +lxst_sha="$(git -C "${LXST_DIR}" rev-parse HEAD)" +echo "Ratspeak stack ready: rsReticulum @ ${rns_sha:0:12} (${rns_mode}), rsLXMF @ ${lxmf_sha:0:12} (${lxmf_mode}), rsNomad @ ${nomad_sha:0:12} (${nomad_mode}), rsLXST @ ${lxst_sha:0:12} (${lxst_mode})" +echo "Ratspeak stack SHAs (full): rsReticulum=${rns_sha} rsLXMF=${lxmf_sha} rsNomad=${nomad_sha} rsLXST=${lxst_sha}" diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs index 5d15a000a..3c4e43efa 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -32,6 +32,13 @@ describe('clone-ratspeak-stack.sh float policy', () => { expect(cloneScript).not.toContain('RS_NOMAD_SKIP_PIN'); }); + it('floats rsLXST to origin/main with optional RS_LXST_REF pin', () => { + expect(cloneScript).toMatch(/RS_LXST_REF="\$\{RS_LXST_REF:-\}"/); + expect(cloneScript).toContain('ratspeak/rsLXST.git'); + expect(cloneScript).toContain("lxst_mode='floated origin/main'"); + expect(cloneScript).toContain('rsLXST @'); + }); + it('applies rsReticulum and rsLXMF overlays after checkout via shared list', () => { expect(cloneScript).toContain('apply_ratspeak_rns_overlays'); expect(cloneScript).toContain('apply_ratspeak_lxmf_overlays'); diff --git a/scripts/update.sh b/scripts/update.sh index e00a63795..aeffed69f 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -404,9 +404,10 @@ process.stdin.on("end", () => { # Curated release watch + known org repos (keep in sync when adopting new ratspeak libs). # Format: "owner/repo|stub-kind-or-empty|display-label" -# stub-kind: voice|games → warn while mesh-client still has sidecar stubs only. +# stub-kind: games → warn while mesh-client still has sidecar stubs only. +# (voice cleared after lxst-telephony integration; empty stub field = informational.) RATSPEAK_RELEASE_WATCH_ENTRIES=( - 'ratspeak/rsLXST|voice|rsLXST voice (sidecar stub)' + 'ratspeak/rsLXST||rsLXST voice (lxst-telephony)' 'ratspeak/lrgp-rs|games|lrgp-rs games (sidecar stub)' 'ratspeak/Ratspeak||Ratspeak client (reference)' 'ratspeak/LXMFace||LXMFace identicons (vendored in renderer)' diff --git a/scripts/update.test.mjs b/scripts/update.test.mjs index f1c5cf352..a15dadef1 100644 --- a/scripts/update.test.mjs +++ b/scripts/update.test.mjs @@ -97,7 +97,7 @@ describe('update.sh Reticulum stack functionality check', () => { const result = runUpdate([], { UPDATE_SH_TEST_HOOK: 'upstream-catalog-only' }); expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES:'); - expect(result.stdout).toContain('ratspeak/rsLXST|voice|'); + expect(result.stdout).toContain('ratspeak/rsLXST||rsLXST voice (lxst-telephony)'); expect(result.stdout).toContain('ratspeak/lrgp-rs|games|'); expect(result.stdout).toContain('ratspeak/Ratspeak||'); expect(result.stdout).toContain('ratspeak/LXMFace||'); diff --git a/src/preload/index.ts b/src/preload/index.ts index bf6df9abb..9cb1ebf39 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1174,6 +1174,24 @@ contextBridge.exposeInMainWorld('electronAPI', { getStatus: () => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rnsh/status')), }, + voice: { + getStatus: () => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/voice/status')), + call: (opts: { identity_hash: string }) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/call', opts)), + answer: () => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/answer', {})), + reject: () => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/reject', {})), + hangup: () => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/hangup', {})), + mute: (opts: { muted: boolean }) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/mute', opts)), + sendAudio: (opts: { profile?: number; channels: number; samples_b64: string }) => + unwrapReticulumProxy( + ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/audio', opts), + ), + }, rncp: { send: (opts: { destination_hash: string; path: string }) => ipcRenderer.invoke('reticulum:rncpSend', opts), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 808398a49..3b327749d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -82,6 +82,7 @@ import { ProtocolAutoConnectCoordinator } from './components/ProtocolAutoConnect import { ProtocolSwitcher } from './components/ProtocolSwitcher'; import { RncpEnableRequestModal } from './components/remote/RncpEnableRequestModal'; import RemoteAdminErrorNotifier from './components/RemoteAdminErrorNotifier'; +import { ReticulumVoiceOverlay } from './components/reticulum/ReticulumVoiceOverlay'; import { ReticulumStackAutostartCoordinator } from './components/ReticulumStackAutostartCoordinator'; import Sidebar from './components/Sidebar'; import { LinkIcon } from './components/SignalBars'; @@ -479,6 +480,7 @@ export default function App() { + ); @@ -3056,6 +3058,7 @@ function AppContent() { protocol={protocol} dmOnlyChat={capabilities.hasReticulumInterfaceConfig} hasRncpTransfer={capabilities.hasRncpTransfer} + hasLxstVoice={capabilities.hasLxstVoice} showLxmfDeliveryStatus={capabilities.hasLxmfDeliveryStatus} showLxmfAttachmentLine={capabilities.hasReticulumInterfaceConfig} composerPayloadLimit={capabilities.lxmfPayloadLimit} @@ -3269,6 +3272,7 @@ function AppContent() { } groupMemberIds={contactGroups.groupMemberIds} contactGroupsEnabled={capabilities.hasUserManagedContactGroups} + hasLxstVoice={capabilities.hasLxstVoice} /> ) : ( ) : null; + const voiceCallControl = + protocol === 'reticulum' && hasLxstVoice && reticulumDmDestinationHash != null ? ( + + ) : null; const peerDetailsAppearance = reticulumDmDestinationHash ? peerAppearanceByHash.get(reticulumDmDestinationHash) : undefined; @@ -2296,13 +2309,16 @@ function ChatPanel({ {t('chatPanel.openPeerDetails')} ) : null; - if (!pathBadge && !dmNode && !rncpControl && !peerDetailsControl) return null; + if (!pathBadge && !dmNode && !rncpControl && !voiceCallControl && !peerDetailsControl) { + return null; + } return (
{pathBadge} {pathActions} {peerDetailsControl} {dmNode ? : null} + {voiceCallControl} {rncpControl}
); diff --git a/src/renderer/components/ReticulumPeerListPanel.test.tsx b/src/renderer/components/ReticulumPeerListPanel.test.tsx index bbcbd9ce9..11ad06da9 100644 --- a/src/renderer/components/ReticulumPeerListPanel.test.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.test.tsx @@ -36,6 +36,7 @@ const reticulumSidecarMocks = vi.hoisted(() => ({ })); vi.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: () => {} }, useTranslation: () => ({ t: (key: string, opts?: Record) => { if (opts && 'count' in opts) return `${key}:${String(opts.count)}`; diff --git a/src/renderer/components/ReticulumPeerListPanel.tsx b/src/renderer/components/ReticulumPeerListPanel.tsx index 1eabd9355..26327fed9 100644 --- a/src/renderer/components/ReticulumPeerListPanel.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.tsx @@ -55,6 +55,7 @@ import { useReticulumPeerStore, } from '../stores/reticulumPeerStore'; import { ReticulumPeerPathsDetail } from './reticulum/ReticulumPeerPathsDetail'; +import { ReticulumVoiceCallButton } from './reticulum/ReticulumVoiceCallButton'; import { ReticulumProfileIconSlot } from './ReticulumProfileIcon'; import { useToast } from './Toast'; @@ -79,6 +80,8 @@ export interface ReticulumPeerListPanelProps { onManageGroups?: () => void; groupMemberIds?: Set; contactGroupsEnabled?: boolean; + /** LXST voice Call button on each peer row. */ + hasLxstVoice?: boolean; } function peerHashToNodeNum(hash: string): number { @@ -238,6 +241,7 @@ export default function ReticulumPeerListPanel({ onManageGroups, groupMemberIds, contactGroupsEnabled = false, + hasLxstVoice = false, }: ReticulumPeerListPanelProps) { const { t } = useTranslation(); const { addToast } = useToast(); @@ -578,6 +582,9 @@ export default function ReticulumPeerListPanel({ > + {hasLxstVoice ? ( + + ) : null} + ); +} diff --git a/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx new file mode 100644 index 000000000..7c70725f1 --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; + +import { ReticulumVoiceOverlay } from './ReticulumVoiceOverlay'; + +const answer = vi.fn(); +const reject = vi.fn(); +const hangup = vi.fn(); +const setMuted = vi.fn(); + +vi.mock('@/renderer/lib/reticulumVoiceSession', () => ({ + reticulumVoiceAnswer: () => answer(), + reticulumVoiceReject: () => reject(), + reticulumVoiceHangup: () => hangup(), + reticulumVoiceSetMuted: (...args: unknown[]) => setMuted(...args), + startReticulumVoiceMediaForActiveCall: vi.fn(), + stopReticulumVoiceMedia: vi.fn(), +})); + +describe('ReticulumVoiceOverlay', () => { + beforeEach(() => { + answer.mockReset(); + reject.mockReset(); + hangup.mockReset(); + setMuted.mockReset(); + act(() => { + useReticulumVoiceStore.getState().clearCall(); + }); + }); + + it('shows incoming dialog and fires answer/reject', async () => { + const user = userEvent.setup(); + act(() => { + useReticulumVoiceStore.getState().applyIncoming({ + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'ringing', + }); + }); + const { container } = render(); + expect(screen.getByRole('dialog', { name: /incoming voice call/i })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /answer voice call/i })); + expect(answer).toHaveBeenCalled(); + act(() => { + useReticulumVoiceStore.getState().applyIncoming({ + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'ringing', + }); + }); + render(); + await user.click(screen.getAllByRole('button', { name: /reject voice call/i })[0]); + expect(reject).toHaveBeenCalled(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('in-call bar mute and hangup fire helpers with no axe violations', async () => { + const user = userEvent.setup(); + act(() => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'snapshot', + active_call: { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'outgoing', + status: 'established', + }, + }); + }); + const { container } = render(); + expect(screen.getByRole('status', { name: /in call/i })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /mute microphone/i })); + expect(setMuted).toHaveBeenCalledWith(true); + await user.click(screen.getByRole('button', { name: /hang up voice call/i })); + expect(hangup).toHaveBeenCalled(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx b/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx new file mode 100644 index 000000000..8b3d01ccb --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx @@ -0,0 +1,122 @@ +import { Mic, MicOff, PhoneOff } from 'lucide-react-motion'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + reticulumVoiceAnswer, + reticulumVoiceHangup, + reticulumVoiceReject, + reticulumVoiceSetMuted, + startReticulumVoiceMediaForActiveCall, + stopReticulumVoiceMedia, +} from '@/renderer/lib/reticulumVoiceSession'; +import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; + +/** + * Incoming-call modal + compact in-call bar. Mount once from App when hasLxstVoice. + */ +export function ReticulumVoiceOverlay() { + const { t } = useTranslation(); + const incoming = useReticulumVoiceStore((s) => s.incomingCall); + const active = useReticulumVoiceStore((s) => s.activeCall); + const muted = useReticulumVoiceStore((s) => s.microphoneMuted); + + useEffect(() => { + const status = active?.status; + if (status === 'established' || status === 'connecting') { + void startReticulumVoiceMediaForActiveCall(); + } + if (active == null) { + stopReticulumVoiceMedia(); + } + }, [active]); + + const showIncoming = + incoming?.role === 'incoming' && + (incoming.status === 'ringing' || incoming.status === 'available'); + + const showInCall = + active != null && + !showIncoming && + (active.status === 'calling' || + active.status === 'connecting' || + active.status === 'established' || + active.status === 'ringing'); + + if (!showIncoming && !showInCall) return null; + + if (showIncoming && incoming) { + return ( +
+
+

+ {t('reticulumVoice.incomingTitle')} +

+

{incoming.remote_identity}

+
+ + +
+
+
+ ); + } + + if (!active) return null; + + return ( +
+ + {active.status === 'calling' || active.status === 'connecting' + ? t('reticulumVoice.calling') + : t('reticulumVoice.inCall')} + + + {active.remote_identity} + + + +
+ ); +} diff --git a/src/renderer/lib/radio/BaseRadioProvider.ts b/src/renderer/lib/radio/BaseRadioProvider.ts index b949fbbec..0a041fb4b 100644 --- a/src/renderer/lib/radio/BaseRadioProvider.ts +++ b/src/renderer/lib/radio/BaseRadioProvider.ts @@ -147,6 +147,8 @@ export interface ProtocolCapabilities { hasReticulumRemotePanel: boolean; /** Reticulum: rncp file transfer available from Chat DM header */ hasRncpTransfer: boolean; + /** Reticulum: LXST voice calls (Peers / Chat DM) */ + hasLxstVoice: boolean; /** DM composer payload limit (Reticulum LXMF only) */ lxmfPayloadLimit?: number; } @@ -222,6 +224,7 @@ export const MESHTASTIC_CAPABILITIES: ProtocolCapabilities = { hasReticulumAdminPanel: false, hasReticulumRemotePanel: false, hasRncpTransfer: false, + hasLxstVoice: false, }; export const MESHCORE_CAPABILITIES: ProtocolCapabilities = { @@ -296,6 +299,7 @@ export const MESHCORE_CAPABILITIES: ProtocolCapabilities = { hasReticulumAdminPanel: false, hasReticulumRemotePanel: false, hasRncpTransfer: false, + hasLxstVoice: false, }; export const RETICULUM_CAPABILITIES: ProtocolCapabilities = { @@ -369,5 +373,6 @@ export const RETICULUM_CAPABILITIES: ProtocolCapabilities = { hasReticulumAdminPanel: true, hasReticulumRemotePanel: true, hasRncpTransfer: true, + hasLxstVoice: true, lxmfPayloadLimit: RETICULUM_LXMF_PAYLOAD_LIMIT, }; diff --git a/src/renderer/lib/radio/protocol-capabilities.test.ts b/src/renderer/lib/radio/protocol-capabilities.test.ts index ff51b383a..fb3bd0cb3 100644 --- a/src/renderer/lib/radio/protocol-capabilities.test.ts +++ b/src/renderer/lib/radio/protocol-capabilities.test.ts @@ -86,6 +86,7 @@ const REQUIRED_CAPABILITY_KEYS: (keyof ProtocolCapabilities)[] = [ 'hasReticulumAdminPanel', 'hasReticulumRemotePanel', 'hasRncpTransfer', + 'hasLxstVoice', 'hasDiagnosticsPanel', 'nodeStaleThresholdMs', 'nodeOfflineThresholdMs', @@ -138,6 +139,7 @@ describe('ProtocolCapabilities contract', () => { "hasIpTunnel": true, "hasJsonRadioConfigImport": false, "hasLxmfDeliveryStatus": false, + "hasLxstVoice": false, "hasMapReport": true, "hasModemPresets": true, "hasMqttConnectionPanel": true, @@ -218,6 +220,7 @@ describe('ProtocolCapabilities contract', () => { "hasIpTunnel": false, "hasJsonRadioConfigImport": true, "hasLxmfDeliveryStatus": false, + "hasLxstVoice": false, "hasMapReport": false, "hasModemPresets": false, "hasMqttConnectionPanel": true, @@ -304,6 +307,7 @@ describe('ProtocolCapabilities contract', () => { "hasIpTunnel": false, "hasJsonRadioConfigImport": true, "hasLxmfDeliveryStatus": true, + "hasLxstVoice": true, "hasMapReport": false, "hasModemPresets": false, "hasMqttConnectionPanel": false, diff --git a/src/renderer/lib/reticulum/clearReticulumSessionStores.ts b/src/renderer/lib/reticulum/clearReticulumSessionStores.ts index 7881320c3..824fed1bc 100644 --- a/src/renderer/lib/reticulum/clearReticulumSessionStores.ts +++ b/src/renderer/lib/reticulum/clearReticulumSessionStores.ts @@ -1,6 +1,8 @@ import { releaseReticulumBleRnodeConnect } from '@/renderer/lib/reticulum/reticulumBleAdapterConflict'; +import { stopReticulumVoiceMedia } from '@/renderer/lib/reticulumVoiceSession'; import { useReticulumDiscoveryMapStore } from '@/renderer/stores/reticulumDiscoveryMapStore'; import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; +import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; import { useRnshSessionStore } from '@/renderer/stores/rnshSessionStore'; import { useRrcHubStore } from '@/renderer/stores/rrcHubStore'; @@ -15,5 +17,7 @@ export function clearReticulumSessionStores(): void { useRrcHubStore.getState().clear(); useRnshSessionStore.getState().clearAll(); useRncpTransferStore.getState().clearAll(); + stopReticulumVoiceMedia(); + useReticulumVoiceStore.getState().clearCall(); void releaseReticulumBleRnodeConnect(); } diff --git a/src/renderer/lib/reticulumVoiceAudio.test.ts b/src/renderer/lib/reticulumVoiceAudio.test.ts new file mode 100644 index 000000000..4d237fbee --- /dev/null +++ b/src/renderer/lib/reticulumVoiceAudio.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + decodeF32LeBase64, + encodeF32LeBase64, + LXST_QUALITY_HIGH_FRAME_SAMPLES, + packQualityHighFrame, + resolveVoiceDialIdentityHash, +} from './reticulumVoiceAudio'; + +describe('reticulumVoiceAudio', () => { + it('round-trips f32 le base64', () => { + const src = new Float32Array([0, 0.5, -0.25]); + const decoded = decodeF32LeBase64(encodeF32LeBase64(src)); + expect(decoded.length).toBe(3); + expect(decoded[0]).toBeCloseTo(0); + expect(decoded[1]).toBeCloseTo(0.5); + expect(decoded[2]).toBeCloseTo(-0.25); + }); + + it('returns empty on short/invalid base64', () => { + expect(decodeF32LeBase64('').length).toBe(0); + expect(decodeF32LeBase64('@@@').length).toBe(0); + }); + + it('packs QualityHigh frame from 48k mono', () => { + const input = new Float32Array(LXST_QUALITY_HIGH_FRAME_SAMPLES).fill(0.1); + const packed = packQualityHighFrame(input, 48_000, 1); + expect(packed).not.toBeNull(); + expect(packed!.length).toBe(LXST_QUALITY_HIGH_FRAME_SAMPLES); + }); + + it('returns null for empty input', () => { + expect(packQualityHighFrame(new Float32Array(0), 48_000)).toBeNull(); + }); + + it('resolves dial identity preferring identity_hash', () => { + const id = 'a'.repeat(32); + expect( + resolveVoiceDialIdentityHash({ + identityHash: id, + candidateIdentityHashes: ['b'.repeat(32)], + }), + ).toEqual({ identityHash: id }); + }); + + it('falls back to candidates and surfaces error key', () => { + const id = 'c'.repeat(32); + expect(resolveVoiceDialIdentityHash({ candidateIdentityHashes: [id] })).toEqual({ + identityHash: id, + }); + expect(resolveVoiceDialIdentityHash({})).toEqual({ + errorKey: 'reticulumVoice.errors.noIdentity', + }); + }); +}); diff --git a/src/renderer/lib/reticulumVoiceAudio.ts b/src/renderer/lib/reticulumVoiceAudio.ts new file mode 100644 index 000000000..fba52dac5 --- /dev/null +++ b/src/renderer/lib/reticulumVoiceAudio.ts @@ -0,0 +1,99 @@ +/** + * Renderer PCM helpers for LXST voice (QualityHigh contract from lxst-core). + * Sidecar owns Opus encode/decode; we pack/resample float PCM for `/voice/audio`. + */ + +/** Profile::QualityHigh wire value */ +export const LXST_QUALITY_HIGH_PROFILE = 0x50; +export const LXST_QUALITY_HIGH_CHANNELS = 1; +export const LXST_QUALITY_HIGH_SAMPLE_RATE_HZ = 48_000; +export const LXST_QUALITY_HIGH_FRAME_SAMPLES = 2_880; + +export function encodeF32LeBase64(samples: Float32Array | number[]): string { + const bytes = new Uint8Array(samples.length * 4); + const view = new DataView(bytes.buffer); + for (let i = 0; i < samples.length; i += 1) { + view.setFloat32(i * 4, samples[i] ?? 0, true); + } + let binary = ''; + for (const b of bytes) { + binary += String.fromCodePoint(b); + } + return btoa(binary); +} + +export function decodeF32LeBase64(samplesB64: string): Float32Array { + if (!samplesB64) return new Float32Array(0); + let binary: string; + try { + binary = atob(samplesB64); + } catch { + // catch-no-log-ok malformed base64 from sidecar + return new Float32Array(0); + } + if (binary.length % 4 !== 0) return new Float32Array(0); + const out = new Float32Array(binary.length / 4); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.codePointAt(i) ?? 0; + } + const view = new DataView(bytes.buffer); + for (let i = 0; i < out.length; i += 1) { + out[i] = view.getFloat32(i * 4, true); + } + return out; +} + +/** + * Linear-resample mono (or mix-down) PCM to QualityHigh frame size. + * Returns null when input is empty. + */ +export function packQualityHighFrame( + input: Float32Array, + inputSampleRateHz: number, + inputChannels = 1, +): Float32Array | null { + if (input.length === 0 || inputSampleRateHz <= 0) return null; + const channels = Math.max(1, Math.floor(inputChannels)); + const inputFrames = Math.floor(input.length / channels); + if (inputFrames <= 0) return null; + + // Mix to mono + const mono = new Float32Array(inputFrames); + for (let i = 0; i < inputFrames; i += 1) { + let sum = 0; + for (let c = 0; c < channels; c += 1) { + sum += input[i * channels + c] ?? 0; + } + mono[i] = sum / channels; + } + + const out = new Float32Array(LXST_QUALITY_HIGH_FRAME_SAMPLES); + const ratio = inputSampleRateHz / LXST_QUALITY_HIGH_SAMPLE_RATE_HZ; + for (let i = 0; i < LXST_QUALITY_HIGH_FRAME_SAMPLES; i += 1) { + const srcPos = i * ratio; + const i0 = Math.floor(srcPos); + const i1 = Math.min(i0 + 1, mono.length - 1); + const t = srcPos - i0; + const s0 = mono[Math.min(i0, mono.length - 1)] ?? 0; + const s1 = mono[i1] ?? s0; + out[i] = s0 + (s1 - s0) * t; + } + return out; +} + +/** Resolve identity hash for dial: prefer identity_hash, else first from candidates. */ +export function resolveVoiceDialIdentityHash(opts: { + identityHash?: string | null; + candidateIdentityHashes?: Iterable; +}): { identityHash: string } | { errorKey: 'reticulumVoice.errors.noIdentity' } { + const direct = opts.identityHash?.trim().toLowerCase(); + if (direct && /^[0-9a-f]{32}$/.test(direct)) { + return { identityHash: direct }; + } + for (const c of opts.candidateIdentityHashes ?? []) { + const h = c.trim().toLowerCase(); + if (/^[0-9a-f]{32}$/.test(h)) return { identityHash: h }; + } + return { errorKey: 'reticulumVoice.errors.noIdentity' }; +} diff --git a/src/renderer/lib/reticulumVoiceSession.ts b/src/renderer/lib/reticulumVoiceSession.ts new file mode 100644 index 000000000..309af4740 --- /dev/null +++ b/src/renderer/lib/reticulumVoiceSession.ts @@ -0,0 +1,233 @@ +/** + * High-level LXST voice session helpers (dial / answer / hangup + mic stream). + */ + +import { pushAppToast } from '@/renderer/components/Toast'; +import i18n from '@/renderer/lib/i18n'; +import { + encodeF32LeBase64, + LXST_QUALITY_HIGH_CHANNELS, + LXST_QUALITY_HIGH_FRAME_SAMPLES, + LXST_QUALITY_HIGH_PROFILE, + LXST_QUALITY_HIGH_SAMPLE_RATE_HZ, + packQualityHighFrame, + resolveVoiceDialIdentityHash, +} from '@/renderer/lib/reticulumVoiceAudio'; +import { collectIdentityHashesForLxmfPeer } from '@/renderer/lib/rncpOfferPeerMatch'; +import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; + +let captureCtx: AudioContext | null = null; +let captureSource: MediaStreamAudioSourceNode | null = null; +// ScriptProcessor is deprecated in favor of AudioWorklet; kept for first-slice PCM +// capture without shipping a worklet module (Electron getUserMedia path). +// eslint-disable-next-line @typescript-eslint/no-deprecated -- see comment above +let captureProcessor: ScriptProcessorNode | null = null; +let captureStream: MediaStream | null = null; +let playbackCtx: AudioContext | null = null; +let audioUnsub: (() => void) | null = null; +let txTimer: ReturnType | null = null; +let pendingSamples: number[] = []; + +async function ensureMicAccess(): Promise { + try { + const result = await window.electronAPI.media.ensureMicrophoneAccess(); + if (!result.granted) return false; + } catch { + // catch-no-log-ok continue to getUserMedia; Chromium may still prompt + } + return true; +} + +function stopCapture(): void { + if (txTimer != null) { + clearInterval(txTimer); + txTimer = null; + } + pendingSamples = []; + try { + captureProcessor?.disconnect(); + } catch { + // catch-no-log-ok + } + try { + captureSource?.disconnect(); + } catch { + // catch-no-log-ok + } + captureProcessor = null; + captureSource = null; + if (captureStream) { + for (const t of captureStream.getTracks()) { + t.stop(); + } + captureStream = null; + } + if (captureCtx) { + void captureCtx.close().catch(() => { + // catch-no-log-ok close during teardown + }); + captureCtx = null; + } +} + +function stopPlayback(): void { + if (audioUnsub) { + audioUnsub(); + audioUnsub = null; + } + if (playbackCtx) { + void playbackCtx.close().catch(() => { + // catch-no-log-ok close during teardown + }); + playbackCtx = null; + } +} + +export function stopReticulumVoiceMedia(): void { + stopCapture(); + stopPlayback(); +} + +async function startCaptureAndTx(): Promise { + stopCapture(); + if (!(await ensureMicAccess())) { + pushAppToast(i18n.t('reticulumVoice.errors.notRunning'), 'error'); + return; + } + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + }, + }); + captureStream = stream; + const ctx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); + captureCtx = ctx; + const source = ctx.createMediaStreamSource(stream); + captureSource = source; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- AudioWorklet deferred; see captureProcessor note + const processor = ctx.createScriptProcessor(4096, 1, 1); + captureProcessor = processor; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- paired with createScriptProcessor + processor.onaudioprocess = (ev) => { + if (useReticulumVoiceStore.getState().microphoneMuted) return; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- paired with createScriptProcessor + const input = ev.inputBuffer.getChannelData(0); + for (const s of input) { + pendingSamples.push(s); + } + }; + source.connect(processor); + processor.connect(ctx.destination); + + const frameMs = (LXST_QUALITY_HIGH_FRAME_SAMPLES / LXST_QUALITY_HIGH_SAMPLE_RATE_HZ) * 1000; + txTimer = setInterval( + () => { + if (useReticulumVoiceStore.getState().microphoneMuted) { + pendingSamples = []; + return; + } + if (pendingSamples.length < LXST_QUALITY_HIGH_FRAME_SAMPLES) return; + const chunk = pendingSamples.splice(0, LXST_QUALITY_HIGH_FRAME_SAMPLES); + const packed = packQualityHighFrame( + Float32Array.from(chunk), + LXST_QUALITY_HIGH_SAMPLE_RATE_HZ, + 1, + ); + if (!packed) return; + void window.electronAPI.reticulum.voice.sendAudio({ + profile: LXST_QUALITY_HIGH_PROFILE, + channels: LXST_QUALITY_HIGH_CHANNELS, + samples_b64: encodeF32LeBase64(packed), + }); + }, + Math.max(20, frameMs / 2), + ); +} + +function startPlayback(): void { + stopPlayback(); + playbackCtx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); + audioUnsub = useReticulumVoiceStore.getState().subscribeAudio((channels, samples) => { + const ctx = playbackCtx; + if (!ctx || samples.length === 0) return; + const frames = Math.floor(samples.length / Math.max(1, channels)); + if (frames <= 0) return; + const buffer = ctx.createBuffer(Math.max(1, channels), frames, ctx.sampleRate); + if (channels <= 1) { + buffer.copyToChannel(Float32Array.from(samples), 0); + } else { + for (let c = 0; c < channels; c += 1) { + const ch = new Float32Array(frames); + for (let i = 0; i < frames; i += 1) { + ch[i] = samples[i * channels + c] ?? 0; + } + buffer.copyToChannel(ch, c); + } + } + const node = ctx.createBufferSource(); + node.buffer = buffer; + node.connect(ctx.destination); + node.start(); + }); +} + +export async function startReticulumVoiceMediaForActiveCall(): Promise { + startPlayback(); + try { + await startCaptureAndTx(); + } catch (e) { + console.warn('[reticulumVoice] mic capture failed', e); + } +} + +export async function reticulumVoiceCallPeer(lxmfPeerHash: string): Promise { + const api = window.electronAPI.reticulum.voice; + const status = await api.getStatus(); + useReticulumVoiceStore.getState().applyStatus(status); + if (!status.enabled || status.running === false) { + pushAppToast(i18n.t('reticulumVoice.errors.notRunning'), 'error'); + return; + } + const candidates = collectIdentityHashesForLxmfPeer(lxmfPeerHash); + const resolved = resolveVoiceDialIdentityHash({ + candidateIdentityHashes: candidates, + }); + if ('errorKey' in resolved) { + pushAppToast(i18n.t(resolved.errorKey), 'error'); + return; + } + const resp = await api.call({ identity_hash: resolved.identityHash }); + if (!resp.ok) { + pushAppToast(resp.error || i18n.t('reticulumVoice.errors.callFailed'), 'error'); + } else { + await startReticulumVoiceMediaForActiveCall(); + } +} + +export async function reticulumVoiceAnswer(): Promise { + const resp = await window.electronAPI.reticulum.voice.answer(); + if (!resp.ok) { + pushAppToast(resp.error || i18n.t('reticulumVoice.errors.callFailed'), 'error'); + return; + } + await startReticulumVoiceMediaForActiveCall(); +} + +export async function reticulumVoiceReject(): Promise { + stopReticulumVoiceMedia(); + await window.electronAPI.reticulum.voice.reject(); + useReticulumVoiceStore.getState().clearCall(); +} + +export async function reticulumVoiceHangup(): Promise { + stopReticulumVoiceMedia(); + await window.electronAPI.reticulum.voice.hangup(); + useReticulumVoiceStore.getState().clearCall(); +} + +export async function reticulumVoiceSetMuted(muted: boolean): Promise { + await window.electronAPI.reticulum.voice.mute({ muted }); + useReticulumVoiceStore.getState().setMicrophoneMuted(muted); +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 4e1c8f270..b95bda0bf 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -5104,5 +5104,24 @@ "offlineTitle": "{{name}} přešel do režimu offline", "offlineBody": "Naposledy slyšet: {{time}}", "unknown": "neznámý" + }, + "reticulumVoice": { + "call": "Výzva", + "answer": "Odpověď", + "reject": "Zamítnout", + "incomingTitle": "Příchozí hlasový hovor", + "inCall": "Při hovoru", + "calling": "Volání", + "callAria": "Zahájit hlasový hovor LXST", + "answerAria": "Přijetí hlasového hovoru", + "rejectAria": "Odmítnout hlasový hovor", + "hangupAria": "Zavěsit hlasový hovor", + "muteAria": "Ztlumení mikrofonu", + "unmuteAria": "Zrušit ztlumení mikrofonu", + "errors": { + "noIdentity": "Nelze volat: identita protějšku zatím není známa. Počkejte na oznámení nebo sondujte kolegu.", + "callFailed": "Hlasový hovor se nezdařil.", + "notRunning": "Hlas není k dispozici, dokud není spuštěn zásobník Reticulum." + } } } diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 34cc6428a..cb37c6235 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} ist offline gegangen", "offlineBody": "Zuletzt gehört: {{time}}", "unknown": "unbekannt" + }, + "reticulumVoice": { + "call": "Anrufen", + "answer": "Antwort", + "reject": "Ablehnen", + "incomingTitle": "Eingehender Anruf", + "inCall": "Im Gespräch", + "calling": "Anruf ...", + "callAria": "LXST-Sprachanruf starten", + "answerAria": "Sprachanruf annehmen", + "rejectAria": "Sprachanruf ablehnen", + "hangupAria": "Sprachanruf auflegen", + "muteAria": "Mikrofon stummschalten", + "unmuteAria": "Mikrofon Stummschaltung aufheben", + "errors": { + "noIdentity": "Kann nicht aufrufen: Peer-Identität noch nicht bekannt. Warten Sie auf eine Ankündigung oder fragen Sie den Kollegen.", + "callFailed": "Sprachanruf fehlgeschlagen.", + "notRunning": "Stimme ist erst verfügbar, wenn der Reticulum-Stapel läuft." + } } } diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 8b8b601ee..e40aff072 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -729,6 +729,25 @@ "serialReselectAction": "Select serial port", "statusRegion": "Connection status" }, + "reticulumVoice": { + "call": "Call", + "answer": "Answer", + "reject": "Reject", + "incomingTitle": "Incoming voice call", + "inCall": "In call", + "calling": "Calling…", + "callAria": "Start LXST voice call", + "answerAria": "Answer voice call", + "rejectAria": "Reject voice call", + "hangupAria": "Hang up voice call", + "muteAria": "Mute microphone", + "unmuteAria": "Unmute microphone", + "errors": { + "noIdentity": "Cannot call: peer identity not known yet. Wait for an announce or probe the peer.", + "callFailed": "Voice call failed.", + "notRunning": "Voice is not available until the Reticulum stack is running." + } + }, "connectionPanel": { "maxReconnectAttempts": "Max MQTT reconnect attempts", "letsMeshRegion": "LetsMesh region", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 4247d6b8e..a3a62f9ae 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} se desconectó", "offlineBody": "Última escucha: {{time}}", "unknown": "desconocido" + }, + "reticulumVoice": { + "call": "Convocatoria", + "answer": "Respuesta", + "reject": "Rechazar", + "incomingTitle": "Llamada de voz entrante", + "inCall": "En llamada", + "calling": "Llamando...", + "callAria": "Iniciar llamada de voz LXST", + "answerAria": "Responder llamada de voz", + "rejectAria": "Rechazar llamada de voz", + "hangupAria": "Colgar voz", + "muteAria": "Silencie el micrófono", + "unmuteAria": "Reactivar micrófono", + "errors": { + "noIdentity": "No se puede llamar: la identidad del compañero aún no se conoce. Espere un anuncio o sondee al compañero.", + "callFailed": "La llamada de voz ha fallado.", + "notRunning": "La voz no está disponible hasta que la pila de Reticulum esté en ejecución." + } } } diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index a9d24df4f..3d965afa6 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} s'est déconnecté", "offlineBody": "Dernière écoute : {{time}}", "unknown": "inconnu" + }, + "reticulumVoice": { + "call": "Appeler", + "answer": "Réponse", + "reject": "Rejeter", + "incomingTitle": "Appel vocal entrant", + "inCall": "En appel", + "calling": "Appel en cours...", + "callAria": "Démarrer l'appel vocal", + "answerAria": "Répondre comme un appel vocal", + "rejectAria": "Refuser l'appel vocal", + "hangupAria": "Raccrocher l'appel vocal", + "muteAria": "Désactiver le microphone", + "unmuteAria": "Activer le son du microphone", + "errors": { + "noIdentity": "Impossible d'appeler : l'identité de l'homologue n'est pas encore connue. Attendez une annonce ou sondez le pair.", + "callFailed": "Échec de l'appel vocal.", + "notRunning": "La voix n'est pas disponible tant que la pile Reticulum n'est pas en cours d'exécution." + } } } diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index a267a9892..73f84b157 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} offline", "offlineBody": "Terakhir didengar: {{time}}", "unknown": "tidak dikenal" + }, + "reticulumVoice": { + "call": "Panggil", + "answer": "Jawaban", + "reject": "Tolak", + "incomingTitle": "Panggilan suara masuk", + "inCall": "Dalam panggilan", + "calling": "Memanggil…", + "callAria": "Mulai panggilan suara LXST", + "answerAria": "Jawab panggilan suara", + "rejectAria": "Tolak panggilan suara", + "hangupAria": "Menutup panggilan suara", + "muteAria": "Bisukan mikrofon", + "unmuteAria": "Unmute microphone", + "errors": { + "noIdentity": "Tidak dapat menelepon: identitas teman sebaya belum diketahui. Tunggu pengumuman atau selidiki rekan sejawat.", + "callFailed": "Panggilan suara gagal.", + "notRunning": "Suara tidak tersedia sampai tumpukan Reticulum berjalan." + } } } diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index fa6c3470c..3d26769cc 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} è andato offline", "offlineBody": "Ultimo ascolto: {{time}}", "unknown": "sconosciuto" + }, + "reticulumVoice": { + "call": "Chiama", + "answer": "Risposta", + "reject": "Rifiuta", + "incomingTitle": "Chiamata vocale in arrivo", + "inCall": "In chiamata", + "calling": "Chiamata in corso...", + "callAria": "Avvia chiamata vocale LXST", + "answerAria": "Rispondi alla chiamata vocale", + "rejectAria": "Rifiuta chiamata vocale", + "hangupAria": "Riaggancia chiamata vocale", + "muteAria": "Disattiva microfono", + "unmuteAria": "Attiva microfono", + "errors": { + "noIdentity": "Impossibile chiamare: identità peer non ancora nota. Attendere un annuncio o sondare il peer.", + "callFailed": "Chiamata vocale non riuscita.", + "notRunning": "La voce non è disponibile fino a quando lo stack Reticulum non è in esecuzione." + } } } diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index f10e92359..31feb1196 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} がオフラインになりました", "offlineBody": "最後に聞いた音: {{time}}", "unknown": "未知" + }, + "reticulumVoice": { + "call": "通話する", + "answer": "返信", + "reject": "許可しない", + "incomingTitle": "着信ボイス", + "inCall": "通話中", + "calling": "呼び出す", + "callAria": "LXST音声通話を開始する", + "answerAria": "音声通話に応答する", + "rejectAria": "音声通話を拒否する", + "hangupAria": "音声通話を切る", + "muteAria": "マイクをミュートする", + "unmuteAria": "マイクのミュートを解除", + "errors": { + "noIdentity": "呼び出すことができません:ピアIDはまだわかりません。アナウンスを待つか、ピアをプローブします。", + "callFailed": "音声通話に失敗しました。", + "notRunning": "Reticulumスタックが実行されるまで、音声は使用できません。" + } } } diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index aa2e26a60..02d98f6b7 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}}이 오프라인 상태가 되었습니다.", "offlineBody": "마지막으로 청취한 날짜: {{time}}", "unknown": "알려지지 않은" + }, + "reticulumVoice": { + "call": "전화", + "answer": "답안", + "reject": "거부", + "incomingTitle": "수신 음성 통화", + "inCall": "통화 중", + "calling": "부르심", + "callAria": "LXST 음성 통화 시작", + "answerAria": "음성 통화 받기", + "rejectAria": "음성 통화 거부", + "hangupAria": "음성 통화 끊기", + "muteAria": "마이크 음소거", + "unmuteAria": "마이크 음소거 해제", + "errors": { + "noIdentity": "호출할 수 없음: 피어 ID를 아직 알 수 없습니다. 피어 발표 또는 조사를 기다립니다.", + "callFailed": "음성 통화에 실패했습니다.", + "notRunning": "Reticulum 스택이 실행될 때까지 음성을 사용할 수 없습니다." + } } } diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index a8707777f..828a86d15 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} is offline gegaan", "offlineBody": "Laatst gehoord: {{time}}", "unknown": "onbekend" + }, + "reticulumVoice": { + "call": "Oproep", + "answer": "Antwoord", + "reject": "Afwijzen", + "incomingTitle": "Binnenkomende spraakoproep", + "inCall": "In gesprek", + "calling": "Bellen...", + "callAria": "LXST-spraakoproep starten", + "answerAria": "Spraakoproep beantwoorden", + "rejectAria": "Spraakoproep weigeren", + "hangupAria": "Ophangen spraakoproep", + "muteAria": "Microfoon dempen", + "unmuteAria": "Dempen van de microfoon opheffen", + "errors": { + "noIdentity": "Kan niet aanroepen: peer-identiteit nog niet bekend. Wacht op een aankondiging of onderzoek de peer.", + "callFailed": "Spraakoproep mislukt.", + "notRunning": "Stem is niet beschikbaar totdat de Reticulum-stack actief is." + } } } diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index cfa85fd3b..b7a0d01bb 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -5106,5 +5106,24 @@ "offlineTitle": "{{name}} przeszedł w tryb offline", "offlineBody": "Ostatnio słyszano: {{time}}", "unknown": "nieznany" + }, + "reticulumVoice": { + "call": "Zadzwoń", + "answer": "Odpowiedź", + "reject": "Odrzuć", + "incomingTitle": "przychodzące połączenie głosowe...", + "inCall": "W trakcie połączenia", + "calling": "Dzwoni", + "callAria": "Rozpocznij połączenie głosowe LXST", + "answerAria": "Odbierz połączenie głosowe", + "rejectAria": "Odrzuć połączenie głosowe", + "hangupAria": "Odłóż połączenie głosowe", + "muteAria": "Wycisz mikrofon", + "unmuteAria": "wycisz/wyłącz wyciszenie mikrofonu", + "errors": { + "noIdentity": "Nie można wywołać: tożsamość rówieśnika nie jest jeszcze znana. Poczekaj na ogłoszenie lub zbadaj rówieśnika.", + "callFailed": "Połączenie głosowe nie powiodło się.", + "notRunning": "Głos nie jest dostępny, dopóki stos Reticulum nie zostanie uruchomiony." + } } } diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 55487f278..72ffc3a38 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} ficou off-line", "offlineBody": "Última audição: {{time}}", "unknown": "desconhecido" + }, + "reticulumVoice": { + "call": "Ligue para", + "answer": "Respostas", + "reject": "Rejeitar", + "incomingTitle": "Chamada de voz recebida", + "inCall": "Em chamada", + "calling": "A chamar…", + "callAria": "Iniciar chamada de voz LXST", + "answerAria": "Atender chamada de voz", + "rejectAria": "Rejeitar chamada de voz", + "hangupAria": "Desligar chamada de voz", + "muteAria": "Silenciar Microfone", + "unmuteAria": "Microfone ligado", + "errors": { + "noIdentity": "Não é possível chamar: identidade de par ainda não conhecida. Aguarde um anúncio ou investigue o colega.", + "callFailed": "Falha na chamada de voz.", + "notRunning": "A voz não está disponível até que a pilha Reticulum esteja em execução." + } } } diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 09440c744..c5ba5a417 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -5104,5 +5104,24 @@ "offlineTitle": "{{name}} вышел из сети", "offlineBody": "Последний раз слышал: {{time}}", "unknown": "неизвестный" + }, + "reticulumVoice": { + "call": "Вызов", + "answer": "Ответ", + "reject": "Отклонить", + "incomingTitle": "Входящий голосовой вызов", + "inCall": "В разговоре", + "calling": "Вызов…", + "callAria": "Запуск голосового вызова LXST", + "answerAria": "Ответить на голосовой вызов", + "rejectAria": "Отклонить голосовой вызов", + "hangupAria": "Повесьте трубку для голосового вызова", + "muteAria": "Микрофон отключен", + "unmuteAria": "Включить микрофон", + "errors": { + "noIdentity": "Невозможно вызвать: идентификатор однорангового узла еще не известен. Дождитесь объявления или проверьте коллегу.", + "callFailed": "Сбой голосового вызова.", + "notRunning": "Голос недоступен до тех пор, пока не будет запущен стек Reticulum." + } } } diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 0adebd2e9..67f40716e 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} çevrimdışı oldu", "offlineBody": "Son duyulma tarihi: {{time}}", "unknown": "bilinmiyor" + }, + "reticulumVoice": { + "call": "Ara", + "answer": "Yanıt", + "reject": "Reddet", + "incomingTitle": "Gelen sesli arama", + "inCall": "Aramada", + "calling": "Aranıyor", + "callAria": "LXST sesli aramasını başlat", + "answerAria": "Sesli aramayı yanıtla", + "rejectAria": "Sesli aramayı reddet", + "hangupAria": "Sesli aramayı kapat", + "muteAria": "Mikrofonu kapat", + "unmuteAria": "Mikrofonu aç", + "errors": { + "noIdentity": "Aranamıyor: akran kimliği henüz bilinmiyor. Bir duyuru bekleyin veya akranınızı sorgulayın.", + "callFailed": "Sesli arama başarısız.", + "notRunning": "Reticulum yığını çalışana kadar ses kullanılamaz." + } } } diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 823faa70f..55e00ca91 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -5104,5 +5104,24 @@ "offlineTitle": "{{name}} вийшов з мережі", "offlineBody": "Останнє чуття: {{time}}", "unknown": "невідомий" + }, + "reticulumVoice": { + "call": "Виклик", + "answer": "Відповісти", + "reject": "Відхилити", + "incomingTitle": "Вхідний голосовий виклик", + "inCall": "У розмові", + "calling": "Поклик", + "callAria": "Почати голосовий дзвінок LXST", + "answerAria": "Відповісти на голосовий дзвінок", + "rejectAria": "Відхилити голосовий дзвінок", + "hangupAria": "Покласти слухавку голосового дзвінка", + "muteAria": "Вимкнути мікрофон", + "unmuteAria": "Увімкнути мікрофон", + "errors": { + "noIdentity": "Неможливо зателефонувати: ідентифікатор вузла ще не відомий. Дочекайтеся оголошення або перевірте колегу.", + "callFailed": "Не вдалося здійснити голосовий виклик.", + "notRunning": "Голосовий зв'язок недоступний до запуску стека Reticulum." + } } } diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 73ccbfd14..d1063e0ad 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -5102,5 +5102,24 @@ "offlineTitle": "{{name}} 离线", "offlineBody": "最后一次听到:{{time}}", "unknown": "未知" + }, + "reticulumVoice": { + "call": "电话", + "answer": "答案", + "reject": "不同意", + "incomingTitle": "传入语音呼叫", + "inCall": "通话中", + "calling": "呼叫中", + "callAria": "开始LXST语音通话", + "answerAria": "应答语音呼叫", + "rejectAria": "拒绝语音通话", + "hangupAria": "挂断语音通话", + "muteAria": "静音麦克风", + "unmuteAria": "取消麦克风静音", + "errors": { + "noIdentity": "无法调用:对等身份尚不清楚。等待通知或探查对等项。", + "callFailed": "语音通话失败。", + "notRunning": "在 Reticulum 堆栈运行之前,语音不可用。" + } } } diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 5c22887e1..0bb17ea74 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -140,6 +140,7 @@ import { import { getIdentityIdForProtocol } from '../lib/identityByProtocol'; import { getOfflineIdentityIdForProtocol } from '../lib/offlineProtocolIdentities'; +import { decodeF32LeBase64 } from '../lib/reticulumVoiceAudio'; import { resolveRrcInvoluntaryPartBannerKey } from '../lib/rrcInvoluntaryPartBanner'; import { isRrcJoinInfoNotice, @@ -189,6 +190,7 @@ import { reticulumSelfIdentityToNodeRecord, useReticulumPeerStore, } from '../stores/reticulumPeerStore'; +import { useReticulumVoiceStore } from '../stores/reticulumVoiceStore'; import { useRncpEnableRequestStore } from '../stores/rncpEnableRequestStore'; import { useRncpTransferStore } from '../stores/rncpTransferStore'; import { useRnshSessionStore } from '../stores/rnshSessionStore'; @@ -1167,6 +1169,28 @@ export function useReticulumRuntime(): ProtocolRuntime { .applyError(p.session_id, p.reason_key ?? 'error', p.message ?? ''); } } + if (evt.type === 'voice.update' && evt.payload && typeof evt.payload === 'object') { + useReticulumVoiceStore.getState().applyUpdate(evt.payload); + } + if (evt.type === 'voice.incoming' && evt.payload && typeof evt.payload === 'object') { + useReticulumVoiceStore.getState().applyIncoming(evt.payload); + } + if (evt.type === 'voice.terminated' && evt.payload && typeof evt.payload === 'object') { + const p = evt.payload as { link_id?: string }; + useReticulumVoiceStore.getState().applyTerminated(p.link_id ?? null); + } + if (evt.type === 'voice.error' && evt.payload && typeof evt.payload === 'object') { + const p = evt.payload as { message?: string }; + useReticulumVoiceStore.getState().applyError(p.message ?? 'voice error'); + } + if (evt.type === 'voice.audio' && evt.payload && typeof evt.payload === 'object') { + const p = evt.payload as { channels?: number; samples_b64?: string }; + if (typeof p.samples_b64 === 'string') { + const samples = decodeF32LeBase64(p.samples_b64); + const channels = typeof p.channels === 'number' && p.channels > 0 ? p.channels : 1; + useReticulumVoiceStore.getState().emitAudio(channels, samples); + } + } if (evt.type === 'rncp.progress' && evt.payload && typeof evt.payload === 'object') { const p = evt.payload as { transfer_id?: string; progress?: number }; if (p.transfer_id && typeof p.progress === 'number') { diff --git a/src/renderer/runtime/useReticulumRuntime.voice.test.ts b/src/renderer/runtime/useReticulumRuntime.voice.test.ts new file mode 100644 index 000000000..1b37b14d9 --- /dev/null +++ b/src/renderer/runtime/useReticulumRuntime.voice.test.ts @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +/** + * Source contract tests for LXST voice WebSocket event routing. + */ +import { describe, expect, it } from 'vitest'; + +import { loadRuntimeSource } from '../lib/sourceContractTestHelpers'; + +const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); + +describe('useReticulumRuntime voice event routing (regression)', () => { + it('routes voice.update / incoming / terminated / error / audio', () => { + expect(SOURCE).toMatch(/evt\.type === 'voice\.update'/); + expect(SOURCE).toMatch(/useReticulumVoiceStore\.getState\(\)\.applyUpdate/); + expect(SOURCE).toMatch(/evt\.type === 'voice\.incoming'/); + expect(SOURCE).toMatch(/applyIncoming\(evt\.payload\)/); + expect(SOURCE).toMatch(/evt\.type === 'voice\.terminated'/); + expect(SOURCE).toMatch(/applyTerminated\(p\.link_id/); + expect(SOURCE).toMatch(/evt\.type === 'voice\.error'/); + expect(SOURCE).toMatch(/applyError\(p\.message/); + expect(SOURCE).toMatch(/evt\.type === 'voice\.audio'/); + expect(SOURCE).toMatch(/decodeF32LeBase64/); + expect(SOURCE).toMatch(/emitAudio\(channels, samples\)/); + }); +}); diff --git a/src/renderer/stores/reticulumVoiceStore.test.ts b/src/renderer/stores/reticulumVoiceStore.test.ts new file mode 100644 index 000000000..fa2f85fbc --- /dev/null +++ b/src/renderer/stores/reticulumVoiceStore.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useReticulumVoiceStore } from './reticulumVoiceStore'; + +const CALL = { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming' as const, + status: 'ringing', + answered: false, +}; + +describe('reticulumVoiceStore', () => { + beforeEach(() => { + useReticulumVoiceStore.getState().clearCall(); + useReticulumVoiceStore.setState({ + enabled: false, + running: false, + microphoneMuted: false, + lastError: null, + callGeneration: 0, + }); + }); + + it('tracks idle → incoming → terminated', () => { + const store = useReticulumVoiceStore.getState(); + store.applyIncoming(CALL); + expect(useReticulumVoiceStore.getState().incomingCall?.status).toBe('ringing'); + expect(useReticulumVoiceStore.getState().activeCall?.remote_identity).toBe( + CALL.remote_identity, + ); + + store.applyUpdate({ + type: 'snapshot', + active_call: { ...CALL, status: 'established', answered: true }, + }); + expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('established'); + + store.applyTerminated(CALL.link_id); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + expect(useReticulumVoiceStore.getState().incomingCall).toBeNull(); + }); + + it('toggles mute and clears on error', () => { + const store = useReticulumVoiceStore.getState(); + store.applyIncoming(CALL); + store.setMicrophoneMuted(true); + expect(useReticulumVoiceStore.getState().microphoneMuted).toBe(true); + store.applyError('boom'); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + expect(useReticulumVoiceStore.getState().lastError).toBe('boom'); + }); + + it('ignores stale terminated after a new call generation', () => { + const store = useReticulumVoiceStore.getState(); + store.applyIncoming(CALL); + const oldLink = CALL.link_id; + store.applyUpdate({ + type: 'outgoing', + link_id: 'c'.repeat(32), + remote_identity: 'd'.repeat(32), + }); + store.applyTerminated(oldLink); + expect(useReticulumVoiceStore.getState().activeCall?.link_id).toBe('c'.repeat(32)); + }); +}); diff --git a/src/renderer/stores/reticulumVoiceStore.ts b/src/renderer/stores/reticulumVoiceStore.ts new file mode 100644 index 000000000..6cef94d74 --- /dev/null +++ b/src/renderer/stores/reticulumVoiceStore.ts @@ -0,0 +1,153 @@ +import { create } from 'zustand'; + +import type { VoiceActiveCall } from '@/shared/voice-types'; +import { isVoiceActiveCall } from '@/shared/voice-types'; + +export type VoiceAudioListener = (channels: number, samples: Float32Array) => void; + +interface ReticulumVoiceStoreState { + enabled: boolean; + running: boolean; + microphoneMuted: boolean; + activeCall: VoiceActiveCall | null; + incomingCall: VoiceActiveCall | null; + lastError: string | null; + /** Generation bumped on each new call so stale terminated events can be ignored. */ + callGeneration: number; + audioListeners: Set; + + applyStatus: (status: { + enabled?: boolean; + running?: boolean; + microphone_muted?: boolean; + active_call?: unknown; + last_error?: string | null; + }) => void; + applyIncoming: (call: unknown) => void; + applyUpdate: (payload: unknown) => void; + applyTerminated: (linkId?: string | null) => void; + applyError: (message: string) => void; + setMicrophoneMuted: (muted: boolean) => void; + clearCall: () => void; + subscribeAudio: (listener: VoiceAudioListener) => () => void; + emitAudio: (channels: number, samples: Float32Array) => void; +} + +function asActiveCall(value: unknown): VoiceActiveCall | null { + return isVoiceActiveCall(value) ? value : null; +} + +export const useReticulumVoiceStore = create((set, get) => ({ + enabled: false, + running: false, + microphoneMuted: false, + activeCall: null, + incomingCall: null, + lastError: null, + callGeneration: 0, + audioListeners: new Set(), + + applyStatus: (status) => { + set((s) => ({ + enabled: status.enabled ?? s.enabled, + running: status.running ?? s.running, + microphoneMuted: status.microphone_muted ?? s.microphoneMuted, + activeCall: + status.active_call === undefined ? s.activeCall : asActiveCall(status.active_call), + lastError: status.last_error === undefined ? s.lastError : (status.last_error ?? null), + })); + }, + + applyIncoming: (call) => { + const active = asActiveCall(call); + if (!active) return; + set((s) => ({ + incomingCall: active, + activeCall: active, + lastError: null, + callGeneration: s.callGeneration + 1, + })); + }, + + applyUpdate: (payload) => { + if (!payload || typeof payload !== 'object') return; + const p = payload as Record; + if (p.type === 'snapshot' && 'active_call' in p) { + const active = asActiveCall(p.active_call); + set((s) => ({ + activeCall: active, + incomingCall: + active?.role === 'incoming' && active.status === 'ringing' ? active : s.incomingCall, + lastError: null, + })); + return; + } + if (p.type === 'outgoing_pending' || p.type === 'outgoing') { + const remote = typeof p.remote_identity === 'string' ? p.remote_identity.toLowerCase() : null; + if (!remote) return; + const linkId = typeof p.link_id === 'string' ? p.link_id : ''; + set((s) => ({ + activeCall: { + link_id: linkId, + remote_identity: remote, + role: 'outgoing', + status: p.type === 'outgoing_pending' ? 'calling' : 'connecting', + answered: false, + }, + incomingCall: null, + lastError: null, + callGeneration: s.callGeneration + 1, + })); + } + }, + + applyTerminated: (linkId) => { + set((s) => { + if ( + linkId && + s.activeCall?.link_id && + s.activeCall.link_id.toLowerCase() !== linkId.toLowerCase() + ) { + // Stale terminate for a previous call. + return s; + } + return { + activeCall: null, + incomingCall: null, + }; + }); + }, + + applyError: (message) => { + set({ lastError: message, activeCall: null, incomingCall: null }); + }, + + setMicrophoneMuted: (muted) => { + set({ microphoneMuted: muted }); + }, + + clearCall: () => { + set({ activeCall: null, incomingCall: null, lastError: null }); + }, + + subscribeAudio: (listener) => { + set((s) => { + const audioListeners = new Set(s.audioListeners); + audioListeners.add(listener); + return { audioListeners }; + }); + return () => { + set((s) => { + const audioListeners = new Set(s.audioListeners); + audioListeners.delete(listener); + return { audioListeners }; + }); + }; + }, + + emitAudio: (channels, samples) => { + for (const listener of get().audioListeners) { + listener(channels, samples); + } + }, +})); diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index 38d628fe4..b2d47c607 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -411,6 +411,22 @@ export function createElectronAPIMock(): ElectronAPI { disconnect: vi.fn().mockResolvedValue({ ok: true }), getStatus: vi.fn().mockResolvedValue({ sessions: [] }), }, + voice: { + getStatus: vi.fn().mockResolvedValue({ + available: true, + enabled: true, + running: true, + microphone_muted: false, + codec: 'opus', + active_call: null, + }), + call: vi.fn().mockResolvedValue({ ok: true }), + answer: vi.fn().mockResolvedValue({ ok: true }), + reject: vi.fn().mockResolvedValue({ ok: true }), + hangup: vi.fn().mockResolvedValue({ ok: true }), + mute: vi.fn().mockResolvedValue({ ok: true, microphone_muted: false }), + sendAudio: vi.fn().mockResolvedValue({ ok: true }), + }, rncp: { send: vi.fn().mockResolvedValue({ ok: true }), fetch: vi.fn().mockResolvedValue({ ok: true }), diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index 0406b7378..a669be218 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -45,6 +45,13 @@ import type { } from './rrc-types'; import type { SupportBundleMode } from './support-bundle.types'; import type { TAKClientInfo, TAKServerStatus, TAKSettings } from './tak-types'; +import type { + VoiceAudioRequest, + VoiceCallRequest, + VoiceMuteRequest, + VoiceOkResponse, + VoiceStatusResponse, +} from './voice-types'; export type { MeshProtocol, SupportBundleMode }; @@ -1105,6 +1112,16 @@ export interface ElectronAPI { disconnect: (opts: RnshDisconnectRequest) => Promise; getStatus: () => Promise; }; + /** LXST voice (rsLXST telephony) — thin wrappers over `/api/v1/voice/*`. */ + voice: { + getStatus: () => Promise; + call: (opts: VoiceCallRequest) => Promise; + answer: () => Promise; + reject: () => Promise; + hangup: () => Promise; + mute: (opts: VoiceMuteRequest) => Promise; + sendAudio: (opts: VoiceAudioRequest) => Promise; + }; /** * rncp (file transfer). `send` / `fetch` / `setListener` are picker-backed * (path must match the last `showOpenFileDialog` / `showSaveDirectoryDialog` diff --git a/src/shared/voice-types.test.ts b/src/shared/voice-types.test.ts new file mode 100644 index 000000000..9f78d8914 --- /dev/null +++ b/src/shared/voice-types.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { isVoiceActiveCall, isVoiceStatusResponse } from './voice-types'; + +describe('voice-types guards', () => { + it('accepts a valid status snapshot', () => { + expect( + isVoiceStatusResponse({ + available: true, + enabled: true, + running: true, + codec: 'opus', + active_call: null, + }), + ).toBe(true); + }); + + it('rejects garbage status', () => { + expect(isVoiceStatusResponse(null)).toBe(false); + expect(isVoiceStatusResponse({ available: true })).toBe(false); + }); + + it('accepts active_call shape', () => { + expect( + isVoiceActiveCall({ + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'ringing', + }), + ).toBe(true); + }); + + it('rejects incomplete active_call', () => { + expect(isVoiceActiveCall({ link_id: 'x' })).toBe(false); + }); +}); diff --git a/src/shared/voice-types.ts b/src/shared/voice-types.ts new file mode 100644 index 000000000..ac9c9da7f --- /dev/null +++ b/src/shared/voice-types.ts @@ -0,0 +1,72 @@ +/** Shared LXST voice types (sidecar HTTP + WS ↔ renderer). */ + +export type VoiceCallRole = 'incoming' | 'outgoing'; + +export type VoiceSignallingStatus = + 'busy' | 'rejected' | 'calling' | 'available' | 'ringing' | 'connecting' | 'established'; + +export interface VoiceActiveCall { + link_id: string; + remote_identity: string; + role: VoiceCallRole; + status: VoiceSignallingStatus; + profile?: number | null; + answered?: boolean; +} + +export interface VoiceStatusResponse { + available: boolean; + enabled: boolean; + running?: boolean; + microphone_muted?: boolean; + codec?: string; + reason?: string; + active_call?: VoiceActiveCall | null; + last_error?: string | null; +} + +export interface VoiceCallRequest { + identity_hash: string; +} + +export interface VoiceOkResponse { + ok: boolean; + error?: string; + identity_hash?: string; + microphone_muted?: boolean; + dropped?: string; +} + +export interface VoiceMuteRequest { + muted: boolean; +} + +export interface VoiceAudioRequest { + profile?: number; + channels: number; + samples_b64: string; +} + +export interface VoiceAudioPayload { + link_id: string; + profile: number; + channels: number; + samples_b64: string; +} + +export function isVoiceActiveCall(value: unknown): value is VoiceActiveCall { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.link_id === 'string' && + typeof v.remote_identity === 'string' && + typeof v.role === 'string' && + typeof v.status === 'string' + ); +} + +export function isVoiceStatusResponse(value: unknown): value is VoiceStatusResponse { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return typeof v.available === 'boolean' && typeof v.enabled === 'boolean'; +} From 69fbf565a0ae6b1327a27988b529831f4651e323 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 16:48:10 -0600 Subject: [PATCH 03/27] refactor(reticulum): drop ineffective peerStore dynamic imports Extract pure path-slot helpers so pathMedium can import the store statically without a cycle, silencing the Vite INEFFECTIVE_DYNAMIC_IMPORT warning. --- .../components/ReticulumPeerDetailModal.tsx | 2 +- .../lib/reticulum/reticulumPathMedium.ts | 37 +++++-------------- .../lib/reticulum/reticulumPathSlots.ts | 34 +++++++++++++++++ src/renderer/stores/reticulumPeerStore.ts | 2 +- 4 files changed, 45 insertions(+), 30 deletions(-) create mode 100644 src/renderer/lib/reticulum/reticulumPathSlots.ts diff --git a/src/renderer/components/ReticulumPeerDetailModal.tsx b/src/renderer/components/ReticulumPeerDetailModal.tsx index e55285b46..21710dd1a 100644 --- a/src/renderer/components/ReticulumPeerDetailModal.tsx +++ b/src/renderer/components/ReticulumPeerDetailModal.tsx @@ -34,6 +34,7 @@ import { writeClipboardText } from '@/renderer/lib/writeClipboardText'; import { useBlockStore } from '@/renderer/stores/blockStore'; import { useReticulumIdentityActivityStore } from '@/renderer/stores/reticulumIdentityActivityStore'; import { + refreshReticulumPeersFromSidecar, resolveReticulumPeerLabel, useReticulumPeerStore, } from '@/renderer/stores/reticulumPeerStore'; @@ -395,7 +396,6 @@ export default function ReticulumPeerDetailModal({ favorited: Boolean(peer.favorited), }); registerReticulumDestinationHash(reticulumHashToNodeId(key), key); - const { refreshReticulumPeersFromSidecar } = await import('../stores/reticulumPeerStore'); await refreshReticulumPeersFromSidecar(); } catch (e) { console.warn('[ReticulumPeerDetailModal] save contact ' + errLikeToLogString(e)); diff --git a/src/renderer/lib/reticulum/reticulumPathMedium.ts b/src/renderer/lib/reticulum/reticulumPathMedium.ts index 8e30e1270..9bf3c67fc 100644 --- a/src/renderer/lib/reticulum/reticulumPathMedium.ts +++ b/src/renderer/lib/reticulum/reticulumPathMedium.ts @@ -7,25 +7,21 @@ import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { classifyReticulumVia } from '@/renderer/lib/reticulum/classifyReticulumVia'; +import { + activeReticulumPathSlot, + backupReticulumPathSlots, + type PathMedium, + type ReticulumPathSlot, +} from '@/renderer/lib/reticulum/reticulumPathSlots'; import { isReticulumSidecarRunning } from '@/renderer/lib/reticulum/reticulumSidecarReads'; +import { applyReticulumPeerActivePathSlot } from '@/renderer/stores/reticulumPeerStore'; export type PathMediumPreference = 'lowest' | 'network' | 'rf'; -export type PathMedium = 'rf' | 'network'; +export type { PathMedium, ReticulumPathSlot }; +export { activeReticulumPathSlot, backupReticulumPathSlots }; /** UI pin control: Auto follows the global preference. */ export type PeerMediumPinChoice = 'auto' | PathMedium; -export interface ReticulumPathSlot { - active: boolean; - hops: number | null; - via_hash: string | null; - interface: string | null; - interface_id: number | null; - medium: PathMedium | null; - timestamp: number | null; - expires: number | null; - expired: boolean; -} - export interface ReticulumPeerPathsResult { ok: boolean; destination_hash?: string; @@ -174,20 +170,6 @@ export const RETICULUM_PATH_SETTLE_MS = 800; /** Second attempt when the first `/paths` read is still empty. */ export const RETICULUM_PATH_RETRY_MS = 1200; -/** Active path: marked active + live, else first live slot, else first slot. */ -export function activeReticulumPathSlot( - paths: readonly ReticulumPathSlot[], -): ReticulumPathSlot | null { - if (paths.length === 0) return null; - return paths.find((s) => s.active && !s.expired) ?? paths.find((s) => !s.expired) ?? paths[0]; -} - -/** Non-expired slots excluding the chosen active slot (transport caps at 3 total). */ -export function backupReticulumPathSlots(paths: readonly ReticulumPathSlot[]): ReticulumPathSlot[] { - const active = activeReticulumPathSlot(paths); - return paths.filter((s) => !s.expired && s !== active); -} - function sleepMs(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -203,7 +185,6 @@ export async function refreshReticulumPeerRouteFromPaths( hash: string, opts?: { settleMs?: number; retryMs?: number }, ): Promise { - const { applyReticulumPeerActivePathSlot } = await import('@/renderer/stores/reticulumPeerStore'); const settleMs = opts?.settleMs ?? 0; if (settleMs > 0) await sleepMs(settleMs); const first = await fetchReticulumPeerPaths(hash); diff --git a/src/renderer/lib/reticulum/reticulumPathSlots.ts b/src/renderer/lib/reticulum/reticulumPathSlots.ts new file mode 100644 index 000000000..a9c5db36f --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumPathSlots.ts @@ -0,0 +1,34 @@ +/** + * Pure path-slot types/helpers (no store / HTTP). + * + * Kept separate from `reticulumPathMedium` so `reticulumPeerStore` can depend on + * slot helpers without a cycle when pathMedium applies routes onto the store. + */ + +export type PathMedium = 'rf' | 'network'; + +export interface ReticulumPathSlot { + active: boolean; + hops: number | null; + via_hash: string | null; + interface: string | null; + interface_id: number | null; + medium: PathMedium | null; + timestamp: number | null; + expires: number | null; + expired: boolean; +} + +/** Active path: marked active + live, else first live slot, else first slot. */ +export function activeReticulumPathSlot( + paths: readonly ReticulumPathSlot[], +): ReticulumPathSlot | null { + if (paths.length === 0) return null; + return paths.find((s) => s.active && !s.expired) ?? paths.find((s) => !s.expired) ?? paths[0]; +} + +/** Non-expired slots excluding the chosen active slot (transport caps at 3 total). */ +export function backupReticulumPathSlots(paths: readonly ReticulumPathSlot[]): ReticulumPathSlot[] { + const active = activeReticulumPathSlot(paths); + return paths.filter((s) => !s.expired && s !== active); +} diff --git a/src/renderer/stores/reticulumPeerStore.ts b/src/renderer/stores/reticulumPeerStore.ts index 3f5d57847..d22c05291 100644 --- a/src/renderer/stores/reticulumPeerStore.ts +++ b/src/renderer/stores/reticulumPeerStore.ts @@ -13,7 +13,7 @@ import { import { activeReticulumPathSlot, type ReticulumPathSlot, -} from '@/renderer/lib/reticulum/reticulumPathMedium'; +} from '@/renderer/lib/reticulum/reticulumPathSlots'; import { MAX_MESH_ENTITY_CAP } from '@/renderer/lib/sessionMemoryCaps'; import { useNodeStore } from '@/renderer/stores/nodeStore'; import { From be42b7fce7fef034ecc7370fbfbaf05272ae71ba Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 17:17:51 -0600 Subject: [PATCH 04/27] fix(reticulum): voice call mini-panel, tones, and audit logging Keep hangup available during optimistic dial, improve identity resolution and LXST capability hints, add ringback/busy/fail tones with safety timeout, surface TX/RX stats, and log lifecycle-only call audits without per-packet noise. --- docs/reticulum-sidecar-ipc.md | 2 +- reticulum-sidecar/src/stack/voice_session.rs | 401 +++++++++++++++++- src/main/index.contract.test.ts | 1 + src/main/ipc/reticulum-handlers.test.ts | 36 +- src/main/ipc/reticulum-handlers.ts | 38 ++ ...eticulum-proxy-rate-limit.contract.test.ts | 12 + src/preload/index.ts | 4 +- src/renderer/components/ChatPanel.tsx | 7 + .../components/ReticulumPeerListPanel.tsx | 6 +- .../ReticulumVoiceCallButton.test.tsx | 6 +- .../reticulum/ReticulumVoiceCallButton.tsx | 61 ++- .../reticulum/ReticulumVoiceOverlay.test.tsx | 21 + .../reticulum/ReticulumVoiceOverlay.tsx | 133 ++++-- src/renderer/lib/reticulumVoiceAudio.test.ts | 13 +- src/renderer/lib/reticulumVoiceAudio.ts | 32 +- .../lib/reticulumVoiceCallTones.test.ts | 79 ++++ src/renderer/lib/reticulumVoiceCallTones.ts | 133 ++++++ .../lib/reticulumVoiceCapability.test.ts | 66 +++ src/renderer/lib/reticulumVoiceCapability.ts | 56 +++ .../lib/reticulumVoiceOutcome.test.ts | 22 + src/renderer/lib/reticulumVoiceOutcome.ts | 49 +++ .../lib/reticulumVoiceSession.test.ts | 126 ++++++ src/renderer/lib/reticulumVoiceSession.ts | 182 +++++++- src/renderer/lib/timeConstants.ts | 6 + src/renderer/locales/cs/translation.json | 20 + src/renderer/locales/de/translation.json | 20 + src/renderer/locales/en/translation.json | 20 + src/renderer/locales/es/translation.json | 20 + src/renderer/locales/fr/translation.json | 20 + src/renderer/locales/id/translation.json | 20 + src/renderer/locales/it/translation.json | 20 + src/renderer/locales/ja/translation.json | 20 + src/renderer/locales/ko/translation.json | 20 + src/renderer/locales/nl/translation.json | 20 + src/renderer/locales/pl/translation.json | 20 + src/renderer/locales/pt-BR/translation.json | 20 + src/renderer/locales/ru/translation.json | 20 + src/renderer/locales/tr/translation.json | 20 + src/renderer/locales/uk/translation.json | 20 + src/renderer/locales/zh/translation.json | 20 + src/renderer/runtime/useReticulumRuntime.ts | 15 +- .../runtime/useReticulumRuntime.voice.test.ts | 7 +- .../stores/reticulumVoiceStore.test.ts | 78 +++- src/renderer/stores/reticulumVoiceStore.ts | 189 +++++++-- src/shared/voice-types.test.ts | 29 +- src/shared/voice-types.ts | 38 ++ vitest.config.mts | 3 + 47 files changed, 2043 insertions(+), 128 deletions(-) create mode 100644 src/renderer/lib/reticulumVoiceCallTones.test.ts create mode 100644 src/renderer/lib/reticulumVoiceCallTones.ts create mode 100644 src/renderer/lib/reticulumVoiceCapability.test.ts create mode 100644 src/renderer/lib/reticulumVoiceCapability.ts create mode 100644 src/renderer/lib/reticulumVoiceOutcome.test.ts create mode 100644 src/renderer/lib/reticulumVoiceOutcome.ts create mode 100644 src/renderer/lib/reticulumVoiceSession.test.ts diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index ce4a6ada2..dd7cef999 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -231,7 +231,7 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi | POST | `/api/v1/voice/reject` | | Reject ringing call | | POST | `/api/v1/voice/hangup` | | End active call | | POST | `/api/v1/voice/mute` | `{ muted }` | Renderer mute flag (sidecar drops PCM ingest) | -| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX | +| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX. Renderer must use dedicated IPC `reticulum:voiceSendAudio` (own ~2000/min budget); generic `reticulum:proxyPost` rejects this path so realtime PCM does not starve the shared 300/min proxy ceiling. | | GET | `/api/v1/games/status` | | LRGP stub status | | GET | `/api/v1/identities` | | `{ identities: […] }` — slots under `config/identities//` + `active_identity`; working key remains `config/identity`; flat identity migrates to `identities/default/` | | POST | `/api/v1/identities` | `{ display_name? }` | `{ ok, id, identity }` or `{ ok: false, error }` — `rns-stack` only; stage slot → apply working key → commit pointer last (rollback on failure). Cap 16 slots. Errors: `identity_slot_limit_reached`, `display_name_*`. Emits restart. | diff --git a/reticulum-sidecar/src/stack/voice_session.rs b/reticulum-sidecar/src/stack/voice_session.rs index e05d15843..28b68fc76 100644 --- a/reticulum-sidecar/src/stack/voice_session.rs +++ b/reticulum-sidecar/src/stack/voice_session.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use base64::Engine as _; use lxst_core::{CallRole, Profile, RawAudioFrame, SignallingStatus}; @@ -24,6 +24,36 @@ use super::live::parse_hash16; const DEFAULT_CALL_PROFILE: Profile = Profile::QualityHigh; const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(15); +/// QualityHigh frame duration in ms (2880 samples @ 48 kHz). +const QUALITY_HIGH_FRAME_MS: u64 = 60; + +#[derive(Debug, Clone, Copy, Default)] +pub struct VoiceMediaCounters { + pub tx_frames: u64, + pub tx_packets: u64, + pub rx_frames: u64, + pub local_tx_drops: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VoiceAuditLogPhase { + None, + StartLogged, + OutcomeLogged, + EndLogged, +} + +#[derive(Debug, Clone)] +pub struct VoiceAuditSession { + pub role: &'static str, + pub remote_hex: String, + pub link_id_hex: Option, + pub started_at: Instant, + pub established_at: Option, + pub ever_established: bool, + log_phase: VoiceAuditLogPhase, +} + #[derive(Default)] struct VoiceState { running: bool, @@ -32,6 +62,8 @@ struct VoiceState { last_error: Option, /// Maps LXMF / peer destination hashes → identity hashes (from announce cache). dest_to_identity: HashMap, + media: VoiceMediaCounters, + audit: Option, } struct ManagerShared { @@ -205,7 +237,15 @@ impl VoiceSessionManager { .await { Ok(()) => json!({ "ok": true }), - Err(e) => json!({ "ok": false, "error": format!("voice control closed: {e}") }), + Err(e) => { + { + let mut st = self.shared.state.write().await; + if st.active_call.is_some() { + st.media.local_tx_drops = st.media.local_tx_drops.saturating_add(1); + } + } + json!({ "ok": false, "error": format!("voice control closed: {e}") }) + } } } @@ -242,12 +282,27 @@ impl VoiceSessionManager { async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent) { match evt { TelephonyServiceEvent::OutgoingCallPending { remote_identity } => { + let remote_hex = hex::encode(remote_identity); + let call = json!({ + "link_id": "", + "remote_identity": remote_hex, + "role": "outgoing", + "status": "calling", + "answered": false, + }); + { + let mut st = shared.state.write().await; + st.active_call = Some(call.clone()); + st.last_error = None; + st.media = VoiceMediaCounters::default(); + begin_audit_session(&mut st, "outgoing", &remote_hex, None); + } emit( &shared.event_tx, "voice.update", &json!({ "type": "outgoing_pending", - "remote_identity": hex::encode(remote_identity), + "remote_identity": remote_hex, }), ); } @@ -255,13 +310,31 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent link_id, remote_identity, } => { + let link_hex = hex::encode(link_id); + let remote_hex = hex::encode(remote_identity); + let call = json!({ + "link_id": link_hex, + "remote_identity": remote_hex, + "role": "outgoing", + "status": "connecting", + "answered": false, + }); + { + let mut st = shared.state.write().await; + st.active_call = Some(call.clone()); + if let Some(audit) = st.audit.as_mut() { + audit.link_id_hex = Some(link_hex.clone()); + } else { + begin_audit_session(&mut st, "outgoing", &remote_hex, Some(link_hex.clone())); + } + } emit( &shared.event_tx, "voice.update", &json!({ "type": "outgoing", - "link_id": hex::encode(link_id), - "remote_identity": hex::encode(remote_identity), + "link_id": link_hex, + "remote_identity": remote_hex, }), ); } @@ -269,17 +342,19 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent remote_identity, message, } => { + let remote_hex = hex::encode(remote_identity); { let mut st = shared.state.write().await; st.active_call = None; st.last_error = Some(message.clone()); + log_audit_outcome_and_end(&mut st, "failed", Some(message.as_str())); } emit( &shared.event_tx, "voice.error", &json!({ "type": "outgoing_failed", - "remote_identity": hex::encode(remote_identity), + "remote_identity": remote_hex, "message": message, }), ); @@ -288,9 +363,11 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent link_id, remote_identity, } => { + let link_hex = hex::encode(link_id); + let remote_hex = hex::encode(remote_identity); let call = json!({ - "link_id": hex::encode(link_id), - "remote_identity": hex::encode(remote_identity), + "link_id": link_hex, + "remote_identity": remote_hex, "role": "incoming", "status": "ringing", "answered": false, @@ -299,20 +376,30 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent let mut st = shared.state.write().await; st.active_call = Some(call.clone()); st.last_error = None; + st.media = VoiceMediaCounters::default(); + begin_audit_session(&mut st, "incoming", &remote_hex, Some(link_hex)); } emit(&shared.event_tx, "voice.incoming", &call); } TelephonyServiceEvent::CallTerminated { link_id, reason } => { + let reason_str = reason.map(signalling_status_str); + let outcome = match reason_str { + Some("busy") => "busy", + Some("rejected") => "rejected", + Some("established") => "completed", + Some(_) | None => "terminated", + }; { let mut st = shared.state.write().await; st.active_call = None; + log_audit_outcome_and_end(&mut st, outcome, reason_str); } emit( &shared.event_tx, "voice.terminated", &json!({ "link_id": hex::encode(link_id), - "reason": reason.map(signalling_status_str), + "reason": reason_str, }), ); } @@ -330,6 +417,11 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent { let mut st = shared.state.write().await; st.active_call = active.clone(); + if let Some(c) = snap.active_call.as_ref() { + if c.status == SignallingStatus::Established { + mark_audit_established(&mut st); + } + } } emit( &shared.event_tx, @@ -342,6 +434,39 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent }), ); } + TelephonyServiceEvent::MediaSent { + link_id, + frames, + packets, + } => { + let link_hex = hex::encode(link_id); + let payload = { + let mut st = shared.state.write().await; + st.media.tx_frames = st.media.tx_frames.saturating_add(frames as u64); + st.media.tx_packets = st.media.tx_packets.saturating_add(packets as u64); + json!({ + "link_id": link_hex, + "tx_frames": st.media.tx_frames, + "tx_packets": st.media.tx_packets, + "rx_frames": st.media.rx_frames, + }) + }; + emit(&shared.event_tx, "voice.stats", &payload); + } + TelephonyServiceEvent::MediaReceived { link_id, frames } => { + let link_hex = hex::encode(link_id); + let payload = { + let mut st = shared.state.write().await; + st.media.rx_frames = st.media.rx_frames.saturating_add(frames as u64); + json!({ + "link_id": link_hex, + "tx_frames": st.media.tx_frames, + "tx_packets": st.media.tx_packets, + "rx_frames": st.media.rx_frames, + }) + }; + emit(&shared.event_tx, "voice.stats", &payload); + } TelephonyServiceEvent::OpusFramesReceived { link_id, profile, @@ -365,6 +490,10 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent { let mut st = shared.state.write().await; st.last_error = Some(message.clone()); + if st.active_call.is_some() { + st.active_call = None; + log_audit_outcome_and_end(&mut st, "error", Some(message.as_str())); + } } emit( &shared.event_tx, @@ -376,11 +505,11 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent let mut st = shared.state.write().await; st.running = false; st.active_call = None; + if st.audit.is_some() { + log_audit_outcome_and_end(&mut st, "stopped", None); + } } - // Media accounting / stream lifecycle — not required by the UI. - TelephonyServiceEvent::MediaSent { .. } - | TelephonyServiceEvent::MediaReceived { .. } - | TelephonyServiceEvent::OpusTransmitStreamStarted { .. } + TelephonyServiceEvent::OpusTransmitStreamStarted { .. } | TelephonyServiceEvent::OpusTransmitStreamStopped { .. } | TelephonyServiceEvent::OpusReceiveStreamStarted { .. } | TelephonyServiceEvent::OpusReceiveStreamStopped { .. } @@ -389,6 +518,170 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent } } +fn begin_audit_session( + st: &mut VoiceState, + role: &'static str, + remote_hex: &str, + link_id_hex: Option, +) { + st.audit = Some(VoiceAuditSession { + role, + remote_hex: remote_hex.to_string(), + link_id_hex, + started_at: Instant::now(), + established_at: None, + ever_established: false, + log_phase: VoiceAuditLogPhase::None, + }); + log_audit_start(st); +} + +fn log_audit_start(st: &mut VoiceState) { + let Some(audit) = st.audit.as_mut() else { + return; + }; + if audit.log_phase != VoiceAuditLogPhase::None { + return; + } + audit.log_phase = VoiceAuditLogPhase::StartLogged; + let remote_prefix = remote_prefix(&audit.remote_hex); + let link = audit.link_id_hex.as_deref().unwrap_or("-"); + tracing::info!( + target: "voice", + "call start role={} remote={} link_id={}", + audit.role, + remote_prefix, + link + ); +} + +fn mark_audit_established(st: &mut VoiceState) { + let Some(audit) = st.audit.as_mut() else { + return; + }; + if audit.ever_established { + return; + } + audit.ever_established = true; + audit.established_at = Some(Instant::now()); + if matches!( + audit.log_phase, + VoiceAuditLogPhase::None | VoiceAuditLogPhase::StartLogged + ) { + audit.log_phase = VoiceAuditLogPhase::OutcomeLogged; + let remote_prefix = remote_prefix(&audit.remote_hex); + tracing::info!( + target: "voice", + "call connected role={} remote={}", + audit.role, + remote_prefix + ); + } +} + +fn log_audit_outcome_and_end(st: &mut VoiceState, outcome: &str, reason: Option<&str>) { + let Some(mut audit) = st.audit.take() else { + return; + }; + if matches!( + audit.log_phase, + VoiceAuditLogPhase::None | VoiceAuditLogPhase::StartLogged + ) { + audit.log_phase = VoiceAuditLogPhase::OutcomeLogged; + let remote_prefix = remote_prefix(&audit.remote_hex); + let reason_s = reason.unwrap_or(outcome); + let successful = + outcome == "completed" || (audit.ever_established && outcome == "terminated"); + if successful && audit.ever_established && outcome == "terminated" { + tracing::info!( + target: "voice", + "call ended role={} remote={} reason={}", + audit.role, + remote_prefix, + reason_s + ); + } else if outcome == "completed" { + tracing::info!( + target: "voice", + "call connected role={} remote={}", + audit.role, + remote_prefix + ); + } else { + tracing::warn!( + target: "voice", + "call {} role={} remote={} reason={}", + outcome, + audit.role, + remote_prefix, + reason_s + ); + } + } + if audit.log_phase != VoiceAuditLogPhase::EndLogged { + audit.log_phase = VoiceAuditLogPhase::EndLogged; + let summary = format_voice_audit_end_summary(&audit, outcome, &st.media, Instant::now()); + tracing::info!(target: "voice", "{summary}"); + } + st.media = VoiceMediaCounters::default(); +} + +fn remote_prefix(remote_hex: &str) -> &str { + let len = remote_hex.len().min(16); + &remote_hex[..len] +} + +/// Estimated RX gap percent from established duration vs received frames (QualityHigh). +pub fn estimate_rx_gap_pct(established_ms: u64, rx_frames: u64) -> u32 { + if established_ms == 0 { + return 0; + } + let expected = established_ms.saturating_add(QUALITY_HIGH_FRAME_MS.saturating_sub(1)) + / QUALITY_HIGH_FRAME_MS; + if expected == 0 { + return 0; + } + if rx_frames >= expected { + return 0; + } + let missing = expected - rx_frames; + ((missing.saturating_mul(100)) / expected) as u32 +} + +/// Lifecycle end summary line (no per-packet logs). Pure for unit tests. +pub fn format_voice_audit_end_summary( + audit: &VoiceAuditSession, + outcome: &str, + media: &VoiceMediaCounters, + now: Instant, +) -> String { + let duration_ms = now.duration_since(audit.started_at).as_millis(); + let loss_field = if audit.ever_established { + let established_ms = audit + .established_at + .map(|t| now.duration_since(t).as_millis() as u64) + .unwrap_or(0); + format!( + "est_rx_gap_pct={}", + estimate_rx_gap_pct(established_ms, media.rx_frames) + ) + } else { + "packet_loss=n/a".to_string() + }; + format!( + "call end outcome={} role={} remote={} duration_ms={} tx_frames={} tx_packets={} rx_frames={} local_tx_drops={} {}", + outcome, + audit.role, + remote_prefix(&audit.remote_hex), + duration_ms, + media.tx_frames, + media.tx_packets, + media.rx_frames, + media.local_tx_drops, + loss_field + ) +} + fn emit(event_tx: &broadcast::Sender, event_type: &str, payload: &serde_json::Value) { let frame = json!({ "type": event_type, "payload": payload }); let _ = event_tx.send(frame.to_string()); @@ -508,6 +801,70 @@ mod tests { assert_eq!(Profile::QualityHigh.sample_frames_per_packet(), 2_880); } + #[test] + fn estimate_rx_gap_pct_zero_when_full() { + // 600 ms ≈ 10 QualityHigh frames. + assert_eq!(estimate_rx_gap_pct(600, 10), 0); + assert_eq!(estimate_rx_gap_pct(600, 5), 50); + assert_eq!(estimate_rx_gap_pct(600, 0), 100); + } + + #[test] + fn audit_end_summary_marks_packet_loss_na_when_never_established() { + let started = Instant::now(); + let audit = VoiceAuditSession { + role: "outgoing", + remote_hex: "aabbccddeeff00112233445566778899".into(), + link_id_hex: None, + started_at: started, + established_at: None, + ever_established: false, + log_phase: VoiceAuditLogPhase::OutcomeLogged, + }; + let summary = format_voice_audit_end_summary( + &audit, + "failed", + &VoiceMediaCounters::default(), + started + Duration::from_secs(2), + ); + assert!(summary.contains("outcome=failed")); + assert!(summary.contains("packet_loss=n/a")); + assert!(summary.contains("tx_frames=0")); + assert!(summary.contains("tx_packets=0")); + assert!(summary.contains("rx_frames=0")); + } + + #[test] + fn audit_end_summary_includes_est_rx_gap_after_established() { + let started = Instant::now(); + let established = started + Duration::from_secs(1); + let audit = VoiceAuditSession { + role: "outgoing", + remote_hex: "aabbccddeeff00112233445566778899".into(), + link_id_hex: Some("11".repeat(16)), + started_at: started, + established_at: Some(established), + ever_established: true, + log_phase: VoiceAuditLogPhase::OutcomeLogged, + }; + let media = VoiceMediaCounters { + tx_frames: 20, + tx_packets: 20, + rx_frames: 5, + local_tx_drops: 1, + }; + let summary = format_voice_audit_end_summary( + &audit, + "completed", + &media, + established + Duration::from_millis(600), + ); + assert!(summary.contains("tx_packets=20")); + assert!(summary.contains("local_tx_drops=1")); + assert!(summary.contains("est_rx_gap_pct=")); + assert!(!summary.contains("packet_loss=n/a")); + } + #[tokio::test] async fn status_shape_when_manager_spawns() { let (transport_tx, mut transport_rx) = mpsc::channel::(4); @@ -624,6 +981,23 @@ mod tests { .await; let update = event_rx.try_recv().expect("voice.update"); assert!(update.contains("\"type\":\"voice.update\"")); + assert!( + shared.state.read().await.active_call.is_some(), + "outgoing pending must set active_call" + ); + + bridge_service_event( + &shared, + TelephonyServiceEvent::MediaSent { + link_id: link, + frames: 2, + packets: 2, + }, + ) + .await; + let stats = event_rx.try_recv().expect("voice.stats"); + assert!(stats.contains("\"type\":\"voice.stats\"")); + assert!(stats.contains("\"tx_frames\":2")); bridge_service_event( &shared, @@ -636,6 +1010,7 @@ mod tests { let terminated = event_rx.try_recv().expect("voice.terminated"); assert!(terminated.contains("\"type\":\"voice.terminated\"")); assert!(shared.state.read().await.active_call.is_none()); + assert!(shared.state.read().await.audit.is_none()); bridge_service_event( &shared, diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 0c6710c47..3362d45cf 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -317,6 +317,7 @@ describe('Reticulum sidecar IPC handlers (source contract)', () => { expect(PRELOAD_SOURCE).toContain("'/api/v1/rrc/hubs'"); expect(PRELOAD_SOURCE).toContain('rrc:'); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyPost'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:voiceSendAudio'"); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyPut'"); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyDelete'"); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:readDefaultConfigFile'"); diff --git a/src/main/ipc/reticulum-handlers.test.ts b/src/main/ipc/reticulum-handlers.test.ts index 9a9847356..404b6ddd4 100644 --- a/src/main/ipc/reticulum-handlers.test.ts +++ b/src/main/ipc/reticulum-handlers.test.ts @@ -148,6 +148,7 @@ describe('registerReticulumIpcHandlers', () => { 'reticulum:syncInterfaceIssueScope', 'reticulum:proxyGet', 'reticulum:proxyPost', + 'reticulum:voiceSendAudio', 'reticulum:proxyPut', 'reticulum:proxyDelete', 'reticulum:readDefaultConfigFile', @@ -174,6 +175,10 @@ describe('registerReticulumIpcHandlers', () => { handlers.get('reticulum:syncInterfaceIssueScope')?.(event, []); await handlers.get('reticulum:proxyGet')?.(event, '/api/v1/x'); await handlers.get('reticulum:proxyPost')?.(event, '/api/v1/x', {}); + await handlers.get('reticulum:voiceSendAudio')?.(event, { + channels: 1, + samples_b64: 'AAAA', + }); await handlers.get('reticulum:proxyPut')?.(event, '/api/v1/x', {}); await handlers.get('reticulum:proxyDelete')?.(event, '/api/v1/x'); handlers.get('reticulum:readDefaultConfigFile')?.(event); @@ -183,9 +188,10 @@ describe('registerReticulumIpcHandlers', () => { await handlers.get('reticulum:setNomadContentSource')?.(event, '/tmp/site'); await handlers.get('reticulum:validateConfig')?.(event); - expect(assertIpcSenderMock).toHaveBeenCalledTimes(14); + expect(assertIpcSenderMock).toHaveBeenCalledTimes(15); expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:start'); expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:proxyPost'); + expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:voiceSendAudio'); expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:setNomadContentSource'); expect(assertIpcSenderMock).toHaveBeenCalledWith(event, 'reticulum:validateConfig'); }); @@ -592,6 +598,34 @@ describe('registerReticulumIpcHandlers', () => { ).rejects.toThrow(/rncpSend\/rncpFetch\/setRncpListener/); expect(manager.proxyPost).not.toHaveBeenCalled(); }); + + it('proxyPost rejects voice PCM path; voiceSendAudio forwards validated frames', async () => { + await expect( + handlers.get('reticulum:proxyPost')?.(event, '/api/v1/voice/audio', { + channels: 1, + samples_b64: 'AAAA', + }), + ).rejects.toThrow(/voiceSendAudio/); + expect(manager.proxyPost).not.toHaveBeenCalled(); + + const ok = await handlers.get('reticulum:voiceSendAudio')?.(event, { + channels: 1, + samples_b64: 'AAAA', + profile: 0x50, + }); + expect(ok).toEqual({ ok: true }); + expect(manager.proxyPost).toHaveBeenCalledWith('/api/v1/voice/audio', { + channels: 1, + samples_b64: 'AAAA', + profile: 0x50, + }); + + const bad = await handlers.get('reticulum:voiceSendAudio')?.(event, { + channels: 1, + samples_b64: '', + }); + expect(bad).toEqual({ ok: false, error: 'empty_samples_b64' }); + }); }); }); diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index fc50be9d8..65a4f3670 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -12,6 +12,7 @@ import { reticulumProxyIpcErrorEnvelope, } from '../../shared/reticulumProxyIpcError'; import { MS_PER_MINUTE } from '../../shared/timeConstants'; +import { parseVoiceAudioRequest, VOICE_AUDIO_API_PATH } from '../../shared/voice-types'; import { createIpcRateLimiter } from '../ipcRateLimit'; import { sanitizeLogMessage } from '../log-service'; import { @@ -43,6 +44,20 @@ const reticulumProxyIpcRateLimit = createIpcRateLimiter({ label: 'reticulum:proxy', }); +/** + * Realtime LXST PCM ingest: QualityHigh is ~16.7 frames/s (~1000/min). + * Separate from the shared 300/min proxy bucket so calls do not starve mesh control IPC. + */ +const reticulumVoiceAudioIpcRateLimit = createIpcRateLimiter({ + max: 2000, + windowMs: MS_PER_MINUTE, + label: 'reticulum:voiceSendAudio', +}); + +function isVoiceAudioApiPath(apiPath: string): boolean { + return apiPath === VOICE_AUDIO_API_PATH; +} + export interface ReticulumIpcDeps { idleStatus: ReticulumSidecarStatus; ensureManager: () => ReticulumSidecarManager; @@ -200,6 +215,9 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { 'rncp send/fetch/listener changes require reticulum:rncpSend/rncpFetch/setRncpListener (picker-backed)', ); } + if (isVoiceAudioApiPath(pathArg)) { + throw new Error('voice PCM ingest requires reticulum:voiceSendAudio'); + } try { const m = ensureManager(); return await m.proxyPost(pathArg, body); @@ -209,6 +227,26 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { } }); + /** + * Realtime LXST PCM frames. Uses a dedicated rate limit (not the shared 300/min + * proxy ceiling) so voice TX does not starve control-plane proxy IPC. + */ + ipcMain.handle('reticulum:voiceSendAudio', async (event, opts: unknown) => { + assertIpcSender(event, 'reticulum:voiceSendAudio'); + reticulumVoiceAudioIpcRateLimit.checkOrThrow(); + const parsed = parseVoiceAudioRequest(opts); + if ('error' in parsed) { + return { ok: false, error: parsed.error }; + } + try { + const m = ensureManager(); + return await m.proxyPost(VOICE_AUDIO_API_PATH, parsed); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('voiceSendAudio', err, VOICE_AUDIO_API_PATH); + } + }); + ipcMain.handle('reticulum:factoryReset', async (event) => { assertIpcSender(event, 'reticulum:factoryReset'); try { diff --git a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts index e7d4454a6..3f7566c98 100644 --- a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts +++ b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts @@ -41,6 +41,18 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () } }); + it('routes LXST PCM through a dedicated higher-budget IPC channel', () => { + expect(HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:voiceSendAudio'"); + expect(HANDLERS_SOURCE).toMatch(/max:\s*2000/); + expect(HANDLERS_SOURCE).toContain("label: 'reticulum:voiceSendAudio'"); + expect(HANDLERS_SOURCE).toContain('reticulumVoiceAudioIpcRateLimit.checkOrThrow()'); + expect(HANDLERS_SOURCE).toContain('voice PCM ingest requires reticulum:voiceSendAudio'); + expect(HANDLERS_SOURCE).toContain('VOICE_AUDIO_API_PATH'); + const preload = readFileSync(join(__dirname, '../../preload/index.ts'), 'utf-8'); + expect(preload).toContain("ipcRenderer.invoke('reticulum:voiceSendAudio'"); + expect(preload).not.toMatch(/invoke\('reticulum:proxyPost',\s*'\/api\/v1\/voice\/audio'/); + }); + it('aligns sidecar peer cache and WS added batch with ~100k scale', () => { expect(SIDECAR_STACK_SOURCE).toMatch(/const MAX_PEER_CACHE: usize = 100_000;/); expect(SIDECAR_LIVE_SOURCE).toMatch(/const MAX_PEERS_UPDATED_ADDED: usize = 4096;/); diff --git a/src/preload/index.ts b/src/preload/index.ts index 9cb1ebf39..2d97469fa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1188,9 +1188,7 @@ contextBridge.exposeInMainWorld('electronAPI', { mute: (opts: { muted: boolean }) => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/mute', opts)), sendAudio: (opts: { profile?: number; channels: number; samples_b64: string }) => - unwrapReticulumProxy( - ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/voice/audio', opts), - ), + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:voiceSendAudio', opts)), }, rncp: { send: (opts: { destination_hash: string; path: string }) => diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index d7a8d51eb..a752d679f 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -1744,6 +1744,11 @@ function ChatPanel({ return peer?.hops ?? null; }); + const reticulumDmIdentityHash = useReticulumPeerStore((s) => { + if (!reticulumDmDestinationHash) return null; + return s.getPeer(reticulumDmDestinationHash)?.identity_hash ?? null; + }); + const reticulumDmPassiveHops = useMemo(() => { if (reticulumDmPeerHops != null) return reticulumDmPeerHops; if (activeDmNode == null) return null; @@ -2282,8 +2287,10 @@ function ChatPanel({ ) : null; const peerDetailsAppearance = reticulumDmDestinationHash diff --git a/src/renderer/components/ReticulumPeerListPanel.tsx b/src/renderer/components/ReticulumPeerListPanel.tsx index 26327fed9..1a9e407f0 100644 --- a/src/renderer/components/ReticulumPeerListPanel.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.tsx @@ -583,7 +583,11 @@ export default function ReticulumPeerListPanel({ {hasLxstVoice ? ( - + ) : null} + + + {showHelp ? ( + + {t('reticulumVoice.help.interop')} + + ) : null} + ); } diff --git a/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx index 7c70725f1..f70337b1a 100644 --- a/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx +++ b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx @@ -21,6 +21,7 @@ vi.mock('@/renderer/lib/reticulumVoiceSession', () => ({ reticulumVoiceSetMuted: (...args: unknown[]) => setMuted(...args), startReticulumVoiceMediaForActiveCall: vi.fn(), stopReticulumVoiceMedia: vi.fn(), + syncReticulumVoiceProgressTones: vi.fn(), })); describe('ReticulumVoiceOverlay', () => { @@ -85,4 +86,24 @@ describe('ReticulumVoiceOverlay', () => { hydrateAxeThemeColors(container); expect(await axe(container)).toHaveNoViolations(); }); + + it('shows hangup while optimistic calling with TX/RX counters', async () => { + const user = userEvent.setup(); + act(() => { + useReticulumVoiceStore.getState().beginOutgoing('b'.repeat(32)); + useReticulumVoiceStore.getState().applyStats({ + tx_frames: 4, + tx_packets: 3, + rx_frames: 1, + }); + }); + const { container } = render(); + expect(screen.getByRole('status', { name: /calling/i })).toBeTruthy(); + expect(screen.getByText(/TX 4/i)).toBeTruthy(); + expect(screen.getByText(/RX 1/i)).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /hang up voice call/i })); + expect(hangup).toHaveBeenCalled(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); }); diff --git a/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx b/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx index 8b3d01ccb..6e086a331 100644 --- a/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx +++ b/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx @@ -1,5 +1,5 @@ import { Mic, MicOff, PhoneOff } from 'lucide-react-motion'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -9,27 +9,73 @@ import { reticulumVoiceSetMuted, startReticulumVoiceMediaForActiveCall, stopReticulumVoiceMedia, + syncReticulumVoiceProgressTones, } from '@/renderer/lib/reticulumVoiceSession'; import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; +function formatElapsed(ms: number): string { + const totalSec = Math.max(0, Math.floor(ms / 1000)); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +function phaseLabelKey(status: string): string { + switch (status) { + case 'calling': + case 'ringing': + return 'reticulumVoice.calling'; + case 'connecting': + return 'reticulumVoice.connecting'; + case 'established': + return 'reticulumVoice.inCall'; + default: + return 'reticulumVoice.calling'; + } +} + /** - * Incoming-call modal + compact in-call bar. Mount once from App when hasLxstVoice. + * Incoming-call modal + compact in-call mini-panel. Mount once from App when hasLxstVoice. */ export function ReticulumVoiceOverlay() { const { t } = useTranslation(); const incoming = useReticulumVoiceStore((s) => s.incomingCall); const active = useReticulumVoiceStore((s) => s.activeCall); const muted = useReticulumVoiceStore((s) => s.microphoneMuted); + const callStartedAtMs = useReticulumVoiceStore((s) => s.callStartedAtMs); + const stats = useReticulumVoiceStore((s) => s.stats); + const [nowMs, setNowMs] = useState(null); + + const activeStatus = active?.status ?? null; + const activeLinkId = active?.link_id ?? null; useEffect(() => { - const status = active?.status; - if (status === 'established' || status === 'connecting') { + syncReticulumVoiceProgressTones(activeStatus); + if (activeStatus === 'established' || activeStatus === 'connecting') { void startReticulumVoiceMediaForActiveCall(); } - if (active == null) { + if (activeStatus == null) { stopReticulumVoiceMedia(); + syncReticulumVoiceProgressTones(null); } - }, [active]); + }, [activeStatus, activeLinkId]); + + useEffect(() => { + if (callStartedAtMs == null) return; + const immediate = setTimeout(() => { + setNowMs(Date.now()); + }, 0); + const id = setInterval(() => { + setNowMs(Date.now()); + }, 1000); + return () => { + clearTimeout(immediate); + clearInterval(id); + }; + }, [callStartedAtMs]); + + const elapsedMs = + callStartedAtMs == null || nowMs == null ? 0 : Math.max(0, nowMs - callStartedAtMs); const showIncoming = incoming?.role === 'incoming' && @@ -85,38 +131,53 @@ export function ReticulumVoiceOverlay() { return (
- - {active.status === 'calling' || active.status === 'connecting' - ? t('reticulumVoice.calling') - : t('reticulumVoice.inCall')} - - - {active.remote_identity} - - - +
+ {t(phaseLabelKey(active.status))} + + {formatElapsed(elapsedMs)} + + + {active.remote_identity} + + + +
+
+ + {t('reticulumVoice.txFrames', { count: stats.txFrames })} + {stats.txPackets > 0 + ? ` · ${t('reticulumVoice.txPackets', { count: stats.txPackets })}` + : ''} + + + {t('reticulumVoice.rxFrames', { count: stats.rxFrames })} + +
); } diff --git a/src/renderer/lib/reticulumVoiceAudio.test.ts b/src/renderer/lib/reticulumVoiceAudio.test.ts index 4d237fbee..63ad37a63 100644 --- a/src/renderer/lib/reticulumVoiceAudio.test.ts +++ b/src/renderer/lib/reticulumVoiceAudio.test.ts @@ -40,14 +40,21 @@ describe('reticulumVoiceAudio', () => { resolveVoiceDialIdentityHash({ identityHash: id, candidateIdentityHashes: ['b'.repeat(32)], + destinationHash: 'c'.repeat(32), }), - ).toEqual({ identityHash: id }); + ).toEqual({ dialHash: id, source: 'identity' }); }); - it('falls back to candidates and surfaces error key', () => { + it('falls back to candidates then destination hash', () => { const id = 'c'.repeat(32); + const dest = 'd'.repeat(32); expect(resolveVoiceDialIdentityHash({ candidateIdentityHashes: [id] })).toEqual({ - identityHash: id, + dialHash: id, + source: 'candidate', + }); + expect(resolveVoiceDialIdentityHash({ destinationHash: dest })).toEqual({ + dialHash: dest, + source: 'destination', }); expect(resolveVoiceDialIdentityHash({})).toEqual({ errorKey: 'reticulumVoice.errors.noIdentity', diff --git a/src/renderer/lib/reticulumVoiceAudio.ts b/src/renderer/lib/reticulumVoiceAudio.ts index fba52dac5..cc76a4137 100644 --- a/src/renderer/lib/reticulumVoiceAudio.ts +++ b/src/renderer/lib/reticulumVoiceAudio.ts @@ -82,18 +82,34 @@ export function packQualityHighFrame( return out; } -/** Resolve identity hash for dial: prefer identity_hash, else first from candidates. */ +export type VoiceDialResolution = + | { dialHash: string; source: 'identity' | 'candidate' | 'destination' } + | { errorKey: 'reticulumVoice.errors.noIdentity' }; + +function asHash32(value: string | null | undefined): string | null { + const h = value?.trim().toLowerCase() ?? ''; + return /^[0-9a-f]{32}$/.test(h) ? h : null; +} + +/** + * Resolve dial target for `/voice/call`: + * 1) explicit identity_hash + * 2) candidate identities (activity / Remote) + * 3) LXMF destination hash (sidecar dest→identity cache) + */ export function resolveVoiceDialIdentityHash(opts: { identityHash?: string | null; candidateIdentityHashes?: Iterable; -}): { identityHash: string } | { errorKey: 'reticulumVoice.errors.noIdentity' } { - const direct = opts.identityHash?.trim().toLowerCase(); - if (direct && /^[0-9a-f]{32}$/.test(direct)) { - return { identityHash: direct }; - } + /** LXMF peer destination — last-resort dial target for sidecar resolve. */ + destinationHash?: string | null; +}): VoiceDialResolution { + const direct = asHash32(opts.identityHash); + if (direct) return { dialHash: direct, source: 'identity' }; for (const c of opts.candidateIdentityHashes ?? []) { - const h = c.trim().toLowerCase(); - if (/^[0-9a-f]{32}$/.test(h)) return { identityHash: h }; + const h = asHash32(c); + if (h) return { dialHash: h, source: 'candidate' }; } + const dest = asHash32(opts.destinationHash); + if (dest) return { dialHash: dest, source: 'destination' }; return { errorKey: 'reticulumVoice.errors.noIdentity' }; } diff --git a/src/renderer/lib/reticulumVoiceCallTones.test.ts b/src/renderer/lib/reticulumVoiceCallTones.test.ts new file mode 100644 index 000000000..3f71e41de --- /dev/null +++ b/src/renderer/lib/reticulumVoiceCallTones.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CHAT_NOTIF_MUTED_STORAGE_KEY } from './chatInactiveNotifications'; +import { + playVoiceBusyTone, + playVoiceFailTone, + resetVoiceCallTonesForTests, + startVoiceRingback, + stopVoiceCallTones, +} from './reticulumVoiceCallTones'; + +describe('reticulumVoiceCallTones', () => { + let oscillatorCount = 0; + + beforeEach(() => { + oscillatorCount = 0; + resetVoiceCallTonesForTests(); + localStorage.removeItem(CHAT_NOTIF_MUTED_STORAGE_KEY); + class MockAudioContext { + state: AudioContextState = 'running'; + currentTime = 0; + destination = {} as AudioDestinationNode; + createOscillator() { + oscillatorCount += 1; + return { + frequency: { value: 0 }, + connect: () => undefined, + start: () => undefined, + stop: () => undefined, + }; + } + createGain() { + return { + gain: { + setValueAtTime: () => undefined, + exponentialRampToValueAtTime: () => undefined, + }, + connect: () => undefined, + }; + } + resume() { + return Promise.resolve(); + } + } + vi.stubGlobal('AudioContext', MockAudioContext); + }); + + afterEach(() => { + resetVoiceCallTonesForTests(); + // Do not vi.unstubAllGlobals() — that strips jsdom localStorage for later tests. + vi.stubGlobal('AudioContext', undefined); + }); + + it('starts ringback oscillators and stops cleanly', () => { + startVoiceRingback(); + expect(oscillatorCount).toBeGreaterThan(0); + const afterStart = oscillatorCount; + startVoiceRingback(); // idempotent + expect(oscillatorCount).toBe(afterStart); + stopVoiceCallTones(); + }); + + it('plays busy and fail tones', () => { + playVoiceBusyTone(); + expect(oscillatorCount).toBeGreaterThan(0); + oscillatorCount = 0; + playVoiceFailTone(); + expect(oscillatorCount).toBe(2); + }); + + it('suppresses tones when notif muted', () => { + localStorage.setItem(CHAT_NOTIF_MUTED_STORAGE_KEY, '1'); + startVoiceRingback(); + playVoiceBusyTone(); + playVoiceFailTone(); + expect(oscillatorCount).toBe(0); + }); +}); diff --git a/src/renderer/lib/reticulumVoiceCallTones.ts b/src/renderer/lib/reticulumVoiceCallTones.ts new file mode 100644 index 000000000..c7c93f0ba --- /dev/null +++ b/src/renderer/lib/reticulumVoiceCallTones.ts @@ -0,0 +1,133 @@ +/** + * LXST call progress tones (ringback / busy / fail) via Web Audio. + * Honors global chat notification mute (`mesh-client:notifMuted`). + */ + +import { CHAT_NOTIF_MUTED_STORAGE_KEY } from '@/renderer/lib/chatInactiveNotifications'; + +let sharedAudioContext: AudioContext | null = null; +let ringbackTimer: ReturnType | null = null; +let busyStopTimer: ReturnType | null = null; + +function getSharedAudioContext(): AudioContext | null { + try { + if (!sharedAudioContext || sharedAudioContext.state === 'closed') { + sharedAudioContext = new AudioContext(); + } + return sharedAudioContext; + } catch { + // catch-no-log-ok: AudioContext unavailable in test/headless environments + return null; + } +} + +/** @internal Test helper — reset singleton between tests. */ +export function resetVoiceCallTonesForTests(): void { + stopVoiceCallTones(); + sharedAudioContext = null; +} + +function isNotifMuted(): boolean { + try { + return localStorage.getItem(CHAT_NOTIF_MUTED_STORAGE_KEY) === '1'; + } catch { + // catch-no-log-ok: localStorage may throw in private/restricted contexts + return false; + } +} + +function playTonePulse(ctx: AudioContext, freq: number, dur: number, startTime: number): void { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.25, startTime); + gain.gain.exponentialRampToValueAtTime(0.001, startTime + dur); + osc.start(startTime); + osc.stop(startTime + dur); +} + +function withRunningContext(run: (ctx: AudioContext) => void): void { + if (isNotifMuted()) return; + const ctx = getSharedAudioContext(); + if (!ctx) return; + const go = () => { + try { + run(ctx); + } catch { + // catch-no-log-ok: AudioContext unavailable in test/headless environments + } + }; + if (ctx.state === 'suspended') { + void ctx + .resume() + .then(go) + .catch(() => { + // catch-no-log-ok: resume blocked without user gesture in some environments + }); + return; + } + go(); +} + +function scheduleRingbackBurst(ctx: AudioContext): void { + // US-style ringback: 440+480 Hz dual tone ~2s on, ~4s off (burst only; interval handles off). + const now = ctx.currentTime; + const dur = 2; + playTonePulse(ctx, 440, dur, now); + playTonePulse(ctx, 480, dur, now); +} + +/** Start looping ringback while calling/ringing. Idempotent. */ +export function startVoiceRingback(): void { + if (isNotifMuted()) return; + if (ringbackTimer != null) return; + withRunningContext((ctx) => { + scheduleRingbackBurst(ctx); + }); + ringbackTimer = setInterval(() => { + withRunningContext((ctx) => { + scheduleRingbackBurst(ctx); + }); + }, 6000); +} + +/** Stop ringback / cancel pending busy cadence. */ +export function stopVoiceCallTones(): void { + if (ringbackTimer != null) { + clearInterval(ringbackTimer); + ringbackTimer = null; + } + if (busyStopTimer != null) { + clearTimeout(busyStopTimer); + busyStopTimer = null; + } +} + +/** Short repeating busy cadence (~0.5s on/off), then stop. */ +export function playVoiceBusyTone(): void { + stopVoiceCallTones(); + if (isNotifMuted()) return; + withRunningContext((ctx) => { + const now = ctx.currentTime; + for (let i = 0; i < 6; i += 1) { + const t = now + i; + playTonePulse(ctx, 480, 0.45, t); + } + }); + busyStopTimer = setTimeout(() => { + busyStopTimer = null; + }, 6500); +} + +/** Distinct short down-tone for no-answer / reject / generic fail. */ +export function playVoiceFailTone(): void { + stopVoiceCallTones(); + if (isNotifMuted()) return; + withRunningContext((ctx) => { + const now = ctx.currentTime; + playTonePulse(ctx, 480, 0.2, now); + playTonePulse(ctx, 360, 0.35, now + 0.22); + }); +} diff --git a/src/renderer/lib/reticulumVoiceCapability.test.ts b/src/renderer/lib/reticulumVoiceCapability.test.ts new file mode 100644 index 000000000..4e3501013 --- /dev/null +++ b/src/renderer/lib/reticulumVoiceCapability.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useReticulumIdentityActivityStore } from '../stores/reticulumIdentityActivityStore'; +import { + activityShowsLxstTelephony, + peerLxstTelephonyCapability, +} from './reticulumVoiceCapability'; + +describe('reticulumVoiceCapability', () => { + beforeEach(() => { + useReticulumIdentityActivityStore.setState({ byDestination: new Map() }); + }); + + it('detects lxst.telephony rows for identity', () => { + const id = 'a'.repeat(32); + expect( + activityShowsLxstTelephony( + [ + { + destination_hash: 'b'.repeat(32), + aspect: 'lxst.telephony', + identity_hash: id, + last_seen: 1, + }, + ], + id, + ), + ).toBe(true); + expect( + activityShowsLxstTelephony( + [ + { + destination_hash: 'b'.repeat(32), + aspect: 'lxmf.delivery', + identity_hash: id, + last_seen: 1, + }, + ], + id, + ), + ).toBe(false); + }); + + it('reports heard vs unknown from activity store', () => { + const dest = 'c'.repeat(32); + const id = 'd'.repeat(32); + expect(peerLxstTelephonyCapability({ lxmfPeerHash: dest, identityHash: id })).toBe('unknown'); + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + dest, + [ + { + destination_hash: dest, + aspect: 'lxst.telephony', + identity_hash: id, + last_seen: Date.now(), + }, + ], + ], + ]), + }); + expect(peerLxstTelephonyCapability({ lxmfPeerHash: dest, identityHash: id })).toBe('heard'); + }); +}); diff --git a/src/renderer/lib/reticulumVoiceCapability.ts b/src/renderer/lib/reticulumVoiceCapability.ts new file mode 100644 index 000000000..f2498ed93 --- /dev/null +++ b/src/renderer/lib/reticulumVoiceCapability.ts @@ -0,0 +1,56 @@ +/** + * Soft LXST telephony capability from identity-activity announces. + * Unknown ≠ incompatible — Call stays enabled when aspect has not been heard. + */ + +import type { ReticulumIdentityActivityRow } from '@/renderer/stores/reticulumIdentityActivityStore'; +import { useReticulumIdentityActivityStore } from '@/renderer/stores/reticulumIdentityActivityStore'; +import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; + +export const LXST_TELEPHONY_ASPECT = 'lxst.telephony'; + +export type LxstTelephonyCapability = 'heard' | 'unknown'; + +export function activityShowsLxstTelephony( + rows: Iterable, + identityHash?: string | null, +): boolean { + const id = identityHash ? canonicalizeReticulumDestinationHash(identityHash) : null; + for (const row of rows) { + if (row.aspect !== LXST_TELEPHONY_ASPECT) continue; + if (!id) return true; + const rowId = row.identity_hash + ? canonicalizeReticulumDestinationHash(row.identity_hash) + : null; + if (rowId && rowId === id) return true; + // Activity keyed on telephony destination for this identity — match dest load below. + if (!rowId) return true; + } + return false; +} + +/** + * Soft capability for Call UI: `heard` when any lxst.telephony activity matches the + * peer LXMF dest and/or known identity hash. + */ +export function peerLxstTelephonyCapability(opts: { + lxmfPeerHash: string; + identityHash?: string | null; +}): LxstTelephonyCapability { + const dest = canonicalizeReticulumDestinationHash(opts.lxmfPeerHash); + if (!dest) return 'unknown'; + const store = useReticulumIdentityActivityStore.getState(); + const destRows = store.getActivity(dest); + if (activityShowsLxstTelephony(destRows, opts.identityHash)) return 'heard'; + + const id = opts.identityHash ? canonicalizeReticulumDestinationHash(opts.identityHash) : null; + if (id) { + const idRows = store.getActivity(id); + if (activityShowsLxstTelephony(idRows, id)) return 'heard'; + // Scan all cached destinations for telephony aspect tied to this identity. + for (const rows of store.byDestination.values()) { + if (activityShowsLxstTelephony(rows, id)) return 'heard'; + } + } + return 'unknown'; +} diff --git a/src/renderer/lib/reticulumVoiceOutcome.test.ts b/src/renderer/lib/reticulumVoiceOutcome.test.ts new file mode 100644 index 000000000..77486486d --- /dev/null +++ b/src/renderer/lib/reticulumVoiceOutcome.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyVoiceTerminalReason, voiceToastKeyForTerminal } from './reticulumVoiceOutcome'; + +describe('classifyVoiceTerminalReason', () => { + it('maps busy / rejected / timeout / clean hangup', () => { + expect(classifyVoiceTerminalReason('busy')).toBe('busy'); + expect(classifyVoiceTerminalReason('LineBusy')).toBe('busy'); + expect(classifyVoiceTerminalReason('rejected')).toBe('rejected'); + expect(classifyVoiceTerminalReason('discovery timeout')).toBe('noAnswer'); + expect(classifyVoiceTerminalReason('safety_timeout')).toBe('noAnswer'); + expect(classifyVoiceTerminalReason(null)).toBe('completed'); + expect(classifyVoiceTerminalReason('hangup')).toBe('completed'); + expect(classifyVoiceTerminalReason('encode exploded')).toBe('failed'); + }); + + it('maps toast keys for unsuccessful kinds only', () => { + expect(voiceToastKeyForTerminal('busy')).toBe('reticulumVoice.toast.busy'); + expect(voiceToastKeyForTerminal('noAnswer')).toBe('reticulumVoice.toast.noAnswer'); + expect(voiceToastKeyForTerminal('completed')).toBeNull(); + }); +}); diff --git a/src/renderer/lib/reticulumVoiceOutcome.ts b/src/renderer/lib/reticulumVoiceOutcome.ts new file mode 100644 index 000000000..621ace123 --- /dev/null +++ b/src/renderer/lib/reticulumVoiceOutcome.ts @@ -0,0 +1,49 @@ +/** + * Map LXST / sidecar voice failure reasons to toast keys + progress tones. + */ + +export type VoiceTerminalKind = 'busy' | 'rejected' | 'noAnswer' | 'failed' | 'completed'; + +export function classifyVoiceTerminalReason(reason: string | null | undefined): VoiceTerminalKind { + const r = (reason ?? '').trim().toLowerCase(); + // Empty reason = clean hangup / remote end without Busy|Rejected signal. + if (!r || r === 'completed' || r === 'hangup' || r === 'cancelled' || r === 'canceled') { + return 'completed'; + } + if ( + r === 'busy' || + r.includes('linebusy') || + r.includes('line_busy') || + r.includes('line busy') + ) { + return 'busy'; + } + if (r === 'rejected' || r.includes('reject')) return 'rejected'; + if ( + r.includes('timeout') || + r.includes('timed out') || + r.includes('no answer') || + r.includes('discovery') || + r.includes('ring_timeout') || + r.includes('outgoing_timeout') || + r.includes('safety') + ) { + return 'noAnswer'; + } + return 'failed'; +} + +export function voiceToastKeyForTerminal(kind: VoiceTerminalKind): string | null { + switch (kind) { + case 'busy': + return 'reticulumVoice.toast.busy'; + case 'rejected': + return 'reticulumVoice.toast.rejected'; + case 'noAnswer': + return 'reticulumVoice.toast.noAnswer'; + case 'failed': + return 'reticulumVoice.toast.failed'; + case 'completed': + return null; + } +} diff --git a/src/renderer/lib/reticulumVoiceSession.test.ts b/src/renderer/lib/reticulumVoiceSession.test.ts new file mode 100644 index 000000000..64a26f88e --- /dev/null +++ b/src/renderer/lib/reticulumVoiceSession.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useReticulumPeerStore } from '../stores/reticulumPeerStore'; +import { useReticulumVoiceStore } from '../stores/reticulumVoiceStore'; +import { + resetReticulumVoiceSessionTimersForTests, + reticulumVoiceCallPeer, + reticulumVoiceHangup, +} from './reticulumVoiceSession'; +import { RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS } from './timeConstants'; + +vi.mock('@/renderer/components/Toast', () => ({ + pushAppToast: vi.fn(), +})); + +vi.mock('./reticulumVoiceCallTones', () => ({ + startVoiceRingback: vi.fn(), + stopVoiceCallTones: vi.fn(), + playVoiceBusyTone: vi.fn(), + playVoiceFailTone: vi.fn(), +})); + +const voiceApi = { + getStatus: vi.fn(), + call: vi.fn(), + hangup: vi.fn(), + answer: vi.fn(), + reject: vi.fn(), + mute: vi.fn(), + sendAudio: vi.fn(), +}; + +describe('reticulumVoiceSession', () => { + beforeEach(() => { + vi.useFakeTimers(); + resetReticulumVoiceSessionTimersForTests(); + useReticulumVoiceStore.getState().clearCall(); + useReticulumPeerStore.setState({ + peers: new Map(), + contacts: new Map(), + history: new Map(), + }); + voiceApi.getStatus.mockReset(); + voiceApi.call.mockReset(); + voiceApi.hangup.mockReset(); + voiceApi.getStatus.mockResolvedValue({ + available: true, + enabled: true, + running: true, + }); + voiceApi.call.mockResolvedValue({ ok: true, identity_hash: 'a'.repeat(32) }); + voiceApi.hangup.mockResolvedValue({ ok: true }); + Object.assign(window, { + electronAPI: { + reticulum: { voice: voiceApi }, + media: { + ensureMicrophoneAccess: vi.fn(() => + Promise.resolve({ granted: true, status: 'granted' }), + ), + }, + }, + }); + }); + + afterEach(() => { + resetReticulumVoiceSessionTimersForTests(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('dials with peer identity_hash and sets optimistic calling without mic helper', async () => { + const dest = 'b'.repeat(32); + const id = 'a'.repeat(32); + useReticulumPeerStore.getState().updatePeer(dest, { + destination_hash: dest, + identity_hash: id, + }); + await reticulumVoiceCallPeer(dest); + expect(voiceApi.call).toHaveBeenCalledWith({ identity_hash: id }); + expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('calling'); + }); + + it('falls back to destination hash when identity unknown', async () => { + const dest = 'c'.repeat(32); + await reticulumVoiceCallPeer(dest); + expect(voiceApi.call).toHaveBeenCalledWith({ identity_hash: dest }); + expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('calling'); + }); + + it('hangup clears optimistic calling even without WS', async () => { + useReticulumVoiceStore.getState().beginOutgoing('d'.repeat(32)); + await reticulumVoiceHangup(); + expect(voiceApi.hangup).toHaveBeenCalled(); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + }); + + it('safety hangup fires after timeout when never established', async () => { + const dest = 'e'.repeat(32); + await reticulumVoiceCallPeer(dest); + expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('calling'); + await vi.advanceTimersByTimeAsync(RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS + 10); + expect(voiceApi.hangup).toHaveBeenCalled(); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + }); + + it('safety hangup does not fire after established', async () => { + const dest = 'f'.repeat(32); + await reticulumVoiceCallPeer(dest); + useReticulumVoiceStore.getState().applyUpdate({ + type: 'snapshot', + active_call: { + link_id: '1'.repeat(32), + remote_identity: dest, + role: 'outgoing', + status: 'established', + }, + }); + // Overlay/sync would clear timer on established; simulate hangup path clear via store status. + // Manually clear by calling hangup cancel through sync — establish via applying status then advance. + const { syncReticulumVoiceProgressTones } = await import('./reticulumVoiceSession'); + syncReticulumVoiceProgressTones('established'); + await vi.advanceTimersByTimeAsync(RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS + 10); + expect(voiceApi.hangup).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/reticulumVoiceSession.ts b/src/renderer/lib/reticulumVoiceSession.ts index 309af4740..142c40f5b 100644 --- a/src/renderer/lib/reticulumVoiceSession.ts +++ b/src/renderer/lib/reticulumVoiceSession.ts @@ -13,8 +13,21 @@ import { packQualityHighFrame, resolveVoiceDialIdentityHash, } from '@/renderer/lib/reticulumVoiceAudio'; +import { + playVoiceBusyTone, + playVoiceFailTone, + startVoiceRingback, + stopVoiceCallTones, +} from '@/renderer/lib/reticulumVoiceCallTones'; +import { + classifyVoiceTerminalReason, + voiceToastKeyForTerminal, +} from '@/renderer/lib/reticulumVoiceOutcome'; import { collectIdentityHashesForLxmfPeer } from '@/renderer/lib/rncpOfferPeerMatch'; +import { RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS } from '@/renderer/lib/timeConstants'; +import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; +import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; let captureCtx: AudioContext | null = null; let captureSource: MediaStreamAudioSourceNode | null = null; @@ -27,6 +40,7 @@ let playbackCtx: AudioContext | null = null; let audioUnsub: (() => void) | null = null; let txTimer: ReturnType | null = null; let pendingSamples: number[] = []; +let safetyHangupTimer: ReturnType | null = null; async function ensureMicAccess(): Promise { try { @@ -88,6 +102,31 @@ export function stopReticulumVoiceMedia(): void { stopPlayback(); } +function clearSafetyHangupTimer(): void { + if (safetyHangupTimer != null) { + clearTimeout(safetyHangupTimer); + safetyHangupTimer = null; + } +} + +/** @internal Test helper */ +export function resetReticulumVoiceSessionTimersForTests(): void { + clearSafetyHangupTimer(); +} + +function scheduleOutgoingSafetyHangup(generation: number): void { + clearSafetyHangupTimer(); + safetyHangupTimer = setTimeout(() => { + safetyHangupTimer = null; + const state = useReticulumVoiceStore.getState(); + if (state.callGeneration !== generation) return; + const status = state.activeCall?.status; + if (!status || status === 'established') return; + console.warn('[reticulumVoice] safety hangup — outgoing never established'); + void reticulumVoiceHangup({ terminalReason: 'safety_timeout' }); + }, RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS); +} + async function startCaptureAndTx(): Promise { stopCapture(); if (!(await ensureMicAccess())) { @@ -121,6 +160,8 @@ async function startCaptureAndTx(): Promise { source.connect(processor); processor.connect(ctx.destination); + // One QualityHigh frame ≈ 60 ms @ 48 kHz; tick near frame duration (not 2×) so we + // stay around ~16–17 IPC posts/s under the dedicated voiceSendAudio rate limit. const frameMs = (LXST_QUALITY_HIGH_FRAME_SAMPLES / LXST_QUALITY_HIGH_SAMPLE_RATE_HZ) * 1000; txTimer = setInterval( () => { @@ -136,13 +177,22 @@ async function startCaptureAndTx(): Promise { 1, ); if (!packed) return; - void window.electronAPI.reticulum.voice.sendAudio({ - profile: LXST_QUALITY_HIGH_PROFILE, - channels: LXST_QUALITY_HIGH_CHANNELS, - samples_b64: encodeF32LeBase64(packed), - }); + void window.electronAPI.reticulum.voice + .sendAudio({ + profile: LXST_QUALITY_HIGH_PROFILE, + channels: LXST_QUALITY_HIGH_CHANNELS, + samples_b64: encodeF32LeBase64(packed), + }) + .then((resp) => { + if (!resp.ok) { + useReticulumVoiceStore.getState().incrementLocalTxDrops(); + } + }) + .catch(() => { + useReticulumVoiceStore.getState().incrementLocalTxDrops(); + }); }, - Math.max(20, frameMs / 2), + Math.max(20, frameMs), ); } @@ -182,7 +232,17 @@ export async function startReticulumVoiceMediaForActiveCall(): Promise { } } -export async function reticulumVoiceCallPeer(lxmfPeerHash: string): Promise { +function peerIdentityForLxmfDest(lxmfPeerHash: string): string | null { + const dest = canonicalizeReticulumDestinationHash(lxmfPeerHash); + if (!dest) return null; + const peer = useReticulumPeerStore.getState().getPeer(dest); + return peer?.identity_hash ? canonicalizeReticulumDestinationHash(peer.identity_hash) : null; +} + +export async function reticulumVoiceCallPeer( + lxmfPeerHash: string, + opts?: { identityHash?: string | null }, +): Promise { const api = window.electronAPI.reticulum.voice; const status = await api.getStatus(); useReticulumVoiceStore.getState().applyStatus(status); @@ -190,20 +250,44 @@ export async function reticulumVoiceCallPeer(lxmfPeerHash: string): Promise(); const resolved = resolveVoiceDialIdentityHash({ + identityHash: peerIdentity, candidateIdentityHashes: candidates, + destinationHash: dest, }); if ('errorKey' in resolved) { + console.warn('[reticulumVoice] dial aborted — no identity or destination hash'); pushAppToast(i18n.t(resolved.errorKey), 'error'); return; } - const resp = await api.call({ identity_hash: resolved.identityHash }); + + console.info( + `[reticulumVoice] call start role=outgoing remote=${resolved.dialHash.slice(0, 16)} source=${resolved.source}`, + ); + + // Optimistic UI so Hang up is available before WS voice.update. + useReticulumVoiceStore.getState().beginOutgoing(resolved.dialHash); + const generation = useReticulumVoiceStore.getState().callGeneration; + startVoiceRingback(); + scheduleOutgoingSafetyHangup(generation); + + const resp = await api.call({ identity_hash: resolved.dialHash }); if (!resp.ok) { - pushAppToast(resp.error || i18n.t('reticulumVoice.errors.callFailed'), 'error'); - } else { - await startReticulumVoiceMediaForActiveCall(); + const msg = resp.error || i18n.t('reticulumVoice.errors.callFailed'); + console.warn(`[reticulumVoice] call failed reason=${msg}`); + clearSafetyHangupTimer(); + stopVoiceCallTones(); + playVoiceFailTone(); + useReticulumVoiceStore.getState().applyError(msg); + pushAppToast(msg, 'error'); + return; } + // Mic deferred until connecting/established (overlay effect). } export async function reticulumVoiceAnswer(): Promise { @@ -212,18 +296,40 @@ export async function reticulumVoiceAnswer(): Promise { pushAppToast(resp.error || i18n.t('reticulumVoice.errors.callFailed'), 'error'); return; } + stopVoiceCallTones(); await startReticulumVoiceMediaForActiveCall(); } export async function reticulumVoiceReject(): Promise { + clearSafetyHangupTimer(); stopReticulumVoiceMedia(); + stopVoiceCallTones(); await window.electronAPI.reticulum.voice.reject(); useReticulumVoiceStore.getState().clearCall(); } -export async function reticulumVoiceHangup(): Promise { +export async function reticulumVoiceHangup(opts?: { + terminalReason?: string | null; +}): Promise { + clearSafetyHangupTimer(); stopReticulumVoiceMedia(); - await window.electronAPI.reticulum.voice.hangup(); + const reason = opts?.terminalReason ?? 'hangup'; + const kind = classifyVoiceTerminalReason(reason); + stopVoiceCallTones(); + if (kind === 'busy') playVoiceBusyTone(); + else if (kind === 'noAnswer' || kind === 'failed' || kind === 'rejected') playVoiceFailTone(); + + const toastKey = voiceToastKeyForTerminal(kind); + if (toastKey && kind !== 'completed') { + pushAppToast(i18n.t(toastKey), 'error'); + } + + console.info(`[reticulumVoice] hangup reason=${reason}`); + try { + await window.electronAPI.reticulum.voice.hangup(); + } catch (e) { + console.warn('[reticulumVoice] hangup IPC failed', e); + } useReticulumVoiceStore.getState().clearCall(); } @@ -231,3 +337,51 @@ export async function reticulumVoiceSetMuted(muted: boolean): Promise { await window.electronAPI.reticulum.voice.mute({ muted }); useReticulumVoiceStore.getState().setMicrophoneMuted(muted); } + +/** + * Handle terminal signalling from WS (busy / rejected / timeout / completed). + * Clears media, tones, safety timer; toast for unsuccessful outcomes. + */ +export function handleReticulumVoiceTerminal(opts: { + linkId?: string | null; + reason?: string | null; + errorMessage?: string | null; +}): void { + clearSafetyHangupTimer(); + stopReticulumVoiceMedia(); + const reason = opts.errorMessage ?? opts.reason ?? null; + const kind = classifyVoiceTerminalReason(reason); + stopVoiceCallTones(); + if (kind === 'busy') playVoiceBusyTone(); + else if (kind === 'rejected' || kind === 'noAnswer' || kind === 'failed') playVoiceFailTone(); + + const toastKey = voiceToastKeyForTerminal(kind); + if (toastKey) { + pushAppToast(i18n.t(toastKey), 'error'); + } + + if (opts.errorMessage) { + useReticulumVoiceStore.getState().applyError(opts.errorMessage); + } else { + useReticulumVoiceStore.getState().applyTerminated(opts.linkId ?? null, reason); + } +} + +/** Sync ringback / stop tones from active call status (overlay / runtime). */ +export function syncReticulumVoiceProgressTones(status: string | null | undefined): void { + if (status === 'calling' || status === 'ringing') { + startVoiceRingback(); + return; + } + if (status === 'established' || status === 'connecting') { + stopVoiceCallTones(); + if (status === 'established') { + clearSafetyHangupTimer(); + } + return; + } + if (!status) { + stopVoiceCallTones(); + clearSafetyHangupTimer(); + } +} diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index 4a0c99dfb..c1301434b 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -294,3 +294,9 @@ export const NOMAD_PAGE_FETCH_DEBOUNCE_MS = 300; * firmware's exact 2000ms boundary for serial/BLE/TCP write and timer-granularity slack. */ export const MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS = 2.5 * MS_PER_SECOND; + +/** + * Renderer safety hangup for optimistic LXST dial when WS never reaches Established. + * Slightly above rsLXST `outgoing_call_timeout` (70s). + */ +export const RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS = 75 * MS_PER_SECOND; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index b95bda0bf..ea85ab953 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -5122,6 +5122,26 @@ "noIdentity": "Nelze volat: identita protějšku zatím není známa. Počkejte na oznámení nebo sondujte kolegu.", "callFailed": "Hlasový hovor se nezdařil.", "notRunning": "Hlas není k dispozici, dokud není spuštěn zásobník Reticulum." + }, + "connecting": "Připojování…", + "elapsedAria": "Uplynulý čas hovoru", + "txAria": "Přenášené hlasové rámce", + "rxAria": "Přijaté hlasové rámce", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "LXST telefonie oznamuje slyšení pro tohoto kolegu", + "capabilityUnknown": "Telefonní hlášení LXST ještě nebylo vyslyšeno (hovor může stále fungovat)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "Hlas LXST potřebuje kolegu, který používá telefonii LXST (Sideband, Ratspeak nebo mesh-client). Není to klip s hlasovou poznámkou. Python LXST je referenční; rsLXST je experimentální." + }, + "toast": { + "busy": "Linka je obsazená.", + "rejected": "Hovor byl zamítnut.", + "noAnswer": "Žádná odpověď — vypršel časový limit hovoru.", + "failed": "Hlasový hovor se nezdařil." } } } diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index cb37c6235..d36be606a 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "Kann nicht aufrufen: Peer-Identität noch nicht bekannt. Warten Sie auf eine Ankündigung oder fragen Sie den Kollegen.", "callFailed": "Sprachanruf fehlgeschlagen.", "notRunning": "Stimme ist erst verfügbar, wenn der Reticulum-Stapel läuft." + }, + "connecting": "verbinden …", + "elapsedAria": "Verstrichene Zeit", + "txAria": "Gesendete Sprachrahmen", + "rxAria": "Empfangene Sprachbilder", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "LXST-Telefonie für diesen Peer ankündigen", + "capabilityUnknown": "LXST-Telefonansage noch nicht gehört (Anruf kann noch funktionieren)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": ".?", + "help": { + "interop": "LXST Voice benötigt einen Peer, der LXST-Telefonie ausführt (Sideband, Ratspeak oder mesh-client). Kein Sprachnotizclip. Python LXST ist die Referenz; rsLXST ist experimentell." + }, + "toast": { + "busy": "Leitung besetzt", + "rejected": "Anruf abgelehnt", + "noAnswer": "Keine Antwort — Anruf abgelaufen.", + "failed": "Sprachanruf fehlgeschlagen." } } } diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index e40aff072..8d857fc5e 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -736,12 +736,32 @@ "incomingTitle": "Incoming voice call", "inCall": "In call", "calling": "Calling…", + "connecting": "Connecting…", "callAria": "Start LXST voice call", "answerAria": "Answer voice call", "rejectAria": "Reject voice call", "hangupAria": "Hang up voice call", "muteAria": "Mute microphone", "unmuteAria": "Unmute microphone", + "elapsedAria": "Call elapsed time", + "txAria": "Transmitted voice frames", + "rxAria": "Received voice frames", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "LXST telephony announce heard for this peer", + "capabilityUnknown": "LXST telephony announce not heard yet (call may still work)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "LXST voice needs a peer running LXST telephony (Sideband, Ratspeak, or mesh-client). Not a voice-note clip. Python LXST is the reference; rsLXST is experimental." + }, + "toast": { + "busy": "Line busy.", + "rejected": "Call rejected.", + "noAnswer": "No answer — call timed out.", + "failed": "Voice call failed." + }, "errors": { "noIdentity": "Cannot call: peer identity not known yet. Wait for an announce or probe the peer.", "callFailed": "Voice call failed.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index a3a62f9ae..105629af2 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "No se puede llamar: la identidad del compañero aún no se conoce. Espere un anuncio o sondee al compañero.", "callFailed": "La llamada de voz ha fallado.", "notRunning": "La voz no está disponible hasta que la pila de Reticulum esté en ejecución." + }, + "connecting": "Conectando…", + "elapsedAria": "Tiempo transcurrido", + "txAria": "Tramas de voz transmitidas", + "rxAria": "Tramas de voz recibidas", + "txFrames": "TX {{count}}", + "txPackets": "pKT {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "Anuncio de telefonía LXST escuchado para este compañero", + "capabilityUnknown": "Anuncio de telefonía LXST aún no escuchado (la llamada aún puede funcionar)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "¿?", + "help": { + "interop": "La voz LXST necesita una telefonía LXST entre pares (Sideband, Ratspeak o mesh-client). No es un clip de nota de voz. Python LXST es la referencia; rsLXST es experimental." + }, + "toast": { + "busy": "Línea ocupada", + "rejected": "Llamada rechazada", + "noAnswer": "Sin respuesta: el tiempo de espera de la llamada ha expirado.", + "failed": "La llamada de voz ha fallado." } } } diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 3d965afa6..6becca3e7 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "Impossible d'appeler : l'identité de l'homologue n'est pas encore connue. Attendez une annonce ou sondez le pair.", "callFailed": "Échec de l'appel vocal.", "notRunning": "La voix n'est pas disponible tant que la pile Reticulum n'est pas en cours d'exécution." + }, + "connecting": "Connexion…", + "elapsedAria": "Temps écoulé", + "txAria": "Trames vocales transmises", + "rxAria": "Trames vocales reçues", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "Annonce téléphonique LXST entendue pour ce pair", + "capabilityUnknown": "Annonce téléphonique LXST pas encore entendue (l'appel peut encore fonctionner)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "it", + "help": { + "interop": "La voix LXST a besoin d'un pair exécutant la téléphonie LXST (Sideband, Ratspeak ou mesh-client). Pas un clip de note vocale. Python LXST est la référence ; rsLXST est expérimental." + }, + "toast": { + "busy": "Ligne occupée", + "rejected": "Appel rejeté", + "noAnswer": "Pas de réponse — appel expiré.", + "failed": "Échec de l'appel vocal." } } } diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 73f84b157..f49da379a 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "Tidak dapat menelepon: identitas teman sebaya belum diketahui. Tunggu pengumuman atau selidiki rekan sejawat.", "callFailed": "Panggilan suara gagal.", "notRunning": "Suara tidak tersedia sampai tumpukan Reticulum berjalan." + }, + "connecting": "Menyambung…", + "elapsedAria": "Waktu berlalu", + "txAria": "Bingkai suara yang ditransmisikan", + "rxAria": "Bingkai suara yang diterima", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "Pengumuman telepon LXST terdengar untuk rekan ini", + "capabilityUnknown": "Pengumuman telepon LXST belum terdengar (panggilan mungkin masih berfungsi)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "Suara LXST membutuhkan rekan yang menjalankan telepon LXST (Sideband, Ratspeak, atau mesh-client). Bukan klip catatan suara. Python LXST adalah referensi; rsLXST adalah eksperimental." + }, + "toast": { + "busy": "Line sedang sibuk", + "rejected": "Panggilan ditolak.", + "noAnswer": "Tidak ada jawaban — waktu panggilan habis.", + "failed": "Panggilan suara gagal." } } } diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 3d26769cc..3426f746e 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "Impossibile chiamare: identità peer non ancora nota. Attendere un annuncio o sondare il peer.", "callFailed": "Chiamata vocale non riuscita.", "notRunning": "La voce non è disponibile fino a quando lo stack Reticulum non è in esecuzione." + }, + "connecting": "Collegamento in corso…", + "elapsedAria": "Tempo trascorso", + "txAria": "Frame vocali trasmessi", + "rxAria": "Frame vocali ricevuti", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "Annuncio telefonico LXST ascoltato per questo peer", + "capabilityUnknown": "Annuncio telefonico LXST non ancora sentito (la chiamata potrebbe ancora funzionare)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "dove...?", + "help": { + "interop": "La voce LXST ha bisogno di un peer che esegua la telefonia LXST (Sideband, Ratspeak o mesh-client). Non una clip di note vocali. Python LXST è il riferimento; rsLXST è sperimentale." + }, + "toast": { + "busy": "Linea occupata.", + "rejected": "Chiamata respinta", + "noAnswer": "Nessuna risposta — chiamata scaduta.", + "failed": "Chiamata vocale non riuscita." } } } diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 31feb1196..4fbcf615e 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "呼び出すことができません:ピアIDはまだわかりません。アナウンスを待つか、ピアをプローブします。", "callFailed": "音声通話に失敗しました。", "notRunning": "Reticulumスタックが実行されるまで、音声は使用できません。" + }, + "connecting": "連携中…", + "elapsedAria": "経過時間", + "txAria": "送信された音声フレーム", + "rxAria": "受信したボイスフレーム", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "このピアのLXSTテレフォニーアナウンスが聞こえました", + "capabilityUnknown": "LXSTテレフォニーのアナウンスがまだ聞こえていません(通話がまだ機能する可能性があります)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "LXST音声には、LXSTテレフォニー(サイドバンド、Ratspeak、またはmesh-client)を実行するピアが必要です。ボイスノートクリップではありません。Python LXSTがリファレンスです。rsLXSTは実験的です。" + }, + "toast": { + "busy": "回線はビジー状態です。", + "rejected": "コールは拒否されました", + "noAnswer": "応答がありません—通話がタイムアウトしました。", + "failed": "音声通話に失敗しました。" } } } diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 02d98f6b7..f108b2f2a 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "호출할 수 없음: 피어 ID를 아직 알 수 없습니다. 피어 발표 또는 조사를 기다립니다.", "callFailed": "음성 통화에 실패했습니다.", "notRunning": "Reticulum 스택이 실행될 때까지 음성을 사용할 수 없습니다." + }, + "connecting": "Connecting", + "elapsedAria": "경과 시간", + "txAria": "전송된 음성 프레임", + "rxAria": "수신된 음성 프레임", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "LXST TELEPHONY는 이 피어에 대해 들었음을 발표합니다.", + "capabilityUnknown": "LXST 전화 발표가 아직 들리지 않음 (통화가 계속 작동할 수 있음)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "LXST 음성은 LXST 전화 (Sideband, Ratspeak 또는 mesh-client) 를 실행하는 피어가 필요합니다. 음성 메모 클립이 아닙니다. 파이썬 LXST가 기준입니다. rsLXST는 실험적입니다." + }, + "toast": { + "busy": "라인 사용 중.", + "rejected": "통화가 거부되었습니다.", + "noAnswer": "응답 없음 — 통화 시간이 초과되었습니다.", + "failed": "음성 통화에 실패했습니다." } } } diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 828a86d15..a427ba8b6 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "Kan niet aanroepen: peer-identiteit nog niet bekend. Wacht op een aankondiging of onderzoek de peer.", "callFailed": "Spraakoproep mislukt.", "notRunning": "Stem is niet beschikbaar totdat de Reticulum-stack actief is." + }, + "connecting": "Verbinden…", + "elapsedAria": "Verstreken tijd", + "txAria": "Verzonden spraakframes", + "rxAria": "Ontvangen spraakframes", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "LXST-telefonie-aankondiging gehoord voor deze collega", + "capabilityUnknown": "LXST-telefonie-aankondiging nog niet gehoord (oproep kan nog steeds werken)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "Welk model wordt dan gebruikt?", + "help": { + "interop": "LXST-spraak heeft een peer-running LXST-telefonie nodig (Sideband, Ratspeak of mesh-client). Geen voice-note clip. Python LXST is de referentie; rsLXST is experimenteel." + }, + "toast": { + "busy": "Lijn bezet", + "rejected": "Oproep afgewezen", + "noAnswer": "Geen antwoord — er is een time-out opgetreden.", + "failed": "Spraakoproep mislukt." } } } diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index b7a0d01bb..3188c3816 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -5124,6 +5124,26 @@ "noIdentity": "Nie można wywołać: tożsamość rówieśnika nie jest jeszcze znana. Poczekaj na ogłoszenie lub zbadaj rówieśnika.", "callFailed": "Połączenie głosowe nie powiodło się.", "notRunning": "Głos nie jest dostępny, dopóki stos Reticulum nie zostanie uruchomiony." + }, + "connecting": "Łączenie…", + "elapsedAria": "Czas, jaki upłynął", + "txAria": "Przesyłane ramki głosowe", + "rxAria": "Odebrane ramki głosowe", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "Ogłoszenie telefoniczne LXST wysłuchane przez tego kolegę", + "capabilityUnknown": "Ogłoszenie telefoniczne LXST nie zostało jeszcze usłyszane (połączenie może nadal działać)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "Głos LXST potrzebuje telefonii LXST (Sideband, Ratspeak lub mesh-client). Nie klip z notatką głosową. Python LXST jest odniesieniem; rsLXST jest eksperymentalny." + }, + "toast": { + "busy": "\"Linia jest zajęta\"", + "rejected": "Połączenie odrzucone", + "noAnswer": "Brak odpowiedzi — przekroczono limit czasu połączenia.", + "failed": "Połączenie głosowe nie powiodło się." } } } diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 72ffc3a38..307db264e 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "Não é possível chamar: identidade de par ainda não conhecida. Aguarde um anúncio ou investigue o colega.", "callFailed": "Falha na chamada de voz.", "notRunning": "A voz não está disponível até que a pilha Reticulum esteja em execução." + }, + "connecting": "Conectando…", + "elapsedAria": "Tempo corrido:", + "txAria": "Quadros de voz transmitidos", + "rxAria": "Quadros de voz recebidos", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "Anúncio de telefonia LXST ouvido para este colega", + "capabilityUnknown": "Anúncio de telefonia LXST ainda não ouvido (a chamada ainda pode funcionar)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "A voz LXST precisa de uma telefonia LXST peer running (Sideband, Ratspeak ou mesh-client). Não é um clipe de nota de voz. Python LXST é a referência; rsLXST é experimental." + }, + "toast": { + "busy": "Linha ocupada.", + "rejected": "Chamada rejeitada", + "noAnswer": "Sem resposta — a chamada expirou.", + "failed": "Falha na chamada de voz." } } } diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index c5ba5a417..17890d044 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -5122,6 +5122,26 @@ "noIdentity": "Невозможно вызвать: идентификатор однорангового узла еще не известен. Дождитесь объявления или проверьте коллегу.", "callFailed": "Сбой голосового вызова.", "notRunning": "Голос недоступен до тех пор, пока не будет запущен стек Reticulum." + }, + "connecting": "Подключение…", + "elapsedAria": "ПРОДОЛЖИТЕЛЬНОСТЬ", + "txAria": "Переданные голосовые кадры", + "rxAria": "Полученные голосовые кадры", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "Объявление о телефонии LXST, услышанное для этого коллеги", + "capabilityUnknown": "Объявление о телефонии LXST еще не прослушано (звонок все еще может работать)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "Голос LXST нуждается в одноранговой телефонии LXST (Sideband, Ratspeak или mesh-client). Не клип голосовой заметки. Python LXST является эталоном; rsLXST является экспериментальным." + }, + "toast": { + "busy": "Линия занята.", + "rejected": "Вызов отклонен", + "noAnswer": "Нет ответа — время ожидания звонка истекло.", + "failed": "Сбой голосового вызова." } } } diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 67f40716e..f2b908acc 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "Aranamıyor: akran kimliği henüz bilinmiyor. Bir duyuru bekleyin veya akranınızı sorgulayın.", "callFailed": "Sesli arama başarısız.", "notRunning": "Reticulum yığını çalışana kadar ses kullanılamaz." + }, + "connecting": "Bağlanıyor…", + "elapsedAria": "Geçen zaman", + "txAria": "İletilen ses çerçeveleri", + "rxAria": "Alınan ses çerçeveleri", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "Bu akran için LXST telefon anonsu duyuldu", + "capabilityUnknown": "LXST telefon anonsu henüz duyulmadı (arama hala çalışıyor olabilir)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "lehine propaganda yapıyordun?", + "help": { + "interop": "LXST sesi, LXST telefonunu çalıştıran bir eşe ihtiyaç duyar (Sideband, Ratspeak veya mesh-client). Sesli not klibi değil. Python LXST referans; rsLXST deneyseldir." + }, + "toast": { + "busy": "Hat meşgul.", + "rejected": "Çağrı reddedildi.", + "noAnswer": "Cevap yok — çağrı zaman aşımına uğradı.", + "failed": "Sesli arama başarısız." } } } diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 55e00ca91..1b309f266 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -5122,6 +5122,26 @@ "noIdentity": "Неможливо зателефонувати: ідентифікатор вузла ще не відомий. Дочекайтеся оголошення або перевірте колегу.", "callFailed": "Не вдалося здійснити голосовий виклик.", "notRunning": "Голосовий зв'язок недоступний до запуску стека Reticulum." + }, + "connecting": "Підключення…", + "elapsedAria": "Минулий час", + "txAria": "Передані голосові кадри", + "rxAria": "Отримані голосові кадри", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "Оголошення про телефонний зв'язок LXST, почуте для цього колеги", + "capabilityUnknown": "Оголошення про телефонний зв'язок LXST ще не прослухано (дзвінок може все ще працювати)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "Голос LXST потребує однорангової телефонії LXST (бічний діапазон, Ratspeak або mesh-client). Не кліп голосової замітки. Python LXST є еталоном; rsLXST є експериментальним." + }, + "toast": { + "busy": "Лінія зайнята.", + "rejected": "Виклик відхилено.", + "noAnswer": "Немає відповіді — тайм-аут дзвінка.", + "failed": "Не вдалося здійснити голосовий виклик." } } } diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index d1063e0ad..17a0ca21f 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -5120,6 +5120,26 @@ "noIdentity": "无法调用:对等身份尚不清楚。等待通知或探查对等项。", "callFailed": "语音通话失败。", "notRunning": "在 Reticulum 堆栈运行之前,语音不可用。" + }, + "connecting": "连接中…", + "elapsedAria": "运行时间", + "txAria": "传输的语音帧", + "rxAria": "已接收语音帧", + "txFrames": "TX {{count}}", + "txPackets": "pkt {{count}}", + "rxFrames": "RX {{count}}", + "capabilityHeard": "听到此同行的LXST电话公告", + "capabilityUnknown": "尚未听到LXST电话公告(通话可能仍然有效)", + "capabilityHeardShort": "LXST", + "capabilityUnknownShort": "?", + "help": { + "interop": "LXST语音需要运行LXST电话的对等端(边带、Ratspeak或mesh-client)。不是语音备注剪辑。Python LXST是参考; rsLXST是实验。" + }, + "toast": { + "busy": "线路繁忙。", + "rejected": "呼叫被拒绝。", + "noAnswer": "无应答—呼叫超时。", + "failed": "语音通话失败。" } } } diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 0bb17ea74..441b55533 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -141,6 +141,7 @@ import { import { getIdentityIdForProtocol } from '../lib/identityByProtocol'; import { getOfflineIdentityIdForProtocol } from '../lib/offlineProtocolIdentities'; import { decodeF32LeBase64 } from '../lib/reticulumVoiceAudio'; +import { handleReticulumVoiceTerminal } from '../lib/reticulumVoiceSession'; import { resolveRrcInvoluntaryPartBannerKey } from '../lib/rrcInvoluntaryPartBanner'; import { isRrcJoinInfoNotice, @@ -1175,13 +1176,21 @@ export function useReticulumRuntime(): ProtocolRuntime { if (evt.type === 'voice.incoming' && evt.payload && typeof evt.payload === 'object') { useReticulumVoiceStore.getState().applyIncoming(evt.payload); } + if (evt.type === 'voice.stats' && evt.payload && typeof evt.payload === 'object') { + useReticulumVoiceStore.getState().applyStats(evt.payload); + } if (evt.type === 'voice.terminated' && evt.payload && typeof evt.payload === 'object') { - const p = evt.payload as { link_id?: string }; - useReticulumVoiceStore.getState().applyTerminated(p.link_id ?? null); + const p = evt.payload as { link_id?: string; reason?: string | null }; + handleReticulumVoiceTerminal({ + linkId: p.link_id ?? null, + reason: p.reason ?? null, + }); } if (evt.type === 'voice.error' && evt.payload && typeof evt.payload === 'object') { const p = evt.payload as { message?: string }; - useReticulumVoiceStore.getState().applyError(p.message ?? 'voice error'); + handleReticulumVoiceTerminal({ + errorMessage: p.message ?? 'voice error', + }); } if (evt.type === 'voice.audio' && evt.payload && typeof evt.payload === 'object') { const p = evt.payload as { channels?: number; samples_b64?: string }; diff --git a/src/renderer/runtime/useReticulumRuntime.voice.test.ts b/src/renderer/runtime/useReticulumRuntime.voice.test.ts index 1b37b14d9..cf6cbc3f8 100644 --- a/src/renderer/runtime/useReticulumRuntime.voice.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.voice.test.ts @@ -9,15 +9,16 @@ import { loadRuntimeSource } from '../lib/sourceContractTestHelpers'; const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); describe('useReticulumRuntime voice event routing (regression)', () => { - it('routes voice.update / incoming / terminated / error / audio', () => { + it('routes voice.update / incoming / stats / terminated / error / audio', () => { expect(SOURCE).toMatch(/evt\.type === 'voice\.update'/); expect(SOURCE).toMatch(/useReticulumVoiceStore\.getState\(\)\.applyUpdate/); expect(SOURCE).toMatch(/evt\.type === 'voice\.incoming'/); expect(SOURCE).toMatch(/applyIncoming\(evt\.payload\)/); + expect(SOURCE).toMatch(/evt\.type === 'voice\.stats'/); + expect(SOURCE).toMatch(/applyStats\(evt\.payload\)/); expect(SOURCE).toMatch(/evt\.type === 'voice\.terminated'/); - expect(SOURCE).toMatch(/applyTerminated\(p\.link_id/); + expect(SOURCE).toMatch(/handleReticulumVoiceTerminal/); expect(SOURCE).toMatch(/evt\.type === 'voice\.error'/); - expect(SOURCE).toMatch(/applyError\(p\.message/); expect(SOURCE).toMatch(/evt\.type === 'voice\.audio'/); expect(SOURCE).toMatch(/decodeF32LeBase64/); expect(SOURCE).toMatch(/emitAudio\(channels, samples\)/); diff --git a/src/renderer/stores/reticulumVoiceStore.test.ts b/src/renderer/stores/reticulumVoiceStore.test.ts index fa2f85fbc..1d8d8297b 100644 --- a/src/renderer/stores/reticulumVoiceStore.test.ts +++ b/src/renderer/stores/reticulumVoiceStore.test.ts @@ -18,7 +18,11 @@ describe('reticulumVoiceStore', () => { running: false, microphoneMuted: false, lastError: null, + lastTerminalReason: null, callGeneration: 0, + callStartedAtMs: null, + callEstablishedAtMs: null, + stats: { txFrames: 0, txPackets: 0, rxFrames: 0, localTxDrops: 0 }, }); }); @@ -29,19 +33,69 @@ describe('reticulumVoiceStore', () => { expect(useReticulumVoiceStore.getState().activeCall?.remote_identity).toBe( CALL.remote_identity, ); + expect(useReticulumVoiceStore.getState().callStartedAtMs).toBeTypeOf('number'); store.applyUpdate({ type: 'snapshot', active_call: { ...CALL, status: 'established', answered: true }, }); expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('established'); + expect(useReticulumVoiceStore.getState().callEstablishedAtMs).toBeTypeOf('number'); store.applyTerminated(CALL.link_id); expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); expect(useReticulumVoiceStore.getState().incomingCall).toBeNull(); + expect(useReticulumVoiceStore.getState().callStartedAtMs).toBeNull(); }); - it('toggles mute and clears on error', () => { + it('beginOutgoing sets calling before WS and hangup clears stats', () => { + const id = 'c'.repeat(32); + useReticulumVoiceStore.getState().beginOutgoing(id); + const state = useReticulumVoiceStore.getState(); + expect(state.activeCall?.status).toBe('calling'); + expect(state.activeCall?.remote_identity).toBe(id); + expect(state.callStartedAtMs).toBeTypeOf('number'); + + useReticulumVoiceStore.getState().applyStats({ + link_id: '', + tx_frames: 3, + tx_packets: 2, + rx_frames: 1, + }); + expect(useReticulumVoiceStore.getState().stats.txFrames).toBe(3); + + useReticulumVoiceStore.getState().clearCall(); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + expect(useReticulumVoiceStore.getState().stats.txFrames).toBe(0); + expect(useReticulumVoiceStore.getState().callStartedAtMs).toBeNull(); + }); + + it('accumulates stats and ignores stale link_id', () => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'outgoing', + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + }); + useReticulumVoiceStore.getState().applyStats({ + link_id: 'a'.repeat(32), + tx_frames: 5, + tx_packets: 4, + rx_frames: 2, + }); + useReticulumVoiceStore.getState().applyStats({ + link_id: 'f'.repeat(32), + tx_frames: 99, + rx_frames: 99, + }); + expect(useReticulumVoiceStore.getState().stats).toEqual({ + txFrames: 5, + txPackets: 4, + rxFrames: 2, + localTxDrops: 0, + }); + }); + + it('toggles mute and clears on error with terminal reason', () => { const store = useReticulumVoiceStore.getState(); store.applyIncoming(CALL); store.setMicrophoneMuted(true); @@ -49,6 +103,7 @@ describe('reticulumVoiceStore', () => { store.applyError('boom'); expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); expect(useReticulumVoiceStore.getState().lastError).toBe('boom'); + expect(useReticulumVoiceStore.getState().lastTerminalReason).toBe('boom'); }); it('ignores stale terminated after a new call generation', () => { @@ -60,7 +115,26 @@ describe('reticulumVoiceStore', () => { link_id: 'c'.repeat(32), remote_identity: 'd'.repeat(32), }); - store.applyTerminated(oldLink); + store.applyTerminated(oldLink, 'busy'); expect(useReticulumVoiceStore.getState().activeCall?.link_id).toBe('c'.repeat(32)); }); + + it('stores busy terminal reason on terminate', () => { + useReticulumVoiceStore.getState().beginOutgoing('e'.repeat(32)); + useReticulumVoiceStore.getState().applyUpdate({ + type: 'outgoing', + link_id: 'a'.repeat(32), + remote_identity: 'e'.repeat(32), + }); + useReticulumVoiceStore.getState().applyTerminated('a'.repeat(32), 'busy'); + expect(useReticulumVoiceStore.getState().lastTerminalReason).toBe('busy'); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + }); + + it('increments local TX drops', () => { + useReticulumVoiceStore.getState().beginOutgoing('a'.repeat(32)); + useReticulumVoiceStore.getState().incrementLocalTxDrops(); + useReticulumVoiceStore.getState().incrementLocalTxDrops(); + expect(useReticulumVoiceStore.getState().stats.localTxDrops).toBe(2); + }); }); diff --git a/src/renderer/stores/reticulumVoiceStore.ts b/src/renderer/stores/reticulumVoiceStore.ts index 6cef94d74..82b611cd0 100644 --- a/src/renderer/stores/reticulumVoiceStore.ts +++ b/src/renderer/stores/reticulumVoiceStore.ts @@ -5,6 +5,15 @@ import { isVoiceActiveCall } from '@/shared/voice-types'; export type VoiceAudioListener = (channels: number, samples: Float32Array) => void; +export interface VoiceCallStats { + txFrames: number; + txPackets: number; + rxFrames: number; + localTxDrops: number; +} + +export type VoiceTerminalReason = string | null; + interface ReticulumVoiceStoreState { enabled: boolean; running: boolean; @@ -12,6 +21,13 @@ interface ReticulumVoiceStoreState { activeCall: VoiceActiveCall | null; incomingCall: VoiceActiveCall | null; lastError: string | null; + /** Last terminal reason (busy / rejected / timeout / …) for tones/toasts. */ + lastTerminalReason: VoiceTerminalReason; + /** Wall-clock ms when the local session began (dial / incoming). */ + callStartedAtMs: number | null; + /** Wall-clock ms when status first became established (for RX gap estimate). */ + callEstablishedAtMs: number | null; + stats: VoiceCallStats; /** Generation bumped on each new call so stale terminated events can be ignored. */ callGeneration: number; audioListeners: Set; @@ -23,20 +39,51 @@ interface ReticulumVoiceStoreState { active_call?: unknown; last_error?: string | null; }) => void; + beginOutgoing: (remoteIdentity: string) => void; applyIncoming: (call: unknown) => void; applyUpdate: (payload: unknown) => void; - applyTerminated: (linkId?: string | null) => void; + applyStats: (payload: unknown) => void; + applyTerminated: (linkId?: string | null, reason?: string | null) => void; applyError: (message: string) => void; + incrementLocalTxDrops: () => void; setMicrophoneMuted: (muted: boolean) => void; clearCall: () => void; subscribeAudio: (listener: VoiceAudioListener) => () => void; emitAudio: (channels: number, samples: Float32Array) => void; } +const EMPTY_STATS: VoiceCallStats = { + txFrames: 0, + txPackets: 0, + rxFrames: 0, + localTxDrops: 0, +}; + function asActiveCall(value: unknown): VoiceActiveCall | null { return isVoiceActiveCall(value) ? value : null; } +function resetSessionFields(): Pick< + ReticulumVoiceStoreState, + | 'activeCall' + | 'incomingCall' + | 'lastError' + | 'lastTerminalReason' + | 'callStartedAtMs' + | 'callEstablishedAtMs' + | 'stats' +> { + return { + activeCall: null, + incomingCall: null, + lastError: null, + lastTerminalReason: null, + callStartedAtMs: null, + callEstablishedAtMs: null, + stats: { ...EMPTY_STATS }, + }; +} + export const useReticulumVoiceStore = create((set, get) => ({ enabled: false, running: false, @@ -44,17 +91,49 @@ export const useReticulumVoiceStore = create((set, get activeCall: null, incomingCall: null, lastError: null, + lastTerminalReason: null, + callStartedAtMs: null, + callEstablishedAtMs: null, + stats: { ...EMPTY_STATS }, callGeneration: 0, audioListeners: new Set(), applyStatus: (status) => { + set((s) => { + const nextActive = + status.active_call === undefined ? s.activeCall : asActiveCall(status.active_call); + return { + enabled: status.enabled ?? s.enabled, + running: status.running ?? s.running, + microphoneMuted: status.microphone_muted ?? s.microphoneMuted, + activeCall: nextActive, + lastError: status.last_error === undefined ? s.lastError : (status.last_error ?? null), + callEstablishedAtMs: + nextActive?.status === 'established' && s.callEstablishedAtMs == null + ? Date.now() + : s.callEstablishedAtMs, + }; + }); + }, + + beginOutgoing: (remoteIdentity) => { + const remote = remoteIdentity.trim().toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(remote)) return; set((s) => ({ - enabled: status.enabled ?? s.enabled, - running: status.running ?? s.running, - microphoneMuted: status.microphone_muted ?? s.microphoneMuted, - activeCall: - status.active_call === undefined ? s.activeCall : asActiveCall(status.active_call), - lastError: status.last_error === undefined ? s.lastError : (status.last_error ?? null), + activeCall: { + link_id: '', + remote_identity: remote, + role: 'outgoing', + status: 'calling', + answered: false, + }, + incomingCall: null, + lastError: null, + lastTerminalReason: null, + callStartedAtMs: Date.now(), + callEstablishedAtMs: null, + stats: { ...EMPTY_STATS }, + callGeneration: s.callGeneration + 1, })); }, @@ -65,6 +144,10 @@ export const useReticulumVoiceStore = create((set, get incomingCall: active, activeCall: active, lastError: null, + lastTerminalReason: null, + callStartedAtMs: Date.now(), + callEstablishedAtMs: null, + stats: { ...EMPTY_STATS }, callGeneration: s.callGeneration + 1, })); }, @@ -79,6 +162,11 @@ export const useReticulumVoiceStore = create((set, get incomingCall: active?.role === 'incoming' && active.status === 'ringing' ? active : s.incomingCall, lastError: null, + callStartedAtMs: active && s.callStartedAtMs == null ? Date.now() : s.callStartedAtMs, + callEstablishedAtMs: + active?.status === 'established' + ? (s.callEstablishedAtMs ?? Date.now()) + : s.callEstablishedAtMs, })); return; } @@ -86,22 +174,65 @@ export const useReticulumVoiceStore = create((set, get const remote = typeof p.remote_identity === 'string' ? p.remote_identity.toLowerCase() : null; if (!remote) return; const linkId = typeof p.link_id === 'string' ? p.link_id : ''; - set((s) => ({ - activeCall: { - link_id: linkId, - remote_identity: remote, - role: 'outgoing', - status: p.type === 'outgoing_pending' ? 'calling' : 'connecting', - answered: false, - }, - incomingCall: null, - lastError: null, - callGeneration: s.callGeneration + 1, - })); + set((s) => { + const sameRemote = + s.activeCall?.role === 'outgoing' && + s.activeCall.remote_identity.toLowerCase() === remote; + return { + activeCall: { + link_id: linkId || s.activeCall?.link_id || '', + remote_identity: remote, + role: 'outgoing', + status: p.type === 'outgoing_pending' ? 'calling' : 'connecting', + answered: false, + }, + incomingCall: null, + lastError: null, + callStartedAtMs: sameRemote && s.callStartedAtMs != null ? s.callStartedAtMs : Date.now(), + callEstablishedAtMs: sameRemote ? s.callEstablishedAtMs : null, + stats: sameRemote ? s.stats : { ...EMPTY_STATS }, + callGeneration: sameRemote ? s.callGeneration : s.callGeneration + 1, + }; + }); } }, - applyTerminated: (linkId) => { + applyStats: (payload) => { + if (!payload || typeof payload !== 'object') return; + const p = payload as Record; + set((s) => { + const linkId = typeof p.link_id === 'string' ? p.link_id : null; + if ( + linkId && + s.activeCall?.link_id && + s.activeCall.link_id.toLowerCase() !== linkId.toLowerCase() + ) { + return s; + } + const txFrames = + typeof p.tx_frames === 'number' && Number.isFinite(p.tx_frames) + ? Math.max(0, Math.floor(p.tx_frames)) + : s.stats.txFrames; + const txPackets = + typeof p.tx_packets === 'number' && Number.isFinite(p.tx_packets) + ? Math.max(0, Math.floor(p.tx_packets)) + : s.stats.txPackets; + const rxFrames = + typeof p.rx_frames === 'number' && Number.isFinite(p.rx_frames) + ? Math.max(0, Math.floor(p.rx_frames)) + : s.stats.rxFrames; + return { + stats: { + ...s.stats, + txFrames, + txPackets, + rxFrames, + }, + }; + }); + }, + + applyTerminated: (linkId, reason) => { set((s) => { if ( linkId && @@ -112,14 +243,24 @@ export const useReticulumVoiceStore = create((set, get return s; } return { - activeCall: null, - incomingCall: null, + ...resetSessionFields(), + lastTerminalReason: reason ?? null, }; }); }, applyError: (message) => { - set({ lastError: message, activeCall: null, incomingCall: null }); + set({ + ...resetSessionFields(), + lastError: message, + lastTerminalReason: message, + }); + }, + + incrementLocalTxDrops: () => { + set((s) => ({ + stats: { ...s.stats, localTxDrops: s.stats.localTxDrops + 1 }, + })); }, setMicrophoneMuted: (muted) => { @@ -127,7 +268,7 @@ export const useReticulumVoiceStore = create((set, get }, clearCall: () => { - set({ activeCall: null, incomingCall: null, lastError: null }); + set(resetSessionFields()); }, subscribeAudio: (listener) => { diff --git a/src/shared/voice-types.test.ts b/src/shared/voice-types.test.ts index 9f78d8914..ffc08dd95 100644 --- a/src/shared/voice-types.test.ts +++ b/src/shared/voice-types.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { isVoiceActiveCall, isVoiceStatusResponse } from './voice-types'; +import { + isVoiceActiveCall, + isVoiceStatusResponse, + parseVoiceAudioRequest, + VOICE_AUDIO_SAMPLES_B64_MAX, +} from './voice-types'; describe('voice-types guards', () => { it('accepts a valid status snapshot', () => { @@ -34,4 +39,26 @@ describe('voice-types guards', () => { it('rejects incomplete active_call', () => { expect(isVoiceActiveCall({ link_id: 'x' })).toBe(false); }); + + it('parseVoiceAudioRequest accepts a QualityHigh-shaped frame', () => { + expect( + parseVoiceAudioRequest({ + profile: 0x50, + channels: 1, + samples_b64: 'AAAA', + }), + ).toEqual({ profile: 0x50, channels: 1, samples_b64: 'AAAA' }); + }); + + it('parseVoiceAudioRequest rejects empty or oversized samples', () => { + expect(parseVoiceAudioRequest({ channels: 1, samples_b64: '' })).toEqual({ + error: 'empty_samples_b64', + }); + expect( + parseVoiceAudioRequest({ + channels: 1, + samples_b64: 'A'.repeat(VOICE_AUDIO_SAMPLES_B64_MAX + 1), + }), + ).toEqual({ error: 'samples_b64_too_large' }); + }); }); diff --git a/src/shared/voice-types.ts b/src/shared/voice-types.ts index ac9c9da7f..4e49e7253 100644 --- a/src/shared/voice-types.ts +++ b/src/shared/voice-types.ts @@ -47,6 +47,44 @@ export interface VoiceAudioRequest { samples_b64: string; } +/** Sidecar HTTP path for renderer PCM ingest (dedicated IPC; not generic proxyPost). */ +export const VOICE_AUDIO_API_PATH = '/api/v1/voice/audio'; + +/** + * Cap for one PCM frame's base64 payload. + * QualityHigh is 2880 f32 samples (~15 KiB b64); allow ~2 frames of headroom. + */ +export const VOICE_AUDIO_SAMPLES_B64_MAX = 32_768; + +/** Parse/validate `reticulum:voiceSendAudio` / `/voice/audio` body. */ +export function parseVoiceAudioRequest(opts: unknown): VoiceAudioRequest | { error: string } { + if (!opts || typeof opts !== 'object' || Array.isArray(opts)) { + return { error: 'invalid_audio_request' }; + } + const o = opts as Record; + const channels = o.channels; + if (typeof channels !== 'number' || !Number.isInteger(channels) || channels < 1 || channels > 2) { + return { error: 'invalid_channels' }; + } + const samplesB64 = o.samples_b64; + if (typeof samplesB64 !== 'string' || samplesB64.length === 0) { + return { error: 'empty_samples_b64' }; + } + if (samplesB64.length > VOICE_AUDIO_SAMPLES_B64_MAX) { + return { error: 'samples_b64_too_large' }; + } + let profile: number | undefined; + if (o.profile !== undefined) { + if (typeof o.profile !== 'number' || !Number.isFinite(o.profile)) { + return { error: 'invalid_profile' }; + } + profile = o.profile; + } + return profile === undefined + ? { channels, samples_b64: samplesB64 } + : { profile, channels, samples_b64: samplesB64 }; +} + export interface VoiceAudioPayload { link_id: string; profile: number; diff --git a/vitest.config.mts b/vitest.config.mts index b01b63027..1b63edf6e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -70,6 +70,9 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/appSettingsStorage.test.ts', 'src/renderer/lib/bleReconnectHelper.test.ts', 'src/renderer/lib/chatNotifications.test.ts', + 'src/renderer/lib/reticulumVoiceCallTones.test.ts', + 'src/renderer/lib/reticulumVoiceSession.test.ts', + 'src/renderer/lib/reticulumVoiceCapability.test.ts', 'src/renderer/lib/chatPanelProtocolStorage.test.ts', 'src/renderer/lib/chatScrollUtils.test.ts', 'src/renderer/lib/connection.ble-retry.test.ts', From 02401f74cee0ba10125d7faff8f48c16cfa4d525 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 17:32:31 -0600 Subject: [PATCH 05/27] fix(reticulum): harden LXST voice after review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move PCM off the shared WS bus onto /ws/voice and reticulum:voiceAudio, bound dest→identity cache growth, and fix incoming-call UI plus session IPC/media races so answer, mute, and playback stay reliable. --- docs/reticulum-sidecar-ipc.md | 7 +- reticulum-sidecar/src/api/mod.rs | 1 + reticulum-sidecar/src/api/ws.rs | 25 +- reticulum-sidecar/src/stack/live.rs | 4 + reticulum-sidecar/src/stack/mod.rs | 12 + reticulum-sidecar/src/stack/voice_session.rs | 83 ++++++- scripts/clone-ratspeak-stack.sh | 43 ++-- scripts/clone-ratspeak-stack.test.mjs | 119 ++++++++- src/main/ipc/reticulum-handlers.test.ts | 16 ++ src/main/ipc/reticulum-handlers.ts | 5 + src/main/reticulum-sidecar-manager.test.ts | 40 ++- src/main/reticulum-sidecar-manager.ts | 97 ++++++++ src/preload/index.ts | 7 + src/renderer/components/ChatPanel.tsx | 16 +- .../ReticulumDmPathReachabilityBadge.tsx | 26 +- .../components/ReticulumPeerListPanel.tsx | 2 +- .../components/remote/ChatDmRncpControl.tsx | 9 +- .../reticulum/ReticulumVoiceCallButton.tsx | 59 ++--- .../reticulum/ReticulumVoiceOverlay.test.tsx | 45 ++++ src/renderer/lib/devElectronApiStub.ts | 1 + src/renderer/lib/reticulumDmHeaderActions.ts | 10 + src/renderer/lib/reticulumVoiceAudio.test.ts | 36 +++ src/renderer/lib/reticulumVoiceAudio.ts | 20 +- src/renderer/lib/reticulumVoiceSession.ts | 227 +++++++++++++----- src/renderer/locales/cs/translation.json | 4 +- src/renderer/locales/de/translation.json | 4 +- src/renderer/locales/en/translation.json | 4 +- src/renderer/locales/es/translation.json | 4 +- src/renderer/locales/fr/translation.json | 4 +- src/renderer/locales/id/translation.json | 4 +- src/renderer/locales/it/translation.json | 4 +- src/renderer/locales/ja/translation.json | 4 +- src/renderer/locales/ko/translation.json | 4 +- src/renderer/locales/nl/translation.json | 4 +- src/renderer/locales/pl/translation.json | 4 +- src/renderer/locales/pt-BR/translation.json | 4 +- src/renderer/locales/ru/translation.json | 4 +- src/renderer/locales/tr/translation.json | 4 +- src/renderer/locales/uk/translation.json | 4 +- src/renderer/locales/zh/translation.json | 4 +- src/renderer/runtime/useReticulumRuntime.ts | 43 +++- .../runtime/useReticulumRuntime.voice.test.ts | 180 ++++++++++++-- .../stores/reticulumVoiceStore.test.ts | 21 ++ src/renderer/stores/reticulumVoiceStore.ts | 27 ++- src/renderer/vitest.electronApiMock.ts | 1 + src/shared/electron-api.types.ts | 5 + src/shared/voice-types.test.ts | 19 +- src/shared/voice-types.ts | 15 +- 48 files changed, 1059 insertions(+), 226 deletions(-) create mode 100644 src/renderer/lib/reticulumDmHeaderActions.ts diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index dd7cef999..b8ee0c4ae 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -246,10 +246,12 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi { "type": "lxmf_message", "payload": { ... } } ``` -Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.audio` (PCM `samples_b64`). +Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice signalling: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.stats`. **Note:** Live `wire_packet` frames are **not** pushed on `/ws` (they starved critical `lxmf_message` events on large meshes). Sniffer/Stats poll `GET /api/v1/packets` while those panels are mounted. PacketTap rows still feed the sidecar packet log and LXMF egress evidence. +**`GET /ws/voice`:** dedicated high-rate stream for LXST PCM receive frames (`voice.audio` with `link_id`, `profile`, `channels`, `samples_b64`). Same starvation rationale as packet tap — do not put realtime audio on shared `/ws`. Electron main bridges this to `reticulum:voiceAudio` (not `reticulum:event`). + - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. - **`lxmf_outbound_status`:** authoritative outbound delivery updates. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. @@ -279,7 +281,8 @@ Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sand | `reticulum:rncpSend` / `rncpFetch` / `setRncpListener` | Picker-gated rncp send/fetch/listener (path must match `reticulum-remote-paths` allowlist) | | `reticulum:showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` | Native pickers that seed the rncp send-file / save-dir+fetch-jail allowlists | | `reticulum:revealInFolder` | Reveal a path in the OS file manager when it matches an rncp picker allowlist | -| `reticulum:onEvent` / `onStatus` | WS events and sidecar status | +| `reticulum:onEvent` / `onStatus` | Shared `/ws` events and sidecar status | +| `reticulum:onVoiceAudio` | Dedicated `/ws/voice` → `reticulum:voiceAudio` PCM frames (`voice.audio`) | `getStatus` / `onStatus` may include `interfaceIssueAlert` (TCP connect failures, TX queue drops, link-delivery timeouts, transport saturation / slow queries, **`bleBondRemoved`** stale RNode bonds, **`blePairingTimedOut`** OS passkey / TX-read timeouts). Per-entry latch timestamps use a **5-minute** stale window (`RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS`). Connection syncs **enabled** interface names via `syncInterfaceIssueScope` so disabling or removing an interface clears that name immediately and rejects re-latch from lagging log lines. Stopping the stack (or unexpected process exit) clears the tracker. diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index 5fea57953..f6888849a 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -260,6 +260,7 @@ pub fn router(stack: Arc) -> Router { .route("/api/v1/identities/switch", post(system::switch_identity)) .route("/api/v1/identities/delete", post(system::delete_identity)) .route("/ws", get(ws::ws_handler)) + .route("/ws/voice", get(ws::ws_voice_handler)) .layer(DefaultBodyLimit::max(4 * 1024 * 1024)) .layer(localhost_cors_layer()) .with_state(stack) diff --git a/reticulum-sidecar/src/api/ws.rs b/reticulum-sidecar/src/api/ws.rs index aeb0c756c..bc383b933 100644 --- a/reticulum-sidecar/src/api/ws.rs +++ b/reticulum-sidecar/src/api/ws.rs @@ -4,6 +4,7 @@ use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::response::IntoResponse; use futures_util::StreamExt; +use tokio::sync::broadcast; use crate::stack::StackHandle; @@ -11,11 +12,22 @@ pub async fn ws_handler( ws: WebSocketUpgrade, State(stack): State>, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_ws(socket, stack)) + ws.on_upgrade(move |socket| handle_ws(socket, stack.subscribe_events(), true)) } -async fn handle_ws(mut socket: WebSocket, stack: Arc) { - let mut rx = stack.subscribe_events(); +/// Dedicated high-rate stream for `voice.audio` PCM (keeps shared `/ws` free for LXMF). +pub async fn ws_voice_handler( + ws: WebSocketUpgrade, + State(stack): State>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_ws(socket, stack.subscribe_voice_audio(), false)) +} + +async fn handle_ws( + mut socket: WebSocket, + mut rx: broadcast::Receiver, + emit_lag_notice: bool, +) { loop { tokio::select! { evt = rx.recv() => { @@ -26,6 +38,13 @@ async fn handle_ws(mut socket: WebSocket, stack: Arc) { } } Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + if !emit_lag_notice { + tracing::warn!( + skipped, + "voice audio websocket subscriber lagged; frames dropped" + ); + continue; + } // Critical events (lxmf_message) may have been dropped. Notify the // client so it can catch up via GET /api/v1/lxmf/recent. tracing::warn!( diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 9666769ba..d4e97b9ac 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -2133,6 +2133,10 @@ impl LiveBridge { self.voice_session.status().await } + pub fn subscribe_voice_audio(&self) -> broadcast::Receiver { + self.voice_session.subscribe_voice_audio() + } + pub async fn voice_call(&self, identity_hash: &str) -> serde_json::Value { self.voice_session.call(identity_hash).await } diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 7c992ecfe..077aa1e5a 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -307,6 +307,18 @@ impl StackHandle { self.event_tx.subscribe() } + /// High-rate `voice.audio` PCM frames (dedicated `/ws/voice` bus, not shared `/ws`). + pub fn subscribe_voice_audio(&self) -> broadcast::Receiver { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.subscribe_voice_audio(); + } + // No live stack: closed channel so `/ws/voice` clients exit cleanly. + let (tx, rx) = broadcast::channel(1); + drop(tx); + rx + } + pub fn list_packets(&self, limit: usize) -> Vec { self.packet_log.snapshot(limit) } diff --git a/reticulum-sidecar/src/stack/voice_session.rs b/reticulum-sidecar/src/stack/voice_session.rs index 28b68fc76..c5f9cf6be 100644 --- a/reticulum-sidecar/src/stack/voice_session.rs +++ b/reticulum-sidecar/src/stack/voice_session.rs @@ -66,9 +66,27 @@ struct VoiceState { audit: Option, } +/// Bound dest→identity cache independently of the live path table (same cap as display names). +const MAX_DEST_TO_IDENTITY_CACHE: usize = 100_000; + +/// High-rate PCM frames — keep off the shared `/ws` event bus (same intent as packet tap). +const VOICE_AUDIO_BROADCAST_CAP: usize = 256; + +/// Insert or refresh a dest→identity mapping; evict an arbitrary entry when full. +fn insert_dest_identity_bounded(cache: &mut HashMap, dest: String, id: String) { + if cache.len() >= MAX_DEST_TO_IDENTITY_CACHE && !cache.contains_key(&dest) { + if let Some(evict) = cache.keys().next().cloned() { + cache.remove(&evict); + } + } + cache.insert(dest, id); +} + struct ManagerShared { control_tx: Option>, event_tx: broadcast::Sender, + /// Dedicated bus for `voice.audio` PCM frames (not shared `event_tx` / `/ws`). + voice_audio_tx: broadcast::Sender, state: RwLock, /// When true, PCM ingest is dropped (renderer mute). muted: AtomicBool, @@ -94,9 +112,11 @@ impl VoiceSessionManager { control_tx, mut event_rx, }) => { + let (voice_audio_tx, _) = broadcast::channel::(VOICE_AUDIO_BROADCAST_CAP); let shared = Arc::new(ManagerShared { control_tx: Some(control_tx), event_tx: event_tx.clone(), + voice_audio_tx, state: RwLock::new(VoiceState { running: true, ..VoiceState::default() @@ -122,10 +142,12 @@ impl VoiceSessionManager { Err(e) => { let msg = format!("lxst telephony register: {e}"); tracing::error!(target: "voice", "{msg}"); + let (voice_audio_tx, _) = broadcast::channel::(VOICE_AUDIO_BROADCAST_CAP); Self { shared: Arc::new(ManagerShared { control_tx: None, event_tx, + voice_audio_tx, state: RwLock::new(VoiceState::default()), muted: AtomicBool::new(false), register_error: Some(msg), @@ -135,6 +157,11 @@ impl VoiceSessionManager { } } + /// Subscribe to high-rate `voice.audio` frames (dedicated bus, not shared `/ws`). + pub fn subscribe_voice_audio(&self) -> broadcast::Receiver { + self.shared.voice_audio_tx.subscribe() + } + pub async fn status(&self) -> serde_json::Value { let st = self.shared.state.read().await; if let Some(ref err) = self.shared.register_error { @@ -254,12 +281,8 @@ impl VoiceSessionManager { let dest = destination_hash.trim().to_lowercase(); let id = identity_hash.trim().to_lowercase(); if dest.len() == 32 && id.len() == 32 { - self.shared - .state - .write() - .await - .dest_to_identity - .insert(dest, id); + let mut st = self.shared.state.write().await; + insert_dest_identity_bounded(&mut st.dest_to_identity, dest, id); } } @@ -474,8 +497,9 @@ async fn bridge_service_event(shared: &ManagerShared, evt: TelephonyServiceEvent } => { for frame in frames { let samples_b64 = encode_f32_le_b64(&frame.samples); + // Dedicated high-rate bus — do not use shared event_tx / `/ws`. emit( - &shared.event_tx, + &shared.voice_audio_tx, "voice.audio", &json!({ "link_id": hex::encode(link_id), @@ -915,10 +939,12 @@ mod tests { } fn disabled_manager(event_tx: broadcast::Sender) -> VoiceSessionManager { + let (voice_audio_tx, _) = broadcast::channel::(4); VoiceSessionManager { shared: Arc::new(ManagerShared { control_tx: None, event_tx, + voice_audio_tx, state: RwLock::new(VoiceState::default()), muted: AtomicBool::new(false), register_error: Some("voice disabled for test".into()), @@ -950,9 +976,11 @@ mod tests { #[tokio::test] async fn bridge_emits_incoming_update_terminated_and_error() { let (event_tx, mut event_rx) = broadcast::channel::(16); + let (voice_audio_tx, mut voice_audio_rx) = broadcast::channel::(16); let shared = Arc::new(ManagerShared { control_tx: None, event_tx, + voice_audio_tx, state: RwLock::new(VoiceState::default()), muted: AtomicBool::new(false), register_error: None, @@ -999,6 +1027,26 @@ mod tests { assert!(stats.contains("\"type\":\"voice.stats\"")); assert!(stats.contains("\"tx_frames\":2")); + let pcm = RawAudioFrame::new(1, vec![0.0f32; 48]).expect("pcm"); + bridge_service_event( + &shared, + TelephonyServiceEvent::OpusFramesReceived { + link_id: link, + profile: Profile::QualityHigh, + frames: vec![pcm], + }, + ) + .await; + assert!( + event_rx.try_recv().is_err(), + "voice.audio must not use shared event bus" + ); + let audio = voice_audio_rx + .try_recv() + .expect("voice.audio on dedicated bus"); + assert!(audio.contains("\"type\":\"voice.audio\"")); + assert!(audio.contains("samples_b64")); + bridge_service_event( &shared, TelephonyServiceEvent::CallTerminated { @@ -1027,14 +1075,35 @@ mod tests { ); } + #[test] + fn dest_identity_cache_evicts_when_full() { + let mut cache = HashMap::new(); + for i in 0..MAX_DEST_TO_IDENTITY_CACHE { + let dest = format!("{i:032x}"); + let id = format!("{:032x}", i + 1); + insert_dest_identity_bounded(&mut cache, dest, id); + } + assert_eq!(cache.len(), MAX_DEST_TO_IDENTITY_CACHE); + let overflow_dest = "f".repeat(32); + let overflow_id = "e".repeat(32); + insert_dest_identity_bounded(&mut cache, overflow_dest.clone(), overflow_id.clone()); + assert_eq!(cache.len(), MAX_DEST_TO_IDENTITY_CACHE); + assert_eq!( + cache.get(&overflow_dest).map(String::as_str), + Some(overflow_id.as_str()) + ); + } + #[tokio::test] async fn send_audio_accepts_quality_high_frame_size() { let (control_tx, mut control_rx) = mpsc::channel::(4); let (event_tx, _) = broadcast::channel::(4); + let (voice_audio_tx, _) = broadcast::channel::(4); let mgr = VoiceSessionManager { shared: Arc::new(ManagerShared { control_tx: Some(control_tx), event_tx, + voice_audio_tx, state: RwLock::new(VoiceState { running: true, ..VoiceState::default() diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index 93a3c63dd..d344150bb 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -24,10 +24,25 @@ RS_LXMF_REF="${RS_LXMF_REF:-}" RS_NOMAD_REF="${RS_NOMAD_REF:-}" RS_LXST_REF="${RS_LXST_REF:-}" +# Last selected ref from ensure_repo (origin/main, origin/master, or pin). +ENSURE_REPO_SELECTED_REF='' + +# Format status mode from the ref ensure_repo actually selected. +format_repo_mode() { + local selected_ref="$1" pin_ref="$2" + if [[ -n "${pin_ref}" ]]; then + echo "pinned ${pin_ref:0:12}" + else + echo "floated ${selected_ref}" + fi +} + # Ensure an existing checkout has the correct origin remote and is on the # requested ref (or floated origin/main). If the directory does not exist, clone it first. +# Sets ENSURE_REPO_SELECTED_REF to the ref that was (or would be) checked out. ensure_repo() { local dir="$1" expected_origin="$2" ref_or_empty="$3" label="$4" + ENSURE_REPO_SELECTED_REF='' if [[ ! -d "${dir}/.git" ]]; then git clone "${expected_origin}" "${dir}" fi @@ -73,6 +88,8 @@ ensure_repo() { target_sha="$(git -C "${dir}" rev-parse --verify "${target_ref}^{commit}")" fi + ENSURE_REPO_SELECTED_REF="${target_ref}" + if [[ -n "$(git -C "${dir}" status --porcelain)" ]]; then # Overlays leave siblings dirty after a successful float — allow that when already # on the target tip. Refuse only when checkout would rewrite a dirty tree. @@ -94,39 +111,31 @@ ensure_repo() { fi } +# Allow tests to `source` this script and call ensure_repo without cloning remotes. +if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then + return 0 +fi + echo "Preparing Ratspeak stack (rsReticulum/rsLXMF/rsNomad/rsLXST float origin/main unless RS_*_REF set)..." ensure_repo "${RNS_DIR}" 'https://github.com/ratspeak/rsReticulum.git' \ "${RS_RETICULUM_REF}" 'rsReticulum' +rns_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_RETICULUM_REF}")" apply_ratspeak_rns_overlays "${SCRIPT_DIR}" ensure_repo "${LXMF_DIR}" 'https://github.com/ratspeak/rsLXMF.git' \ "${RS_LXMF_REF}" 'rsLXMF' +lxmf_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_LXMF_REF}")" apply_ratspeak_lxmf_overlays "${SCRIPT_DIR}" # Float Colorado-Mesh/rsNomad to origin/main (override via RS_NOMAD_REF above). ensure_repo "${NOMAD_DIR}" 'https://github.com/Colorado-Mesh/rsNomad.git' "${RS_NOMAD_REF}" 'rsNomad' +nomad_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_NOMAD_REF}")" # rsLXST (LXST telephony) — required for rns-stack voice; float origin/main unless RS_LXST_REF set. ensure_repo "${LXST_DIR}" 'https://github.com/ratspeak/rsLXST.git' "${RS_LXST_REF}" 'rsLXST' - -rns_mode='floated origin/main' -lxmf_mode='floated origin/main' -nomad_mode='floated origin/main' -lxst_mode='floated origin/main' -if [[ -n "${RS_RETICULUM_REF}" ]]; then - rns_mode="pinned ${RS_RETICULUM_REF:0:12}" -fi -if [[ -n "${RS_LXMF_REF}" ]]; then - lxmf_mode="pinned ${RS_LXMF_REF:0:12}" -fi -if [[ -n "${RS_NOMAD_REF}" ]]; then - nomad_mode="pinned ${RS_NOMAD_REF:0:12}" -fi -if [[ -n "${RS_LXST_REF}" ]]; then - lxst_mode="pinned ${RS_LXST_REF:0:12}" -fi +lxst_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_LXST_REF}")" rns_sha="$(git -C "${RNS_DIR}" rev-parse HEAD)" lxmf_sha="$(git -C "${LXMF_DIR}" rev-parse HEAD)" diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs index 3c4e43efa..c8bf84c36 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -1,11 +1,88 @@ -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; const cloneScriptPath = fileURLToPath(new URL('./clone-ratspeak-stack.sh', import.meta.url)); const cloneScript = readFileSync(cloneScriptPath, 'utf8'); +const tempDirs = []; + +function makeTempDir(prefix) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } + } +}); + +function git(cwd, ...args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + }).trim(); +} + +/** Local bare remote with a default branch (main or master) + optional tagged pin. */ +function createLocalRemote({ defaultBranch = 'main', pinTag = null } = {}) { + const remote = makeTempDir('rsLXST-remote-'); + const seed = makeTempDir('rsLXST-seed-'); + git(seed, 'init', '-b', defaultBranch); + git(seed, 'config', 'user.email', 'test@example.com'); + git(seed, 'config', 'user.name', 'test'); + writeFileSync(join(seed, 'README'), 'seed\n'); + git(seed, 'add', 'README'); + git(seed, 'commit', '-m', 'seed'); + const tipSha = git(seed, 'rev-parse', 'HEAD'); + let pinSha = tipSha; + if (pinTag) { + writeFileSync(join(seed, 'PIN'), 'pin\n'); + git(seed, 'add', 'PIN'); + git(seed, 'commit', '-m', 'pin'); + pinSha = git(seed, 'rev-parse', 'HEAD'); + git(seed, 'tag', pinTag); + // Float tip stays on default branch first commit for clearer float vs pin. + git(seed, 'reset', '--hard', tipSha); + } + git(seed, 'clone', '--bare', seed, remote); + return { remote, tipSha, pinSha }; +} + +function runEnsureRepo({ remoteUrl, destDir, pinRef = '' }) { + // Plain strings so bash ${...}/$(...) is not JS template interpolation. + const script = [ + 'set -euo pipefail', + 'source ' + JSON.stringify(cloneScriptPath), + 'ensure_repo ' + + JSON.stringify(destDir) + + ' ' + + JSON.stringify(remoteUrl) + + ' ' + + JSON.stringify(pinRef) + + " 'rsLXST'", + 'echo "SELECTED=${ENSURE_REPO_SELECTED_REF}"', + 'echo "MODE=$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" ' + JSON.stringify(pinRef) + ')"', + 'echo "SHA=$(git -C ' + JSON.stringify(destDir) + ' rev-parse HEAD)"', + ].join('\n'); + return execFileSync('bash', ['-c', script], { + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + }); +} + describe('clone-ratspeak-stack.sh float policy', () => { it('floats rsReticulum and rsLXMF to origin/main by default', () => { expect(cloneScript).toContain("target_ref='origin/main'"); @@ -20,6 +97,8 @@ describe('clone-ratspeak-stack.sh float policy', () => { expect(cloneScript).toContain('origin/${ref_or_empty}'); expect(cloneScript).toContain('Ratspeak stack SHAs (full)'); expect(cloneScript).toContain('ratspeak-overlay-apply-list.sh'); + expect(cloneScript).toContain('format_repo_mode'); + expect(cloneScript).toContain('ENSURE_REPO_SELECTED_REF'); expect(cloneScript).not.toMatch(/9928abed269a83ec5a7ef165ff1142d938cad706/); expect(cloneScript).not.toMatch(/68ad7c835187c052c763bb28c41b04a655f35c64/); }); @@ -27,16 +106,40 @@ describe('clone-ratspeak-stack.sh float policy', () => { it('floats rsNomad to origin/main with optional RS_NOMAD_REF pin', () => { expect(cloneScript).toMatch(/RS_NOMAD_REF="\$\{RS_NOMAD_REF:-\}"/); expect(cloneScript).toContain('Colorado-Mesh/rsNomad.git'); - expect(cloneScript).toContain("nomad_mode='floated origin/main'"); + expect(cloneScript).toContain( + 'format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_NOMAD_REF}"', + ); expect(cloneScript).not.toContain('6e3b288fbc6931b1e2633d986cf0d49608d578b7'); expect(cloneScript).not.toContain('RS_NOMAD_SKIP_PIN'); }); - it('floats rsLXST to origin/main with optional RS_LXST_REF pin', () => { - expect(cloneScript).toMatch(/RS_LXST_REF="\$\{RS_LXST_REF:-\}"/); - expect(cloneScript).toContain('ratspeak/rsLXST.git'); - expect(cloneScript).toContain("lxst_mode='floated origin/main'"); - expect(cloneScript).toContain('rsLXST @'); + it('ensure_repo floats rsLXST and reports selected ref + SHA', () => { + const { remote, tipSha } = createLocalRemote({ defaultBranch: 'main' }); + const dest = join(makeTempDir('workspace-'), 'rsLXST'); + const out = runEnsureRepo({ remoteUrl: remote, destDir: dest }); + expect(out).toContain('SELECTED=origin/main'); + expect(out).toContain('MODE=floated origin/main'); + expect(out).toContain(`SHA=${tipSha}`); + expect(git(dest, 'rev-parse', 'HEAD')).toBe(tipSha); + }); + + it('ensure_repo falls back to origin/master when main is absent', () => { + const { remote, tipSha } = createLocalRemote({ defaultBranch: 'master' }); + const dest = join(makeTempDir('workspace-'), 'rsLXST'); + const out = runEnsureRepo({ remoteUrl: remote, destDir: dest }); + expect(out).toContain('SELECTED=origin/master'); + expect(out).toContain('MODE=floated origin/master'); + expect(out).toContain(`SHA=${tipSha}`); + }); + + it('ensure_repo pins RS_LXST_REF and reports pinned mode + checkout SHA', () => { + const { remote, pinSha } = createLocalRemote({ defaultBranch: 'main', pinTag: 'v-test-pin' }); + const dest = join(makeTempDir('workspace-'), 'rsLXST'); + const out = runEnsureRepo({ remoteUrl: remote, destDir: dest, pinRef: 'v-test-pin' }); + expect(out).toMatch(/SELECTED=v-test-pin|SELECTED=origin\/v-test-pin/); + expect(out).toContain('MODE=pinned v-test-pin'); + expect(out).toContain(`SHA=${pinSha}`); + expect(git(dest, 'rev-parse', 'HEAD')).toBe(pinSha); }); it('applies rsReticulum and rsLXMF overlays after checkout via shared list', () => { diff --git a/src/main/ipc/reticulum-handlers.test.ts b/src/main/ipc/reticulum-handlers.test.ts index 404b6ddd4..92c71f7d6 100644 --- a/src/main/ipc/reticulum-handlers.test.ts +++ b/src/main/ipc/reticulum-handlers.test.ts @@ -653,6 +653,22 @@ describe('wireReticulumSidecarBridge', () => { }); }); + it('forwards manager "voiceAudio" emissions to reticulum:voiceAudio', () => { + const manager = createManagerStub(); + const win = createWinStub(); + wireReticulumSidecarBridge(manager as never, () => win as unknown as BrowserWindow); + + const voiceHandler = manager.on.mock.calls.find((call) => call[0] === 'voiceAudio')?.[1] as ( + evt: unknown, + ) => void; + expect(voiceHandler).toBeTypeOf('function'); + voiceHandler({ type: 'voice.audio', payload: { channels: 1, samples_b64: 'AAAA' } }); + expect(win.webContents.send).toHaveBeenCalledWith('reticulum:voiceAudio', { + type: 'voice.audio', + payload: { channels: 1, samples_b64: 'AAAA' }, + }); + }); + it('forwards manager "status" emissions to the main window', () => { const manager = createManagerStub(); const win = createWinStub(); diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index 65a4f3670..36bfc1b9f 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -555,6 +555,11 @@ export function wireReticulumSidecarBridge( if (!win || win.isDestroyed()) return; win.webContents.send('reticulum:event', evt); }); + manager.on('voiceAudio', (evt) => { + const win = getMainWindow(); + if (!win || win.isDestroyed()) return; + win.webContents.send('reticulum:voiceAudio', evt); + }); manager.on('status', (status) => { const win = getMainWindow(); if (!win || win.isDestroyed()) return; diff --git a/src/main/reticulum-sidecar-manager.test.ts b/src/main/reticulum-sidecar-manager.test.ts index 159b411c8..58a5fdaad 100644 --- a/src/main/reticulum-sidecar-manager.test.ts +++ b/src/main/reticulum-sidecar-manager.test.ts @@ -42,6 +42,7 @@ vi.mock('./reticulum-ble-rnode-config', () => ({ const mockWsInstances: MockWebSocketInstance[] = []; interface MockWebSocketInstance { + url: string; handlers: Map void>; close: ReturnType; removeAllListeners: ReturnType; @@ -476,14 +477,20 @@ describe('ReticulumSidecarManager', () => { const manager = new ReticulumSidecarManager(); await manager.start(); - expect(mockWsInstances.length).toBeGreaterThan(0); - const wsInstance = mockWsInstances[mockWsInstances.length - 1]; - expect(wsInstance.options).toEqual({ maxPayload: RETICULUM_WS_MAX_MESSAGE_BYTES }); + expect(mockWsInstances.length).toBeGreaterThanOrEqual(2); + const wsInstance = mockWsInstances.find((w) => w.url.endsWith('/ws')); + const voiceWsInstance = mockWsInstances.find((w) => w.url.endsWith('/ws/voice')); + expect(wsInstance).toBeDefined(); + expect(voiceWsInstance).toBeDefined(); + expect(wsInstance!.options).toEqual({ maxPayload: RETICULUM_WS_MAX_MESSAGE_BYTES }); + expect(voiceWsInstance!.options).toEqual({ maxPayload: RETICULUM_WS_MAX_MESSAGE_BYTES }); const events: unknown[] = []; + const voiceAudio: unknown[] = []; manager.on('event', (e) => events.push(e)); + manager.on('voiceAudio', (e) => voiceAudio.push(e)); - const openHandler = wsInstance.handlers.get('open'); + const openHandler = wsInstance!.handlers.get('open'); expect(openHandler).toBeDefined(); openHandler?.(); expect(events).toEqual([{ type: 'ws_connected', payload: { reconnect: false } }]); @@ -494,7 +501,7 @@ describe('ReticulumSidecarManager', () => { expect(events).toEqual([{ type: 'ws_connected', payload: { reconnect: true } }]); events.length = 0; - const messageHandler = wsInstance.handlers.get('message'); + const messageHandler = wsInstance!.handlers.get('message'); expect(messageHandler).toBeDefined(); // Oversized frame is dropped, not forwarded as an 'event'. @@ -507,6 +514,29 @@ describe('ReticulumSidecarManager', () => { messageHandler?.(normal); expect(events).toEqual([{ type: 'status', payload: { ok: true } }]); + // Stray voice.audio on shared /ws is ignored (dedicated /ws/voice owns PCM). + messageHandler?.( + Buffer.from( + JSON.stringify({ type: 'voice.audio', payload: { channels: 1, samples_b64: 'AA' } }), + ), + ); + expect(events).toHaveLength(1); // still only status above + events.length = 0; + + const voiceMessageHandler = voiceWsInstance!.handlers.get('message'); + expect(voiceMessageHandler).toBeDefined(); + voiceMessageHandler?.( + Buffer.from( + JSON.stringify({ + type: 'voice.audio', + payload: { channels: 1, samples_b64: 'AAAA' }, + }), + ), + ); + expect(voiceAudio).toEqual([ + { type: 'voice.audio', payload: { channels: 1, samples_b64: 'AAAA' } }, + ]); + await manager.stop(); existsSpy.mockRestore(); mkdirSpy.mockRestore(); diff --git a/src/main/reticulum-sidecar-manager.ts b/src/main/reticulum-sidecar-manager.ts index 42eb40a86..43d499aca 100644 --- a/src/main/reticulum-sidecar-manager.ts +++ b/src/main/reticulum-sidecar-manager.ts @@ -162,9 +162,12 @@ async function pollSidecarHealth(port: number): Promise export class ReticulumSidecarManager extends EventEmitter { private proc: ChildProcess | null = null; private ws: { close: () => void } | null = null; + private voiceWs: { close: () => void } | null = null; private wsPort = 0; private wsReconnectTimer: ReturnType | null = null; + private voiceWsReconnectTimer: ReturnType | null = null; private wsReconnectAttempt = 0; + private voiceWsReconnectAttempt = 0; /** True after the first successful WS open for this sidecar process (reconnects set reconnect=true). */ private wsEverConnected = false; private startPromise: Promise | null = null; @@ -637,6 +640,8 @@ export class ReticulumSidecarManager extends EventEmitter { const text = data.toString('utf8'); try { const parsed = JSON.parse(text) as { type?: string; payload?: unknown }; + // voice.audio must arrive on /ws/voice → voiceAudio (not shared event bus). + if (parsed.type === 'voice.audio') return; this.emit('event', { type: parsed.type ?? 'message', payload: parsed.payload ?? parsed, @@ -676,6 +681,70 @@ export class ReticulumSidecarManager extends EventEmitter { ); this.scheduleWsReconnect(); } + this.connectVoiceWs(port); + } + + /** Dedicated high-rate PCM stream (`/ws/voice` → `voiceAudio` IPC). */ + private connectVoiceWs(port: number): void { + this.clearVoiceWsReconnectTimer(); + const prev = this.voiceWs; + this.voiceWs = null; + prev?.close(); + try { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws/voice`, { + maxPayload: RETICULUM_WS_MAX_MESSAGE_BYTES, + }); + socket.on('open', () => { + this.voiceWsReconnectAttempt = 0; + }); + socket.on('message', (data: Buffer) => { + if (data.length > RETICULUM_WS_MAX_MESSAGE_BYTES) { + console.warn( + `[ReticulumSidecar] voice ws message exceeded ${RETICULUM_WS_MAX_MESSAGE_BYTES} byte cap, dropping`, + ); + return; + } + const text = data.toString('utf8'); + try { + const parsed = JSON.parse(text) as { type?: string; payload?: unknown }; + if (parsed.type !== 'voice.audio') return; + this.emit('voiceAudio', { + type: 'voice.audio', + payload: parsed.payload ?? parsed, + }); + } catch { + // catch-no-log-ok: ignore non-JSON on the voice audio socket + } + }); + socket.on('error', (err: Error) => { + console.warn('[ReticulumSidecar] voice ws error:', sanitizeLogMessage(err.message)); + }); + socket.on('close', () => { + if (this.wsPort === port) { + this.voiceWs = null; + this.scheduleVoiceWsReconnect(); + } + }); + this.voiceWs = { + close: () => { + try { + socket.removeAllListeners(); + socket.on('error', () => { + // catch-no-log-ok: intentional teardown; CONNECTING abort is expected + }); + socket.close(); + } catch { + // catch-no-log-ok: socket may already be closed + } + }, + }; + } catch (err) { + console.warn( + '[ReticulumSidecar] voice ws bridge unavailable:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + this.scheduleVoiceWsReconnect(); + } } private clearWsReconnectTimer(): void { @@ -685,6 +754,13 @@ export class ReticulumSidecarManager extends EventEmitter { } } + private clearVoiceWsReconnectTimer(): void { + if (this.voiceWsReconnectTimer) { + clearTimeout(this.voiceWsReconnectTimer); + this.voiceWsReconnectTimer = null; + } + } + /** Reconnect WS while the sidecar HTTP process is still running (event loss otherwise). */ private scheduleWsReconnect(): void { this.clearWsReconnectTimer(); @@ -702,14 +778,35 @@ export class ReticulumSidecarManager extends EventEmitter { }, delayMs); } + private scheduleVoiceWsReconnect(): void { + this.clearVoiceWsReconnectTimer(); + if (!this._status.running || this.wsPort <= 0) return; + const attempt = this.voiceWsReconnectAttempt; + this.voiceWsReconnectAttempt = Math.min(attempt + 1, 8); + const delayMs = Math.min(30_000, 500 * 2 ** attempt); + this.voiceWsReconnectTimer = setTimeout(() => { + this.voiceWsReconnectTimer = null; + if (!this._status.running || this.wsPort <= 0) return; + console.debug( + `[ReticulumSidecar] voice ws reconnect attempt=${this.voiceWsReconnectAttempt} port=${this.wsPort}`, + ); + this.connectVoiceWs(this.wsPort); + }, delayMs); + } + /** Tear down the WS bridge and cancel reconnect (used on sidecar stop). */ private teardownWs(): void { this.clearWsReconnectTimer(); + this.clearVoiceWsReconnectTimer(); this.wsPort = 0; this.wsReconnectAttempt = 0; + this.voiceWsReconnectAttempt = 0; this.wsEverConnected = false; const prev = this.ws; this.ws = null; prev?.close(); + const prevVoice = this.voiceWs; + this.voiceWs = null; + prevVoice?.close(); } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2d97469fa..4b6f6dff3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1104,6 +1104,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('reticulum:event', handler); return () => ipcRenderer.off('reticulum:event', handler); }, + onVoiceAudio: (cb: (event: ReticulumSidecarEvent) => void): (() => void) => { + const handler = (_: unknown, event: ReticulumSidecarEvent) => { + cb(event); + }; + ipcRenderer.on('reticulum:voiceAudio', handler); + return () => ipcRenderer.off('reticulum:voiceAudio', handler); + }, onStatus: (cb: (status: ReticulumSidecarStatus) => void): (() => void) => { const handler = (_: unknown, status: ReticulumSidecarStatus) => { cb(status); diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index a752d679f..b95c277d5 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -59,6 +59,10 @@ import { openReticulumDmFromHash, parseReticulumDestinationInput, } from '@/renderer/lib/reticulum/reticulumDestinationInput'; +import { + RETICULUM_DM_HEADER_ACTION_CLASS, + RETICULUM_DM_HEADER_STATUS_CLASS, +} from '@/renderer/lib/reticulumDmHeaderActions'; import { writeClipboardText } from '@/renderer/lib/writeClipboardText'; import type { ChatExportMessage } from '@/shared/electron-api.types'; import { formatIsoDate, formatIsoDateTime } from '@/shared/formatIsoDate'; @@ -230,7 +234,7 @@ function DmPeerInfoBar({ dmNode, nowMs, t }: { dmNode: MeshNode; nowMs: number; if (parts.length === 0) return null; return (
@@ -2289,8 +2293,7 @@ function ChatPanel({ lxmfPeerHash={reticulumDmDestinationHash} identityHash={reticulumDmIdentityHash} disabled={!reticulumStackLive} - className="text-cyan-400 hover:underline disabled:opacity-40" - showHelp + className={RETICULUM_DM_HEADER_ACTION_CLASS} /> ) : null; const peerDetailsAppearance = reticulumDmDestinationHash @@ -2300,7 +2303,7 @@ function ChatPanel({ protocol === 'reticulum' && onPeerClick && reticulumDmDestinationHash != null ? (
); diff --git a/src/renderer/components/ReticulumPeerListPanel.tsx b/src/renderer/components/ReticulumPeerListPanel.tsx index 1a9e407f0..bf159caf7 100644 --- a/src/renderer/components/ReticulumPeerListPanel.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.tsx @@ -586,7 +586,7 @@ export default function ReticulumPeerListPanel({ ) : null} - {showHelp ? ( - - {t('reticulumVoice.help.interop')} - - ) : null} - + {capability === 'heard' + ? t('reticulumVoice.capabilityHeardShort') + : t('reticulumVoice.capabilityUnknownShort')} + + ); } diff --git a/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx index f70337b1a..d8ccddbc0 100644 --- a/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx +++ b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx @@ -87,6 +87,51 @@ describe('ReticulumVoiceOverlay', () => { expect(await axe(container)).toHaveNoViolations(); }); + it('closes incoming dialog after answer progresses to connecting/established', async () => { + const user = userEvent.setup(); + act(() => { + useReticulumVoiceStore.getState().applyIncoming({ + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'ringing', + }); + }); + render(); + expect(screen.getByRole('dialog', { name: /incoming voice call/i })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /answer voice call/i })); + expect(answer).toHaveBeenCalled(); + act(() => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'snapshot', + active_call: { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'connecting', + answered: true, + }, + }); + }); + expect(screen.queryByRole('dialog', { name: /incoming voice call/i })).toBeNull(); + expect(screen.getByRole('status', { name: /connecting|in call/i })).toBeTruthy(); + + act(() => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'snapshot', + active_call: { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'established', + answered: true, + }, + }); + }); + expect(screen.queryByRole('dialog', { name: /incoming voice call/i })).toBeNull(); + expect(screen.getByRole('status', { name: /in call/i })).toBeTruthy(); + }); + it('shows hangup while optimistic calling with TX/RX counters', async () => { const user = userEvent.setup(); act(() => { diff --git a/src/renderer/lib/devElectronApiStub.ts b/src/renderer/lib/devElectronApiStub.ts index 641b885e8..681f64b9f 100644 --- a/src/renderer/lib/devElectronApiStub.ts +++ b/src/renderer/lib/devElectronApiStub.ts @@ -319,6 +319,7 @@ export function createDevElectronApiStub(): typeof window.electronAPI { readDefaultConfigFile: async () => ({ path: null, content: null }), showConfigImportDialog: async () => ({ path: null, content: null }), onEvent: noopUnsub, + onVoiceAudio: noopUnsub, onStatus: noopUnsub, rrc: { listHubs: async () => ({ hubs: [] }), diff --git a/src/renderer/lib/reticulumDmHeaderActions.ts b/src/renderer/lib/reticulumDmHeaderActions.ts new file mode 100644 index 000000000..5be9c6280 --- /dev/null +++ b/src/renderer/lib/reticulumDmHeaderActions.ts @@ -0,0 +1,10 @@ +/** + * Shared text-link style for Reticulum Chat DM header actions + * (Probe / Peer details / Call / Send file). Status chips stay pill-shaped. + */ +export const RETICULUM_DM_HEADER_ACTION_CLASS = + 'inline-flex items-center gap-1 text-xs text-cyan-400 hover:underline disabled:cursor-not-allowed disabled:opacity-40 disabled:no-underline'; + +/** Non-interactive status chips (path reachability, last heard). */ +export const RETICULUM_DM_HEADER_STATUS_CLASS = + 'inline-flex items-center gap-1.5 rounded-lg bg-slate-800/60 px-2.5 py-1 text-xs'; diff --git a/src/renderer/lib/reticulumVoiceAudio.test.ts b/src/renderer/lib/reticulumVoiceAudio.test.ts index 63ad37a63..90a3f7b6a 100644 --- a/src/renderer/lib/reticulumVoiceAudio.test.ts +++ b/src/renderer/lib/reticulumVoiceAudio.test.ts @@ -30,6 +30,27 @@ describe('reticulumVoiceAudio', () => { expect(packed!.length).toBe(LXST_QUALITY_HIGH_FRAME_SAMPLES); }); + it('resamples non-48kHz mono into QualityHigh frame size', () => { + const input = new Float32Array(1_440).fill(0.2); // 30 ms @ 48 kHz-equivalent length @ 24 kHz + const packed = packQualityHighFrame(input, 24_000, 1); + expect(packed).not.toBeNull(); + expect(packed!.length).toBe(LXST_QUALITY_HIGH_FRAME_SAMPLES); + expect(packed![0]).toBeCloseTo(0.2, 5); + }); + + it('mixes multichannel input down to mono before packing', () => { + const frames = 1_000; + const stereo = new Float32Array(frames * 2); + for (let i = 0; i < frames; i += 1) { + stereo[i * 2] = 0.5; + stereo[i * 2 + 1] = -0.5; + } + const packed = packQualityHighFrame(stereo, 48_000, 2); + expect(packed).not.toBeNull(); + expect(packed!.length).toBe(LXST_QUALITY_HIGH_FRAME_SAMPLES); + expect(packed![0]).toBeCloseTo(0, 5); + }); + it('returns null for empty input', () => { expect(packQualityHighFrame(new Float32Array(0), 48_000)).toBeNull(); }); @@ -45,6 +66,21 @@ describe('reticulumVoiceAudio', () => { ).toEqual({ dialHash: id, source: 'identity' }); }); + it('normalizes uppercase identity hashes and rejects invalid lengths', () => { + const upper = 'A'.repeat(32); + expect( + resolveVoiceDialIdentityHash({ + identityHash: upper, + }), + ).toEqual({ dialHash: 'a'.repeat(32), source: 'identity' }); + expect( + resolveVoiceDialIdentityHash({ + identityHash: 'abc', + destinationHash: 'not-32-chars', + }), + ).toEqual({ errorKey: 'reticulumVoice.errors.noIdentity' }); + }); + it('falls back to candidates then destination hash', () => { const id = 'c'.repeat(32); const dest = 'd'.repeat(32); diff --git a/src/renderer/lib/reticulumVoiceAudio.ts b/src/renderer/lib/reticulumVoiceAudio.ts index cc76a4137..e7bb71e62 100644 --- a/src/renderer/lib/reticulumVoiceAudio.ts +++ b/src/renderer/lib/reticulumVoiceAudio.ts @@ -9,17 +9,25 @@ export const LXST_QUALITY_HIGH_CHANNELS = 1; export const LXST_QUALITY_HIGH_SAMPLE_RATE_HZ = 48_000; export const LXST_QUALITY_HIGH_FRAME_SAMPLES = 2_880; +/** Chunk size keeps String.fromCharCode under engine argument limits. */ +const FROM_CHAR_CODE_CHUNK = 0x8000; + +function bytesToBinaryString(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i += FROM_CHAR_CODE_CHUNK) { + const slice = bytes.subarray(i, Math.min(i + FROM_CHAR_CODE_CHUNK, bytes.length)); + binary += String.fromCharCode(...slice); + } + return binary; +} + export function encodeF32LeBase64(samples: Float32Array | number[]): string { const bytes = new Uint8Array(samples.length * 4); const view = new DataView(bytes.buffer); for (let i = 0; i < samples.length; i += 1) { view.setFloat32(i * 4, samples[i] ?? 0, true); } - let binary = ''; - for (const b of bytes) { - binary += String.fromCodePoint(b); - } - return btoa(binary); + return btoa(bytesToBinaryString(bytes)); } export function decodeF32LeBase64(samplesB64: string): Float32Array { @@ -35,7 +43,7 @@ export function decodeF32LeBase64(samplesB64: string): Float32Array { const out = new Float32Array(binary.length / 4); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.codePointAt(i) ?? 0; + bytes[i] = binary.charCodeAt(i); } const view = new DataView(bytes.buffer); for (let i = 0; i < out.length; i += 1) { diff --git a/src/renderer/lib/reticulumVoiceSession.ts b/src/renderer/lib/reticulumVoiceSession.ts index 142c40f5b..a0cfd5efb 100644 --- a/src/renderer/lib/reticulumVoiceSession.ts +++ b/src/renderer/lib/reticulumVoiceSession.ts @@ -28,6 +28,7 @@ import { RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS } from '@/renderer/lib/timeCo import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; +import { isVoiceStatusResponse } from '@/shared/voice-types'; let captureCtx: AudioContext | null = null; let captureSource: MediaStreamAudioSourceNode | null = null; @@ -35,13 +36,52 @@ let captureSource: MediaStreamAudioSourceNode | null = null; // capture without shipping a worklet module (Electron getUserMedia path). // eslint-disable-next-line @typescript-eslint/no-deprecated -- see comment above let captureProcessor: ScriptProcessorNode | null = null; +/** Muted sink so ScriptProcessor stays in the graph without mic monitor playback. */ +let captureGain: GainNode | null = null; let captureStream: MediaStream | null = null; +let captureGeneration = 0; let playbackCtx: AudioContext | null = null; +let playbackCursor = 0; let audioUnsub: (() => void) | null = null; let txTimer: ReturnType | null = null; let pendingSamples: number[] = []; let safetyHangupTimer: ReturnType | null = null; +function discardCaptureResources( + stream: MediaStream | null, + ctx: AudioContext | null, + source: MediaStreamAudioSourceNode | null, + // eslint-disable-next-line @typescript-eslint/no-deprecated -- paired with createScriptProcessor + processor: ScriptProcessorNode | null, + gain: GainNode | null, +): void { + try { + processor?.disconnect(); + } catch { + // catch-no-log-ok + } + try { + source?.disconnect(); + } catch { + // catch-no-log-ok + } + try { + gain?.disconnect(); + } catch { + // catch-no-log-ok + } + if (stream) { + for (const t of stream.getTracks()) { + t.stop(); + } + } + if (ctx) { + void ctx.close().catch(() => { + // catch-no-log-ok close during stale-start cleanup + }); + } +} + async function ensureMicAccess(): Promise { try { const result = await window.electronAPI.media.ensureMicrophoneAccess(); @@ -53,35 +93,18 @@ async function ensureMicAccess(): Promise { } function stopCapture(): void { + captureGeneration += 1; if (txTimer != null) { clearInterval(txTimer); txTimer = null; } pendingSamples = []; - try { - captureProcessor?.disconnect(); - } catch { - // catch-no-log-ok - } - try { - captureSource?.disconnect(); - } catch { - // catch-no-log-ok - } + discardCaptureResources(captureStream, captureCtx, captureSource, captureProcessor, captureGain); captureProcessor = null; captureSource = null; - if (captureStream) { - for (const t of captureStream.getTracks()) { - t.stop(); - } - captureStream = null; - } - if (captureCtx) { - void captureCtx.close().catch(() => { - // catch-no-log-ok close during teardown - }); - captureCtx = null; - } + captureGain = null; + captureStream = null; + captureCtx = null; } function stopPlayback(): void { @@ -89,6 +112,7 @@ function stopPlayback(): void { audioUnsub(); audioUnsub = null; } + playbackCursor = 0; if (playbackCtx) { void playbackCtx.close().catch(() => { // catch-no-log-ok close during teardown @@ -129,36 +153,69 @@ function scheduleOutgoingSafetyHangup(generation: number): void { async function startCaptureAndTx(): Promise { stopCapture(); + const generation = captureGeneration; if (!(await ensureMicAccess())) { - pushAppToast(i18n.t('reticulumVoice.errors.notRunning'), 'error'); + if (generation !== captureGeneration) return; + pushAppToast(i18n.t('reticulumVoice.errors.micFailed'), 'error'); return; } - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: 1, - echoCancellation: true, - noiseSuppression: true, - }, - }); - captureStream = stream; - const ctx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); - captureCtx = ctx; - const source = ctx.createMediaStreamSource(stream); - captureSource = source; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- AudioWorklet deferred; see captureProcessor note - const processor = ctx.createScriptProcessor(4096, 1, 1); - captureProcessor = processor; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- paired with createScriptProcessor - processor.onaudioprocess = (ev) => { - if (useReticulumVoiceStore.getState().microphoneMuted) return; + if (generation !== captureGeneration) return; + + let stream: MediaStream | null = null; + let ctx: AudioContext | null = null; + let source: MediaStreamAudioSourceNode | null = null; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- see captureProcessor note + let processor: ScriptProcessorNode | null = null; + let gain: GainNode | null = null; + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + }, + }); + if (generation !== captureGeneration) { + discardCaptureResources(stream, null, null, null, null); + return; + } + ctx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); + if (generation !== captureGeneration) { + discardCaptureResources(stream, ctx, null, null, null); + return; + } + source = ctx.createMediaStreamSource(stream); + // eslint-disable-next-line @typescript-eslint/no-deprecated -- AudioWorklet deferred; see captureProcessor note + processor = ctx.createScriptProcessor(4096, 1, 1); + gain = ctx.createGain(); + gain.gain.value = 0; // eslint-disable-next-line @typescript-eslint/no-deprecated -- paired with createScriptProcessor - const input = ev.inputBuffer.getChannelData(0); - for (const s of input) { - pendingSamples.push(s); + processor.onaudioprocess = (ev) => { + if (useReticulumVoiceStore.getState().microphoneMuted) return; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- paired with createScriptProcessor + const input = ev.inputBuffer.getChannelData(0); + for (const s of input) { + pendingSamples.push(s); + } + }; + source.connect(processor); + processor.connect(gain); + gain.connect(ctx.destination); + + if (generation !== captureGeneration) { + discardCaptureResources(stream, ctx, source, processor, gain); + return; } - }; - source.connect(processor); - processor.connect(ctx.destination); + + captureStream = stream; + captureCtx = ctx; + captureSource = source; + captureProcessor = processor; + captureGain = gain; + } catch (e) { + discardCaptureResources(stream, ctx, source, processor, gain); + throw e; + } // One QualityHigh frame ≈ 60 ms @ 48 kHz; tick near frame duration (not 2×) so we // stay around ~16–17 IPC posts/s under the dedicated voiceSendAudio rate limit. @@ -199,6 +256,7 @@ async function startCaptureAndTx(): Promise { function startPlayback(): void { stopPlayback(); playbackCtx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); + playbackCursor = playbackCtx.currentTime; audioUnsub = useReticulumVoiceStore.getState().subscribeAudio((channels, samples) => { const ctx = playbackCtx; if (!ctx || samples.length === 0) return; @@ -219,7 +277,9 @@ function startPlayback(): void { const node = ctx.createBufferSource(); node.buffer = buffer; node.connect(ctx.destination); - node.start(); + const startAt = Math.max(playbackCursor, ctx.currentTime); + node.start(startAt); + playbackCursor = startAt + buffer.duration; }); } @@ -229,6 +289,7 @@ export async function startReticulumVoiceMediaForActiveCall(): Promise { await startCaptureAndTx(); } catch (e) { console.warn('[reticulumVoice] mic capture failed', e); + pushAppToast(i18n.t('reticulumVoice.errors.micFailed'), 'error'); } } @@ -239,14 +300,30 @@ function peerIdentityForLxmfDest(lxmfPeerHash: string): string | null { return peer?.identity_hash ? canonicalizeReticulumDestinationHash(peer.identity_hash) : null; } +function toastCallFailed(detail?: unknown): void { + console.warn('[reticulumVoice] call IPC failed', detail); + pushAppToast(i18n.t('reticulumVoice.errors.callFailed'), 'error'); +} + export async function reticulumVoiceCallPeer( lxmfPeerHash: string, opts?: { identityHash?: string | null }, ): Promise { const api = window.electronAPI.reticulum.voice; - const status = await api.getStatus(); - useReticulumVoiceStore.getState().applyStatus(status); - if (!status.enabled || status.running === false) { + let statusRaw: unknown; + try { + statusRaw = await api.getStatus(); + } catch (e) { + console.warn('[reticulumVoice] getStatus IPC failed', e); + toastCallFailed(e); + return; + } + if (!isVoiceStatusResponse(statusRaw)) { + toastCallFailed('invalid voice status'); + return; + } + useReticulumVoiceStore.getState().applyStatus(statusRaw); + if (!statusRaw.enabled || statusRaw.running === false) { pushAppToast(i18n.t('reticulumVoice.errors.notRunning'), 'error'); return; } @@ -276,24 +353,38 @@ export async function reticulumVoiceCallPeer( startVoiceRingback(); scheduleOutgoingSafetyHangup(generation); - const resp = await api.call({ identity_hash: resolved.dialHash }); - if (!resp.ok) { - const msg = resp.error || i18n.t('reticulumVoice.errors.callFailed'); - console.warn(`[reticulumVoice] call failed reason=${msg}`); + try { + const resp = await api.call({ identity_hash: resolved.dialHash }); + if (!resp.ok) { + const msg = resp.error || i18n.t('reticulumVoice.errors.callFailed'); + console.warn(`[reticulumVoice] call failed reason=${msg}`); + clearSafetyHangupTimer(); + stopVoiceCallTones(); + playVoiceFailTone(); + useReticulumVoiceStore.getState().applyError(msg); + pushAppToast(msg, 'error'); + } + } catch (e) { + console.warn('[reticulumVoice] call IPC failed', e); clearSafetyHangupTimer(); stopVoiceCallTones(); playVoiceFailTone(); - useReticulumVoiceStore.getState().applyError(msg); - pushAppToast(msg, 'error'); - return; + useReticulumVoiceStore.getState().applyError(i18n.t('reticulumVoice.errors.callFailed')); + toastCallFailed(e); } // Mic deferred until connecting/established (overlay effect). } export async function reticulumVoiceAnswer(): Promise { - const resp = await window.electronAPI.reticulum.voice.answer(); - if (!resp.ok) { - pushAppToast(resp.error || i18n.t('reticulumVoice.errors.callFailed'), 'error'); + try { + const resp = await window.electronAPI.reticulum.voice.answer(); + if (!resp.ok) { + pushAppToast(resp.error || i18n.t('reticulumVoice.errors.callFailed'), 'error'); + return; + } + } catch (e) { + console.warn('[reticulumVoice] answer IPC failed', e); + pushAppToast(i18n.t('reticulumVoice.errors.callFailed'), 'error'); return; } stopVoiceCallTones(); @@ -304,7 +395,12 @@ export async function reticulumVoiceReject(): Promise { clearSafetyHangupTimer(); stopReticulumVoiceMedia(); stopVoiceCallTones(); - await window.electronAPI.reticulum.voice.reject(); + try { + await window.electronAPI.reticulum.voice.reject(); + } catch (e) { + console.warn('[reticulumVoice] reject IPC failed', e); + pushAppToast(i18n.t('reticulumVoice.errors.callFailed'), 'error'); + } useReticulumVoiceStore.getState().clearCall(); } @@ -334,7 +430,12 @@ export async function reticulumVoiceHangup(opts?: { } export async function reticulumVoiceSetMuted(muted: boolean): Promise { - await window.electronAPI.reticulum.voice.mute({ muted }); + try { + await window.electronAPI.reticulum.voice.mute({ muted }); + } catch (e) { + console.warn('[reticulumVoice] mute IPC failed', e); + pushAppToast(i18n.t('reticulumVoice.errors.muteFailed'), 'error'); + } useReticulumVoiceStore.getState().setMicrophoneMuted(muted); } diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index ea85ab953..6b31efa25 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -5121,7 +5121,9 @@ "errors": { "noIdentity": "Nelze volat: identita protějšku zatím není známa. Počkejte na oznámení nebo sondujte kolegu.", "callFailed": "Hlasový hovor se nezdařil.", - "notRunning": "Hlas není k dispozici, dokud není spuštěn zásobník Reticulum." + "notRunning": "Hlas není k dispozici, dokud není spuštěn zásobník Reticulum.", + "micFailed": "Nelze získat přístup k mikrofonu pro hlas.", + "muteFailed": "Nelze aktualizovat ztlumení mikrofonu v relaci hlasu." }, "connecting": "Připojování…", "elapsedAria": "Uplynulý čas hovoru", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index d36be606a..47206a126 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "Kann nicht aufrufen: Peer-Identität noch nicht bekannt. Warten Sie auf eine Ankündigung oder fragen Sie den Kollegen.", "callFailed": "Sprachanruf fehlgeschlagen.", - "notRunning": "Stimme ist erst verfügbar, wenn der Reticulum-Stapel läuft." + "notRunning": "Stimme ist erst verfügbar, wenn der Reticulum-Stapel läuft.", + "micFailed": "Konnte nicht auf das Mikrofon für Sprache zugreifen.", + "muteFailed": "Mikrofon-Stummschaltung in der Sprachsitzung konnte nicht aktualisiert werden." }, "connecting": "verbinden …", "elapsedAria": "Verstrichene Zeit", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 8d857fc5e..9218fa631 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -765,7 +765,9 @@ "errors": { "noIdentity": "Cannot call: peer identity not known yet. Wait for an announce or probe the peer.", "callFailed": "Voice call failed.", - "notRunning": "Voice is not available until the Reticulum stack is running." + "notRunning": "Voice is not available until the Reticulum stack is running.", + "micFailed": "Could not access the microphone for voice.", + "muteFailed": "Could not update microphone mute on the voice session." } }, "connectionPanel": { diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 105629af2..86b6efe9d 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "No se puede llamar: la identidad del compañero aún no se conoce. Espere un anuncio o sondee al compañero.", "callFailed": "La llamada de voz ha fallado.", - "notRunning": "La voz no está disponible hasta que la pila de Reticulum esté en ejecución." + "notRunning": "La voz no está disponible hasta que la pila de Reticulum esté en ejecución.", + "micFailed": "No se ha podido acceder al micrófono para voz.", + "muteFailed": "No se ha podido actualizar el silencio del micrófono en la sesión de voz." }, "connecting": "Conectando…", "elapsedAria": "Tiempo transcurrido", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 6becca3e7..58d30bcfa 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "Impossible d'appeler : l'identité de l'homologue n'est pas encore connue. Attendez une annonce ou sondez le pair.", "callFailed": "Échec de l'appel vocal.", - "notRunning": "La voix n'est pas disponible tant que la pile Reticulum n'est pas en cours d'exécution." + "notRunning": "La voix n'est pas disponible tant que la pile Reticulum n'est pas en cours d'exécution.", + "micFailed": "Impossible d'accéder au microphone pour la voix.", + "muteFailed": "Impossible de mettre à jour le microphone muet sur la session vocale." }, "connecting": "Connexion…", "elapsedAria": "Temps écoulé", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index f49da379a..63f82fe83 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "Tidak dapat menelepon: identitas teman sebaya belum diketahui. Tunggu pengumuman atau selidiki rekan sejawat.", "callFailed": "Panggilan suara gagal.", - "notRunning": "Suara tidak tersedia sampai tumpukan Reticulum berjalan." + "notRunning": "Suara tidak tersedia sampai tumpukan Reticulum berjalan.", + "micFailed": "Tidak dapat mengakses mikrofon untuk suara.", + "muteFailed": "Tidak dapat memperbarui mikrofon bisu pada sesi suara." }, "connecting": "Menyambung…", "elapsedAria": "Waktu berlalu", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 3426f746e..1571926b8 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "Impossibile chiamare: identità peer non ancora nota. Attendere un annuncio o sondare il peer.", "callFailed": "Chiamata vocale non riuscita.", - "notRunning": "La voce non è disponibile fino a quando lo stack Reticulum non è in esecuzione." + "notRunning": "La voce non è disponibile fino a quando lo stack Reticulum non è in esecuzione.", + "micFailed": "Impossibile accedere al microfono per la voce.", + "muteFailed": "Impossibile aggiornare il silenziamento del microfono nella sessione vocale." }, "connecting": "Collegamento in corso…", "elapsedAria": "Tempo trascorso", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 4fbcf615e..752bd3964 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "呼び出すことができません:ピアIDはまだわかりません。アナウンスを待つか、ピアをプローブします。", "callFailed": "音声通話に失敗しました。", - "notRunning": "Reticulumスタックが実行されるまで、音声は使用できません。" + "notRunning": "Reticulumスタックが実行されるまで、音声は使用できません。", + "micFailed": "音声用のマイクにアクセスできませんでした。", + "muteFailed": "音声セッションでマイクのミュートを更新できませんでした。" }, "connecting": "連携中…", "elapsedAria": "経過時間", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index f108b2f2a..a4ab75799 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "호출할 수 없음: 피어 ID를 아직 알 수 없습니다. 피어 발표 또는 조사를 기다립니다.", "callFailed": "음성 통화에 실패했습니다.", - "notRunning": "Reticulum 스택이 실행될 때까지 음성을 사용할 수 없습니다." + "notRunning": "Reticulum 스택이 실행될 때까지 음성을 사용할 수 없습니다.", + "micFailed": "음성을 위해 마이크에 액세스할 수 없습니다.", + "muteFailed": "음성 세션에서 마이크 음소거를 업데이트할 수 없습니다." }, "connecting": "Connecting", "elapsedAria": "경과 시간", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index a427ba8b6..57ce2ddb2 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "Kan niet aanroepen: peer-identiteit nog niet bekend. Wacht op een aankondiging of onderzoek de peer.", "callFailed": "Spraakoproep mislukt.", - "notRunning": "Stem is niet beschikbaar totdat de Reticulum-stack actief is." + "notRunning": "Stem is niet beschikbaar totdat de Reticulum-stack actief is.", + "micFailed": "Kon geen toegang krijgen tot de microfoon voor spraak.", + "muteFailed": "Kan microfoondemping niet bijwerken tijdens de spraaksessie." }, "connecting": "Verbinden…", "elapsedAria": "Verstreken tijd", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 3188c3816..2581a87cc 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -5123,7 +5123,9 @@ "errors": { "noIdentity": "Nie można wywołać: tożsamość rówieśnika nie jest jeszcze znana. Poczekaj na ogłoszenie lub zbadaj rówieśnika.", "callFailed": "Połączenie głosowe nie powiodło się.", - "notRunning": "Głos nie jest dostępny, dopóki stos Reticulum nie zostanie uruchomiony." + "notRunning": "Głos nie jest dostępny, dopóki stos Reticulum nie zostanie uruchomiony.", + "micFailed": "Nie można uzyskać dostępu do mikrofonu dla głosu.", + "muteFailed": "Nie można zaktualizować wyciszenia mikrofonu w sesji głosowej." }, "connecting": "Łączenie…", "elapsedAria": "Czas, jaki upłynął", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 307db264e..8a48bd17c 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "Não é possível chamar: identidade de par ainda não conhecida. Aguarde um anúncio ou investigue o colega.", "callFailed": "Falha na chamada de voz.", - "notRunning": "A voz não está disponível até que a pilha Reticulum esteja em execução." + "notRunning": "A voz não está disponível até que a pilha Reticulum esteja em execução.", + "micFailed": "Não foi possível acessar o microfone para voz.", + "muteFailed": "Não foi possível atualizar o mudo do microfone na sessão de voz." }, "connecting": "Conectando…", "elapsedAria": "Tempo corrido:", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 17890d044..8574352f5 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -5121,7 +5121,9 @@ "errors": { "noIdentity": "Невозможно вызвать: идентификатор однорангового узла еще не известен. Дождитесь объявления или проверьте коллегу.", "callFailed": "Сбой голосового вызова.", - "notRunning": "Голос недоступен до тех пор, пока не будет запущен стек Reticulum." + "notRunning": "Голос недоступен до тех пор, пока не будет запущен стек Reticulum.", + "micFailed": "Не удалось получить доступ к микрофону для голоса.", + "muteFailed": "Не удалось обновить отключение микрофона во время голосового сеанса." }, "connecting": "Подключение…", "elapsedAria": "ПРОДОЛЖИТЕЛЬНОСТЬ", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index f2b908acc..03029e89f 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "Aranamıyor: akran kimliği henüz bilinmiyor. Bir duyuru bekleyin veya akranınızı sorgulayın.", "callFailed": "Sesli arama başarısız.", - "notRunning": "Reticulum yığını çalışana kadar ses kullanılamaz." + "notRunning": "Reticulum yığını çalışana kadar ses kullanılamaz.", + "micFailed": "Ses için mikrofona erişilemedi.", + "muteFailed": "Sesli oturumda mikrofon sessize alınamadı." }, "connecting": "Bağlanıyor…", "elapsedAria": "Geçen zaman", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 1b309f266..ea018b2de 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -5121,7 +5121,9 @@ "errors": { "noIdentity": "Неможливо зателефонувати: ідентифікатор вузла ще не відомий. Дочекайтеся оголошення або перевірте колегу.", "callFailed": "Не вдалося здійснити голосовий виклик.", - "notRunning": "Голосовий зв'язок недоступний до запуску стека Reticulum." + "notRunning": "Голосовий зв'язок недоступний до запуску стека Reticulum.", + "micFailed": "Не вдалося отримати доступ до мікрофона для голосу.", + "muteFailed": "Не вдалося оновити вимкнення мікрофона під час голосового сеансу." }, "connecting": "Підключення…", "elapsedAria": "Минулий час", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 17a0ca21f..d39ef4e42 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -5119,7 +5119,9 @@ "errors": { "noIdentity": "无法调用:对等身份尚不清楚。等待通知或探查对等项。", "callFailed": "语音通话失败。", - "notRunning": "在 Reticulum 堆栈运行之前,语音不可用。" + "notRunning": "在 Reticulum 堆栈运行之前,语音不可用。", + "micFailed": "无法访问麦克风进行语音通话。", + "muteFailed": "无法更新语音会话上的麦克风静音。" }, "connecting": "连接中…", "elapsedAria": "运行时间", diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 441b55533..d069ad15b 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -261,6 +261,7 @@ export function useReticulumRuntime(): ProtocolRuntime { MAX_RAW_PACKET_LOG_ENTRIES, ); const unsubEventRef = useRef<(() => void) | null>(null); + const unsubVoiceAudioRef = useRef<(() => void) | null>(null); const connectRef = useRef<(() => Promise) | null>(null); const restartStackRef = useRef<(() => Promise) | null>(null); const connectInFlightRef = useRef(false); @@ -1192,14 +1193,6 @@ export function useReticulumRuntime(): ProtocolRuntime { errorMessage: p.message ?? 'voice error', }); } - if (evt.type === 'voice.audio' && evt.payload && typeof evt.payload === 'object') { - const p = evt.payload as { channels?: number; samples_b64?: string }; - if (typeof p.samples_b64 === 'string') { - const samples = decodeF32LeBase64(p.samples_b64); - const channels = typeof p.channels === 'number' && p.channels > 0 ? p.channels : 1; - useReticulumVoiceStore.getState().emitAudio(channels, samples); - } - } if (evt.type === 'rncp.progress' && evt.payload && typeof evt.payload === 'object') { const p = evt.payload as { transfer_id?: string; progress?: number }; if (p.transfer_id && typeof p.progress === 'number') { @@ -1332,9 +1325,28 @@ export function useReticulumRuntime(): ProtocolRuntime { ], ); + const subscribeSidecarEventBridges = useCallback(() => { + unsubEventRef.current?.(); + unsubVoiceAudioRef.current?.(); + unsubEventRef.current = window.electronAPI.reticulum.onEvent(handleSidecarEvent); + unsubVoiceAudioRef.current = window.electronAPI.reticulum.onVoiceAudio((evt) => { + if (evt.type !== 'voice.audio' || !evt.payload || typeof evt.payload !== 'object') return; + const p = evt.payload as { channels?: number; samples_b64?: string }; + if (typeof p.samples_b64 !== 'string') return; + const samples = decodeF32LeBase64(p.samples_b64); + const channels = + typeof p.channels === 'number' && Number.isInteger(p.channels) && p.channels > 0 + ? p.channels + : 1; + useReticulumVoiceStore.getState().emitAudio(channels, samples); + }); + }, [handleSidecarEvent]); + const tearDownFromSidecarStop = useCallback(() => { unsubEventRef.current?.(); unsubEventRef.current = null; + unsubVoiceAudioRef.current?.(); + unsubVoiceAudioRef.current = null; localInterfacesRef.current = []; setSelfLxmfHash(null); rawPacketAppenderRef.current?.clearPending(); @@ -1407,6 +1419,8 @@ export function useReticulumRuntime(): ProtocolRuntime { } unsubEventRef.current?.(); unsubEventRef.current = null; + unsubVoiceAudioRef.current?.(); + unsubVoiceAudioRef.current = null; // Dev HMR remounts App without an explicit disconnect — keep the sidecar alive. if (!import.meta.env.DEV) { void window.electronAPI.reticulum.stop().catch((e: unknown) => { @@ -1444,8 +1458,7 @@ export function useReticulumRuntime(): ProtocolRuntime { setState((s) => ({ ...s, status: 'connecting', connectionType: null })); syncConnectionStore({ status: 'connecting', connectionType: null }); await window.electronAPI.reticulum.start({ reuseIfRunning: true }); - unsubEventRef.current?.(); - unsubEventRef.current = window.electronAPI.reticulum.onEvent(handleSidecarEvent); + subscribeSidecarEventBridges(); const lxmfHash = await refreshIdentityFromSidecar(); const connectedNodeId = lxmfHash ? reticulumHashToNodeId(lxmfHash) : 0; if (connectedNodeId > 0) { @@ -1496,7 +1509,7 @@ export function useReticulumRuntime(): ProtocolRuntime { connectInFlightDoneRef.current = null; } }, [ - handleSidecarEvent, + subscribeSidecarEventBridges, refreshContactsFromSidecar, refreshIdentityFromSidecar, refreshLocalInterfacesFromSidecar, @@ -1523,6 +1536,8 @@ export function useReticulumRuntime(): ProtocolRuntime { } unsubEventRef.current?.(); unsubEventRef.current = null; + unsubVoiceAudioRef.current?.(); + unsubVoiceAudioRef.current = null; try { await window.electronAPI.reticulum.stop(); } catch (e) { @@ -1563,9 +1578,11 @@ export function useReticulumRuntime(): ProtocolRuntime { syncConnectionStore({ status: 'connecting', connectionType: null }); unsubEventRef.current?.(); unsubEventRef.current = null; + unsubVoiceAudioRef.current?.(); + unsubVoiceAudioRef.current = null; await window.electronAPI.reticulum.stop(); await window.electronAPI.reticulum.start({ reuseIfRunning: false }); - unsubEventRef.current = window.electronAPI.reticulum.onEvent(handleSidecarEvent); + subscribeSidecarEventBridges(); const lxmfHash = await refreshIdentityFromSidecar(); const connectedNodeId = lxmfHash ? reticulumHashToNodeId(lxmfHash) : 0; await refreshContactsFromSidecar(); @@ -1602,7 +1619,7 @@ export function useReticulumRuntime(): ProtocolRuntime { connectInFlightDoneRef.current = null; } }, [ - handleSidecarEvent, + subscribeSidecarEventBridges, refreshContactsFromSidecar, refreshIdentityFromSidecar, refreshLocalInterfacesFromSidecar, diff --git a/src/renderer/runtime/useReticulumRuntime.voice.test.ts b/src/renderer/runtime/useReticulumRuntime.voice.test.ts index cf6cbc3f8..f8a604276 100644 --- a/src/renderer/runtime/useReticulumRuntime.voice.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.voice.test.ts @@ -1,26 +1,164 @@ // @vitest-environment jsdom /** - * Source contract tests for LXST voice WebSocket event routing. + * Behavioral tests for LXST voice WebSocket / voiceAudio event routing. */ -import { describe, expect, it } from 'vitest'; - -import { loadRuntimeSource } from '../lib/sourceContractTestHelpers'; - -const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); - -describe('useReticulumRuntime voice event routing (regression)', () => { - it('routes voice.update / incoming / stats / terminated / error / audio', () => { - expect(SOURCE).toMatch(/evt\.type === 'voice\.update'/); - expect(SOURCE).toMatch(/useReticulumVoiceStore\.getState\(\)\.applyUpdate/); - expect(SOURCE).toMatch(/evt\.type === 'voice\.incoming'/); - expect(SOURCE).toMatch(/applyIncoming\(evt\.payload\)/); - expect(SOURCE).toMatch(/evt\.type === 'voice\.stats'/); - expect(SOURCE).toMatch(/applyStats\(evt\.payload\)/); - expect(SOURCE).toMatch(/evt\.type === 'voice\.terminated'/); - expect(SOURCE).toMatch(/handleReticulumVoiceTerminal/); - expect(SOURCE).toMatch(/evt\.type === 'voice\.error'/); - expect(SOURCE).toMatch(/evt\.type === 'voice\.audio'/); - expect(SOURCE).toMatch(/decodeF32LeBase64/); - expect(SOURCE).toMatch(/emitAudio\(channels, samples\)/); +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetReticulumManualStackStopSuppressForTests } from '@/renderer/lib/reticulum/reticulumManualStackStopSuppress'; +import { encodeF32LeBase64 } from '@/renderer/lib/reticulumVoiceAudio'; +import { useReticulumRuntime } from '@/renderer/runtime/useReticulumRuntime'; +import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; +import type { ReticulumSidecarEvent } from '@/shared/reticulum-types'; + +vi.mock('@/renderer/lib/reticulum/fetchRecentInboundLxmf', () => ({ + fetchRecentInboundLxmf: vi.fn().mockResolvedValue([]), + fetchRecentInboundLxmfDetailed: vi.fn().mockResolvedValue({ messages: [], ringLen: 0 }), +})); + +vi.mock('@/renderer/lib/reticulum/useReticulumNobleBleYieldWatcher', () => ({ + useReticulumNobleBleYieldWatcher: () => {}, +})); + +vi.mock('@/renderer/lib/reticulum/useReticulumPropagationAutoSync', () => ({ + useReticulumPropagationAutoSync: () => {}, +})); + +vi.mock('@/renderer/components/Toast', () => ({ + pushAppToast: vi.fn(), + useToast: () => ({ addToast: vi.fn() }), +})); + +vi.mock('@/renderer/lib/reticulumVoiceCallTones', () => ({ + startVoiceRingback: vi.fn(), + stopVoiceCallTones: vi.fn(), + playVoiceBusyTone: vi.fn(), + playVoiceFailTone: vi.fn(), + syncReticulumVoiceProgressTones: vi.fn(), +})); + +const CALL = { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming' as const, + status: 'ringing' as const, + answered: false, +}; + +describe('useReticulumRuntime voice event routing', () => { + let eventHandler: ((evt: ReticulumSidecarEvent) => void) | null = null; + let voiceAudioHandler: ((evt: ReticulumSidecarEvent) => void) | null = null; + + beforeEach(() => { + resetReticulumManualStackStopSuppressForTests(); + useReticulumVoiceStore.getState().clearCall(); + eventHandler = null; + voiceAudioHandler = null; + vi.mocked(window.electronAPI.reticulum.onEvent).mockImplementation((cb) => { + eventHandler = cb; + return () => { + if (eventHandler === cb) eventHandler = null; + }; + }); + vi.mocked(window.electronAPI.reticulum.onVoiceAudio).mockImplementation((cb) => { + voiceAudioHandler = cb; + return () => { + if (voiceAudioHandler === cb) voiceAudioHandler = null; + }; + }); + vi.mocked(window.electronAPI.reticulum.start).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + }); + vi.mocked(window.electronAPI.reticulum.stop).mockResolvedValue(undefined); + vi.mocked(window.electronAPI.reticulum.getStatus).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + healthy: true, + }); + }); + + afterEach(() => { + vi.mocked(window.electronAPI.reticulum.onEvent).mockReset(); + vi.mocked(window.electronAPI.reticulum.onEvent).mockReturnValue(() => {}); + vi.mocked(window.electronAPI.reticulum.onVoiceAudio).mockReset(); + vi.mocked(window.electronAPI.reticulum.onVoiceAudio).mockReturnValue(() => {}); + useReticulumVoiceStore.getState().clearCall(); + }); + + async function connectAndGetHandlers() { + const { result, unmount } = renderHook(() => useReticulumRuntime()); + await act(async () => { + await result.current.connect(); + }); + expect(eventHandler).toBeTruthy(); + expect(voiceAudioHandler).toBeTruthy(); + return { onEvent: eventHandler!, onVoiceAudio: voiceAudioHandler!, unmount }; + } + + it('routes voice.update / incoming / terminated / error and delivers decoded audio', async () => { + const { onEvent, onVoiceAudio, unmount } = await connectAndGetHandlers(); + + act(() => { + onEvent({ type: 'voice.incoming', payload: CALL }); + }); + expect(useReticulumVoiceStore.getState().incomingCall?.status).toBe('ringing'); + expect(useReticulumVoiceStore.getState().activeCall?.remote_identity).toBe( + CALL.remote_identity, + ); + + act(() => { + onEvent({ + type: 'voice.update', + payload: { + type: 'snapshot', + active_call: { ...CALL, status: 'established', answered: true }, + }, + }); + }); + expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('established'); + expect(useReticulumVoiceStore.getState().incomingCall).toBeNull(); + + const heard: Float32Array[] = []; + const unsub = useReticulumVoiceStore.getState().subscribeAudio((_ch, samples) => { + heard.push(samples); + }); + const pcm = new Float32Array([0.25, -0.5, 0]); + act(() => { + onVoiceAudio({ + type: 'voice.audio', + payload: { + link_id: CALL.link_id, + profile: 0x50, + channels: 1.5, // non-integer → default to 1 + samples_b64: encodeF32LeBase64(pcm), + }, + }); + }); + expect(heard).toHaveLength(1); + expect(heard[0]?.[0]).toBeCloseTo(0.25); + expect(heard[0]?.[1]).toBeCloseTo(-0.5); + unsub(); + + act(() => { + onEvent({ + type: 'voice.terminated', + payload: { link_id: CALL.link_id, reason: 'hangup' }, + }); + }); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + + act(() => { + onEvent({ type: 'voice.incoming', payload: CALL }); + }); + act(() => { + onEvent({ type: 'voice.error', payload: { message: 'codec boom' } }); + }); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + expect(useReticulumVoiceStore.getState().lastError).toBe('codec boom'); + + unmount(); }); }); diff --git a/src/renderer/stores/reticulumVoiceStore.test.ts b/src/renderer/stores/reticulumVoiceStore.test.ts index 1d8d8297b..4d87349f7 100644 --- a/src/renderer/stores/reticulumVoiceStore.test.ts +++ b/src/renderer/stores/reticulumVoiceStore.test.ts @@ -40,6 +40,7 @@ describe('reticulumVoiceStore', () => { active_call: { ...CALL, status: 'established', answered: true }, }); expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('established'); + expect(useReticulumVoiceStore.getState().incomingCall).toBeNull(); expect(useReticulumVoiceStore.getState().callEstablishedAtMs).toBeTypeOf('number'); store.applyTerminated(CALL.link_id); @@ -48,6 +49,26 @@ describe('reticulumVoiceStore', () => { expect(useReticulumVoiceStore.getState().callStartedAtMs).toBeNull(); }); + it('applyStatus clears incomingCall once call leaves ringing/available', () => { + useReticulumVoiceStore.getState().applyIncoming(CALL); + useReticulumVoiceStore.getState().applyStatus({ + enabled: true, + running: true, + active_call: { ...CALL, status: 'connecting', answered: true }, + }); + expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('connecting'); + expect(useReticulumVoiceStore.getState().incomingCall).toBeNull(); + }); + + it('applyTerminated without link id leaves current call unchanged', () => { + useReticulumVoiceStore.getState().applyIncoming(CALL); + useReticulumVoiceStore.getState().applyTerminated(null, 'busy'); + useReticulumVoiceStore.getState().applyTerminated('', 'busy'); + useReticulumVoiceStore.getState().applyTerminated(' ', 'busy'); + expect(useReticulumVoiceStore.getState().activeCall?.link_id).toBe(CALL.link_id); + expect(useReticulumVoiceStore.getState().incomingCall?.status).toBe('ringing'); + }); + it('beginOutgoing sets calling before WS and hangup clears stats', () => { const id = 'c'.repeat(32); useReticulumVoiceStore.getState().beginOutgoing(id); diff --git a/src/renderer/stores/reticulumVoiceStore.ts b/src/renderer/stores/reticulumVoiceStore.ts index 82b611cd0..63a7c7c0c 100644 --- a/src/renderer/stores/reticulumVoiceStore.ts +++ b/src/renderer/stores/reticulumVoiceStore.ts @@ -63,6 +63,17 @@ function asActiveCall(value: unknown): VoiceActiveCall | null { return isVoiceActiveCall(value) ? value : null; } +/** Keep incomingCall only while the call is still ringing / available. */ +function incomingFromActive(active: VoiceActiveCall | null): VoiceActiveCall | null { + if ( + active?.role === 'incoming' && + (active.status === 'ringing' || active.status === 'available') + ) { + return active; + } + return null; +} + function resetSessionFields(): Pick< ReticulumVoiceStoreState, | 'activeCall' @@ -102,11 +113,14 @@ export const useReticulumVoiceStore = create((set, get set((s) => { const nextActive = status.active_call === undefined ? s.activeCall : asActiveCall(status.active_call); + const nextIncoming = + status.active_call === undefined ? s.incomingCall : incomingFromActive(nextActive); return { enabled: status.enabled ?? s.enabled, running: status.running ?? s.running, microphoneMuted: status.microphone_muted ?? s.microphoneMuted, activeCall: nextActive, + incomingCall: nextIncoming, lastError: status.last_error === undefined ? s.lastError : (status.last_error ?? null), callEstablishedAtMs: nextActive?.status === 'established' && s.callEstablishedAtMs == null @@ -159,8 +173,7 @@ export const useReticulumVoiceStore = create((set, get const active = asActiveCall(p.active_call); set((s) => ({ activeCall: active, - incomingCall: - active?.role === 'incoming' && active.status === 'ringing' ? active : s.incomingCall, + incomingCall: incomingFromActive(active), lastError: null, callStartedAtMs: active && s.callStartedAtMs == null ? Date.now() : s.callStartedAtMs, callEstablishedAtMs: @@ -234,11 +247,11 @@ export const useReticulumVoiceStore = create((set, get applyTerminated: (linkId, reason) => { set((s) => { - if ( - linkId && - s.activeCall?.link_id && - s.activeCall.link_id.toLowerCase() !== linkId.toLowerCase() - ) { + if (!linkId?.trim()) { + // Missing/empty link id — ignore (do not blind-clear the current call). + return s; + } + if (s.activeCall?.link_id && s.activeCall.link_id.toLowerCase() !== linkId.toLowerCase()) { // Stale terminate for a previous call. return s; } diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index b2d47c607..4d25dc8b3 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -387,6 +387,7 @@ export function createElectronAPIMock(): ElectronAPI { setNomadContentSource: vi.fn().mockResolvedValue({ ok: true }), validateConfig: vi.fn().mockResolvedValue({ ok: true, issues: [] }), onEvent: vi.fn().mockReturnValue(() => {}), + onVoiceAudio: vi.fn().mockReturnValue(() => {}), onStatus: vi.fn().mockReturnValue(() => {}), rrc: { listHubs: vi.fn().mockResolvedValue({ hubs: [] }), diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index a669be218..2ba7c47be 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -1084,6 +1084,11 @@ export interface ElectronAPI { setNomadContentSource: (path: string) => Promise; validateConfig: () => Promise; onEvent: (cb: (event: ReticulumSidecarEvent) => void) => () => void; + /** + * High-rate LXST PCM receive frames (`voice.audio` via `/ws/voice`). + * Not delivered on {@link onEvent} / shared `/ws`. + */ + onVoiceAudio: (cb: (event: ReticulumSidecarEvent) => void) => () => void; onStatus: (cb: (status: ReticulumSidecarStatus) => void) => () => void; /** Thin typed wrappers over proxyGet/proxyPost for RRC. */ rrc: { diff --git a/src/shared/voice-types.test.ts b/src/shared/voice-types.test.ts index ffc08dd95..05d98a388 100644 --- a/src/shared/voice-types.test.ts +++ b/src/shared/voice-types.test.ts @@ -23,6 +23,7 @@ describe('voice-types guards', () => { it('rejects garbage status', () => { expect(isVoiceStatusResponse(null)).toBe(false); expect(isVoiceStatusResponse({ available: true })).toBe(false); + expect(isVoiceStatusResponse({ available: 'yes', enabled: true })).toBe(false); }); it('accepts active_call shape', () => { @@ -36,8 +37,24 @@ describe('voice-types guards', () => { ).toBe(true); }); - it('rejects incomplete active_call', () => { + it('rejects incomplete or unsupported active_call enums', () => { expect(isVoiceActiveCall({ link_id: 'x' })).toBe(false); + expect( + isVoiceActiveCall({ + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'peer', + status: 'ringing', + }), + ).toBe(false); + expect( + isVoiceActiveCall({ + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming', + status: 'unknown', + }), + ).toBe(false); }); it('parseVoiceAudioRequest accepts a QualityHigh-shaped frame', () => { diff --git a/src/shared/voice-types.ts b/src/shared/voice-types.ts index 4e49e7253..bd514c2e5 100644 --- a/src/shared/voice-types.ts +++ b/src/shared/voice-types.ts @@ -92,6 +92,17 @@ export interface VoiceAudioPayload { samples_b64: string; } +const VOICE_CALL_ROLES: ReadonlySet = new Set(['incoming', 'outgoing']); +const VOICE_SIGNALLING_STATUSES: ReadonlySet = new Set([ + 'busy', + 'rejected', + 'calling', + 'available', + 'ringing', + 'connecting', + 'established', +]); + export function isVoiceActiveCall(value: unknown): value is VoiceActiveCall { if (!value || typeof value !== 'object') return false; const v = value as Record; @@ -99,7 +110,9 @@ export function isVoiceActiveCall(value: unknown): value is VoiceActiveCall { typeof v.link_id === 'string' && typeof v.remote_identity === 'string' && typeof v.role === 'string' && - typeof v.status === 'string' + VOICE_CALL_ROLES.has(v.role) && + typeof v.status === 'string' && + VOICE_SIGNALLING_STATUSES.has(v.status) ); } From 65b930575b6460704ccf27b7ddd54d5e0d6a012f Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 17:36:42 -0600 Subject: [PATCH 06/27] test(reticulum): cover DM header action polish order and styles Lock Probe-before-Path, shared cyan/slate class contracts, and Call/Send-file link shape so the Chat DM header cannot regress to mixed pills and bordered controls. --- src/renderer/components/ChatPanel.test.tsx | 75 +++++++++++++++++ .../ReticulumDmPathReachabilityBadge.test.tsx | 84 +++++++++++++++++++ .../remote/ChatDmRncpControl.test.tsx | 9 ++ .../ReticulumVoiceCallButton.test.tsx | 14 ++++ .../lib/reticulumDmHeaderActions.test.ts | 20 +++++ 5 files changed, 202 insertions(+) create mode 100644 src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx create mode 100644 src/renderer/lib/reticulumDmHeaderActions.test.ts diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index 8fae2fb1b..94ee4eb88 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -4303,6 +4303,81 @@ describe('ChatPanel reticulum dm-only chat', () => { expect(onPeerClick).toHaveBeenCalledExactlyOnceWith(peerHash); }); + it('orders DM header as status → last heard → peer details → Probe → Path → Call → Send file', async () => { + const hash = '368f994c056de0d8882855eb0d627497'; + const peerId = parseInt(hash.slice(0, 12), 16) >>> 0; + probeReticulumPeerMock.mockResolvedValue({ ok: true, hops: 2 }); + const onPeerClick = vi.fn(); + const nodes = new Map([ + [ + peerId, + { + node_id: peerId, + reticulum_destination_hash: hash, + long_name: 'Order Peer', + short_name: 'OP', + hw_model: 'Reticulum', + snr: 0, + battery: 0, + last_heard: Date.now(), + hops_away: 2, + latitude: null, + longitude: null, + favorited: false, + source: 'rf', + }, + ], + ]); + const precedes = (a: Element, b: Element) => + Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + + render( + + + , + ); + + const pathStatus = await screen.findByRole('status', { + name: 'Destination path is reachable', + }); + const peerInfo = screen.getByRole('status', { name: 'DM peer info' }); + const peerDetails = screen.getByRole('button', { + name: /Open peer details for Order Peer/i, + }); + const probe = screen.getByRole('button', { + name: 'Probe Reticulum path reachability for this destination', + }); + const path = screen.getByRole('button', { + name: 'Request Reticulum path to this destination', + }); + const call = screen.getByRole('button', { name: /start lxst voice call/i }); + const sendFile = screen.getByRole('button', { name: /Send file to Order Peer via rncp/i }); + + expect(precedes(pathStatus, peerInfo)).toBe(true); + expect(precedes(peerInfo, peerDetails)).toBe(true); + expect(precedes(peerDetails, probe)).toBe(true); + expect(precedes(probe, path)).toBe(true); + expect(precedes(path, call)).toBe(true); + expect(precedes(call, sendFile)).toBe(true); + + expect(peerDetails.className).toContain('text-cyan-400'); + expect(peerDetails.className).not.toContain('bg-secondary-dark'); + expect(peerDetails.className).not.toContain('rounded-full'); + expect(call.className).toContain('text-cyan-400'); + expect(call.className).not.toContain('ml-2'); + expect( + screen.queryByText(/LXST voice needs a peer running LXST telephony/i), + ).not.toBeInTheDocument(); + }); + it('prefers custom Lucide appearance over LXMFace on DM tab', () => { const peerHash = 'ffffffffffffffffffffffffffffffff'; const peerId = parseInt(peerHash.slice(0, 12), 16) >>> 0; diff --git a/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx b/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx new file mode 100644 index 000000000..f54d01fd2 --- /dev/null +++ b/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RETICULUM_DM_HEADER_ACTION_CLASS } from '@/renderer/lib/reticulumDmHeaderActions'; + +import { + ReticulumDmPathActions, + ReticulumDmPathReachabilityBadge, +} from './ReticulumDmPathReachabilityBadge'; +import { ToastProvider } from './Toast'; + +vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({ + isReticulumSidecarRunning: vi.fn().mockResolvedValue(true), + probeReticulumPeer: vi.fn(), + requestReticulumPeerPath: vi.fn(), + formatReticulumPeerPathToast: () => ({ message: 'ok', variant: 'success' as const }), + formatReticulumPeerProbeToast: () => ({ message: 'ok', variant: 'success' as const }), +})); + +vi.mock('@/renderer/stores/reticulumPeerStore', () => ({ + refreshReticulumPeersFromSidecar: vi.fn(), + useReticulumPeerStore: { getState: () => ({ updatePeer: vi.fn() }) }, +})); + +function precedes(a: Element, b: Element): boolean { + return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); +} + +describe('ReticulumDmPathReachabilityBadge', () => { + it('uses slate status chip tokens for reachable state', () => { + render(); + const badge = screen.getByRole('status', { name: 'Destination path is reachable' }); + expect(badge.className).toContain('bg-slate-800/60'); + expect(badge.className).toContain('rounded-lg'); + }); +}); + +describe('ReticulumDmPathActions', () => { + it('renders Probe before Path with shared cyan action class', () => { + render( + + + , + ); + const probe = screen.getByRole('button', { + name: 'Probe Reticulum path reachability for this destination', + }); + const path = screen.getByRole('button', { + name: 'Request Reticulum path to this destination', + }); + expect(precedes(probe, path)).toBe(true); + expect(probe.className).toBe(RETICULUM_DM_HEADER_ACTION_CLASS); + expect(path.className).toBe(RETICULUM_DM_HEADER_ACTION_CLASS); + expect(probe.className).toContain('text-cyan-400'); + expect(probe.className).not.toMatch(/border-gray-600/); + expect(path.className).not.toMatch(/border-gray-600/); + }); + + it('keeps Probe before Path when unreachable', () => { + render( + + + , + ); + const probe = screen.getByRole('button', { + name: 'Probe Reticulum path reachability for this destination', + }); + const path = screen.getByRole('button', { + name: 'Request Reticulum path to this destination', + }); + expect(precedes(probe, path)).toBe(true); + }); +}); diff --git a/src/renderer/components/remote/ChatDmRncpControl.test.tsx b/src/renderer/components/remote/ChatDmRncpControl.test.tsx index 13a4f6384..96d635da9 100644 --- a/src/renderer/components/remote/ChatDmRncpControl.test.tsx +++ b/src/renderer/components/remote/ChatDmRncpControl.test.tsx @@ -93,6 +93,15 @@ describe('ChatDmRncpControl', () => { expect(screen.getByRole('button', { name: 'Send file to Alice via rncp' })).toBeInTheDocument(); }); + it('styles Send file as a cyan text-link, not a bordered box', () => { + render(); + const trigger = screen.getByRole('button', { name: 'Send file to Alice via rncp' }); + expect(trigger.className).toContain('text-cyan-400'); + expect(trigger.className).toContain('hover:underline'); + expect(trigger.className).not.toMatch(/border-gray-700\/60/); + expect(trigger.className).not.toMatch(/bg-gray-800\/40/); + }); + it('shows a pending-offer badge only for offers from this peer', () => { useRncpTransferStore.getState().applyOffer({ transfer_id: 't1', diff --git a/src/renderer/components/reticulum/ReticulumVoiceCallButton.test.tsx b/src/renderer/components/reticulum/ReticulumVoiceCallButton.test.tsx index 020d5ba02..4e533b3bb 100644 --- a/src/renderer/components/reticulum/ReticulumVoiceCallButton.test.tsx +++ b/src/renderer/components/reticulum/ReticulumVoiceCallButton.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import i18n from '@/renderer/lib/i18n'; import { ReticulumVoiceCallButton } from './ReticulumVoiceCallButton'; @@ -29,4 +30,17 @@ describe('ReticulumVoiceCallButton', () => { hydrateAxeThemeColors(container); expect(await axe(container)).toHaveNoViolations(); }); + + it('uses cyan text-link action class and keeps interop only in aria/title', () => { + render( + , + ); + const btn = screen.getByRole('button', { name: /start lxst voice call/i }); + expect(btn.className).toContain('text-cyan-400'); + expect(btn.className).toContain('hover:underline'); + const interop = i18n.t('reticulumVoice.help.interop'); + expect(btn.getAttribute('aria-label')).toContain(interop); + expect(btn.getAttribute('title')).toContain(interop); + expect(screen.queryByText(interop)).not.toBeInTheDocument(); + }); }); diff --git a/src/renderer/lib/reticulumDmHeaderActions.test.ts b/src/renderer/lib/reticulumDmHeaderActions.test.ts new file mode 100644 index 000000000..37369e4d5 --- /dev/null +++ b/src/renderer/lib/reticulumDmHeaderActions.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { + RETICULUM_DM_HEADER_ACTION_CLASS, + RETICULUM_DM_HEADER_STATUS_CLASS, +} from './reticulumDmHeaderActions'; + +describe('reticulumDmHeaderActions', () => { + it('exports cyan text-link action class without boxed/pill chrome', () => { + expect(RETICULUM_DM_HEADER_ACTION_CLASS).toContain('text-cyan-400'); + expect(RETICULUM_DM_HEADER_ACTION_CLASS).toContain('hover:underline'); + expect(RETICULUM_DM_HEADER_ACTION_CLASS).not.toMatch(/\bborder\b/); + expect(RETICULUM_DM_HEADER_ACTION_CLASS).not.toContain('rounded-full'); + }); + + it('exports slate pill status class', () => { + expect(RETICULUM_DM_HEADER_STATUS_CLASS).toContain('bg-slate-800/60'); + expect(RETICULUM_DM_HEADER_STATUS_CLASS).toContain('rounded-lg'); + }); +}); From b484516997a087ba13bc823ce98520cdf61477b2 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 17:46:41 -0600 Subject: [PATCH 07/27] fix(reticulum): make DM header actions outlined chip buttons Flat cyan text-links did not read as clickable; shared action chrome now uses border/fill/hover so Peer details, Probe, Path, Call, and Send file match. --- src/renderer/components/ChatPanel.test.tsx | 6 ++++-- .../ReticulumDmPathReachabilityBadge.test.tsx | 5 +++-- .../components/ReticulumDmPathReachabilityBadge.tsx | 2 +- .../components/remote/ChatDmRncpControl.test.tsx | 7 ++++--- .../reticulum/ReticulumVoiceCallButton.test.tsx | 7 ++++--- src/renderer/lib/reticulumDmHeaderActions.test.ts | 11 +++++++---- src/renderer/lib/reticulumDmHeaderActions.ts | 6 +++--- 7 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index 94ee4eb88..f1b02af3a 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -4368,10 +4368,12 @@ describe('ChatPanel reticulum dm-only chat', () => { expect(precedes(path, call)).toBe(true); expect(precedes(call, sendFile)).toBe(true); - expect(peerDetails.className).toContain('text-cyan-400'); + expect(peerDetails.className).toContain('border-cyan-500/35'); + expect(peerDetails.className).toMatch(/text-cyan-/); expect(peerDetails.className).not.toContain('bg-secondary-dark'); expect(peerDetails.className).not.toContain('rounded-full'); - expect(call.className).toContain('text-cyan-400'); + expect(call.className).toContain('border-cyan-500/35'); + expect(call.className).toMatch(/text-cyan-/); expect(call.className).not.toContain('ml-2'); expect( screen.queryByText(/LXST voice needs a peer running LXST telephony/i), diff --git a/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx b/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx index f54d01fd2..658aec6d0 100644 --- a/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx +++ b/src/renderer/components/ReticulumDmPathReachabilityBadge.test.tsx @@ -37,7 +37,7 @@ describe('ReticulumDmPathReachabilityBadge', () => { }); describe('ReticulumDmPathActions', () => { - it('renders Probe before Path with shared cyan action class', () => { + it('renders Probe before Path with shared outlined cyan chip class', () => { render( { expect(precedes(probe, path)).toBe(true); expect(probe.className).toBe(RETICULUM_DM_HEADER_ACTION_CLASS); expect(path.className).toBe(RETICULUM_DM_HEADER_ACTION_CLASS); - expect(probe.className).toContain('text-cyan-400'); + expect(probe.className).toContain('border-cyan-500/35'); + expect(probe.className).toMatch(/text-cyan-/); expect(probe.className).not.toMatch(/border-gray-600/); expect(path.className).not.toMatch(/border-gray-600/); }); diff --git a/src/renderer/components/ReticulumDmPathReachabilityBadge.tsx b/src/renderer/components/ReticulumDmPathReachabilityBadge.tsx index 43a9fc8cb..16204fc53 100644 --- a/src/renderer/components/ReticulumDmPathReachabilityBadge.tsx +++ b/src/renderer/components/ReticulumDmPathReachabilityBadge.tsx @@ -162,7 +162,7 @@ export function ReticulumDmPathActions({ }; // Probe first (active reachability), then Path (discovery request) — both use the - // shared DM header text-link style so they match Call / Send file / Peer details. + // shared DM header outlined chip style so they match Call / Send file / Peer details. return (
- + {t('reticulumVoice.txFrames', { count: stats.txFrames })} {stats.txPackets > 0 - ? ` · ${t('reticulumVoice.txPackets', { count: stats.txPackets })}` + ? ` ${t('reticulumVoice.statsSeparator')} ${t('reticulumVoice.txPackets', { count: stats.txPackets })}` : ''} - + {t('reticulumVoice.rxFrames', { count: stats.rxFrames })}
diff --git a/src/renderer/lib/chatPanelProtocolStorage.ts b/src/renderer/lib/chatPanelProtocolStorage.ts index c1496575e..f2d17651d 100644 --- a/src/renderer/lib/chatPanelProtocolStorage.ts +++ b/src/renderer/lib/chatPanelProtocolStorage.ts @@ -1,4 +1,6 @@ import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { normalizeMeshcoreFloodScopeHashtag } from '@/renderer/lib/meshcoreFloodScope'; +import { isValidMeshcoreFloodScopeHashtag } from '@/renderer/lib/meshcoreFloodScopePresetsStorage'; import { type ChatLastReadSanitizeMessage, @@ -11,6 +13,9 @@ import { loadPersistedMeshcoreSelfNodeId } from './meshcoreLastSelfNodeId'; import { parseStoredJson } from './parseStoredJson'; import type { MeshProtocol } from './types'; +/** Cap persisted flood-scope override map size (view keys). */ +const FLOOD_SCOPE_OVERRIDE_MAX_KEYS = 200; + export { type ChatLastReadSanitizeMessage, maxMessageTimestampByViewKey, @@ -132,12 +137,16 @@ export function floodScopeOverridesStorageKey(protocol: MeshProtocol): string { return `mesh-client:floodScopeOverrides:${protocol}`; } -/** True when a stored override is Unscoped or a non-empty named hashtag (not Default). */ +/** True when a stored override is Unscoped or a valid named hashtag (not Default). */ function isPersistedFloodScopeOverride(value: string): boolean { if (value === FLOOD_SCOPE_OVERRIDE_UNSCOPED) return true; - const trimmed = value.trim(); - if (!trimmed || trimmed === '#') return false; - return true; + return isValidMeshcoreFloodScopeHashtag(normalizeMeshcoreFloodScopeHashtag(value)); +} + +function normalizePersistedFloodScopeOverride(value: string): string | null { + if (value === FLOOD_SCOPE_OVERRIDE_UNSCOPED) return FLOOD_SCOPE_OVERRIDE_UNSCOPED; + const normalized = normalizeMeshcoreFloodScopeHashtag(value); + return isValidMeshcoreFloodScopeHashtag(normalized) ? normalized : null; } /** Load persisted Chat flood-scope overrides (viewKey → override) for this protocol. */ @@ -149,8 +158,10 @@ export function loadFloodScopeOverridesInitial(protocol: MeshProtocol): Record = {}; for (const [k, v] of Object.entries(parsed as Record)) { if (typeof v !== 'string') continue; - if (!isPersistedFloodScopeOverride(v)) continue; - result[k] = v === FLOOD_SCOPE_OVERRIDE_UNSCOPED ? FLOOD_SCOPE_OVERRIDE_UNSCOPED : v.trim(); + const normalized = normalizePersistedFloodScopeOverride(v); + if (!normalized) continue; + result[k] = normalized; + if (Object.keys(result).length >= FLOOD_SCOPE_OVERRIDE_MAX_KEYS) break; } return result; } @@ -174,8 +185,19 @@ export function saveFloodScopeOverride( localStorage.setItem(key, JSON.stringify(rest)); return; } - current[viewKey] = - override === FLOOD_SCOPE_OVERRIDE_UNSCOPED ? FLOOD_SCOPE_OVERRIDE_UNSCOPED : override.trim(); + const normalized = normalizePersistedFloodScopeOverride(override); + if (!normalized) { + const rest = Object.fromEntries(Object.entries(current).filter(([k]) => k !== viewKey)); + localStorage.setItem(key, JSON.stringify(rest)); + return; + } + current[viewKey] = normalized; + const entries = Object.entries(current); + if (entries.length > FLOOD_SCOPE_OVERRIDE_MAX_KEYS) { + const trimmed = Object.fromEntries(entries.slice(-FLOOD_SCOPE_OVERRIDE_MAX_KEYS)); + localStorage.setItem(key, JSON.stringify(trimmed)); + return; + } localStorage.setItem(key, JSON.stringify(current)); } catch (e) { console.debug( diff --git a/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts b/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts new file mode 100644 index 000000000..f8ba753b0 --- /dev/null +++ b/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; + +import { clearReticulumSessionStores } from './clearReticulumSessionStores'; + +const hangup = vi.fn(); +const stopMedia = vi.fn(); + +vi.mock('@/renderer/lib/reticulumVoiceSession', () => ({ + stopReticulumVoiceMedia: (...args: unknown[]) => stopMedia(...args), +})); + +vi.mock('@/renderer/lib/reticulum/reticulumBleAdapterConflict', () => ({ + releaseReticulumBleRnodeConnect: vi.fn(() => Promise.resolve()), +})); + +describe('clearReticulumSessionStores', () => { + beforeEach(() => { + hangup.mockReset(); + hangup.mockResolvedValue({ ok: true }); + stopMedia.mockReset(); + useReticulumVoiceStore.getState().clearCall(); + Object.assign(window, { + electronAPI: { + reticulum: { voice: { hangup } }, + }, + }); + }); + + it('stops voice media and clears active call', () => { + useReticulumVoiceStore.getState().beginOutgoing('a'.repeat(32)); + clearReticulumSessionStores(); + expect(stopMedia).toHaveBeenCalled(); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + expect(hangup).toHaveBeenCalled(); + }); + + it('skips hangup when no voice session is busy', () => { + clearReticulumSessionStores(); + expect(hangup).not.toHaveBeenCalled(); + expect(stopMedia).toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/reticulum/clearReticulumSessionStores.ts b/src/renderer/lib/reticulum/clearReticulumSessionStores.ts index 824fed1bc..37861c192 100644 --- a/src/renderer/lib/reticulum/clearReticulumSessionStores.ts +++ b/src/renderer/lib/reticulum/clearReticulumSessionStores.ts @@ -7,6 +7,7 @@ import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; import { useRnshSessionStore } from '@/renderer/stores/rnshSessionStore'; import { useRrcHubStore } from '@/renderer/stores/rrcHubStore'; import { useRrcSessionStore } from '@/renderer/stores/rrcSessionStore'; +import { isReticulumVoiceSessionBusy } from '@/shared/voice-types'; /** Clear Reticulum session-scoped UI stores and release Noble BLE yield on teardown. */ export function clearReticulumSessionStores(): void { @@ -17,6 +18,17 @@ export function clearReticulumSessionStores(): void { useRrcHubStore.getState().clear(); useRnshSessionStore.getState().clearAll(); useRncpTransferStore.getState().clearAll(); + const voiceState = useReticulumVoiceStore.getState(); + if (isReticulumVoiceSessionBusy(voiceState.activeCall ?? voiceState.incomingCall)) { + // Best-effort sidecar hangup before local clear (stack may already be dead). + try { + void window.electronAPI.reticulum.voice.hangup().catch(() => { + // catch-no-log-ok teardown hangup when sidecar is already gone + }); + } catch { + // catch-no-log-ok electronAPI/voice unavailable during teardown + } + } stopReticulumVoiceMedia(); useReticulumVoiceStore.getState().clearCall(); void releaseReticulumBleRnodeConnect(); diff --git a/src/renderer/lib/reticulumVoiceFeedback.ts b/src/renderer/lib/reticulumVoiceFeedback.ts new file mode 100644 index 000000000..c1c43a26a --- /dev/null +++ b/src/renderer/lib/reticulumVoiceFeedback.ts @@ -0,0 +1,45 @@ +/** + * Shared LXST voice terminal feedback (tones + toasts) and IPC error humanization. + */ + +import { pushAppToast } from '@/renderer/components/Toast'; +import i18n from '@/renderer/lib/i18n'; +import { + playVoiceBusyTone, + playVoiceFailTone, + stopVoiceCallTones, +} from '@/renderer/lib/reticulumVoiceCallTones'; +import { + classifyVoiceTerminalReason, + type VoiceTerminalKind, + voiceToastKeyForTerminal, +} from '@/renderer/lib/reticulumVoiceOutcome'; + +/** Map sidecar/IPC English fragments to i18n keys; never toast raw sidecar English. */ +export function humanizeVoiceIpcError(raw: string | null | undefined): string { + const msg = (raw ?? '').trim(); + if (!msg) return i18n.t('reticulumVoice.errors.callFailed'); + const lower = msg.toLowerCase(); + if (lower.includes('voice not available') || lower.includes('not available')) { + return i18n.t('reticulumVoice.errors.notRunning'); + } + return i18n.t('reticulumVoice.errors.callFailed'); +} + +/** Shared busy/fail tones + toast for terminal outcomes (hangup + WS terminate). */ +export function applyVoiceTerminalFeedback( + reason: string | null | undefined, + opts?: { showToast?: boolean }, +): VoiceTerminalKind { + const kind = classifyVoiceTerminalReason(reason); + stopVoiceCallTones(); + if (kind === 'busy') playVoiceBusyTone(); + else if (kind === 'noAnswer' || kind === 'failed' || kind === 'rejected') playVoiceFailTone(); + + const showToast = opts?.showToast !== false; + const toastKey = voiceToastKeyForTerminal(kind); + if (showToast && toastKey) { + pushAppToast(i18n.t(toastKey), 'error'); + } + return kind; +} diff --git a/src/renderer/lib/reticulumVoiceOutcome.test.ts b/src/renderer/lib/reticulumVoiceOutcome.test.ts index 77486486d..639d2f59d 100644 --- a/src/renderer/lib/reticulumVoiceOutcome.test.ts +++ b/src/renderer/lib/reticulumVoiceOutcome.test.ts @@ -11,6 +11,8 @@ describe('classifyVoiceTerminalReason', () => { expect(classifyVoiceTerminalReason('safety_timeout')).toBe('noAnswer'); expect(classifyVoiceTerminalReason(null)).toBe('completed'); expect(classifyVoiceTerminalReason('hangup')).toBe('completed'); + expect(classifyVoiceTerminalReason('established')).toBe('completed'); + expect(classifyVoiceTerminalReason('terminated')).toBe('completed'); expect(classifyVoiceTerminalReason('encode exploded')).toBe('failed'); }); diff --git a/src/renderer/lib/reticulumVoiceOutcome.ts b/src/renderer/lib/reticulumVoiceOutcome.ts index 621ace123..cfc1196a7 100644 --- a/src/renderer/lib/reticulumVoiceOutcome.ts +++ b/src/renderer/lib/reticulumVoiceOutcome.ts @@ -6,8 +6,16 @@ export type VoiceTerminalKind = 'busy' | 'rejected' | 'noAnswer' | 'failed' | 'c export function classifyVoiceTerminalReason(reason: string | null | undefined): VoiceTerminalKind { const r = (reason ?? '').trim().toLowerCase(); - // Empty reason = clean hangup / remote end without Busy|Rejected signal. - if (!r || r === 'completed' || r === 'hangup' || r === 'cancelled' || r === 'canceled') { + // Empty / normal end: clean hangup, sidecar Established→terminate, or generic terminated. + if ( + !r || + r === 'completed' || + r === 'hangup' || + r === 'cancelled' || + r === 'canceled' || + r === 'established' || + r === 'terminated' + ) { return 'completed'; } if ( diff --git a/src/renderer/lib/reticulumVoiceSession.test.ts b/src/renderer/lib/reticulumVoiceSession.test.ts index 64a26f88e..52a871a17 100644 --- a/src/renderer/lib/reticulumVoiceSession.test.ts +++ b/src/renderer/lib/reticulumVoiceSession.test.ts @@ -1,12 +1,23 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { pushAppToast } from '@/renderer/components/Toast'; + import { useReticulumPeerStore } from '../stores/reticulumPeerStore'; import { useReticulumVoiceStore } from '../stores/reticulumVoiceStore'; import { + playVoiceBusyTone, + playVoiceFailTone, + stopVoiceCallTones, +} from './reticulumVoiceCallTones'; +import { + handleReticulumVoiceTerminal, resetReticulumVoiceSessionTimersForTests, + reticulumVoiceAnswer, reticulumVoiceCallPeer, reticulumVoiceHangup, + reticulumVoiceReject, + reticulumVoiceSetMuted, } from './reticulumVoiceSession'; import { RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS } from './timeConstants'; @@ -44,6 +55,9 @@ describe('reticulumVoiceSession', () => { voiceApi.getStatus.mockReset(); voiceApi.call.mockReset(); voiceApi.hangup.mockReset(); + voiceApi.answer.mockReset(); + voiceApi.reject.mockReset(); + voiceApi.mute.mockReset(); voiceApi.getStatus.mockResolvedValue({ available: true, enabled: true, @@ -51,6 +65,13 @@ describe('reticulumVoiceSession', () => { }); voiceApi.call.mockResolvedValue({ ok: true, identity_hash: 'a'.repeat(32) }); voiceApi.hangup.mockResolvedValue({ ok: true }); + voiceApi.answer.mockResolvedValue({ ok: true }); + voiceApi.reject.mockResolvedValue({ ok: true }); + voiceApi.mute.mockResolvedValue({ ok: true, microphone_muted: true }); + vi.mocked(pushAppToast).mockReset(); + vi.mocked(playVoiceFailTone).mockReset(); + vi.mocked(playVoiceBusyTone).mockReset(); + vi.mocked(stopVoiceCallTones).mockReset(); Object.assign(window, { electronAPI: { reticulum: { voice: voiceApi }, @@ -88,6 +109,21 @@ describe('reticulumVoiceSession', () => { expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('calling'); }); + it('blocks a second dial while a call is active', async () => { + useReticulumVoiceStore.getState().beginOutgoing('d'.repeat(32)); + await reticulumVoiceCallPeer('e'.repeat(32)); + expect(voiceApi.call).not.toHaveBeenCalled(); + expect(pushAppToast).toHaveBeenCalled(); + }); + + it('clears optimistic call and plays fail tone when call IPC returns ok false', async () => { + voiceApi.call.mockResolvedValue({ ok: false, error: 'voice not available' }); + await reticulumVoiceCallPeer('f'.repeat(32)); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + expect(playVoiceFailTone).toHaveBeenCalled(); + expect(pushAppToast).toHaveBeenCalled(); + }); + it('hangup clears optimistic calling even without WS', async () => { useReticulumVoiceStore.getState().beginOutgoing('d'.repeat(32)); await reticulumVoiceHangup(); @@ -95,6 +131,96 @@ describe('reticulumVoiceSession', () => { expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); }); + it('hangup leaves call active when IPC fails', async () => { + useReticulumVoiceStore.getState().beginOutgoing('d'.repeat(32)); + voiceApi.hangup.mockResolvedValue({ ok: false, error: 'voice control closed' }); + await reticulumVoiceHangup(); + expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('calling'); + }); + + it('reject clears only after successful IPC', async () => { + useReticulumVoiceStore.getState().applyIncoming({ + link_id: '1'.repeat(32), + remote_identity: '2'.repeat(32), + role: 'incoming', + status: 'ringing', + answered: false, + }); + await reticulumVoiceReject(); + expect(voiceApi.reject).toHaveBeenCalled(); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + }); + + it('reject leaves incoming when IPC fails', async () => { + useReticulumVoiceStore.getState().applyIncoming({ + link_id: '1'.repeat(32), + remote_identity: '2'.repeat(32), + role: 'incoming', + status: 'ringing', + answered: false, + }); + voiceApi.reject.mockResolvedValue({ ok: false, error: 'voice not available' }); + await reticulumVoiceReject(); + expect(useReticulumVoiceStore.getState().incomingCall?.status).toBe('ringing'); + }); + + it('answer does not start media when IPC fails', async () => { + voiceApi.answer.mockResolvedValue({ ok: false, error: 'voice not available' }); + await reticulumVoiceAnswer(); + expect(pushAppToast).toHaveBeenCalled(); + }); + + it('mute updates store only when IPC succeeds', async () => { + voiceApi.mute.mockResolvedValue({ ok: false, error: 'voice not available' }); + await reticulumVoiceSetMuted(true); + expect(useReticulumVoiceStore.getState().microphoneMuted).toBe(false); + voiceApi.mute.mockResolvedValue({ ok: true, microphone_muted: true }); + await reticulumVoiceSetMuted(true); + expect(useReticulumVoiceStore.getState().microphoneMuted).toBe(true); + }); + + it('terminal established reason completes without fail tone', () => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'outgoing', + link_id: '1'.repeat(32), + remote_identity: '2'.repeat(32), + }); + handleReticulumVoiceTerminal({ linkId: '1'.repeat(32), reason: 'established' }); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + expect(playVoiceFailTone).not.toHaveBeenCalled(); + expect(playVoiceBusyTone).not.toHaveBeenCalled(); + }); + + it('terminal busy plays busy tone and toasts', () => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'outgoing', + link_id: '1'.repeat(32), + remote_identity: '2'.repeat(32), + }); + handleReticulumVoiceTerminal({ linkId: '1'.repeat(32), reason: 'busy' }); + expect(playVoiceBusyTone).toHaveBeenCalled(); + expect(pushAppToast).toHaveBeenCalled(); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + }); + + it('ignores stale terminal for a different link_id', () => { + useReticulumVoiceStore.getState().applyUpdate({ + type: 'outgoing', + link_id: '1'.repeat(32), + remote_identity: '2'.repeat(32), + }); + handleReticulumVoiceTerminal({ linkId: '9'.repeat(32), reason: 'busy' }); + expect(useReticulumVoiceStore.getState().activeCall?.link_id).toBe('1'.repeat(32)); + expect(playVoiceBusyTone).not.toHaveBeenCalled(); + }); + + it('hangup with busy terminalReason plays busy tone', async () => { + useReticulumVoiceStore.getState().beginOutgoing('d'.repeat(32)); + await reticulumVoiceHangup({ terminalReason: 'busy' }); + expect(playVoiceBusyTone).toHaveBeenCalled(); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + }); + it('safety hangup fires after timeout when never established', async () => { const dest = 'e'.repeat(32); await reticulumVoiceCallPeer(dest); @@ -116,8 +242,6 @@ describe('reticulumVoiceSession', () => { status: 'established', }, }); - // Overlay/sync would clear timer on established; simulate hangup path clear via store status. - // Manually clear by calling hangup cancel through sync — establish via applying status then advance. const { syncReticulumVoiceProgressTones } = await import('./reticulumVoiceSession'); syncReticulumVoiceProgressTones('established'); await vi.advanceTimersByTimeAsync(RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS + 10); diff --git a/src/renderer/lib/reticulumVoiceSession.ts b/src/renderer/lib/reticulumVoiceSession.ts index a0cfd5efb..70dcd9749 100644 --- a/src/renderer/lib/reticulumVoiceSession.ts +++ b/src/renderer/lib/reticulumVoiceSession.ts @@ -14,21 +14,20 @@ import { resolveVoiceDialIdentityHash, } from '@/renderer/lib/reticulumVoiceAudio'; import { - playVoiceBusyTone, playVoiceFailTone, startVoiceRingback, stopVoiceCallTones, } from '@/renderer/lib/reticulumVoiceCallTones'; import { - classifyVoiceTerminalReason, - voiceToastKeyForTerminal, -} from '@/renderer/lib/reticulumVoiceOutcome'; + applyVoiceTerminalFeedback, + humanizeVoiceIpcError, +} from '@/renderer/lib/reticulumVoiceFeedback'; import { collectIdentityHashesForLxmfPeer } from '@/renderer/lib/rncpOfferPeerMatch'; import { RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS } from '@/renderer/lib/timeConstants'; import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; -import { isVoiceStatusResponse } from '@/shared/voice-types'; +import { isReticulumVoiceSessionBusy, isVoiceStatusResponse } from '@/shared/voice-types'; let captureCtx: AudioContext | null = null; let captureSource: MediaStreamAudioSourceNode | null = null; @@ -46,6 +45,19 @@ let audioUnsub: (() => void) | null = null; let txTimer: ReturnType | null = null; let pendingSamples: number[] = []; let safetyHangupTimer: ReturnType | null = null; +/** Call generation for which capture/playback were last started (dedupe Answer + overlay). */ +let mediaStartedForCallGeneration = -1; +/** Throttle hot-path sendAudio failure logs. */ +let lastTxDropWarnAtMs = 0; +/** + * Contexts created during Call/Answer click (user gesture) before await. + * Taken over by startCapture/startPlayback so Chromium does not leave them suspended. + */ +let primedCaptureCtx: AudioContext | null = null; +let primedPlaybackCtx: AudioContext | null = null; + +/** Cap pending mic samples (~3 QualityHigh frames) when IPC backs up. */ +const PENDING_SAMPLES_MAX = LXST_QUALITY_HIGH_FRAME_SAMPLES * 3; function discardCaptureResources( stream: MediaStream | null, @@ -92,6 +104,66 @@ async function ensureMicAccess(): Promise { return true; } +async function resumeAudioContext(ctx: AudioContext): Promise { + if (ctx.state === 'suspended') { + try { + await ctx.resume(); + } catch { + // catch-no-log-ok resume may still fail without gesture in tests/headless + } + } +} + +/** + * Create/resume primed media contexts while still in a user-gesture stack + * (Call / Answer click) so Chromium does not leave them suspended. + */ +export function warmReticulumVoiceAudioContexts(): void { + try { + if (!primedCaptureCtx || primedCaptureCtx.state === 'closed') { + primedCaptureCtx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); + } + void resumeAudioContext(primedCaptureCtx); + } catch { + // catch-no-log-ok AudioContext unavailable in test/headless + } + try { + if (!primedPlaybackCtx || primedPlaybackCtx.state === 'closed') { + primedPlaybackCtx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); + } + void resumeAudioContext(primedPlaybackCtx); + } catch { + // catch-no-log-ok AudioContext unavailable in test/headless + } +} + +function takePrimedCaptureCtx(): AudioContext | null { + const ctx = primedCaptureCtx; + primedCaptureCtx = null; + return ctx && ctx.state !== 'closed' ? ctx : null; +} + +function takePrimedPlaybackCtx(): AudioContext | null { + const ctx = primedPlaybackCtx; + primedPlaybackCtx = null; + return ctx && ctx.state !== 'closed' ? ctx : null; +} + +function discardPrimedAudioContexts(): void { + if (primedCaptureCtx) { + void primedCaptureCtx.close().catch(() => { + // catch-no-log-ok + }); + primedCaptureCtx = null; + } + if (primedPlaybackCtx) { + void primedPlaybackCtx.close().catch(() => { + // catch-no-log-ok + }); + primedPlaybackCtx = null; + } +} + function stopCapture(): void { captureGeneration += 1; if (txTimer != null) { @@ -122,6 +194,8 @@ function stopPlayback(): void { } export function stopReticulumVoiceMedia(): void { + mediaStartedForCallGeneration = -1; + discardPrimedAudioContexts(); stopCapture(); stopPlayback(); } @@ -136,6 +210,8 @@ function clearSafetyHangupTimer(): void { /** @internal Test helper */ export function resetReticulumVoiceSessionTimersForTests(): void { clearSafetyHangupTimer(); + mediaStartedForCallGeneration = -1; + lastTxDropWarnAtMs = 0; } function scheduleOutgoingSafetyHangup(generation: number): void { @@ -151,18 +227,39 @@ function scheduleOutgoingSafetyHangup(generation: number): void { }, RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS); } +function noteLocalTxDrop(reason: string): void { + useReticulumVoiceStore.getState().incrementLocalTxDrops(); + const now = Date.now(); + if (now - lastTxDropWarnAtMs < 5000) return; + lastTxDropWarnAtMs = now; + console.debug(`[reticulumVoice] sendAudio drop: ${reason}`); +} + async function startCaptureAndTx(): Promise { + const primed = takePrimedCaptureCtx(); stopCapture(); const generation = captureGeneration; if (!(await ensureMicAccess())) { + if (primed) { + void primed.close().catch(() => { + // catch-no-log-ok + }); + } if (generation !== captureGeneration) return; pushAppToast(i18n.t('reticulumVoice.errors.micFailed'), 'error'); return; } - if (generation !== captureGeneration) return; + if (generation !== captureGeneration) { + if (primed) { + void primed.close().catch(() => { + // catch-no-log-ok + }); + } + return; + } let stream: MediaStream | null = null; - let ctx: AudioContext | null = null; + let ctx: AudioContext | null = primed; let source: MediaStreamAudioSourceNode | null = null; // eslint-disable-next-line @typescript-eslint/no-deprecated -- see captureProcessor note let processor: ScriptProcessorNode | null = null; @@ -176,10 +273,13 @@ async function startCaptureAndTx(): Promise { }, }); if (generation !== captureGeneration) { - discardCaptureResources(stream, null, null, null, null); + discardCaptureResources(stream, ctx, null, null, null); return; } - ctx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); + if (!ctx || ctx.state === 'closed') { + ctx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); + } + await resumeAudioContext(ctx); if (generation !== captureGeneration) { discardCaptureResources(stream, ctx, null, null, null); return; @@ -197,6 +297,11 @@ async function startCaptureAndTx(): Promise { for (const s of input) { pendingSamples.push(s); } + if (pendingSamples.length > PENDING_SAMPLES_MAX) { + const drop = pendingSamples.length - PENDING_SAMPLES_MAX; + pendingSamples.splice(0, drop); + noteLocalTxDrop('pending_samples_cap'); + } }; source.connect(processor); processor.connect(gain); @@ -242,20 +347,32 @@ async function startCaptureAndTx(): Promise { }) .then((resp) => { if (!resp.ok) { - useReticulumVoiceStore.getState().incrementLocalTxDrops(); + noteLocalTxDrop(resp.error ?? 'send_not_ok'); } }) .catch(() => { - useReticulumVoiceStore.getState().incrementLocalTxDrops(); + noteLocalTxDrop('send_ipc_throw'); }); }, Math.max(20, frameMs), ); } -function startPlayback(): void { - stopPlayback(); - playbackCtx = new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); +async function startPlayback(): Promise { + const primed = takePrimedPlaybackCtx(); + if (audioUnsub) { + audioUnsub(); + audioUnsub = null; + } + playbackCursor = 0; + if (playbackCtx) { + void playbackCtx.close().catch(() => { + // catch-no-log-ok close during teardown + }); + playbackCtx = null; + } + playbackCtx = primed ?? new AudioContext({ sampleRate: LXST_QUALITY_HIGH_SAMPLE_RATE_HZ }); + await resumeAudioContext(playbackCtx); playbackCursor = playbackCtx.currentTime; audioUnsub = useReticulumVoiceStore.getState().subscribeAudio((channels, samples) => { const ctx = playbackCtx; @@ -284,10 +401,16 @@ function startPlayback(): void { } export async function startReticulumVoiceMediaForActiveCall(): Promise { - startPlayback(); + const generation = useReticulumVoiceStore.getState().callGeneration; + if (generation > 0 && mediaStartedForCallGeneration === generation) { + return; + } + mediaStartedForCallGeneration = generation; try { + await startPlayback(); await startCaptureAndTx(); } catch (e) { + mediaStartedForCallGeneration = -1; console.warn('[reticulumVoice] mic capture failed', e); pushAppToast(i18n.t('reticulumVoice.errors.micFailed'), 'error'); } @@ -305,10 +428,58 @@ function toastCallFailed(detail?: unknown): void { pushAppToast(i18n.t('reticulumVoice.errors.callFailed'), 'error'); } +function abortOutgoingCallAttempt(message?: string): void { + clearSafetyHangupTimer(); + stopVoiceCallTones(); + playVoiceFailTone(); + const humanized = humanizeVoiceIpcError(message); + useReticulumVoiceStore.getState().applyError(humanized); + pushAppToast(humanized, 'error'); +} + +function terminalEventMatchesActiveCall(opts: { + linkId?: string | null; + callGeneration?: number | null; + /** WS voice.error often omits link_id — allow matching the current session. */ + allowMissingLinkId?: boolean; +}): boolean { + const state = useReticulumVoiceStore.getState(); + if ( + opts.callGeneration != null && + Number.isFinite(opts.callGeneration) && + opts.callGeneration !== state.callGeneration + ) { + return false; + } + const active = state.activeCall; + if (!active) return false; + const eventLink = (opts.linkId ?? '').trim().toLowerCase(); + const activeLink = active.link_id.trim().toLowerCase(); + if (eventLink && activeLink && eventLink !== activeLink) { + return false; + } + // Empty event link_id: only accept while local call also has no link yet (outgoing pending), + // unless this is an error path that routinely omits link_id. + if (!eventLink && activeLink && !opts.allowMissingLinkId) { + return false; + } + return true; +} + export async function reticulumVoiceCallPeer( lxmfPeerHash: string, opts?: { identityHash?: string | null }, ): Promise { + const busyCall = + useReticulumVoiceStore.getState().activeCall ?? useReticulumVoiceStore.getState().incomingCall; + if (isReticulumVoiceSessionBusy(busyCall)) { + pushAppToast(i18n.t('reticulumVoice.errors.callInProgress'), 'error'); + return; + } + + // Warm AudioContexts in the click stack before any await. + warmReticulumVoiceAudioContexts(); + const api = window.electronAPI.reticulum.voice; let statusRaw: unknown; try { @@ -358,28 +529,20 @@ export async function reticulumVoiceCallPeer( if (!resp.ok) { const msg = resp.error || i18n.t('reticulumVoice.errors.callFailed'); console.warn(`[reticulumVoice] call failed reason=${msg}`); - clearSafetyHangupTimer(); - stopVoiceCallTones(); - playVoiceFailTone(); - useReticulumVoiceStore.getState().applyError(msg); - pushAppToast(msg, 'error'); + abortOutgoingCallAttempt(msg); } } catch (e) { console.warn('[reticulumVoice] call IPC failed', e); - clearSafetyHangupTimer(); - stopVoiceCallTones(); - playVoiceFailTone(); - useReticulumVoiceStore.getState().applyError(i18n.t('reticulumVoice.errors.callFailed')); - toastCallFailed(e); + abortOutgoingCallAttempt(i18n.t('reticulumVoice.errors.callFailed')); } - // Mic deferred until connecting/established (overlay effect). } export async function reticulumVoiceAnswer(): Promise { + warmReticulumVoiceAudioContexts(); try { const resp = await window.electronAPI.reticulum.voice.answer(); if (!resp.ok) { - pushAppToast(resp.error || i18n.t('reticulumVoice.errors.callFailed'), 'error'); + pushAppToast(humanizeVoiceIpcError(resp.error), 'error'); return; } } catch (e) { @@ -393,14 +556,19 @@ export async function reticulumVoiceAnswer(): Promise { export async function reticulumVoiceReject(): Promise { clearSafetyHangupTimer(); - stopReticulumVoiceMedia(); stopVoiceCallTones(); try { - await window.electronAPI.reticulum.voice.reject(); + const resp = await window.electronAPI.reticulum.voice.reject(); + if (!resp.ok) { + pushAppToast(humanizeVoiceIpcError(resp.error), 'error'); + return; + } } catch (e) { console.warn('[reticulumVoice] reject IPC failed', e); pushAppToast(i18n.t('reticulumVoice.errors.callFailed'), 'error'); + return; } + stopReticulumVoiceMedia(); useReticulumVoiceStore.getState().clearCall(); } @@ -408,33 +576,36 @@ export async function reticulumVoiceHangup(opts?: { terminalReason?: string | null; }): Promise { clearSafetyHangupTimer(); - stopReticulumVoiceMedia(); const reason = opts?.terminalReason ?? 'hangup'; - const kind = classifyVoiceTerminalReason(reason); - stopVoiceCallTones(); - if (kind === 'busy') playVoiceBusyTone(); - else if (kind === 'noAnswer' || kind === 'failed' || kind === 'rejected') playVoiceFailTone(); - - const toastKey = voiceToastKeyForTerminal(kind); - if (toastKey && kind !== 'completed') { - pushAppToast(i18n.t(toastKey), 'error'); - } + applyVoiceTerminalFeedback(reason); console.info(`[reticulumVoice] hangup reason=${reason}`); try { - await window.electronAPI.reticulum.voice.hangup(); + const resp = await window.electronAPI.reticulum.voice.hangup(); + if (!resp.ok) { + pushAppToast(humanizeVoiceIpcError(resp.error), 'error'); + return; + } } catch (e) { console.warn('[reticulumVoice] hangup IPC failed', e); + pushAppToast(i18n.t('reticulumVoice.errors.callFailed'), 'error'); + return; } + stopReticulumVoiceMedia(); useReticulumVoiceStore.getState().clearCall(); } export async function reticulumVoiceSetMuted(muted: boolean): Promise { try { - await window.electronAPI.reticulum.voice.mute({ muted }); + const resp = await window.electronAPI.reticulum.voice.mute({ muted }); + if (!resp.ok) { + pushAppToast(humanizeVoiceIpcError(resp.error ?? 'mute'), 'error'); + return; + } } catch (e) { console.warn('[reticulumVoice] mute IPC failed', e); pushAppToast(i18n.t('reticulumVoice.errors.muteFailed'), 'error'); + return; } useReticulumVoiceStore.getState().setMicrophoneMuted(muted); } @@ -447,22 +618,31 @@ export function handleReticulumVoiceTerminal(opts: { linkId?: string | null; reason?: string | null; errorMessage?: string | null; + callGeneration?: number | null; }): void { + if ( + !terminalEventMatchesActiveCall({ + ...opts, + allowMissingLinkId: Boolean(opts.errorMessage), + }) + ) { + console.debug('[reticulumVoice] ignoring stale terminal event', opts); + return; + } clearSafetyHangupTimer(); stopReticulumVoiceMedia(); const reason = opts.errorMessage ?? opts.reason ?? null; - const kind = classifyVoiceTerminalReason(reason); - stopVoiceCallTones(); - if (kind === 'busy') playVoiceBusyTone(); - else if (kind === 'rejected' || kind === 'noAnswer' || kind === 'failed') playVoiceFailTone(); - - const toastKey = voiceToastKeyForTerminal(kind); - if (toastKey) { - pushAppToast(i18n.t(toastKey), 'error'); - } + applyVoiceTerminalFeedback(opts.errorMessage ? 'failed' : reason, { + // errorMessage path uses applyError toast via humanized message below + showToast: !opts.errorMessage, + }); if (opts.errorMessage) { - useReticulumVoiceStore.getState().applyError(opts.errorMessage); + const humanized = humanizeVoiceIpcError(opts.errorMessage); + pushAppToast(humanized, 'error'); + useReticulumVoiceStore.getState().applyError(humanized, { + callGeneration: useReticulumVoiceStore.getState().callGeneration, + }); } else { useReticulumVoiceStore.getState().applyTerminated(opts.linkId ?? null, reason); } diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 6b31efa25..a1037def7 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -5123,12 +5123,13 @@ "callFailed": "Hlasový hovor se nezdařil.", "notRunning": "Hlas není k dispozici, dokud není spuštěn zásobník Reticulum.", "micFailed": "Nelze získat přístup k mikrofonu pro hlas.", - "muteFailed": "Nelze aktualizovat ztlumení mikrofonu v relaci hlasu." + "muteFailed": "Nelze aktualizovat ztlumení mikrofonu v relaci hlasu.", + "callInProgress": "Hlasový hovor již probíhá." }, "connecting": "Připojování…", - "elapsedAria": "Uplynulý čas hovoru", - "txAria": "Přenášené hlasové rámce", - "rxAria": "Přijaté hlasové rámce", + "elapsedAria": "Uplynulý čas hovoru {{time}}", + "txAria": "Přenášené hlasové rámce {{count}}", + "rxAria": "Přijaté hlasové rámce {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5144,6 +5145,7 @@ "rejected": "Hovor byl zamítnut.", "noAnswer": "Žádná odpověď — vypršel časový limit hovoru.", "failed": "Hlasový hovor se nezdařil." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 47206a126..b6520ad40 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -5105,11 +5105,11 @@ }, "reticulumVoice": { "call": "Anrufen", - "answer": "Antwort", + "answer": "Annehmen", "reject": "Ablehnen", "incomingTitle": "Eingehender Anruf", "inCall": "Im Gespräch", - "calling": "Anruf ...", + "calling": "Anruf…", "callAria": "LXST-Sprachanruf starten", "answerAria": "Sprachanruf annehmen", "rejectAria": "Sprachanruf ablehnen", @@ -5117,23 +5117,25 @@ "muteAria": "Mikrofon stummschalten", "unmuteAria": "Mikrofon Stummschaltung aufheben", "errors": { - "noIdentity": "Kann nicht aufrufen: Peer-Identität noch nicht bekannt. Warten Sie auf eine Ankündigung oder fragen Sie den Kollegen.", + "noIdentity": "Anruf nicht möglich: Peer-Identität noch nicht bekannt. Warten Sie auf eine Ankündigung oder prüfen Sie den Peer.", "callFailed": "Sprachanruf fehlgeschlagen.", - "notRunning": "Stimme ist erst verfügbar, wenn der Reticulum-Stapel läuft.", + "callInProgress": "Ein Sprachanruf läuft bereits.", + "notRunning": "Sprachanrufe sind erst verfügbar, wenn der Reticulum-Stack läuft.", "micFailed": "Konnte nicht auf das Mikrofon für Sprache zugreifen.", "muteFailed": "Mikrofon-Stummschaltung in der Sprachsitzung konnte nicht aktualisiert werden." }, - "connecting": "verbinden …", - "elapsedAria": "Verstrichene Zeit", - "txAria": "Gesendete Sprachrahmen", - "rxAria": "Empfangene Sprachbilder", + "connecting": "Verbinden…", + "elapsedAria": "Verstrichene Zeit {{time}}", + "txAria": "Gesendete Sprachrahmen {{count}}", + "rxAria": "Empfangene Sprachrahmen {{count}}", + "statsSeparator": "·", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", - "capabilityHeard": "LXST-Telefonie für diesen Peer ankündigen", - "capabilityUnknown": "LXST-Telefonansage noch nicht gehört (Anruf kann noch funktionieren)", + "capabilityHeard": "LXST-Telefonie-Announce für diesen Peer gehört", + "capabilityUnknown": "LXST-Telefonie-Announce noch nicht gehört (Anruf kann trotzdem funktionieren)", "capabilityHeardShort": "LXST", - "capabilityUnknownShort": ".?", + "capabilityUnknownShort": "?", "help": { "interop": "LXST Voice benötigt einen Peer, der LXST-Telefonie ausführt (Sideband, Ratspeak oder mesh-client). Kein Sprachnotizclip. Python LXST ist die Referenz; rsLXST ist experimentell." }, diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index ff8c5448e..f88a1b3a2 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -434,12 +434,12 @@ "tooltipLabel": "Channel utilization" }, "chatPanel": { - "floodScopeOverrideLabel": "Flood scope for this channel", - "floodScopeOverrideAria": "Per-channel flood scope override", + "floodScopeOverrideLabel": "Flood scope for this chat view", + "floodScopeOverrideAria": "Per-view flood scope override", "floodScopeOverrideDefault": "Default scope", "floodScopeOverrideUnscoped": "Unscoped", - "floodScopeOverrideMenuButton": "Change flood scope for this channel", - "floodScopeOverrideHint": "Remembered per channel. Default follows the Radio flood-scope setting; Unscoped is mesh-wide (no region filter); a hashtag limits floods to that exact repeater region name. Hierarchy is configured on repeaters, not here.", + "floodScopeOverrideMenuButton": "Change flood scope for this chat view", + "floodScopeOverrideHint": "Remembered per chat view (channel or DM). Default follows the Radio flood-scope setting; Unscoped is mesh-wide (no region filter); a hashtag limits floods to that exact repeater region name. Hierarchy is configured on repeaters, not here.", "floodScopeOverrideCustom": "Custom scope…", "floodScopeOverrideCustomLabel": "Custom flood scope hashtag", "floodScopeOverrideCustomPlaceholder": "#metro", @@ -743,9 +743,10 @@ "hangupAria": "Hang up voice call", "muteAria": "Mute microphone", "unmuteAria": "Unmute microphone", - "elapsedAria": "Call elapsed time", - "txAria": "Transmitted voice frames", - "rxAria": "Received voice frames", + "elapsedAria": "Call elapsed time {{time}}", + "txAria": "Transmitted voice frames {{count}}", + "rxAria": "Received voice frames {{count}}", + "statsSeparator": "·", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -765,6 +766,7 @@ "errors": { "noIdentity": "Cannot call: peer identity not known yet. Wait for an announce or probe the peer.", "callFailed": "Voice call failed.", + "callInProgress": "A voice call is already in progress.", "notRunning": "Voice is not available until the Reticulum stack is running.", "micFailed": "Could not access the microphone for voice.", "muteFailed": "Could not update microphone mute on the voice session." @@ -3452,7 +3454,7 @@ "zeroHopAdvertButton": "Zero-hop Advert", "zeroHopAdvertSent": "Zero-hop advert sent", "floodScopeTitle": "Regional flood scope", - "floodScopeHelp": "Radio-wide default flood region (firmware v8+). Reconnect reapplies this setting. Chat can override per channel (Default follows this; Unscoped stays mesh-wide; named scopes must match repeater region names exactly). Applied custom hashtags are saved for quick pick in Radio and Chat.", + "floodScopeHelp": "Radio-wide default flood region (firmware v8+). Reconnect reapplies this setting. Chat can override per chat view — channel or DM (Default follows this; Unscoped stays mesh-wide; named scopes must match repeater region names exactly). Applied custom hashtags are saved for quick pick in Radio and Chat.", "floodScopeNone": "None (clear scope)", "floodScopeSaved": "Saved scope", "floodScopeSavedSelect": "Saved region hashtag", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 86b6efe9d..c967c9930 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -5121,12 +5121,13 @@ "callFailed": "La llamada de voz ha fallado.", "notRunning": "La voz no está disponible hasta que la pila de Reticulum esté en ejecución.", "micFailed": "No se ha podido acceder al micrófono para voz.", - "muteFailed": "No se ha podido actualizar el silencio del micrófono en la sesión de voz." + "muteFailed": "No se ha podido actualizar el silencio del micrófono en la sesión de voz.", + "callInProgress": "Ya hay una llamada de voz en curso." }, "connecting": "Conectando…", - "elapsedAria": "Tiempo transcurrido", - "txAria": "Tramas de voz transmitidas", - "rxAria": "Tramas de voz recibidas", + "elapsedAria": "Tiempo transcurrido {{time}}", + "txAria": "Tramas de voz transmitidas {{count}}", + "rxAria": "Tramas de voz recibidas {{count}}", "txFrames": "TX {{count}}", "txPackets": "pKT {{count}}", "rxFrames": "RX {{count}}", @@ -5142,6 +5143,7 @@ "rejected": "Llamada rechazada", "noAnswer": "Sin respuesta: el tiempo de espera de la llamada ha expirado.", "failed": "La llamada de voz ha fallado." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 58d30bcfa..86d2ae442 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -5121,12 +5121,13 @@ "callFailed": "Échec de l'appel vocal.", "notRunning": "La voix n'est pas disponible tant que la pile Reticulum n'est pas en cours d'exécution.", "micFailed": "Impossible d'accéder au microphone pour la voix.", - "muteFailed": "Impossible de mettre à jour le microphone muet sur la session vocale." + "muteFailed": "Impossible de mettre à jour le microphone muet sur la session vocale.", + "callInProgress": "Un appel vocal est déjà en cours." }, "connecting": "Connexion…", - "elapsedAria": "Temps écoulé", - "txAria": "Trames vocales transmises", - "rxAria": "Trames vocales reçues", + "elapsedAria": "Temps écoulé {{time}}", + "txAria": "Trames vocales transmises {{count}}", + "rxAria": "Trames vocales reçues {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5142,6 +5143,7 @@ "rejected": "Appel rejeté", "noAnswer": "Pas de réponse — appel expiré.", "failed": "Échec de l'appel vocal." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 63f82fe83..3cc3ca04e 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -5121,12 +5121,13 @@ "callFailed": "Panggilan suara gagal.", "notRunning": "Suara tidak tersedia sampai tumpukan Reticulum berjalan.", "micFailed": "Tidak dapat mengakses mikrofon untuk suara.", - "muteFailed": "Tidak dapat memperbarui mikrofon bisu pada sesi suara." + "muteFailed": "Tidak dapat memperbarui mikrofon bisu pada sesi suara.", + "callInProgress": "A voice call is already in progress." }, "connecting": "Menyambung…", - "elapsedAria": "Waktu berlalu", - "txAria": "Bingkai suara yang ditransmisikan", - "rxAria": "Bingkai suara yang diterima", + "elapsedAria": "Waktu berlalu {{time}}", + "txAria": "Bingkai suara yang ditransmisikan {{count}}", + "rxAria": "Bingkai suara yang diterima {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5142,6 +5143,7 @@ "rejected": "Panggilan ditolak.", "noAnswer": "Tidak ada jawaban — waktu panggilan habis.", "failed": "Panggilan suara gagal." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 1571926b8..331f5caba 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -5121,12 +5121,13 @@ "callFailed": "Chiamata vocale non riuscita.", "notRunning": "La voce non è disponibile fino a quando lo stack Reticulum non è in esecuzione.", "micFailed": "Impossibile accedere al microfono per la voce.", - "muteFailed": "Impossibile aggiornare il silenziamento del microfono nella sessione vocale." + "muteFailed": "Impossibile aggiornare il silenziamento del microfono nella sessione vocale.", + "callInProgress": "Una chiamata vocale è già in corso." }, "connecting": "Collegamento in corso…", - "elapsedAria": "Tempo trascorso", - "txAria": "Frame vocali trasmessi", - "rxAria": "Frame vocali ricevuti", + "elapsedAria": "Tempo trascorso {{time}}", + "txAria": "Frame vocali trasmessi {{count}}", + "rxAria": "Frame vocali ricevuti {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5142,6 +5143,7 @@ "rejected": "Chiamata respinta", "noAnswer": "Nessuna risposta — chiamata scaduta.", "failed": "Chiamata vocale non riuscita." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 752bd3964..afc302cdb 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -5121,12 +5121,13 @@ "callFailed": "音声通話に失敗しました。", "notRunning": "Reticulumスタックが実行されるまで、音声は使用できません。", "micFailed": "音声用のマイクにアクセスできませんでした。", - "muteFailed": "音声セッションでマイクのミュートを更新できませんでした。" + "muteFailed": "音声セッションでマイクのミュートを更新できませんでした。", + "callInProgress": "音声通話はすでに進行中です。" }, "connecting": "連携中…", - "elapsedAria": "経過時間", - "txAria": "送信された音声フレーム", - "rxAria": "受信したボイスフレーム", + "elapsedAria": "経過時間 {{time}}", + "txAria": "送信された音声フレーム {{count}}", + "rxAria": "受信したボイスフレーム {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5142,6 +5143,7 @@ "rejected": "コールは拒否されました", "noAnswer": "応答がありません—通話がタイムアウトしました。", "failed": "音声通話に失敗しました。" - } + }, + "statsSeparator": "・" } } diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index a4ab75799..96aae93be 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -5121,12 +5121,13 @@ "callFailed": "음성 통화에 실패했습니다.", "notRunning": "Reticulum 스택이 실행될 때까지 음성을 사용할 수 없습니다.", "micFailed": "음성을 위해 마이크에 액세스할 수 없습니다.", - "muteFailed": "음성 세션에서 마이크 음소거를 업데이트할 수 없습니다." + "muteFailed": "음성 세션에서 마이크 음소거를 업데이트할 수 없습니다.", + "callInProgress": "음성 통화가 이미 진행 중입니다." }, "connecting": "Connecting", - "elapsedAria": "경과 시간", - "txAria": "전송된 음성 프레임", - "rxAria": "수신된 음성 프레임", + "elapsedAria": "경과 시간 {{time}}", + "txAria": "전송된 음성 프레임 {{count}}", + "rxAria": "수신된 음성 프레임 {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5142,6 +5143,7 @@ "rejected": "통화가 거부되었습니다.", "noAnswer": "응답 없음 — 통화 시간이 초과되었습니다.", "failed": "음성 통화에 실패했습니다." - } + }, + "statsSeparator": "Poisonblow" } } diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 57ce2ddb2..457122562 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -5105,11 +5105,11 @@ }, "reticulumVoice": { "call": "Oproep", - "answer": "Antwoord", + "answer": "Beantwoorden", "reject": "Afwijzen", "incomingTitle": "Binnenkomende spraakoproep", "inCall": "In gesprek", - "calling": "Bellen...", + "calling": "Bellen…", "callAria": "LXST-spraakoproep starten", "answerAria": "Spraakoproep beantwoorden", "rejectAria": "Spraakoproep weigeren", @@ -5117,23 +5117,25 @@ "muteAria": "Microfoon dempen", "unmuteAria": "Dempen van de microfoon opheffen", "errors": { - "noIdentity": "Kan niet aanroepen: peer-identiteit nog niet bekend. Wacht op een aankondiging of onderzoek de peer.", + "noIdentity": "Kan niet bellen: peer-identiteit nog niet bekend. Wacht op een aankondiging of probeer de peer.", "callFailed": "Spraakoproep mislukt.", - "notRunning": "Stem is niet beschikbaar totdat de Reticulum-stack actief is.", + "callInProgress": "Er is al een spraakoproep bezig.", + "notRunning": "Spraak is niet beschikbaar totdat de Reticulum-stack actief is.", "micFailed": "Kon geen toegang krijgen tot de microfoon voor spraak.", "muteFailed": "Kan microfoondemping niet bijwerken tijdens de spraaksessie." }, "connecting": "Verbinden…", - "elapsedAria": "Verstreken tijd", - "txAria": "Verzonden spraakframes", - "rxAria": "Ontvangen spraakframes", + "elapsedAria": "Verstreken tijd {{time}}", + "txAria": "Verzonden spraakframes {{count}}", + "rxAria": "Ontvangen spraakframes {{count}}", + "statsSeparator": "·", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", - "capabilityHeard": "LXST-telefonie-aankondiging gehoord voor deze collega", + "capabilityHeard": "LXST-telefonie-aankondiging gehoord voor deze peer", "capabilityUnknown": "LXST-telefonie-aankondiging nog niet gehoord (oproep kan nog steeds werken)", "capabilityHeardShort": "LXST", - "capabilityUnknownShort": "Welk model wordt dan gebruikt?", + "capabilityUnknownShort": "?", "help": { "interop": "LXST-spraak heeft een peer-running LXST-telefonie nodig (Sideband, Ratspeak of mesh-client). Geen voice-note clip. Python LXST is de referentie; rsLXST is experimenteel." }, diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 2581a87cc..9ca7acd18 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -5125,12 +5125,13 @@ "callFailed": "Połączenie głosowe nie powiodło się.", "notRunning": "Głos nie jest dostępny, dopóki stos Reticulum nie zostanie uruchomiony.", "micFailed": "Nie można uzyskać dostępu do mikrofonu dla głosu.", - "muteFailed": "Nie można zaktualizować wyciszenia mikrofonu w sesji głosowej." + "muteFailed": "Nie można zaktualizować wyciszenia mikrofonu w sesji głosowej.", + "callInProgress": "Połączenie głosowe jest już w toku." }, "connecting": "Łączenie…", - "elapsedAria": "Czas, jaki upłynął", - "txAria": "Przesyłane ramki głosowe", - "rxAria": "Odebrane ramki głosowe", + "elapsedAria": "Czas, jaki upłynął {{time}}", + "txAria": "Przesyłane ramki głosowe {{count}}", + "rxAria": "Odebrane ramki głosowe {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5146,6 +5147,7 @@ "rejected": "Połączenie odrzucone", "noAnswer": "Brak odpowiedzi — przekroczono limit czasu połączenia.", "failed": "Połączenie głosowe nie powiodło się." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 8a48bd17c..72734894b 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -5121,12 +5121,13 @@ "callFailed": "Falha na chamada de voz.", "notRunning": "A voz não está disponível até que a pilha Reticulum esteja em execução.", "micFailed": "Não foi possível acessar o microfone para voz.", - "muteFailed": "Não foi possível atualizar o mudo do microfone na sessão de voz." + "muteFailed": "Não foi possível atualizar o mudo do microfone na sessão de voz.", + "callInProgress": "Uma chamada de voz já está em andamento." }, "connecting": "Conectando…", - "elapsedAria": "Tempo corrido:", - "txAria": "Quadros de voz transmitidos", - "rxAria": "Quadros de voz recebidos", + "elapsedAria": "Tempo corrido: {{time}}", + "txAria": "Quadros de voz transmitidos {{count}}", + "rxAria": "Quadros de voz recebidos {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5142,6 +5143,7 @@ "rejected": "Chamada rejeitada", "noAnswer": "Sem resposta — a chamada expirou.", "failed": "Falha na chamada de voz." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 8574352f5..8e73a43d2 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -5123,12 +5123,13 @@ "callFailed": "Сбой голосового вызова.", "notRunning": "Голос недоступен до тех пор, пока не будет запущен стек Reticulum.", "micFailed": "Не удалось получить доступ к микрофону для голоса.", - "muteFailed": "Не удалось обновить отключение микрофона во время голосового сеанса." + "muteFailed": "Не удалось обновить отключение микрофона во время голосового сеанса.", + "callInProgress": "Голосовой вызов уже выполняется." }, "connecting": "Подключение…", - "elapsedAria": "ПРОДОЛЖИТЕЛЬНОСТЬ", - "txAria": "Переданные голосовые кадры", - "rxAria": "Полученные голосовые кадры", + "elapsedAria": "ПРОДОЛЖИТЕЛЬНОСТЬ {{time}}", + "txAria": "Переданные голосовые кадры {{count}}", + "rxAria": "Полученные голосовые кадры {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5144,6 +5145,7 @@ "rejected": "Вызов отклонен", "noAnswer": "Нет ответа — время ожидания звонка истекло.", "failed": "Сбой голосового вызова." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 03029e89f..f8efb4bea 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -5121,12 +5121,13 @@ "callFailed": "Sesli arama başarısız.", "notRunning": "Reticulum yığını çalışana kadar ses kullanılamaz.", "micFailed": "Ses için mikrofona erişilemedi.", - "muteFailed": "Sesli oturumda mikrofon sessize alınamadı." + "muteFailed": "Sesli oturumda mikrofon sessize alınamadı.", + "callInProgress": "Bir sesli arama zaten devam ediyor." }, "connecting": "Bağlanıyor…", - "elapsedAria": "Geçen zaman", - "txAria": "İletilen ses çerçeveleri", - "rxAria": "Alınan ses çerçeveleri", + "elapsedAria": "Geçen zaman {{time}}", + "txAria": "İletilen ses çerçeveleri {{count}}", + "rxAria": "Alınan ses çerçeveleri {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5142,6 +5143,7 @@ "rejected": "Çağrı reddedildi.", "noAnswer": "Cevap yok — çağrı zaman aşımına uğradı.", "failed": "Sesli arama başarısız." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index ea018b2de..26a43e6ce 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -5123,12 +5123,13 @@ "callFailed": "Не вдалося здійснити голосовий виклик.", "notRunning": "Голосовий зв'язок недоступний до запуску стека Reticulum.", "micFailed": "Не вдалося отримати доступ до мікрофона для голосу.", - "muteFailed": "Не вдалося оновити вимкнення мікрофона під час голосового сеансу." + "muteFailed": "Не вдалося оновити вимкнення мікрофона під час голосового сеансу.", + "callInProgress": "Голосовий дзвінок вже виконується." }, "connecting": "Підключення…", - "elapsedAria": "Минулий час", - "txAria": "Передані голосові кадри", - "rxAria": "Отримані голосові кадри", + "elapsedAria": "Минулий час {{time}}", + "txAria": "Передані голосові кадри {{count}}", + "rxAria": "Отримані голосові кадри {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5144,6 +5145,7 @@ "rejected": "Виклик відхилено.", "noAnswer": "Немає відповіді — тайм-аут дзвінка.", "failed": "Не вдалося здійснити голосовий виклик." - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index d39ef4e42..de68c458d 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -5121,12 +5121,13 @@ "callFailed": "语音通话失败。", "notRunning": "在 Reticulum 堆栈运行之前,语音不可用。", "micFailed": "无法访问麦克风进行语音通话。", - "muteFailed": "无法更新语音会话上的麦克风静音。" + "muteFailed": "无法更新语音会话上的麦克风静音。", + "callInProgress": "语音通话已在进行中。" }, "connecting": "连接中…", - "elapsedAria": "运行时间", - "txAria": "传输的语音帧", - "rxAria": "已接收语音帧", + "elapsedAria": "运行时间 {{time}}", + "txAria": "传输的语音帧 {{count}}", + "rxAria": "已接收语音帧 {{count}}", "txFrames": "TX {{count}}", "txPackets": "pkt {{count}}", "rxFrames": "RX {{count}}", @@ -5142,6 +5143,7 @@ "rejected": "呼叫被拒绝。", "noAnswer": "无应答—呼叫超时。", "failed": "语音通话失败。" - } + }, + "statsSeparator": "·" } } diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index d069ad15b..6d4e74144 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -1188,9 +1188,11 @@ export function useReticulumRuntime(): ProtocolRuntime { }); } if (evt.type === 'voice.error' && evt.payload && typeof evt.payload === 'object') { - const p = evt.payload as { message?: string }; + const p = evt.payload as { message?: string; link_id?: string }; handleReticulumVoiceTerminal({ - errorMessage: p.message ?? 'voice error', + linkId: typeof p.link_id === 'string' ? p.link_id : null, + errorMessage: typeof p.message === 'string' && p.message.trim() ? p.message : 'failed', + callGeneration: useReticulumVoiceStore.getState().callGeneration, }); } if (evt.type === 'rncp.progress' && evt.payload && typeof evt.payload === 'object') { @@ -1331,8 +1333,14 @@ export function useReticulumRuntime(): ProtocolRuntime { unsubEventRef.current = window.electronAPI.reticulum.onEvent(handleSidecarEvent); unsubVoiceAudioRef.current = window.electronAPI.reticulum.onVoiceAudio((evt) => { if (evt.type !== 'voice.audio' || !evt.payload || typeof evt.payload !== 'object') return; - const p = evt.payload as { channels?: number; samples_b64?: string }; + const p = evt.payload as { link_id?: string; channels?: number; samples_b64?: string }; if (typeof p.samples_b64 !== 'string') return; + const active = useReticulumVoiceStore.getState().activeCall; + const status = active?.status; + if (status !== 'established' && status !== 'connecting') return; + const eventLink = typeof p.link_id === 'string' ? p.link_id.trim().toLowerCase() : ''; + const activeLink = (active?.link_id ?? '').trim().toLowerCase(); + if (eventLink && activeLink && eventLink !== activeLink) return; const samples = decodeF32LeBase64(p.samples_b64); const channels = typeof p.channels === 'number' && Number.isInteger(p.channels) && p.channels > 0 diff --git a/src/renderer/runtime/useReticulumRuntime.voice.test.ts b/src/renderer/runtime/useReticulumRuntime.voice.test.ts index f8a604276..05dcb068e 100644 --- a/src/renderer/runtime/useReticulumRuntime.voice.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.voice.test.ts @@ -5,6 +5,7 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import i18n from '@/renderer/lib/i18n'; import { resetReticulumManualStackStopSuppressForTests } from '@/renderer/lib/reticulum/reticulumManualStackStopSuppress'; import { encodeF32LeBase64 } from '@/renderer/lib/reticulumVoiceAudio'; import { useReticulumRuntime } from '@/renderer/runtime/useReticulumRuntime'; @@ -157,7 +158,10 @@ describe('useReticulumRuntime voice event routing', () => { onEvent({ type: 'voice.error', payload: { message: 'codec boom' } }); }); expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); - expect(useReticulumVoiceStore.getState().lastError).toBe('codec boom'); + // Sidecar English is humanized — never stored/toasted raw. + expect(useReticulumVoiceStore.getState().lastError).toBe( + i18n.t('reticulumVoice.errors.callFailed'), + ); unmount(); }); diff --git a/src/renderer/stores/reticulumVoiceStore.test.ts b/src/renderer/stores/reticulumVoiceStore.test.ts index 4d87349f7..03a76cbeb 100644 --- a/src/renderer/stores/reticulumVoiceStore.test.ts +++ b/src/renderer/stores/reticulumVoiceStore.test.ts @@ -60,7 +60,7 @@ describe('reticulumVoiceStore', () => { expect(useReticulumVoiceStore.getState().incomingCall).toBeNull(); }); - it('applyTerminated without link id leaves current call unchanged', () => { + it('applyTerminated without link id leaves call with a real link unchanged', () => { useReticulumVoiceStore.getState().applyIncoming(CALL); useReticulumVoiceStore.getState().applyTerminated(null, 'busy'); useReticulumVoiceStore.getState().applyTerminated('', 'busy'); @@ -69,6 +69,21 @@ describe('reticulumVoiceStore', () => { expect(useReticulumVoiceStore.getState().incomingCall?.status).toBe('ringing'); }); + it('applyTerminated without link id clears outgoing pending (empty link)', () => { + useReticulumVoiceStore.getState().beginOutgoing('c'.repeat(32)); + useReticulumVoiceStore.getState().applyTerminated('', 'busy'); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + }); + + it('applyError ignores stale callGeneration', () => { + useReticulumVoiceStore.getState().beginOutgoing('c'.repeat(32)); + const gen = useReticulumVoiceStore.getState().callGeneration; + useReticulumVoiceStore.getState().applyError('stale', { callGeneration: gen - 1 }); + expect(useReticulumVoiceStore.getState().activeCall?.status).toBe('calling'); + useReticulumVoiceStore.getState().applyError('fresh', { callGeneration: gen }); + expect(useReticulumVoiceStore.getState().activeCall).toBeNull(); + }); + it('beginOutgoing sets calling before WS and hangup clears stats', () => { const id = 'c'.repeat(32); useReticulumVoiceStore.getState().beginOutgoing(id); diff --git a/src/renderer/stores/reticulumVoiceStore.ts b/src/renderer/stores/reticulumVoiceStore.ts index 63a7c7c0c..fa6ad2960 100644 --- a/src/renderer/stores/reticulumVoiceStore.ts +++ b/src/renderer/stores/reticulumVoiceStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import type { VoiceActiveCall } from '@/shared/voice-types'; -import { isVoiceActiveCall } from '@/shared/voice-types'; +import { isReticulumIncomingRinging, isVoiceActiveCall } from '@/shared/voice-types'; export type VoiceAudioListener = (channels: number, samples: Float32Array) => void; @@ -44,7 +44,7 @@ interface ReticulumVoiceStoreState { applyUpdate: (payload: unknown) => void; applyStats: (payload: unknown) => void; applyTerminated: (linkId?: string | null, reason?: string | null) => void; - applyError: (message: string) => void; + applyError: (message: string, opts?: { callGeneration?: number | null }) => void; incrementLocalTxDrops: () => void; setMicrophoneMuted: (muted: boolean) => void; clearCall: () => void; @@ -65,13 +65,7 @@ function asActiveCall(value: unknown): VoiceActiveCall | null { /** Keep incomingCall only while the call is still ringing / available. */ function incomingFromActive(active: VoiceActiveCall | null): VoiceActiveCall | null { - if ( - active?.role === 'incoming' && - (active.status === 'ringing' || active.status === 'available') - ) { - return active; - } - return null; + return isReticulumIncomingRinging(active) ? active : null; } function resetSessionFields(): Pick< @@ -247,12 +241,15 @@ export const useReticulumVoiceStore = create((set, get applyTerminated: (linkId, reason) => { set((s) => { - if (!linkId?.trim()) { - // Missing/empty link id — ignore (do not blind-clear the current call). + if (!s.activeCall) return s; + const eventLink = (linkId ?? '').trim().toLowerCase(); + const activeLink = s.activeCall.link_id.trim().toLowerCase(); + if (eventLink && activeLink && eventLink !== activeLink) { + // Stale terminate for a previous call. return s; } - if (s.activeCall?.link_id && s.activeCall.link_id.toLowerCase() !== linkId.toLowerCase()) { - // Stale terminate for a previous call. + // Empty event link: only clear while local call also has no link (outgoing pending). + if (!eventLink && activeLink) { return s; } return { @@ -262,11 +259,20 @@ export const useReticulumVoiceStore = create((set, get }); }, - applyError: (message) => { - set({ - ...resetSessionFields(), - lastError: message, - lastTerminalReason: message, + applyError: (message, opts) => { + set((s) => { + if ( + opts?.callGeneration != null && + Number.isFinite(opts.callGeneration) && + opts.callGeneration !== s.callGeneration + ) { + return s; + } + return { + ...resetSessionFields(), + lastError: message, + lastTerminalReason: message, + }; }); }, diff --git a/src/shared/voice-types.test.ts b/src/shared/voice-types.test.ts index 05d98a388..86a8750ea 100644 --- a/src/shared/voice-types.test.ts +++ b/src/shared/voice-types.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { + isReticulumIncomingRinging, + isReticulumVoiceSessionBusy, isVoiceActiveCall, isVoiceStatusResponse, parseVoiceAudioRequest, @@ -78,4 +80,17 @@ describe('voice-types guards', () => { }), ).toEqual({ error: 'samples_b64_too_large' }); }); + + it('classifies incoming ringing and session busy', () => { + const ringing = { + link_id: 'a'.repeat(32), + remote_identity: 'b'.repeat(32), + role: 'incoming' as const, + status: 'ringing' as const, + }; + expect(isReticulumIncomingRinging(ringing)).toBe(true); + expect(isReticulumIncomingRinging({ ...ringing, status: 'established' })).toBe(false); + expect(isReticulumVoiceSessionBusy(ringing)).toBe(true); + expect(isReticulumVoiceSessionBusy(null)).toBe(false); + }); }); diff --git a/src/shared/voice-types.ts b/src/shared/voice-types.ts index bd514c2e5..20381bd95 100644 --- a/src/shared/voice-types.ts +++ b/src/shared/voice-types.ts @@ -116,6 +116,25 @@ export function isVoiceActiveCall(value: unknown): value is VoiceActiveCall { ); } +/** Incoming ring UI: role incoming and still ringing/available. */ +export function isReticulumIncomingRinging( + call: VoiceActiveCall | null | undefined, +): call is VoiceActiveCall { + return call?.role === 'incoming' && (call.status === 'ringing' || call.status === 'available'); +} + +/** True when a non-terminal local voice session is in progress. */ +export function isReticulumVoiceSessionBusy(call: VoiceActiveCall | null | undefined): boolean { + if (!call) return false; + return ( + call.status === 'calling' || + call.status === 'ringing' || + call.status === 'connecting' || + call.status === 'established' || + call.status === 'available' + ); +} + export function isVoiceStatusResponse(value: unknown): value is VoiceStatusResponse { if (!value || typeof value !== 'object') return false; const v = value as Record; From 2e8182b911b0b1c9ec73b8f36a4a53fca18171ee Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 19:25:58 -0600 Subject: [PATCH 10/27] fix(flatpak): bump vendored pnpm archives to 11.20.0 Keep Flatpak standalone pnpm URLs/sha256s aligned with package.json packageManager after the pnpm bump. --- org.coloradomesh.MeshClient.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/org.coloradomesh.MeshClient.yml b/org.coloradomesh.MeshClient.yml index ee3c46fdd..18d8e0497 100644 --- a/org.coloradomesh.MeshClient.yml +++ b/org.coloradomesh.MeshClient.yml @@ -88,15 +88,15 @@ modules: path: . - flatpak/generated-sources.json - type: archive - url: https://github.com/pnpm/pnpm/releases/download/v11.15.1/pnpm-linux-x64.tar.gz - sha256: 0c1373b6390f6b89ff8b896d3647c9826577ddc49173a2f732da69b36569f9e0 + url: https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64.tar.gz + sha256: b4ad6ad2b21db2f8cd50af416c3aa148ba704c31c84893f465a770a01c2c4572 dest: pnpm-vendor # pnpm tarball has root-level `pnpm` + `dist/`; default strip-components:1 drops the binary. strip-components: 0 only-arches: [x86_64] - type: archive - url: https://github.com/pnpm/pnpm/releases/download/v11.15.1/pnpm-linux-arm64.tar.gz - sha256: 361e385867146972d0635a41a1871cb44c9c23f65acce78a5f1ca1d44ac0afcd + url: https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64.tar.gz + sha256: f00fc2041bb41742b7943bf2bb24183ad20320e8384824a8031eb94edf2f57a5 dest: pnpm-vendor strip-components: 0 only-arches: [aarch64] From f6547496108b17ffc2549f0170028c3f18b818ec Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 20:59:01 -0600 Subject: [PATCH 11/27] fix(reticulum): show peer name in voice dialing overlay Resolve remote_identity through the peer store so Calling/incoming UI shows a display name instead of a raw hash. --- .../reticulum/ReticulumVoiceOverlay.test.tsx | 52 +++++++++++++- .../reticulum/ReticulumVoiceOverlay.tsx | 17 ++++- .../lib/reticulumVoiceRemoteLabel.test.ts | 72 +++++++++++++++++++ src/renderer/lib/reticulumVoiceRemoteLabel.ts | 58 +++++++++++++++ 4 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/renderer/lib/reticulumVoiceRemoteLabel.test.ts create mode 100644 src/renderer/lib/reticulumVoiceRemoteLabel.ts diff --git a/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx index d8ccddbc0..2d0aa6850 100644 --- a/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx +++ b/src/renderer/components/reticulum/ReticulumVoiceOverlay.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; import { ReticulumVoiceOverlay } from './ReticulumVoiceOverlay'; @@ -32,6 +33,7 @@ describe('ReticulumVoiceOverlay', () => { setMuted.mockReset(); act(() => { useReticulumVoiceStore.getState().clearCall(); + useReticulumPeerStore.getState().clearPeers(); }); }); @@ -134,8 +136,24 @@ describe('ReticulumVoiceOverlay', () => { it('shows hangup while optimistic calling with TX/RX counters', async () => { const user = userEvent.setup(); + const dest = 'c'.repeat(32); + const id = 'b'.repeat(32); act(() => { - useReticulumVoiceStore.getState().beginOutgoing('b'.repeat(32)); + useReticulumPeerStore.setState({ + peers: new Map([ + [ + dest, + { + destination_hash: dest, + identity_hash: id, + display_name: 'Dial Peer', + hops: 1, + }, + ], + ]), + peersRevision: 1, + }); + useReticulumVoiceStore.getState().beginOutgoing(id); useReticulumVoiceStore.getState().applyStats({ tx_frames: 4, tx_packets: 3, @@ -144,6 +162,8 @@ describe('ReticulumVoiceOverlay', () => { }); const { container } = render(); expect(screen.getByRole('status', { name: /calling/i })).toBeTruthy(); + expect(screen.getByText('Dial Peer')).toBeTruthy(); + expect(screen.queryByText(id)).toBeNull(); expect(screen.getByText(/TX 4/i)).toBeTruthy(); expect(screen.getByText(/RX 1/i)).toBeTruthy(); await user.click(screen.getByRole('button', { name: /hang up voice call/i })); @@ -151,4 +171,34 @@ describe('ReticulumVoiceOverlay', () => { hydrateAxeThemeColors(container); expect(await axe(container)).toHaveNoViolations(); }); + + it('shows peer display name on incoming dialog instead of raw hash', () => { + const dest = 'd'.repeat(32); + const id = 'e'.repeat(32); + act(() => { + useReticulumPeerStore.setState({ + peers: new Map([ + [ + dest, + { + destination_hash: dest, + identity_hash: id, + display_name: 'Incoming Peer', + hops: 0, + }, + ], + ]), + peersRevision: 1, + }); + useReticulumVoiceStore.getState().applyIncoming({ + link_id: 'a'.repeat(32), + remote_identity: id, + role: 'incoming', + status: 'ringing', + }); + }); + render(); + expect(screen.getByText('Incoming Peer')).toBeTruthy(); + expect(screen.queryByText(id)).toBeNull(); + }); }); diff --git a/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx b/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx index 780488baa..51de9df44 100644 --- a/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx +++ b/src/renderer/components/reticulum/ReticulumVoiceOverlay.tsx @@ -2,6 +2,7 @@ import { Mic, MicOff, PhoneOff } from 'lucide-react-motion'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { resolveReticulumVoiceRemoteLabel } from '@/renderer/lib/reticulumVoiceRemoteLabel'; import { reticulumVoiceAnswer, reticulumVoiceHangup, @@ -11,6 +12,7 @@ import { stopReticulumVoiceMedia, syncReticulumVoiceProgressTones, } from '@/renderer/lib/reticulumVoiceSession'; +import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; import { isReticulumIncomingRinging } from '@/shared/voice-types'; @@ -46,6 +48,8 @@ export function ReticulumVoiceOverlay() { const callStartedAtMs = useReticulumVoiceStore((s) => s.callStartedAtMs); const callGeneration = useReticulumVoiceStore((s) => s.callGeneration); const stats = useReticulumVoiceStore((s) => s.stats); + // Re-resolve name when peer table updates (dial may start before peers hydrate). + useReticulumPeerStore((s) => s.peersRevision); const [nowMs, setNowMs] = useState(null); const activeStatus = active?.status ?? null; @@ -90,6 +94,11 @@ export function ReticulumVoiceOverlay() { active.status === 'established' || active.status === 'ringing'); + const remoteHash = showIncoming + ? (incoming?.remote_identity ?? '') + : (active?.remote_identity ?? ''); + const remoteLabel = remoteHash ? resolveReticulumVoiceRemoteLabel(remoteHash) : ''; + if (!showIncoming && !showInCall) return null; if (showIncoming && incoming) { @@ -104,7 +113,9 @@ export function ReticulumVoiceOverlay() {

{t('reticulumVoice.incomingTitle')}

-

{incoming.remote_identity}

+

+ {remoteLabel} +

-
- ); - })} +
+ {msg.kind === 'action' ? ( + <> + * {nick} {body} + + ) : msg.kind === 'notice' || + msg.kind === 'system' || + msg.kind === 'error' ? ( + <> + {msg.kind === 'notice' && nick ? ( + -{nick}- + ) : ( + * + )} + {body} + + ) : ( + <> + <{nick}>{' '} + {body} + + )} +
+ + + + ); + })} + + )} +
+
+ {showScrollButton && activeRoom && ( + + )}