feat: VAD
Browse files- bun.lock +38 -0
- package.json +2 -0
- src/friend/FriendService.ts +66 -42
- src/friend/voice/vad-service.ts +290 -0
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
|
| 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
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
}
|
| 235 |
|
| 236 |
/**
|
| 237 |
-
*
|
| 238 |
-
*
|
| 239 |
*
|
| 240 |
-
*
|
| 241 |
-
*
|
|
|
|
| 242 |
*/
|
| 243 |
-
private async
|
| 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
|
| 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
|
| 258 |
await oldConn.finalize().catch(() => {});
|
| 259 |
oldConn.close();
|
| 260 |
|
| 261 |
-
// 3. Send
|
| 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]
|
| 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 |
-
|
| 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
|
| 622 |
onData(chunk);
|
|
|
|
|
|
|
| 623 |
proc.stdout?.removeListener('data', dataHandler);
|
| 624 |
-
proc.stdout?.on('data',
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|