chenbhao commited on
Commit
0bed40c
Β·
1 Parent(s): d2a78c4

feat: VAD

Browse files
bun.lock CHANGED
@@ -20,6 +20,7 @@
20
  "@aws-sdk/credential-providers": "^3.1020.0",
21
  "@azure/identity": "^4.13.1",
22
  "@commander-js/extra-typings": "^14.0.0",
 
23
  "@growthbook/growthbook": "^1.6.5",
24
  "@larksuite/channel": "^0.1.2",
25
  "@larksuiteoapi/node-sdk": "^1.60.0",
@@ -79,6 +80,7 @@
79
  "lru-cache": "^11.2.7",
80
  "marked": "^17.0.5",
81
  "node-edge-tts": "^1.2.10",
 
82
  "p-map": "^7.0.4",
83
  "picomatch": "^4.0.4",
84
  "plist": "^3.1.0",
@@ -242,6 +244,8 @@
242
 
243
  "@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
244
 
 
 
245
  "@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
246
 
247
  "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
@@ -496,6 +500,8 @@
496
 
497
  "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
498
 
 
 
499
  "agent-base": ["agent-base@8.0.0", "", {}, "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg=="],
500
 
501
  "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
@@ -608,8 +614,12 @@
608
 
609
  "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
610
 
 
 
611
  "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
612
 
 
 
613
  "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
614
 
615
  "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
@@ -690,6 +700,8 @@
690
 
691
  "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
692
 
 
 
693
  "flora-colossus": ["flora-colossus@2.0.0", "", { "dependencies": { "debug": "^4.3.4", "fs-extra": "^10.1.0" } }, "sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA=="],
694
 
695
  "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
@@ -728,6 +740,10 @@
728
 
729
  "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
730
 
 
 
 
 
731
  "google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="],
732
 
733
  "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
@@ -740,8 +756,12 @@
740
 
741
  "gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
742
 
 
 
743
  "has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="],
744
 
 
 
745
  "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
746
 
747
  "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
@@ -852,6 +872,8 @@
852
 
853
  "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
854
 
 
 
855
  "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
856
 
857
  "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@@ -898,12 +920,20 @@
898
 
899
  "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
900
 
 
 
901
  "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
902
 
903
  "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
904
 
905
  "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
906
 
 
 
 
 
 
 
907
  "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
908
 
909
  "opus-encdec": ["opus-encdec@0.1.1", "", {}, "sha512-TDzyGqYqrwn5UEUNaLsfLGu8Ma+HRNrgLYj7Vx5wfTnafAA21G6Bnm/qTIa3orQi/yZPZYmkdpO/gez4nfA1Rw=="],
@@ -942,6 +972,8 @@
942
 
943
  "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
944
 
 
 
945
  "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="],
946
 
947
  "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
@@ -1000,6 +1032,8 @@
1000
 
1001
  "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
1002
 
 
 
1003
  "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
1004
 
1005
  "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
@@ -1392,6 +1426,8 @@
1392
 
1393
  "ink/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
1394
 
 
 
1395
  "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
1396
 
1397
  "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -1410,6 +1446,8 @@
1410
 
1411
  "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
1412
 
 
 
1413
  "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
1414
 
1415
  "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@9.0.0", "", {}, "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg=="],
 
20
  "@aws-sdk/credential-providers": "^3.1020.0",
21
  "@azure/identity": "^4.13.1",
22
  "@commander-js/extra-typings": "^14.0.0",
23
+ "@ericedouard/vad-node-realtime": "^0.2.0",
24
  "@growthbook/growthbook": "^1.6.5",
25
  "@larksuite/channel": "^0.1.2",
26
  "@larksuiteoapi/node-sdk": "^1.60.0",
 
80
  "lru-cache": "^11.2.7",
81
  "marked": "^17.0.5",
82
  "node-edge-tts": "^1.2.10",
83
+ "onnxruntime-web": "^1.27.0",
84
  "p-map": "^7.0.4",
85
  "picomatch": "^4.0.4",
86
  "plist": "^3.1.0",
 
244
 
245
  "@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
246
 
247
+ "@ericedouard/vad-node-realtime": ["@ericedouard/vad-node-realtime@0.2.0", "", { "dependencies": { "onnxruntime-node": "^1.14.0" } }, "sha512-hVKup5v4x5ixqZ3TnyLQKsybE3GveVQZmUX3iaZT0mbSendCt7aMhqW1wxDm4fKFgOUryJyV2znaQU2Xyik9wQ=="],
248
+
249
  "@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
250
 
251
  "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
 
500
 
501
  "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
502
 
503
+ "adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="],
504
+
505
  "agent-base": ["agent-base@8.0.0", "", {}, "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg=="],
506
 
507
  "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
 
614
 
615
  "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
616
 
617
+ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
618
+
619
  "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
620
 
621
+ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
622
+
623
  "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
624
 
625
  "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
 
700
 
701
  "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
702
 
703
+ "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
704
+
705
  "flora-colossus": ["flora-colossus@2.0.0", "", { "dependencies": { "debug": "^4.3.4", "fs-extra": "^10.1.0" } }, "sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA=="],
706
 
707
  "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
 
740
 
741
  "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
742
 
743
+ "global-agent": ["global-agent@4.1.3", "", { "dependencies": { "globalthis": "^1.0.2", "matcher": "^4.0.0", "semver": "^7.3.5", "serialize-error": "^8.1.0" } }, "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g=="],
744
+
745
+ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
746
+
747
  "google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="],
748
 
749
  "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
 
756
 
757
  "gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
758
 
759
+ "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
760
+
761
  "has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="],
762
 
763
+ "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
764
+
765
  "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
766
 
767
  "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
 
872
 
873
  "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
874
 
875
+ "matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="],
876
+
877
  "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
878
 
879
  "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
 
920
 
921
  "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
922
 
923
+ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
924
+
925
  "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
926
 
927
  "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
928
 
929
  "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
930
 
931
+ "onnxruntime-common": ["onnxruntime-common@1.27.0", "", {}, "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow=="],
932
+
933
+ "onnxruntime-node": ["onnxruntime-node@1.27.0", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^4.1.3", "onnxruntime-common": "1.27.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-QEzGwrvNBgv4uPVdnbHsOGG4G6T96mdlcFI8aAKPjMU8wOPpVocPXb6k3QGkaZagVTv2G9Bnnbo6Z3JdXr1fQw=="],
934
+
935
+ "onnxruntime-web": ["onnxruntime-web@1.27.0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.27.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-ogDLsqIozHZwifPuN37OproAo0byX6t43/bP8GzeZWBWD6MOGExswFAx3up4NS/vvWBOg2u2PXomDt3rMmdQSg=="],
936
+
937
  "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
938
 
939
  "opus-encdec": ["opus-encdec@0.1.1", "", {}, "sha512-TDzyGqYqrwn5UEUNaLsfLGu8Ma+HRNrgLYj7Vx5wfTnafAA21G6Bnm/qTIa3orQi/yZPZYmkdpO/gez4nfA1Rw=="],
 
972
 
973
  "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
974
 
975
+ "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
976
+
977
  "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="],
978
 
979
  "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
 
1032
 
1033
  "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
1034
 
1035
+ "serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="],
1036
+
1037
  "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
1038
 
1039
  "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
 
1426
 
1427
  "ink/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
1428
 
1429
+ "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
1430
+
1431
  "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
1432
 
1433
  "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
 
1446
 
1447
  "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
1448
 
1449
+ "serialize-error/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
1450
+
1451
  "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
1452
 
1453
  "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@9.0.0", "", {}, "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg=="],
package.json CHANGED
@@ -38,6 +38,7 @@
38
  "@aws-sdk/credential-providers": "^3.1020.0",
39
  "@azure/identity": "^4.13.1",
40
  "@commander-js/extra-typings": "^14.0.0",
 
41
  "@growthbook/growthbook": "^1.6.5",
42
  "@larksuite/channel": "^0.1.2",
43
  "@larksuiteoapi/node-sdk": "^1.60.0",
@@ -97,6 +98,7 @@
97
  "lru-cache": "^11.2.7",
98
  "marked": "^17.0.5",
99
  "node-edge-tts": "^1.2.10",
 
100
  "p-map": "^7.0.4",
101
  "picomatch": "^4.0.4",
102
  "plist": "^3.1.0",
 
38
  "@aws-sdk/credential-providers": "^3.1020.0",
39
  "@azure/identity": "^4.13.1",
40
  "@commander-js/extra-typings": "^14.0.0",
41
+ "@ericedouard/vad-node-realtime": "^0.2.0",
42
  "@growthbook/growthbook": "^1.6.5",
43
  "@larksuite/channel": "^0.1.2",
44
  "@larksuiteoapi/node-sdk": "^1.60.0",
 
98
  "lru-cache": "^11.2.7",
99
  "marked": "^17.0.5",
100
  "node-edge-tts": "^1.2.10",
101
+ "onnxruntime-web": "^1.27.0",
102
  "p-map": "^7.0.4",
103
  "picomatch": "^4.0.4",
104
  "plist": "^3.1.0",
src/friend/FriendService.ts CHANGED
@@ -19,6 +19,7 @@ import { getPrefs } from './prefs.js';
19
  import { stripForTts } from './text-utils.js';
20
  import { edgeTts, qwenTts, registerAudioFile } from './tts.js';
21
  import { splitSentences } from './text-utils.js';
 
22
 
23
  // ── Types ──────────────────────────────────────────────────────────────
24
 
@@ -69,14 +70,13 @@ class FriendService {
69
  private audioCapture: AudioCaptureProvider | null = null;
70
  /** Active STT connection (Anthropic/Doubao/Whisper) during capture */
71
  private sttConnection: { send: (chunk: Buffer) => void; finalize: () => Promise<void>; close: () => void } | null = null;
72
- /** Periodic timer for server-side voice segmentation (~5s intervals) */
73
- private captureTimer: ReturnType<typeof setInterval> | null = null;
74
- private readonly SEGMENT_MS = 5000;
75
  /** STT provider/language cached for connection re-creation during segmentation */
76
  private captureProvider = '';
77
  private captureLanguage = '';
78
- /** Guard against concurrent _flushSegment calls */
79
  private _flushing = false;
 
 
80
 
81
  // ── React sync external store interface ──────────────────────────────
82
 
@@ -102,9 +102,22 @@ class FriendService {
102
  this.setState({ status: 'starting', lastError: undefined });
103
 
104
  try {
105
- // Pre-warm cpal audio module (loaded on first use)
106
- // The voice service lazy-loads audio-capture-napi, so the first
107
- // capture will incur the ~1s dlopen penalty regardless.
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  this.setState({ status: 'running' });
110
  } catch (err) {
@@ -229,64 +242,56 @@ class FriendService {
229
  throw new Error('Native audio capture unavailable');
230
  }
231
 
232
- // 4. Start periodic segmentation timer (voice call auto-send)
233
- this._startSegmentTimer();
 
 
 
 
 
 
 
234
  }
235
 
236
  /**
237
- * Periodically finalize the current STT segment and start a new one,
238
- * sending the transcript to the CLI conversation in real-time.
239
  *
240
- * This replaces browser-based VAD with server-side timer segmentation.
241
- * Without it, voice call text only appears when the user presses F2.
 
242
  */
243
- private async _flushSegment(): Promise<void> {
244
  if (this._flushing || !this.capturing) return;
245
  this._flushing = true;
246
  try {
247
  const oldConn = this.sttConnection;
248
  if (!oldConn) return;
249
 
250
- // 1. Create a new STT connection for ongoing audio first
251
  const newConn = await this.startSttConnectionWithTimeout(
252
  this.captureProvider,
253
  this.captureLanguage,
254
  );
255
  this.sttConnection = newConn;
256
 
257
- // 2. Finalize old connection (sends buffered audio to Groq)
258
  await oldConn.finalize().catch(() => {});
259
  oldConn.close();
260
 
261
- // 3. Send any accumulated transcript to the CLI conversation
262
  const transcript = this.captureTranscripts.join('').trim();
263
  if (transcript) {
264
  this.captureTranscripts = [];
265
  this.sendText(transcript);
266
  }
267
  } catch (err) {
268
- console.error('[FriendService] _flushSegment error:', err);
269
  } finally {
270
  this._flushing = false;
271
  }
272
  }
273
 
274
- private _startSegmentTimer(): void {
275
- this._clearSegmentTimer();
276
- this.captureTimer = setInterval(() => {
277
- this._flushSegment().catch((err) => {
278
- console.error('[FriendService] segment timer error:', err);
279
- });
280
- }, this.SEGMENT_MS);
281
- }
282
-
283
- private _clearSegmentTimer(): void {
284
- if (this.captureTimer) {
285
- clearInterval(this.captureTimer);
286
- this.captureTimer = null;
287
- }
288
- }
289
-
290
  /** Race a promise against a timeout */
291
  private async withTimeout<T>(
292
  promise: Promise<T>,
@@ -403,16 +408,20 @@ class FriendService {
403
  console.log(`[FriendService] _stopCapture: capturing=${this.capturing} sttConnection=${this.sttConnection ? 'exists' : 'null'}`);
404
  if (!this.capturing) return '';
405
 
406
- this.capturing = false;
407
-
408
- // Stop segment timer first
409
- this._clearSegmentTimer();
410
-
411
- // Stop cpal recording
412
  if (this.audioCapture) {
413
  await this.audioCapture.stopRecording().catch(() => {});
414
  }
415
 
 
 
 
 
 
 
 
 
 
416
  // Finalize STT connection
417
  const conn = this.sttConnection;
418
  this.sttConnection = null;
@@ -615,13 +624,28 @@ class FriendService {
615
  let verifyTimer: ReturnType<typeof setTimeout> | null = null;
616
 
617
  const verified = await new Promise<boolean>((resolve) => {
 
 
 
 
 
 
 
 
 
 
618
  const dataHandler = (chunk: Buffer) => {
619
  dataArrived = true;
620
  if (verifyTimer) { clearTimeout(verifyTimer); }
621
- // Forward first chunk, then swap to the permanent handler
622
  onData(chunk);
 
 
623
  proc.stdout?.removeListener('data', dataHandler);
624
- proc.stdout?.on('data', onData);
 
 
 
625
  resolve(true);
626
  };
627
  proc.stdout?.on('data', dataHandler);
 
19
  import { stripForTts } from './text-utils.js';
20
  import { edgeTts, qwenTts, registerAudioFile } from './tts.js';
21
  import { splitSentences } from './text-utils.js';
22
+ import { SileroVad } from './voice/vad-service.js';
23
 
24
  // ── Types ──────────────────────────────────────────────────────────────
25
 
 
70
  private audioCapture: AudioCaptureProvider | null = null;
71
  /** Active STT connection (Anthropic/Doubao/Whisper) during capture */
72
  private sttConnection: { send: (chunk: Buffer) => void; finalize: () => Promise<void>; close: () => void } | null = null;
 
 
 
73
  /** STT provider/language cached for connection re-creation during segmentation */
74
  private captureProvider = '';
75
  private captureLanguage = '';
76
+ /** Guard against concurrent flush calls */
77
  private _flushing = false;
78
+ /** Silero VAD instance (real ML-based voice activity detection) */
79
+ private vadInstance: SileroVad | null = null;
80
 
81
  // ── React sync external store interface ──────────────────────────────
82
 
 
102
  this.setState({ status: 'starting', lastError: undefined });
103
 
104
  try {
105
+ // Pre-warm Silero VAD (loads ONNX model + onnxruntime-web WASM)
106
+ if (!this.vadInstance) {
107
+ const vad = new SileroVad({
108
+ onSpeechStart: () => {},
109
+ onSpeechEnd: (_audio) => {
110
+ this._flushVadSegment().catch((e) =>
111
+ console.error('[FriendService] VAD segment flush error:', e),
112
+ );
113
+ },
114
+ });
115
+ vad.init().then(() => {
116
+ this.vadInstance = vad;
117
+ }).catch((e) => {
118
+ console.warn('[FriendService] VAD init failed (non-fatal, voice capture falls back to F2-only):', e);
119
+ });
120
+ }
121
 
122
  this.setState({ status: 'running' });
123
  } catch (err) {
 
242
  throw new Error('Native audio capture unavailable');
243
  }
244
 
245
+ // 4. Start Silero VAD for real-time speech-end detection
246
+ // audio chunks flow to VAD via the arecord data callback (see loadAudioCapture)
247
+ if (this.vadInstance) {
248
+ try {
249
+ this.vadInstance.start();
250
+ } catch (e) {
251
+ console.warn('[FriendService] VAD start error (non-fatal):', e);
252
+ }
253
+ }
254
  }
255
 
256
  /**
257
+ * Flush the current STT segment and start a new one, triggered by
258
+ * Silero VAD's onSpeechEnd callback.
259
  *
260
+ * The audio from the just-ended speech segment has already been sent
261
+ * to the STT connection. We swap to a fresh connection so the next
262
+ * speech segment starts clean.
263
  */
264
+ private async _flushVadSegment(): Promise<void> {
265
  if (this._flushing || !this.capturing) return;
266
  this._flushing = true;
267
  try {
268
  const oldConn = this.sttConnection;
269
  if (!oldConn) return;
270
 
271
+ // 1. Create a new STT connection for ongoing audio
272
  const newConn = await this.startSttConnectionWithTimeout(
273
  this.captureProvider,
274
  this.captureLanguage,
275
  );
276
  this.sttConnection = newConn;
277
 
278
+ // 2. Finalize old connection (sends buffered audio to STT provider)
279
  await oldConn.finalize().catch(() => {});
280
  oldConn.close();
281
 
282
+ // 3. Send accumulated transcript to the CLI conversation
283
  const transcript = this.captureTranscripts.join('').trim();
284
  if (transcript) {
285
  this.captureTranscripts = [];
286
  this.sendText(transcript);
287
  }
288
  } catch (err) {
289
+ console.error('[FriendService] _flushVadSegment error:', err);
290
  } finally {
291
  this._flushing = false;
292
  }
293
  }
294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  /** Race a promise against a timeout */
296
  private async withTimeout<T>(
297
  promise: Promise<T>,
 
408
  console.log(`[FriendService] _stopCapture: capturing=${this.capturing} sttConnection=${this.sttConnection ? 'exists' : 'null'}`);
409
  if (!this.capturing) return '';
410
 
411
+ // Stop audio recording first (no more audio β†’ no more STT/VAD input)
 
 
 
 
 
412
  if (this.audioCapture) {
413
  await this.audioCapture.stopRecording().catch(() => {});
414
  }
415
 
416
+ this.capturing = false;
417
+
418
+ // Reset VAD state silently (don't fire onSpeechEnd β€” we're finalizing below)
419
+ if (this.vadInstance) {
420
+ try {
421
+ this.vadInstance.reset();
422
+ } catch { /* ignore */ }
423
+ }
424
+
425
  // Finalize STT connection
426
  const conn = this.sttConnection;
427
  this.sttConnection = null;
 
624
  let verifyTimer: ReturnType<typeof setTimeout> | null = null;
625
 
626
  const verified = await new Promise<boolean>((resolve) => {
627
+ const feedVad = (c: Buffer) => {
628
+ if (this.vadInstance) {
629
+ const float32 = new Float32Array(c.length / 2);
630
+ for (let i = 0; i < float32.length; i++) {
631
+ float32[i] = c.readInt16LE(i * 2) / 32768;
632
+ }
633
+ this.vadInstance.processAudio(float32).catch(() => {});
634
+ }
635
+ };
636
+
637
  const dataHandler = (chunk: Buffer) => {
638
  dataArrived = true;
639
  if (verifyTimer) { clearTimeout(verifyTimer); }
640
+ // Forward first chunk to both STT and VAD
641
  onData(chunk);
642
+ feedVad(chunk);
643
+ // Swap to the permanent handler for subsequent chunks
644
  proc.stdout?.removeListener('data', dataHandler);
645
+ proc.stdout?.on('data', (c: Buffer) => {
646
+ onData(c);
647
+ feedVad(c);
648
+ });
649
  resolve(true);
650
  };
651
  proc.stdout?.on('data', dataHandler);
src/friend/voice/vad-service.ts ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Silero VAD service β€” real-time voice activity detection using onnxruntime-web WASM backend.
3
+ *
4
+ * Uses the Silero VAD legacy ONNX model (bundled with @ericedouard/vad-node-realtime).
5
+ * onnxruntime-web (WASM) is used instead of onnxruntime-node (native addon) because
6
+ * Bun does not support the native Node-API addon (crashes with segfault).
7
+ *
8
+ * Architecture:
9
+ * - Audio frames (512 samples @ 16kHz = 32ms) are fed to the Silero model
10
+ * - Speech probability is compared against configurable thresholds
11
+ * - A state machine tracks speech segments with redemption grace period
12
+ * - onSpeechEnd fires when sustained silence is detected
13
+ */
14
+
15
+ import * as ort from 'onnxruntime-web';
16
+ import { readFileSync, existsSync } from 'node:fs';
17
+ import { resolve, dirname } from 'node:path';
18
+
19
+ export interface VadCallbacks {
20
+ onSpeechStart?: () => void;
21
+ onSpeechEnd?: (audio: Float32Array) => void;
22
+ onVADMisfire?: () => void;
23
+ onFrameProcessed?: (prob: number, isSpeech: boolean) => void;
24
+ }
25
+
26
+ export interface VadOptions {
27
+ /** Threshold above which a frame is considered speech (0-1). Default: 0.5 */
28
+ positiveSpeechThreshold?: number;
29
+ /** Threshold below which a frame is considered silence (0-1). Default: 0.35 */
30
+ negativeSpeechThreshold?: number;
31
+ /** Consecutive silence frames before onSpeechEnd fires. Default: 8 (~256ms) */
32
+ redemptionFrames?: number;
33
+ /** Minimum speech frames to avoid misfire. Default: 3 (~96ms) */
34
+ minSpeechFrames?: number;
35
+ /** Frames of pre-speech audio to include in onSpeechEnd segment. Default: 10 */
36
+ preSpeechPadFrames?: number;
37
+ /** Sample rate of input audio (must be 16000). Default: 16000 */
38
+ sampleRate?: number;
39
+ }
40
+
41
+ export class SileroVad {
42
+ private session: ort.InferenceSession | null = null;
43
+ private stateH: ort.Tensor<onnxruntime.TensorType> | null = null;
44
+ private stateC: ort.Tensor<onnxruntime.TensorType> | null = null;
45
+ private sr: ort.Tensor<onnxruntime.TensorType> | null = null;
46
+ private initialized = false;
47
+ private active = false;
48
+
49
+ private readonly opts: Required<VadOptions>;
50
+ private readonly callbacks: Required<VadCallbacks>;
51
+ private readonly frameSize = 512; // Legacy Silero model: 512 samples @ 16kHz per frame
52
+
53
+ // Audio accumulation buffer
54
+ private buffer = new Float32Array(0);
55
+
56
+ // Frame processor state machine
57
+ private speaking = false;
58
+ private redemptionCounter = 0;
59
+ private speechFrameCount = 0;
60
+ private frameHistory: Array<{ frame: Float32Array; isSpeech: boolean }> = [];
61
+
62
+ constructor(callbacks: VadCallbacks, opts?: VadOptions) {
63
+ this.callbacks = {
64
+ onSpeechStart: callbacks.onSpeechStart ?? (() => {}),
65
+ onSpeechEnd: callbacks.onSpeechEnd ?? (() => {}),
66
+ onVADMisfire: callbacks.onVADMisfire ?? (() => {}),
67
+ onFrameProcessed: callbacks.onFrameProcessed ?? (() => {}),
68
+ };
69
+ this.opts = {
70
+ positiveSpeechThreshold: opts?.positiveSpeechThreshold ?? 0.5,
71
+ negativeSpeechThreshold: opts?.negativeSpeechThreshold ?? 0.35,
72
+ redemptionFrames: opts?.redemptionFrames ?? 8,
73
+ minSpeechFrames: opts?.minSpeechFrames ?? 3,
74
+ preSpeechPadFrames: opts?.preSpeechPadFrames ?? 10,
75
+ sampleRate: opts?.sampleRate ?? 16000,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Initialize the VAD: load ONNX model and configure onnxruntime-web WASM backend.
81
+ * Must be called once before start().
82
+ */
83
+ async init(): Promise<void> {
84
+ if (this.initialized) return;
85
+
86
+ // Point onnxruntime-web to the WASM binary files
87
+ const wasmDir = resolveWasmDir();
88
+ ort.env.wasm.wasmPaths = wasmDir + '/';
89
+
90
+ // Load Silero VAD legacy ONNX model
91
+ const modelPath = resolveModelPath();
92
+ const modelBuffer = readFileSync(modelPath);
93
+ this.session = await ort.InferenceSession.create(modelBuffer.buffer, {
94
+ executionProviders: ['wasm'],
95
+ });
96
+
97
+ // Initialize LSTM state tensors (legacy: h=[2,1,64], c=[2,1,64])
98
+ this.stateH = new ort.Tensor('float32', new Float32Array(2 * 64), [2, 1, 64]);
99
+ this.stateC = new ort.Tensor('float32', new Float32Array(2 * 64), [2, 1, 64]);
100
+ this.sr = new ort.Tensor('int64', [BigInt(this.opts.sampleRate)], [1]);
101
+
102
+ this.initialized = true;
103
+ }
104
+
105
+ /** Start VAD processing */
106
+ start(): void {
107
+ this.active = true;
108
+ }
109
+
110
+ /** Pause VAD processing, ending any active speech segment */
111
+ pause(): void {
112
+ this.active = false;
113
+ this.endSegment();
114
+ }
115
+
116
+ /**
117
+ * Feed raw PCM audio (Float32Array, values -1..1, 16kHz) to the VAD.
118
+ * Audio is buffered and processed in 512-sample frames.
119
+ */
120
+ async processAudio(audioData: Float32Array): Promise<void> {
121
+ if (!this.active || !this.initialized || !this.session) return;
122
+
123
+ // Append to internal buffer
124
+ const tmp = new Float32Array(this.buffer.length + audioData.length);
125
+ tmp.set(this.buffer);
126
+ tmp.set(audioData, this.buffer.length);
127
+ this.buffer = tmp;
128
+
129
+ // Process complete 512-sample frames
130
+ while (this.buffer.length >= this.frameSize) {
131
+ const frame = this.buffer.subarray(0, this.frameSize);
132
+ this.buffer = this.buffer.subarray(this.frameSize);
133
+ await this.processFrame(frame);
134
+ }
135
+ }
136
+
137
+ /** Flush any remaining audio and end active speech segment */
138
+ async flush(): Promise<void> {
139
+ if (this.buffer.length > 0) {
140
+ // Pad last partial frame with zeros
141
+ const padded = new Float32Array(this.frameSize);
142
+ padded.set(this.buffer);
143
+ this.buffer = new Float32Array(0);
144
+ await this.processFrame(padded);
145
+ }
146
+ this.endSegment();
147
+ }
148
+
149
+ /** Reset VAD state without destroying the session */
150
+ reset(): void {
151
+ this.buffer = new Float32Array(0);
152
+ this.frameHistory = [];
153
+ this.speaking = false;
154
+ this.redemptionCounter = 0;
155
+ this.speechFrameCount = 0;
156
+ this.stateH = new ort.Tensor('float32', new Float32Array(2 * 64), [2, 1, 64]);
157
+ this.stateC = new ort.Tensor('float32', new Float32Array(2 * 64), [2, 1, 64]);
158
+ }
159
+
160
+ /** Clean up resources */
161
+ destroy(): void {
162
+ this.active = false;
163
+ this.reset();
164
+ if (this.session) {
165
+ try { (this.session as any).dispose?.(); } catch { /* ignore */ }
166
+ this.session = null;
167
+ }
168
+ this.initialized = false;
169
+ }
170
+
171
+ // ── Private: frame processing ──────────────────────────────────────────
172
+
173
+ private async processFrame(frame: Float32Array): Promise<void> {
174
+ if (!this.session || !this.stateH || !this.stateC || !this.sr) return;
175
+
176
+ try {
177
+ const result = await this.session.run({
178
+ input: new ort.Tensor('float32', frame, [1, this.frameSize]),
179
+ sr: this.sr,
180
+ h: this.stateH,
181
+ c: this.stateC,
182
+ });
183
+
184
+ // Update LSTM state for next frame
185
+ this.stateH = result.hn as ort.Tensor<onnxruntime.TensorType>;
186
+ this.stateC = result.cn as ort.Tensor<onnxruntime.TensorType>;
187
+
188
+ const prob = (result.output as ort.Tensor<onnxruntime.TensorType>).data[0] as number;
189
+ const isSpeech = prob >= this.opts.positiveSpeechThreshold;
190
+ const isSilence = prob < this.opts.negativeSpeechThreshold;
191
+
192
+ this.callbacks.onFrameProcessed(prob, isSpeech);
193
+
194
+ // ── State machine ──────────────────────────────────────────────
195
+ if (this.speaking) {
196
+ // Currently in a speech segment
197
+ if (isSilence) {
198
+ this.redemptionCounter++;
199
+ if (this.redemptionCounter >= this.opts.redemptionFrames) {
200
+ this.endSpeech();
201
+ }
202
+ } else {
203
+ this.redemptionCounter = 0;
204
+ }
205
+ this.speechFrameCount++;
206
+ this.frameHistory.push({ frame: frame.slice(), isSpeech });
207
+ } else if (isSpeech) {
208
+ // Transition: silence β†’ speech
209
+ this.speaking = true;
210
+ this.redemptionCounter = 0;
211
+ this.speechFrameCount = 1;
212
+
213
+ // Include pre-padding frames (audio just before speech start)
214
+ this.frameHistory.push({ frame: frame.slice(), isSpeech: true });
215
+ this.callbacks.onSpeechStart();
216
+ }
217
+ } catch (err) {
218
+ console.error('[SileroVad] frame inference error:', err);
219
+ }
220
+ }
221
+
222
+ private endSpeech(): void {
223
+ if (this.speechFrameCount < this.opts.minSpeechFrames) {
224
+ this.callbacks.onVADMisfire();
225
+ } else {
226
+ // Build audio segment with pre-padding for context
227
+ const total = this.frameHistory.length;
228
+ const prePad = Math.min(this.opts.preSpeechPadFrames, total);
229
+ const segFrames = this.frameHistory.slice(total - prePad - this.speechFrameCount, total);
230
+ let totalSamples = 0;
231
+ for (const f of segFrames) totalSamples += f.frame.length;
232
+ const segment = new Float32Array(totalSamples);
233
+ let offset = 0;
234
+ for (const f of segFrames) {
235
+ segment.set(f.frame, offset);
236
+ offset += f.frame.length;
237
+ }
238
+ this.callbacks.onSpeechEnd(segment);
239
+ }
240
+
241
+ // Reset speech state
242
+ this.speaking = false;
243
+ this.redemptionCounter = 0;
244
+ this.speechFrameCount = 0;
245
+ this.frameHistory = [];
246
+ }
247
+
248
+ private endSegment(): void {
249
+ if (this.speaking) {
250
+ this.endSpeech();
251
+ }
252
+ }
253
+ }
254
+
255
+ // ── Module-level helpers ─────────────────────────────────────────────────
256
+
257
+ let cachedWasmDir: string | null = null;
258
+ let cachedModelPath: string | null = null;
259
+
260
+ function resolveWasmDir(): string {
261
+ if (cachedWasmDir) return cachedWasmDir;
262
+
263
+ // Resolve onnxruntime-web's dist directory where WASM files live
264
+ const pkgPath = require.resolve('onnxruntime-web/package.json');
265
+ cachedWasmDir = resolve(dirname(pkgPath), 'dist');
266
+
267
+ if (!existsSync(cachedWasmDir)) {
268
+ throw new Error(
269
+ `onnxruntime-web WASM directory not found at ${cachedWasmDir}. ` +
270
+ 'Ensure onnxruntime-web is installed (it is a peer dependency).',
271
+ );
272
+ }
273
+ return cachedWasmDir;
274
+ }
275
+
276
+ function resolveModelPath(): string {
277
+ if (cachedModelPath) return cachedModelPath;
278
+
279
+ // The Silero ONNX model is bundled with @ericedouard/vad-node-realtime
280
+ const vadPkgPath = require.resolve('@ericedouard/vad-node-realtime/package.json');
281
+ cachedModelPath = resolve(dirname(vadPkgPath), 'silero_vad_legacy.onnx');
282
+
283
+ if (!existsSync(cachedModelPath)) {
284
+ throw new Error(
285
+ `Silero VAD model not found at ${cachedModelPath}. ` +
286
+ 'Ensure @ericedouard/vad-node-realtime is installed.',
287
+ );
288
+ }
289
+ return cachedModelPath;
290
+ }