diff --git a/package.json b/package.json index cc8a36eb..d8caa5f6 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "vite-plugin-generate-file": "^0.3.0", "vite-plugin-html": "^3.2.2", "vite-plugin-node-stdlib-browser": "^0.2.1", + "vite-plugin-static-copy": "^4.0.0", "vite-plugin-svgr": "^4.0.0", "vitest": "^4.0.18", "vitest-axe": "^1.0.0-pre.3" @@ -145,5 +146,8 @@ "qs": "^6.14.1", "js-yaml": "^4.1.1" }, - "packageManager": "yarn@4.7.0" + "packageManager": "yarn@4.7.0", + "dependencies": { + "@ricky0123/vad-web": "^0.0.30" + } } diff --git a/src/livekit/NoiseGateProcessor.worklet.ts b/src/livekit/NoiseGateProcessor.worklet.ts index fa620dcf..ae624fb4 100644 --- a/src/livekit/NoiseGateProcessor.worklet.ts +++ b/src/livekit/NoiseGateProcessor.worklet.ts @@ -30,6 +30,11 @@ interface NoiseGateParams { transientReleaseMs: number; // how quickly suppression fades after transient ends } +interface VADGateMessage { + type: "vad-gate"; + open: boolean; +} + function dbToLinear(db: number): number { return Math.pow(10, db / 20); } @@ -65,12 +70,19 @@ class NoiseGateProcessor extends AudioWorkletProcessor { // Exponential smoothing coefficient for background RMS (~200ms time constant) private rmsCoeff = Math.exp(-1.0 / (0.2 * sampleRate)); + // VAD gate state (controlled externally via port message) + private vadGateOpen = true; // starts open until VAD sends its first decision + private logCounter = 0; public constructor() { super(); - this.port.onmessage = (e: MessageEvent): void => { - this.updateParams(e.data); + this.port.onmessage = (e: MessageEvent): void => { + if ((e.data as VADGateMessage).type === "vad-gate") { + this.vadGateOpen = (e.data as VADGateMessage).open; + } else { + this.updateParams(e.data as NoiseGateParams); + } }; this.updateParams({ threshold: -60, attackMs: 25, holdMs: 200, releaseMs: 150, @@ -148,7 +160,7 @@ class NoiseGateProcessor extends AudioWorkletProcessor { } } - const gain = this.gateAttenuation * transientGain; + const gain = this.gateAttenuation * transientGain * (this.vadGateOpen ? 1.0 : 0.0); for (let c = 0; c < output.length; c++) { const inCh = input[c] ?? input[0]; diff --git a/src/livekit/NoiseGateTransformer.ts b/src/livekit/NoiseGateTransformer.ts index 02d52a3f..33269e3b 100644 --- a/src/livekit/NoiseGateTransformer.ts +++ b/src/livekit/NoiseGateTransformer.ts @@ -119,6 +119,11 @@ export class NoiseGateTransformer implements AudioTrackProcessor { this.sendParams(); } + /** Tell the worklet to open or close the VAD-controlled gate. */ + public setVADOpen(open: boolean): void { + this.workletNode?.port.postMessage({ type: "vad-gate", open }); + } + private sendParams(): void { if (!this.workletNode) return; log.debug("sendParams:", this.params); diff --git a/src/livekit/SileroVADGate.ts b/src/livekit/SileroVADGate.ts new file mode 100644 index 00000000..357c9c62 --- /dev/null +++ b/src/livekit/SileroVADGate.ts @@ -0,0 +1,85 @@ +/* +Copyright 2026 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { MicVAD, getDefaultRealTimeVADOptions } from "@ricky0123/vad-web"; +import { logger } from "matrix-js-sdk/lib/logger"; + +const log = logger.getChild("[SileroVADGate]"); + +const VAD_BASE_PATH = "/vad/"; + +/** + * Wraps @ricky0123/vad-web's MicVAD to feed speech/silence decisions into the + * NoiseGateTransformer's VAD gate. Instead of creating its own microphone + * stream, it receives the existing LiveKit MediaStream so the VAD sees exactly + * the same audio the worklet processes. + * + * Usage: + * const gate = new SileroVADGate(stream, audioContext); + * gate.onSpeechStart = () => transformer.setVADOpen(true); + * gate.onSpeechEnd = () => transformer.setVADOpen(false); + * await gate.start(); + * // later: + * await gate.destroy(); + */ +export class SileroVADGate { + public onSpeechStart: () => void = () => {}; + public onSpeechEnd: () => void = () => {}; + + private vad: MicVAD | null = null; + private readonly stream: MediaStream; + private readonly audioContext: AudioContext; + + public constructor(stream: MediaStream, audioContext: AudioContext) { + this.stream = stream; + this.audioContext = audioContext; + } + + public async start(): Promise { + const stream = this.stream; + const audioContext = this.audioContext; + + log.info("initialising MicVAD, baseAssetPath:", VAD_BASE_PATH); + + this.vad = await MicVAD.new({ + ...getDefaultRealTimeVADOptions("legacy"), + audioContext, + baseAssetPath: VAD_BASE_PATH, + onnxWASMBasePath: VAD_BASE_PATH, + startOnLoad: false, + // Provide the existing stream instead of calling getUserMedia + // eslint-disable-next-line @typescript-eslint/require-await + getStream: async (): Promise => stream, + pauseStream: async (): Promise => {}, + // eslint-disable-next-line @typescript-eslint/require-await + resumeStream: async (): Promise => stream, + onSpeechStart: (): void => { + log.debug("speech start"); + this.onSpeechStart(); + }, + onSpeechEnd: (): void => { + log.debug("speech end"); + this.onSpeechEnd(); + }, + onVADMisfire: (): void => { + log.debug("VAD misfire"); + }, + onFrameProcessed: (): void => {}, + onSpeechRealStart: (): void => {}, + }); + + await this.vad.start(); + log.info("MicVAD started"); + } + + public async destroy(): Promise { + if (this.vad) { + await this.vad.destroy(); + this.vad = null; + } + } +} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index e296702a..b01ee45e 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -32,6 +32,7 @@ import { transientSuppressorEnabled as transientSuppressorEnabledSetting, transientThreshold as transientThresholdSetting, transientRelease as transientReleaseSetting, + vadEnabled as vadEnabledSetting, } from "./settings"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { Slider } from "../Slider"; @@ -129,6 +130,9 @@ export const SettingsModal: FC = ({ const [showAdvancedGate, setShowAdvancedGate] = useState(false); + // Voice activity detection + const [vadActive, setVadActive] = useSetting(vadEnabledSetting); + // Transient suppressor settings const [transientEnabled, setTransientEnabled] = useSetting(transientSuppressorEnabledSetting); const [transientThreshold, setTransientThreshold] = useSetting(transientThresholdSetting); @@ -310,6 +314,31 @@ export const SettingsModal: FC = ({ )} +
+ + Voice Activity Detection + + + + ): void => + setVadActive(e.target.checked) + } + disabled={!noiseGateEnabled} + /> + +
("noise-gate-hold", 200); // Time in ms for the gate to fully close after hold expires export const noiseGateRelease = new Setting("noise-gate-release", 150); +export const vadEnabled = new Setting("vad-enabled", false); + export const transientSuppressorEnabled = new Setting( "transient-suppressor-enabled", false, diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 733594d0..521788bb 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -41,11 +41,13 @@ import { transientSuppressorEnabled, transientThreshold, transientRelease, + vadEnabled, } from "../../../settings/settings.ts"; import { type NoiseGateParams, NoiseGateTransformer, } from "../../../livekit/NoiseGateTransformer.ts"; +import { SileroVADGate } from "../../../livekit/SileroVADGate.ts"; import { observeTrackReference$ } from "../../observeTrackReference"; import { type Connection } from "../remoteMembers/Connection.ts"; import { ObservableScope } from "../../ObservableScope.ts"; @@ -435,6 +437,7 @@ export class Publisher { let transformer: NoiseGateTransformer | null = null; let audioCtx: AudioContext | null = null; + let vadGate: SileroVADGate | null = null; const currentParams = (): NoiseGateParams => ({ threshold: noiseGateThreshold.getValue(), @@ -446,6 +449,32 @@ export class Publisher { transientReleaseMs: transientRelease.getValue(), }); + const stopVAD = (): void => { + if (vadGate) { + void vadGate.destroy(); + vadGate = null; + } + // Reset gate to open so audio flows if VAD is toggled off mid-call + transformer?.setVADOpen(true); + }; + + const startVAD = (track: LocalAudioTrack, ctx: AudioContext): void => { + stopVAD(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawTrack: MediaStreamTrack | undefined = (track as any).mediaStreamTrack; + if (!rawTrack) { + this.logger.warn("[VAD] no underlying MediaStreamTrack — skipping VAD"); + return; + } + const stream = new MediaStream([rawTrack]); + vadGate = new SileroVADGate(stream, ctx); + vadGate.onSpeechStart = (): void => transformer?.setVADOpen(true); + vadGate.onSpeechEnd = (): void => transformer?.setVADOpen(false); + vadGate.start().catch((e: unknown) => { + this.logger.error("[VAD] failed to start", e); + }); + }; + // Attach / detach processor when enabled state or the track changes. combineLatest([audioTrack$, noiseGateEnabled.value$]) .pipe(scope.bind()) @@ -459,18 +488,20 @@ export class Publisher { this.logger.info("[NoiseGate] AudioContext state before resume:", audioCtx.state); // eslint-disable-next-line @typescript-eslint/no-explicit-any (audioTrack as any).setAudioContext(audioCtx); - audioCtx.resume().then(() => { + audioCtx.resume().then(async () => { this.logger.info("[NoiseGate] AudioContext state after resume:", audioCtx?.state); return audioTrack // eslint-disable-next-line @typescript-eslint/no-explicit-any .setProcessor(transformer as any); }).then(() => { this.logger.info("[NoiseGate] setProcessor resolved"); + if (vadEnabled.getValue() && audioCtx) startVAD(audioTrack, audioCtx); }).catch((e: unknown) => { this.logger.error("[NoiseGate] setProcessor failed", e); }); } else if (!enabled && audioTrack.getProcessor()) { this.logger.info("[NoiseGate] removing processor"); + stopVAD(); void audioTrack.stopProcessor(); void audioCtx?.close(); audioCtx = null; @@ -482,6 +513,18 @@ export class Publisher { } }); + // Start/stop VAD when its toggle changes. + combineLatest([audioTrack$, vadEnabled.value$]) + .pipe(scope.bind()) + .subscribe(([audioTrack, enabled]) => { + if (!audioTrack || !audioCtx) return; + if (enabled) { + startVAD(audioTrack, audioCtx); + } else { + stopVAD(); + } + }); + // Push param changes to the live worklet without recreating the processor. combineLatest([ noiseGateThreshold.value$, diff --git a/vite.config.ts b/vite.config.ts index 97d643ec..792f5327 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,7 @@ import { } from "vite"; import svgrPlugin from "vite-plugin-svgr"; import { createHtmlPlugin } from "vite-plugin-html"; +import { viteStaticCopy } from "vite-plugin-static-copy"; import { codecovVitePlugin } from "@codecov/vite-plugin"; import { sentryVitePlugin } from "@sentry/vite-plugin"; @@ -35,6 +36,22 @@ export default ({ // build time. process.env.VITE_PACKAGE = packageType ?? "full"; const plugins: PluginOption[] = [ + viteStaticCopy({ + targets: [ + { + src: "node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js", + dest: "vad", + }, + { + src: "node_modules/@ricky0123/vad-web/dist/silero_vad_legacy.onnx", + dest: "vad", + }, + { + src: "node_modules/onnxruntime-web/dist/*.wasm", + dest: "vad", + }, + ], + }), react(), svgrPlugin({ svgrOptions: { diff --git a/yarn.lock b/yarn.lock index 12e1b857..e92d3172 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3841,6 +3841,79 @@ __metadata: languageName: node linkType: hard +"@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/aspromise@npm:1.1.2" + checksum: 10c0/a83343a468ff5b5ec6bff36fd788a64c839e48a07ff9f4f813564f58caf44d011cd6504ed2147bf34835bd7a7dd2107052af755961c6b098fd8902b4f6500d0f + languageName: node + linkType: hard + +"@protobufjs/base64@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/base64@npm:1.1.2" + checksum: 10c0/eec925e681081af190b8ee231f9bad3101e189abbc182ff279da6b531e7dbd2a56f1f306f37a80b1be9e00aa2d271690d08dcc5f326f71c9eed8546675c8caf6 + languageName: node + linkType: hard + +"@protobufjs/codegen@npm:^2.0.4": + version: 2.0.4 + resolution: "@protobufjs/codegen@npm:2.0.4" + checksum: 10c0/26ae337c5659e41f091606d16465bbcc1df1f37cc1ed462438b1f67be0c1e28dfb2ca9f294f39100c52161aef82edf758c95d6d75650a1ddf31f7ddee1440b43 + languageName: node + linkType: hard + +"@protobufjs/eventemitter@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/eventemitter@npm:1.1.0" + checksum: 10c0/1eb0a75180e5206d1033e4138212a8c7089a3d418c6dfa5a6ce42e593a4ae2e5892c4ef7421f38092badba4040ea6a45f0928869989411001d8c1018ea9a6e70 + languageName: node + linkType: hard + +"@protobufjs/fetch@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/fetch@npm:1.1.0" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.1" + "@protobufjs/inquire": "npm:^1.1.0" + checksum: 10c0/cda6a3dc2d50a182c5865b160f72077aac197046600091dbb005dd0a66db9cce3c5eaed6d470ac8ed49d7bcbeef6ee5f0bc288db5ff9a70cbd003e5909065233 + languageName: node + linkType: hard + +"@protobufjs/float@npm:^1.0.2": + version: 1.0.2 + resolution: "@protobufjs/float@npm:1.0.2" + checksum: 10c0/18f2bdede76ffcf0170708af15c9c9db6259b771e6b84c51b06df34a9c339dbbeec267d14ce0bddd20acc142b1d980d983d31434398df7f98eb0c94a0eb79069 + languageName: node + linkType: hard + +"@protobufjs/inquire@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/inquire@npm:1.1.0" + checksum: 10c0/64372482efcba1fb4d166a2664a6395fa978b557803857c9c03500e0ac1013eb4b1aacc9ed851dd5fc22f81583670b4f4431bae186f3373fedcfde863ef5921a + languageName: node + linkType: hard + +"@protobufjs/path@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/path@npm:1.1.2" + checksum: 10c0/cece0a938e7f5dfd2fa03f8c14f2f1cf8b0d6e13ac7326ff4c96ea311effd5fb7ae0bba754fbf505312af2e38500250c90e68506b97c02360a43793d88a0d8b4 + languageName: node + linkType: hard + +"@protobufjs/pool@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/pool@npm:1.1.0" + checksum: 10c0/eda2718b7f222ac6e6ad36f758a92ef90d26526026a19f4f17f668f45e0306a5bd734def3f48f51f8134ae0978b6262a5c517c08b115a551756d1a3aadfcf038 + languageName: node + linkType: hard + +"@protobufjs/utf8@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/utf8@npm:1.1.0" + checksum: 10c0/a3fe31fe3fa29aa3349e2e04ee13dc170cc6af7c23d92ad49e3eeaf79b9766264544d3da824dba93b7855bd6a2982fb40032ef40693da98a136d835752beb487 + languageName: node + linkType: hard + "@radix-ui/number@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/number@npm:1.1.1" @@ -5022,6 +5095,15 @@ __metadata: languageName: node linkType: hard +"@ricky0123/vad-web@npm:^0.0.30": + version: 0.0.30 + resolution: "@ricky0123/vad-web@npm:0.0.30" + dependencies: + onnxruntime-web: "npm:^1.17.0" + checksum: 10c0/c91e69fb65879d54eb3eeab3cdadca95b9d1fffe3fbecf8c7ab1d59f418b79ad6e1cd0e67df25db26cf286e8fa86e030ce95ee51a749727e344f7c588d689770 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.27": version: 1.0.0-beta.27 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" @@ -5840,6 +5922,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=13.7.0": + version: 25.5.0 + resolution: "@types/node@npm:25.5.0" + dependencies: + undici-types: "npm:~7.18.0" + checksum: 10c0/70c508165b6758c4f88d4f91abca526c3985eee1985503d4c2bd994dbaf588e52ac57e571160f18f117d76e963570ac82bd20e743c18987e82564312b3b62119 + languageName: node + linkType: hard + "@types/node@npm:^24.0.0": version: 24.10.13 resolution: "@types/node@npm:24.10.13" @@ -7387,7 +7478,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.5.3": +"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -8303,6 +8394,7 @@ __metadata: "@radix-ui/react-slider": "npm:^1.1.2" "@radix-ui/react-visually-hidden": "npm:^1.0.3" "@react-spring/web": "npm:^10.0.0" + "@ricky0123/vad-web": "npm:^0.0.30" "@sentry/react": "npm:^8.0.0" "@sentry/vite-plugin": "npm:^3.0.0" "@stylistic/eslint-plugin": "npm:^3.0.0" @@ -8380,6 +8472,7 @@ __metadata: vite-plugin-generate-file: "npm:^0.3.0" vite-plugin-html: "npm:^3.2.2" vite-plugin-node-stdlib-browser: "npm:^0.2.1" + vite-plugin-static-copy: "npm:^4.0.0" vite-plugin-svgr: "npm:^4.0.0" vitest: "npm:^4.0.18" vitest-axe: "npm:^1.0.0-pre.3" @@ -9542,6 +9635,13 @@ __metadata: languageName: node linkType: hard +"flatbuffers@npm:^25.1.24": + version: 25.9.23 + resolution: "flatbuffers@npm:25.9.23" + checksum: 10c0/957c4ae2a02be1703c98b36b4dc8ceb81613cf8e2333026afc95a7b68b088bed5458056dc29d0ab7ce8bc403b8c003732b0968d24aba46f5e7c8f71789a6bd9e + languageName: node + linkType: hard + "flatted@npm:^3.2.9": version: 3.3.1 resolution: "flatted@npm:3.3.1" @@ -9958,6 +10058,13 @@ __metadata: languageName: node linkType: hard +"guid-typescript@npm:^1.0.9": + version: 1.0.9 + resolution: "guid-typescript@npm:1.0.9" + checksum: 10c0/fa0a2b2b4e06e0976a81c947b74e114b92f6647e84b52e24ab0981d2fdbaa1a640641d8fd269004dd7c581baebeb4f9d9782b74391e717e47c9b822bea4b3be6 + languageName: node + linkType: hard + "gulp-sort@npm:^2.0.0": version: 2.0.0 resolution: "gulp-sort@npm:2.0.0" @@ -11228,6 +11335,13 @@ __metadata: languageName: node linkType: hard +"long@npm:^5.0.0, long@npm:^5.2.3": + version: 5.3.2 + resolution: "long@npm:5.3.2" + checksum: 10c0/7130fe1cbce2dca06734b35b70d380ca3f70271c7f8852c922a7c62c86c4e35f0c39290565eca7133c625908d40e126ac57c02b1b1a4636b9457d77e1e60b981 + languageName: node + linkType: hard + "loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -11932,6 +12046,27 @@ __metadata: languageName: node linkType: hard +"onnxruntime-common@npm:1.24.3": + version: 1.24.3 + resolution: "onnxruntime-common@npm:1.24.3" + checksum: 10c0/3160a08a8addf7b1c8f2ff0c12c126c84847d5b36f8c047cf05560d05faf7605fe4c3366c725cfbd305d4399d0930c632a22bd6a600488ac2c6f9ee3e2aebe25 + languageName: node + linkType: hard + +"onnxruntime-web@npm:^1.17.0": + version: 1.24.3 + resolution: "onnxruntime-web@npm:1.24.3" + dependencies: + flatbuffers: "npm:^25.1.24" + guid-typescript: "npm:^1.0.9" + long: "npm:^5.2.3" + onnxruntime-common: "npm:1.24.3" + platform: "npm:^1.3.6" + protobufjs: "npm:^7.2.4" + checksum: 10c0/7975ad03fed7017a84b64e8038187dc52ec383628fd4ac380a9dee628c1aee7959f8398c0ffe3e503b4565d381d9f8ef8863f942386f56ff5ee0915e38f828c3 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -12076,6 +12211,13 @@ __metadata: languageName: node linkType: hard +"p-map@npm:^7.0.4": + version: 7.0.4 + resolution: "p-map@npm:7.0.4" + checksum: 10c0/a5030935d3cb2919d7e89454d1ce82141e6f9955413658b8c9403cfe379283770ed3048146b44cde168aa9e8c716505f196d5689db0ae3ce9a71521a2fef3abd + languageName: node + linkType: hard + "p-retry@npm:7": version: 7.0.0 resolution: "p-retry@npm:7.0.0" @@ -12328,6 +12470,13 @@ __metadata: languageName: node linkType: hard +"platform@npm:^1.3.6": + version: 1.3.6 + resolution: "platform@npm:1.3.6" + checksum: 10c0/69f2eb692e15f1a343dd0d9347babd9ca933824c8673096be746ff66f99f2bdc909fadd8609076132e6ec768349080babb7362299f2a7f885b98f1254ae6224b + languageName: node + linkType: hard + "playwright-core@npm:1.58.2": version: 1.58.2 resolution: "playwright-core@npm:1.58.2" @@ -12883,6 +13032,26 @@ __metadata: languageName: node linkType: hard +"protobufjs@npm:^7.2.4": + version: 7.5.4 + resolution: "protobufjs@npm:7.5.4" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.2" + "@protobufjs/base64": "npm:^1.1.2" + "@protobufjs/codegen": "npm:^2.0.4" + "@protobufjs/eventemitter": "npm:^1.1.0" + "@protobufjs/fetch": "npm:^1.1.0" + "@protobufjs/float": "npm:^1.0.2" + "@protobufjs/inquire": "npm:^1.1.0" + "@protobufjs/path": "npm:^1.1.2" + "@protobufjs/pool": "npm:^1.1.0" + "@protobufjs/utf8": "npm:^1.1.0" + "@types/node": "npm:>=13.7.0" + long: "npm:^5.0.0" + checksum: 10c0/913b676109ffb3c05d3d31e03a684e569be91f3bba8613da4a683d69d9dba948daa2afd7d2e7944d1aa6c417890c35d9d9a8883c1160affafb0f9670d59ef722 + languageName: node + linkType: hard + "proxy-from-env@npm:^1.1.0": version: 1.1.0 resolution: "proxy-from-env@npm:1.1.0" @@ -15337,6 +15506,20 @@ __metadata: languageName: node linkType: hard +"vite-plugin-static-copy@npm:^4.0.0": + version: 4.0.0 + resolution: "vite-plugin-static-copy@npm:4.0.0" + dependencies: + chokidar: "npm:^3.6.0" + p-map: "npm:^7.0.4" + picocolors: "npm:^1.1.1" + tinyglobby: "npm:^0.2.15" + peerDependencies: + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 10c0/05b42b6f942e81b838f828c596fc6d00f72c0b5e1c4d68df13aaa4405c96d71333a082cc1bba96d0153134191addff8b2fe9134ed2e725b3b808e3b8470497c8 + languageName: node + linkType: hard + "vite-plugin-svgr@npm:^4.0.0": version: 4.5.0 resolution: "vite-plugin-svgr@npm:4.5.0"