mirror of https://github.com/jetkvm/kvm.git
Compare commits
21 Commits
88679cda2f
...
44a35aa5c2
Author | SHA1 | Date |
---|---|---|
|
44a35aa5c2 | |
|
3a28105f56 | |
|
a9a1082bcc | |
|
e0f7b1d930 | |
|
5188717bb9 | |
|
70e49a1cac | |
|
9d40263eed | |
|
0651faeceb | |
|
199cca83ed | |
|
a3b2b46f49 | |
|
f729675a3f | |
|
785a68d923 | |
|
57b7bafcc1 | |
|
d952480c2a | |
|
8e27cd6b60 | |
|
bb87fb5a1a | |
|
8527b1eff1 | |
|
9f573200b1 | |
|
608f69db13 | |
|
f7b8efde7c | |
|
33ac9fe0b6 |
|
@ -26,7 +26,7 @@ Welcome to JetKVM development! This guide will help you get started quickly, whe
|
||||||
- **[Git](https://git-scm.com/downloads)** for version control
|
- **[Git](https://git-scm.com/downloads)** for version control
|
||||||
- **[SSH access](https://jetkvm.com/docs/advanced-usage/developing#developer-mode)** to your JetKVM device
|
- **[SSH access](https://jetkvm.com/docs/advanced-usage/developing#developer-mode)** to your JetKVM device
|
||||||
- **Audio build dependencies:**
|
- **Audio build dependencies:**
|
||||||
- **New in this release:** The audio pipeline is now fully in-process using CGO, ALSA, and Opus. You must run the provided scripts in `tools/` to set up the cross-compiler and build static ALSA/Opus libraries for ARM. See below.
|
- **New:** The audio system uses a dual-subprocess architecture with CGO, ALSA, and Opus integration. You must run the provided scripts in `tools/` to set up the cross-compiler and build static ALSA/Opus libraries for ARM. See below.
|
||||||
|
|
||||||
|
|
||||||
### Development Environment
|
### Development Environment
|
||||||
|
@ -71,7 +71,7 @@ This ensures compatibility with shell scripts and build tools used in the projec
|
||||||
# This will run tools/setup_rv1106_toolchain.sh and tools/build_audio_deps.sh
|
# This will run tools/setup_rv1106_toolchain.sh and tools/build_audio_deps.sh
|
||||||
# It will clone the cross-compiler and build ALSA/Opus static libs in $HOME/.jetkvm
|
# It will clone the cross-compiler and build ALSA/Opus static libs in $HOME/.jetkvm
|
||||||
#
|
#
|
||||||
# **Note:** This is required for the new in-process audio pipeline. If you skip this step, audio will not work.
|
# **Note:** This is required for the audio subprocess architecture. If you skip this step, builds will not succeed.
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Find your JetKVM IP address** (check your router or device screen)
|
4. **Find your JetKVM IP address** (check your router or device screen)
|
||||||
|
@ -83,7 +83,7 @@ This ensures compatibility with shell scripts and build tools used in the projec
|
||||||
|
|
||||||
6. **Open in browser:** `http://192.168.1.100`
|
6. **Open in browser:** `http://192.168.1.100`
|
||||||
|
|
||||||
That's it! You're now running your own development version of JetKVM, **with in-process audio streaming for the first time.**
|
That's it! You're now running your own development version of JetKVM, **with bidirectional audio streaming using the dual-subprocess architecture.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -135,14 +135,14 @@ tail -f /var/log/jetkvm.log
|
||||||
│ ├── src/routes/ # Pages (login, settings, etc.)
|
│ ├── src/routes/ # Pages (login, settings, etc.)
|
||||||
│ └── src/components/ # UI components
|
│ └── src/components/ # UI components
|
||||||
├── internal/ # Internal Go packages
|
├── internal/ # Internal Go packages
|
||||||
│ └── audio/ # In-process audio pipeline (CGO, ALSA, Opus) [NEW]
|
│ └── audio/ # Dual-subprocess audio architecture (CGO, ALSA, Opus) [NEW]
|
||||||
├── tools/ # Toolchain and audio dependency setup scripts
|
├── tools/ # Toolchain and audio dependency setup scripts
|
||||||
└── Makefile # Build and dev automation (see audio targets)
|
└── Makefile # Build and dev automation (see audio targets)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key files for beginners:**
|
**Key files for beginners:**
|
||||||
|
|
||||||
- `internal/audio/` - [NEW] In-process audio pipeline (CGO, ALSA, Opus)
|
- `internal/audio/` - [NEW] Dual-subprocess audio architecture (CGO, ALSA, Opus)
|
||||||
- `web.go` - Add new API endpoints here
|
- `web.go` - Add new API endpoints here
|
||||||
- `config.go` - Add new settings here
|
- `config.go` - Add new settings here
|
||||||
- `ui/src/routes/` - Add new pages here
|
- `ui/src/routes/` - Add new pages here
|
||||||
|
@ -174,7 +174,7 @@ npm install
|
||||||
|
|
||||||
### Quick Backend Changes
|
### Quick Backend Changes
|
||||||
|
|
||||||
*Best for: API, backend, or audio logic changes (including audio pipeline)*
|
*Best for: API, backend, or audio logic changes (including audio subprocess architecture)*
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Skip frontend build for faster deployment
|
# Skip frontend build for faster deployment
|
||||||
|
@ -353,7 +353,7 @@ go clean -modcache
|
||||||
go mod tidy
|
go mod tidy
|
||||||
make build_dev
|
make build_dev
|
||||||
# If you see errors about missing ALSA/Opus or toolchain, run:
|
# If you see errors about missing ALSA/Opus or toolchain, run:
|
||||||
make dev_env # Required for new audio support
|
make dev_env # Required for audio subprocess architecture
|
||||||
```
|
```
|
||||||
|
|
||||||
### "Can't connect to device"
|
### "Can't connect to device"
|
||||||
|
|
2
Makefile
2
Makefile
|
@ -34,7 +34,7 @@ OPTIM_CFLAGS := -O3 -mcpu=cortex-a7 -mfpu=neon -mfloat-abi=hard -ftree-vectorize
|
||||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
KVM_PKG_NAME := github.com/jetkvm/kvm
|
KVM_PKG_NAME := github.com/jetkvm/kvm
|
||||||
|
|
||||||
GO_BUILD_ARGS := -tags netgo
|
GO_BUILD_ARGS := -tags netgo -tags timetzdata
|
||||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||||
GO_LDFLAGS := \
|
GO_LDFLAGS := \
|
||||||
-s -w \
|
-s -w \
|
||||||
|
|
|
@ -22,7 +22,7 @@ JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse, *
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse, keyboard, and audio for responsive remote control.
|
- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse, keyboard, and audio for responsive remote control.
|
||||||
- **First-Class Audio Support** - JetKVM now supports in-process, low-latency audio streaming using ALSA and Opus, fully integrated via CGO. No external audio binaries or IPC required—audio is delivered directly from the device to your browser.
|
- **First-Class Audio Support** - JetKVM now supports bidirectional, low-latency audio streaming using a dual-subprocess architecture with ALSA and Opus integration via CGO. Features both audio output (PC→Browser) and audio input (Browser→PC) with dedicated subprocesses for optimal performance and isolation.
|
||||||
- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
|
- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
|
||||||
- **Open-source software** - Written in Golang (with CGO for audio) on Linux. Easily customizable through SSH access to the JetKVM device.
|
- **Open-source software** - Written in Golang (with CGO for audio) on Linux. Easily customizable through SSH access to the JetKVM device.
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ If you've found an issue and want to report it, please check our [Issues](https:
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
JetKVM is written in Go & TypeScript, with some C for low-level integration. **Audio support is now fully in-process using CGO, ALSA, and Opus—no external audio binaries required.**
|
JetKVM is written in Go & TypeScript, with some C for low-level integration. **Audio support uses a sophisticated dual-subprocess architecture with CGO, ALSA, and Opus integration for bidirectional streaming with complete process isolation.**
|
||||||
|
|
||||||
The project contains two main parts: the backend software (Go, CGO) that runs on the KVM device, and the frontend software (React/TypeScript) that is served by the KVM device and the cloud.
|
The project contains two main parts: the backend software (Go, CGO) that runs on the KVM device, and the frontend software (React/TypeScript) that is served by the KVM device and the cloud.
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ For quick device development, use the `./dev_deploy.sh` script. It will build th
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
The backend is written in Go and is responsible for KVM device management, audio/video streaming, the cloud API, and the cloud web. **Audio is now captured and encoded in-process using ALSA and Opus via CGO, with no external processes or IPC.**
|
The backend is written in Go and is responsible for KVM device management, audio/video streaming, the cloud API, and the cloud web. **Audio uses dedicated subprocesses for both output and input streams, with CGO-based ALSA and Opus processing, IPC communication via Unix sockets, and comprehensive process supervision for reliability.**
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
|
|
10
config.go
10
config.go
|
@ -82,6 +82,7 @@ type Config struct {
|
||||||
CloudToken string `json:"cloud_token"`
|
CloudToken string `json:"cloud_token"`
|
||||||
GoogleIdentity string `json:"google_identity"`
|
GoogleIdentity string `json:"google_identity"`
|
||||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||||
|
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
IncludePreRelease bool `json:"include_pre_release"`
|
IncludePreRelease bool `json:"include_pre_release"`
|
||||||
HashedPassword string `json:"hashed_password"`
|
HashedPassword string `json:"hashed_password"`
|
||||||
|
@ -117,7 +118,14 @@ var defaultConfig = &Config{
|
||||||
DisplayMaxBrightness: 64,
|
DisplayMaxBrightness: 64,
|
||||||
DisplayDimAfterSec: 120, // 2 minutes
|
DisplayDimAfterSec: 120, // 2 minutes
|
||||||
DisplayOffAfterSec: 1800, // 30 minutes
|
DisplayOffAfterSec: 1800, // 30 minutes
|
||||||
TLSMode: "",
|
// This is the "Standard" jiggler option in the UI
|
||||||
|
JigglerConfig: &JigglerConfig{
|
||||||
|
InactivityLimitSeconds: 60,
|
||||||
|
JitterPercentage: 25,
|
||||||
|
ScheduleCronTab: "0 * * * * *",
|
||||||
|
Timezone: "UTC",
|
||||||
|
},
|
||||||
|
TLSMode: "",
|
||||||
UsbConfig: &usbgadget.Config{
|
UsbConfig: &usbgadget.Config{
|
||||||
VendorId: "0x1d6b", //The Linux Foundation
|
VendorId: "0x1d6b", //The Linux Foundation
|
||||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||||
|
|
9
go.mod
9
go.mod
|
@ -11,6 +11,7 @@ require (
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/gin-contrib/logger v1.2.6
|
github.com/gin-contrib/logger v1.2.6
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
|
github.com/go-co-op/gocron/v2 v2.16.3
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/guregu/null/v6 v6.0.0
|
github.com/guregu/null/v6 v6.0.0
|
||||||
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341
|
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341
|
||||||
|
@ -28,9 +29,9 @@ require (
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/vishvananda/netlink v1.3.1
|
github.com/vishvananda/netlink v1.3.1
|
||||||
go.bug.st/serial v1.6.4
|
go.bug.st/serial v1.6.4
|
||||||
golang.org/x/crypto v0.39.0
|
golang.org/x/crypto v0.40.0
|
||||||
golang.org/x/net v0.41.0
|
golang.org/x/net v0.41.0
|
||||||
golang.org/x/sys v0.33.0
|
golang.org/x/sys v0.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
||||||
|
@ -50,6 +51,7 @@ require (
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
@ -75,6 +77,7 @@ require (
|
||||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
@ -82,7 +85,7 @@ require (
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
golang.org/x/arch v0.18.0 // indirect
|
golang.org/x/arch v0.18.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/text v0.26.0 // indirect
|
golang.org/x/text v0.27.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
20
go.sum
20
go.sum
|
@ -38,6 +38,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
|
||||||
|
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
@ -62,6 +64,8 @@ github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoN
|
||||||
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
|
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
||||||
|
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||||
|
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
@ -146,6 +150,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||||
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
|
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
|
||||||
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
|
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
@ -175,10 +181,12 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||||
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
@ -188,10 +196,10 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|
|
@ -0,0 +1,338 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdaptiveBufferConfig holds configuration for adaptive buffer sizing
|
||||||
|
type AdaptiveBufferConfig struct {
|
||||||
|
// Buffer size limits (in frames)
|
||||||
|
MinBufferSize int
|
||||||
|
MaxBufferSize int
|
||||||
|
DefaultBufferSize int
|
||||||
|
|
||||||
|
// System load thresholds
|
||||||
|
LowCPUThreshold float64 // Below this, increase buffer size
|
||||||
|
HighCPUThreshold float64 // Above this, decrease buffer size
|
||||||
|
LowMemoryThreshold float64 // Below this, increase buffer size
|
||||||
|
HighMemoryThreshold float64 // Above this, decrease buffer size
|
||||||
|
|
||||||
|
// Latency thresholds (in milliseconds)
|
||||||
|
TargetLatency time.Duration
|
||||||
|
MaxLatency time.Duration
|
||||||
|
|
||||||
|
// Adaptation parameters
|
||||||
|
AdaptationInterval time.Duration
|
||||||
|
SmoothingFactor float64 // 0.0-1.0, higher = more responsive
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAdaptiveBufferConfig returns optimized config for JetKVM hardware
|
||||||
|
func DefaultAdaptiveBufferConfig() AdaptiveBufferConfig {
|
||||||
|
return AdaptiveBufferConfig{
|
||||||
|
// Conservative buffer sizes for 256MB RAM constraint
|
||||||
|
MinBufferSize: 3, // Minimum 3 frames (slightly higher for stability)
|
||||||
|
MaxBufferSize: 20, // Maximum 20 frames (increased for high load scenarios)
|
||||||
|
DefaultBufferSize: 6, // Default 6 frames (increased for better stability)
|
||||||
|
|
||||||
|
// CPU thresholds optimized for single-core ARM Cortex A7 under load
|
||||||
|
LowCPUThreshold: 20.0, // Below 20% CPU
|
||||||
|
HighCPUThreshold: 60.0, // Above 60% CPU (lowered to be more responsive)
|
||||||
|
|
||||||
|
// Memory thresholds for 256MB total RAM
|
||||||
|
LowMemoryThreshold: 35.0, // Below 35% memory usage
|
||||||
|
HighMemoryThreshold: 75.0, // Above 75% memory usage (lowered for earlier response)
|
||||||
|
|
||||||
|
// Latency targets
|
||||||
|
TargetLatency: 20 * time.Millisecond, // Target 20ms latency
|
||||||
|
MaxLatency: 50 * time.Millisecond, // Max acceptable 50ms
|
||||||
|
|
||||||
|
// Adaptation settings
|
||||||
|
AdaptationInterval: 500 * time.Millisecond, // Check every 500ms
|
||||||
|
SmoothingFactor: 0.3, // Moderate responsiveness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdaptiveBufferManager manages dynamic buffer sizing based on system conditions
|
||||||
|
type AdaptiveBufferManager struct {
|
||||||
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
|
currentInputBufferSize int64 // Current input buffer size (atomic)
|
||||||
|
currentOutputBufferSize int64 // Current output buffer size (atomic)
|
||||||
|
averageLatency int64 // Average latency in nanoseconds (atomic)
|
||||||
|
systemCPUPercent int64 // System CPU percentage * 100 (atomic)
|
||||||
|
systemMemoryPercent int64 // System memory percentage * 100 (atomic)
|
||||||
|
adaptationCount int64 // Metrics tracking (atomic)
|
||||||
|
|
||||||
|
config AdaptiveBufferConfig
|
||||||
|
logger zerolog.Logger
|
||||||
|
processMonitor *ProcessMonitor
|
||||||
|
|
||||||
|
// Control channels
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Metrics tracking
|
||||||
|
lastAdaptation time.Time
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdaptiveBufferManager creates a new adaptive buffer manager
|
||||||
|
func NewAdaptiveBufferManager(config AdaptiveBufferConfig) *AdaptiveBufferManager {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
return &AdaptiveBufferManager{
|
||||||
|
currentInputBufferSize: int64(config.DefaultBufferSize),
|
||||||
|
currentOutputBufferSize: int64(config.DefaultBufferSize),
|
||||||
|
config: config,
|
||||||
|
logger: logging.GetDefaultLogger().With().Str("component", "adaptive-buffer").Logger(),
|
||||||
|
processMonitor: GetProcessMonitor(),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
lastAdaptation: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the adaptive buffer management
|
||||||
|
func (abm *AdaptiveBufferManager) Start() {
|
||||||
|
abm.wg.Add(1)
|
||||||
|
go abm.adaptationLoop()
|
||||||
|
abm.logger.Info().Msg("Adaptive buffer manager started")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the adaptive buffer management
|
||||||
|
func (abm *AdaptiveBufferManager) Stop() {
|
||||||
|
abm.cancel()
|
||||||
|
abm.wg.Wait()
|
||||||
|
abm.logger.Info().Msg("Adaptive buffer manager stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInputBufferSize returns the current recommended input buffer size
|
||||||
|
func (abm *AdaptiveBufferManager) GetInputBufferSize() int {
|
||||||
|
return int(atomic.LoadInt64(&abm.currentInputBufferSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOutputBufferSize returns the current recommended output buffer size
|
||||||
|
func (abm *AdaptiveBufferManager) GetOutputBufferSize() int {
|
||||||
|
return int(atomic.LoadInt64(&abm.currentOutputBufferSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLatency updates the current latency measurement
|
||||||
|
func (abm *AdaptiveBufferManager) UpdateLatency(latency time.Duration) {
|
||||||
|
// Use exponential moving average for latency
|
||||||
|
currentAvg := atomic.LoadInt64(&abm.averageLatency)
|
||||||
|
newLatency := latency.Nanoseconds()
|
||||||
|
|
||||||
|
if currentAvg == 0 {
|
||||||
|
atomic.StoreInt64(&abm.averageLatency, newLatency)
|
||||||
|
} else {
|
||||||
|
// Exponential moving average: 70% historical, 30% current
|
||||||
|
newAvg := int64(float64(currentAvg)*0.7 + float64(newLatency)*0.3)
|
||||||
|
atomic.StoreInt64(&abm.averageLatency, newAvg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adaptationLoop is the main loop that adjusts buffer sizes
|
||||||
|
func (abm *AdaptiveBufferManager) adaptationLoop() {
|
||||||
|
defer abm.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(abm.config.AdaptationInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-abm.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
abm.adaptBufferSizes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adaptBufferSizes analyzes system conditions and adjusts buffer sizes
|
||||||
|
func (abm *AdaptiveBufferManager) adaptBufferSizes() {
|
||||||
|
// Collect current system metrics
|
||||||
|
metrics := abm.processMonitor.GetCurrentMetrics()
|
||||||
|
if len(metrics) == 0 {
|
||||||
|
return // No metrics available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate system-wide CPU and memory usage
|
||||||
|
totalCPU := 0.0
|
||||||
|
totalMemory := 0.0
|
||||||
|
processCount := 0
|
||||||
|
|
||||||
|
for _, metric := range metrics {
|
||||||
|
totalCPU += metric.CPUPercent
|
||||||
|
totalMemory += metric.MemoryPercent
|
||||||
|
processCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if processCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store system metrics atomically
|
||||||
|
systemCPU := totalCPU // Total CPU across all monitored processes
|
||||||
|
systemMemory := totalMemory / float64(processCount) // Average memory usage
|
||||||
|
|
||||||
|
atomic.StoreInt64(&abm.systemCPUPercent, int64(systemCPU*100))
|
||||||
|
atomic.StoreInt64(&abm.systemMemoryPercent, int64(systemMemory*100))
|
||||||
|
|
||||||
|
// Get current latency
|
||||||
|
currentLatencyNs := atomic.LoadInt64(&abm.averageLatency)
|
||||||
|
currentLatency := time.Duration(currentLatencyNs)
|
||||||
|
|
||||||
|
// Calculate adaptation factors
|
||||||
|
cpuFactor := abm.calculateCPUFactor(systemCPU)
|
||||||
|
memoryFactor := abm.calculateMemoryFactor(systemMemory)
|
||||||
|
latencyFactor := abm.calculateLatencyFactor(currentLatency)
|
||||||
|
|
||||||
|
// Combine factors with weights (CPU has highest priority for KVM coexistence)
|
||||||
|
combinedFactor := 0.5*cpuFactor + 0.3*memoryFactor + 0.2*latencyFactor
|
||||||
|
|
||||||
|
// Apply adaptation with smoothing
|
||||||
|
currentInput := float64(atomic.LoadInt64(&abm.currentInputBufferSize))
|
||||||
|
currentOutput := float64(atomic.LoadInt64(&abm.currentOutputBufferSize))
|
||||||
|
|
||||||
|
// Calculate new buffer sizes
|
||||||
|
newInputSize := abm.applyAdaptation(currentInput, combinedFactor)
|
||||||
|
newOutputSize := abm.applyAdaptation(currentOutput, combinedFactor)
|
||||||
|
|
||||||
|
// Update buffer sizes if they changed significantly
|
||||||
|
adjustmentMade := false
|
||||||
|
if math.Abs(newInputSize-currentInput) >= 0.5 || math.Abs(newOutputSize-currentOutput) >= 0.5 {
|
||||||
|
atomic.StoreInt64(&abm.currentInputBufferSize, int64(math.Round(newInputSize)))
|
||||||
|
atomic.StoreInt64(&abm.currentOutputBufferSize, int64(math.Round(newOutputSize)))
|
||||||
|
|
||||||
|
atomic.AddInt64(&abm.adaptationCount, 1)
|
||||||
|
abm.mutex.Lock()
|
||||||
|
abm.lastAdaptation = time.Now()
|
||||||
|
abm.mutex.Unlock()
|
||||||
|
adjustmentMade = true
|
||||||
|
|
||||||
|
abm.logger.Debug().
|
||||||
|
Float64("cpu_percent", systemCPU).
|
||||||
|
Float64("memory_percent", systemMemory).
|
||||||
|
Dur("latency", currentLatency).
|
||||||
|
Float64("combined_factor", combinedFactor).
|
||||||
|
Int("new_input_size", int(newInputSize)).
|
||||||
|
Int("new_output_size", int(newOutputSize)).
|
||||||
|
Msg("Adapted buffer sizes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metrics with current state
|
||||||
|
currentInputSize := int(atomic.LoadInt64(&abm.currentInputBufferSize))
|
||||||
|
currentOutputSize := int(atomic.LoadInt64(&abm.currentOutputBufferSize))
|
||||||
|
UpdateAdaptiveBufferMetrics(currentInputSize, currentOutputSize, systemCPU, systemMemory, adjustmentMade)
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateCPUFactor returns adaptation factor based on CPU usage
|
||||||
|
// Returns: -1.0 (decrease buffers) to +1.0 (increase buffers)
|
||||||
|
func (abm *AdaptiveBufferManager) calculateCPUFactor(cpuPercent float64) float64 {
|
||||||
|
if cpuPercent > abm.config.HighCPUThreshold {
|
||||||
|
// High CPU: decrease buffers to reduce latency and give CPU to KVM
|
||||||
|
return -1.0
|
||||||
|
} else if cpuPercent < abm.config.LowCPUThreshold {
|
||||||
|
// Low CPU: increase buffers for better quality
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
// Medium CPU: linear interpolation
|
||||||
|
midpoint := (abm.config.HighCPUThreshold + abm.config.LowCPUThreshold) / 2
|
||||||
|
return (midpoint - cpuPercent) / (midpoint - abm.config.LowCPUThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateMemoryFactor returns adaptation factor based on memory usage
|
||||||
|
func (abm *AdaptiveBufferManager) calculateMemoryFactor(memoryPercent float64) float64 {
|
||||||
|
if memoryPercent > abm.config.HighMemoryThreshold {
|
||||||
|
// High memory: decrease buffers to free memory
|
||||||
|
return -1.0
|
||||||
|
} else if memoryPercent < abm.config.LowMemoryThreshold {
|
||||||
|
// Low memory: increase buffers for better performance
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
// Medium memory: linear interpolation
|
||||||
|
midpoint := (abm.config.HighMemoryThreshold + abm.config.LowMemoryThreshold) / 2
|
||||||
|
return (midpoint - memoryPercent) / (midpoint - abm.config.LowMemoryThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateLatencyFactor returns adaptation factor based on latency
|
||||||
|
func (abm *AdaptiveBufferManager) calculateLatencyFactor(latency time.Duration) float64 {
|
||||||
|
if latency > abm.config.MaxLatency {
|
||||||
|
// High latency: decrease buffers
|
||||||
|
return -1.0
|
||||||
|
} else if latency < abm.config.TargetLatency {
|
||||||
|
// Low latency: can increase buffers
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
// Medium latency: linear interpolation
|
||||||
|
midLatency := (abm.config.MaxLatency + abm.config.TargetLatency) / 2
|
||||||
|
return float64(midLatency-latency) / float64(midLatency-abm.config.TargetLatency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAdaptation applies the adaptation factor to current buffer size
|
||||||
|
func (abm *AdaptiveBufferManager) applyAdaptation(currentSize, factor float64) float64 {
|
||||||
|
// Calculate target size based on factor
|
||||||
|
var targetSize float64
|
||||||
|
if factor > 0 {
|
||||||
|
// Increase towards max
|
||||||
|
targetSize = currentSize + factor*(float64(abm.config.MaxBufferSize)-currentSize)
|
||||||
|
} else {
|
||||||
|
// Decrease towards min
|
||||||
|
targetSize = currentSize + factor*(currentSize-float64(abm.config.MinBufferSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply smoothing
|
||||||
|
newSize := currentSize + abm.config.SmoothingFactor*(targetSize-currentSize)
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
return math.Max(float64(abm.config.MinBufferSize),
|
||||||
|
math.Min(float64(abm.config.MaxBufferSize), newSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns current adaptation statistics
|
||||||
|
func (abm *AdaptiveBufferManager) GetStats() map[string]interface{} {
|
||||||
|
abm.mutex.RLock()
|
||||||
|
lastAdaptation := abm.lastAdaptation
|
||||||
|
abm.mutex.RUnlock()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"input_buffer_size": abm.GetInputBufferSize(),
|
||||||
|
"output_buffer_size": abm.GetOutputBufferSize(),
|
||||||
|
"average_latency_ms": float64(atomic.LoadInt64(&abm.averageLatency)) / 1e6,
|
||||||
|
"system_cpu_percent": float64(atomic.LoadInt64(&abm.systemCPUPercent)) / 100,
|
||||||
|
"system_memory_percent": float64(atomic.LoadInt64(&abm.systemMemoryPercent)) / 100,
|
||||||
|
"adaptation_count": atomic.LoadInt64(&abm.adaptationCount),
|
||||||
|
"last_adaptation": lastAdaptation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global adaptive buffer manager instance
|
||||||
|
var globalAdaptiveBufferManager *AdaptiveBufferManager
|
||||||
|
var adaptiveBufferOnce sync.Once
|
||||||
|
|
||||||
|
// GetAdaptiveBufferManager returns the global adaptive buffer manager instance
|
||||||
|
func GetAdaptiveBufferManager() *AdaptiveBufferManager {
|
||||||
|
adaptiveBufferOnce.Do(func() {
|
||||||
|
globalAdaptiveBufferManager = NewAdaptiveBufferManager(DefaultAdaptiveBufferConfig())
|
||||||
|
})
|
||||||
|
return globalAdaptiveBufferManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAdaptiveBuffering starts the global adaptive buffer manager
|
||||||
|
func StartAdaptiveBuffering() {
|
||||||
|
GetAdaptiveBufferManager().Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAdaptiveBuffering stops the global adaptive buffer manager
|
||||||
|
func StopAdaptiveBuffering() {
|
||||||
|
if globalAdaptiveBufferManager != nil {
|
||||||
|
globalAdaptiveBufferManager.Stop()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,198 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdaptiveOptimizer automatically adjusts audio parameters based on latency metrics
|
||||||
|
type AdaptiveOptimizer struct {
|
||||||
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
|
optimizationCount int64 // Number of optimizations performed (atomic)
|
||||||
|
lastOptimization int64 // Timestamp of last optimization (atomic)
|
||||||
|
optimizationLevel int64 // Current optimization level (0-10) (atomic)
|
||||||
|
|
||||||
|
latencyMonitor *LatencyMonitor
|
||||||
|
bufferManager *AdaptiveBufferManager
|
||||||
|
logger zerolog.Logger
|
||||||
|
|
||||||
|
// Control channels
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
config OptimizerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizerConfig holds configuration for the adaptive optimizer
|
||||||
|
type OptimizerConfig struct {
|
||||||
|
MaxOptimizationLevel int // Maximum optimization level (0-10)
|
||||||
|
CooldownPeriod time.Duration // Minimum time between optimizations
|
||||||
|
Aggressiveness float64 // How aggressively to optimize (0.0-1.0)
|
||||||
|
RollbackThreshold time.Duration // Latency threshold to rollback optimizations
|
||||||
|
StabilityPeriod time.Duration // Time to wait for stability after optimization
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultOptimizerConfig returns a sensible default configuration
|
||||||
|
func DefaultOptimizerConfig() OptimizerConfig {
|
||||||
|
return OptimizerConfig{
|
||||||
|
MaxOptimizationLevel: 8,
|
||||||
|
CooldownPeriod: 30 * time.Second,
|
||||||
|
Aggressiveness: 0.7,
|
||||||
|
RollbackThreshold: 300 * time.Millisecond,
|
||||||
|
StabilityPeriod: 10 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdaptiveOptimizer creates a new adaptive optimizer
|
||||||
|
func NewAdaptiveOptimizer(latencyMonitor *LatencyMonitor, bufferManager *AdaptiveBufferManager, config OptimizerConfig, logger zerolog.Logger) *AdaptiveOptimizer {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
optimizer := &AdaptiveOptimizer{
|
||||||
|
latencyMonitor: latencyMonitor,
|
||||||
|
bufferManager: bufferManager,
|
||||||
|
config: config,
|
||||||
|
logger: logger.With().Str("component", "adaptive-optimizer").Logger(),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register as latency monitor callback
|
||||||
|
latencyMonitor.AddOptimizationCallback(optimizer.handleLatencyOptimization)
|
||||||
|
|
||||||
|
return optimizer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the adaptive optimization process
|
||||||
|
func (ao *AdaptiveOptimizer) Start() {
|
||||||
|
ao.wg.Add(1)
|
||||||
|
go ao.optimizationLoop()
|
||||||
|
ao.logger.Info().Msg("Adaptive optimizer started")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the adaptive optimizer
|
||||||
|
func (ao *AdaptiveOptimizer) Stop() {
|
||||||
|
ao.cancel()
|
||||||
|
ao.wg.Wait()
|
||||||
|
ao.logger.Info().Msg("Adaptive optimizer stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// initializeStrategies sets up the available optimization strategies
|
||||||
|
|
||||||
|
// handleLatencyOptimization is called when latency optimization is needed
|
||||||
|
func (ao *AdaptiveOptimizer) handleLatencyOptimization(metrics LatencyMetrics) error {
|
||||||
|
currentLevel := atomic.LoadInt64(&ao.optimizationLevel)
|
||||||
|
lastOpt := atomic.LoadInt64(&ao.lastOptimization)
|
||||||
|
|
||||||
|
// Check cooldown period
|
||||||
|
if time.Since(time.Unix(0, lastOpt)) < ao.config.CooldownPeriod {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if we need to increase or decrease optimization level
|
||||||
|
targetLevel := ao.calculateTargetOptimizationLevel(metrics)
|
||||||
|
|
||||||
|
if targetLevel > currentLevel {
|
||||||
|
return ao.increaseOptimization(int(targetLevel))
|
||||||
|
} else if targetLevel < currentLevel {
|
||||||
|
return ao.decreaseOptimization(int(targetLevel))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateTargetOptimizationLevel determines the appropriate optimization level
|
||||||
|
func (ao *AdaptiveOptimizer) calculateTargetOptimizationLevel(metrics LatencyMetrics) int64 {
|
||||||
|
// Base calculation on current latency vs target
|
||||||
|
latencyRatio := float64(metrics.Current) / float64(50*time.Millisecond) // 50ms target
|
||||||
|
|
||||||
|
// Adjust based on trend
|
||||||
|
switch metrics.Trend {
|
||||||
|
case LatencyTrendIncreasing:
|
||||||
|
latencyRatio *= 1.2 // Be more aggressive
|
||||||
|
case LatencyTrendDecreasing:
|
||||||
|
latencyRatio *= 0.8 // Be less aggressive
|
||||||
|
case LatencyTrendVolatile:
|
||||||
|
latencyRatio *= 1.1 // Slightly more aggressive
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply aggressiveness factor
|
||||||
|
latencyRatio *= ao.config.Aggressiveness
|
||||||
|
|
||||||
|
// Convert to optimization level
|
||||||
|
targetLevel := int64(latencyRatio * 2) // Scale to 0-10 range
|
||||||
|
if targetLevel > int64(ao.config.MaxOptimizationLevel) {
|
||||||
|
targetLevel = int64(ao.config.MaxOptimizationLevel)
|
||||||
|
}
|
||||||
|
if targetLevel < 0 {
|
||||||
|
targetLevel = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// increaseOptimization applies optimization strategies up to the target level
|
||||||
|
func (ao *AdaptiveOptimizer) increaseOptimization(targetLevel int) error {
|
||||||
|
atomic.StoreInt64(&ao.optimizationLevel, int64(targetLevel))
|
||||||
|
atomic.StoreInt64(&ao.lastOptimization, time.Now().UnixNano())
|
||||||
|
atomic.AddInt64(&ao.optimizationCount, 1)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decreaseOptimization rolls back optimization strategies to the target level
|
||||||
|
func (ao *AdaptiveOptimizer) decreaseOptimization(targetLevel int) error {
|
||||||
|
atomic.StoreInt64(&ao.optimizationLevel, int64(targetLevel))
|
||||||
|
atomic.StoreInt64(&ao.lastOptimization, time.Now().UnixNano())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optimizationLoop runs the main optimization monitoring loop
|
||||||
|
func (ao *AdaptiveOptimizer) optimizationLoop() {
|
||||||
|
defer ao.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(ao.config.StabilityPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ao.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
ao.checkStability()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStability monitors system stability and rolls back if needed
|
||||||
|
func (ao *AdaptiveOptimizer) checkStability() {
|
||||||
|
metrics := ao.latencyMonitor.GetMetrics()
|
||||||
|
|
||||||
|
// Check if we need to rollback due to excessive latency
|
||||||
|
if metrics.Current > ao.config.RollbackThreshold {
|
||||||
|
currentLevel := int(atomic.LoadInt64(&ao.optimizationLevel))
|
||||||
|
if currentLevel > 0 {
|
||||||
|
ao.logger.Warn().Dur("current_latency", metrics.Current).Dur("threshold", ao.config.RollbackThreshold).Msg("Rolling back optimizations due to excessive latency")
|
||||||
|
if err := ao.decreaseOptimization(currentLevel - 1); err != nil {
|
||||||
|
ao.logger.Error().Err(err).Msg("Failed to decrease optimization level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOptimizationStats returns current optimization statistics
|
||||||
|
func (ao *AdaptiveOptimizer) GetOptimizationStats() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"optimization_level": atomic.LoadInt64(&ao.optimizationLevel),
|
||||||
|
"optimization_count": atomic.LoadInt64(&ao.optimizationCount),
|
||||||
|
"last_optimization": time.Unix(0, atomic.LoadInt64(&ao.lastOptimization)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy implementation methods (stubs for now)
|
|
@ -199,7 +199,16 @@ func (bap *BatchAudioProcessor) processBatchRead(batch []batchReadRequest) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
if atomic.CompareAndSwapInt32(&bap.threadPinned, 0, 1) {
|
if atomic.CompareAndSwapInt32(&bap.threadPinned, 0, 1) {
|
||||||
runtime.LockOSThread()
|
runtime.LockOSThread()
|
||||||
|
|
||||||
|
// Set high priority for batch audio processing
|
||||||
|
if err := SetAudioThreadPriority(); err != nil {
|
||||||
|
bap.logger.Warn().Err(err).Msg("Failed to set batch audio processing priority")
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
if err := ResetThreadPriority(); err != nil {
|
||||||
|
bap.logger.Warn().Err(err).Msg("Failed to reset thread priority")
|
||||||
|
}
|
||||||
runtime.UnlockOSThread()
|
runtime.UnlockOSThread()
|
||||||
atomic.StoreInt32(&bap.threadPinned, 0)
|
atomic.StoreInt32(&bap.threadPinned, 0)
|
||||||
bap.stats.OSThreadPinTime += time.Since(start)
|
bap.stats.OSThreadPinTime += time.Since(start)
|
||||||
|
|
|
@ -2,16 +2,41 @@ package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AudioBufferPool struct {
|
type AudioBufferPool struct {
|
||||||
pool sync.Pool
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
bufferSize int
|
currentSize int64 // Current pool size (atomic)
|
||||||
|
hitCount int64 // Pool hit counter (atomic)
|
||||||
|
missCount int64 // Pool miss counter (atomic)
|
||||||
|
|
||||||
|
// Other fields
|
||||||
|
pool sync.Pool
|
||||||
|
bufferSize int
|
||||||
|
maxPoolSize int
|
||||||
|
mutex sync.RWMutex
|
||||||
|
// Memory optimization fields
|
||||||
|
preallocated []*[]byte // Pre-allocated buffers for immediate use
|
||||||
|
preallocSize int // Number of pre-allocated buffers
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAudioBufferPool(bufferSize int) *AudioBufferPool {
|
func NewAudioBufferPool(bufferSize int) *AudioBufferPool {
|
||||||
|
// Pre-allocate 20% of max pool size for immediate availability
|
||||||
|
preallocSize := 20
|
||||||
|
preallocated := make([]*[]byte, 0, preallocSize)
|
||||||
|
|
||||||
|
// Pre-allocate buffers to reduce initial allocation overhead
|
||||||
|
for i := 0; i < preallocSize; i++ {
|
||||||
|
buf := make([]byte, 0, bufferSize)
|
||||||
|
preallocated = append(preallocated, &buf)
|
||||||
|
}
|
||||||
|
|
||||||
return &AudioBufferPool{
|
return &AudioBufferPool{
|
||||||
bufferSize: bufferSize,
|
bufferSize: bufferSize,
|
||||||
|
maxPoolSize: 100, // Limit pool size to prevent excessive memory usage
|
||||||
|
preallocated: preallocated,
|
||||||
|
preallocSize: preallocSize,
|
||||||
pool: sync.Pool{
|
pool: sync.Pool{
|
||||||
New: func() interface{} {
|
New: func() interface{} {
|
||||||
return make([]byte, 0, bufferSize)
|
return make([]byte, 0, bufferSize)
|
||||||
|
@ -21,17 +46,68 @@ func NewAudioBufferPool(bufferSize int) *AudioBufferPool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioBufferPool) Get() []byte {
|
func (p *AudioBufferPool) Get() []byte {
|
||||||
if buf := p.pool.Get(); buf != nil {
|
// First try pre-allocated buffers for fastest access
|
||||||
return *buf.(*[]byte)
|
p.mutex.Lock()
|
||||||
|
if len(p.preallocated) > 0 {
|
||||||
|
buf := p.preallocated[len(p.preallocated)-1]
|
||||||
|
p.preallocated = p.preallocated[:len(p.preallocated)-1]
|
||||||
|
p.mutex.Unlock()
|
||||||
|
atomic.AddInt64(&p.hitCount, 1)
|
||||||
|
return (*buf)[:0] // Reset length but keep capacity
|
||||||
}
|
}
|
||||||
|
p.mutex.Unlock()
|
||||||
|
|
||||||
|
// Try sync.Pool next
|
||||||
|
if buf := p.pool.Get(); buf != nil {
|
||||||
|
bufPtr := buf.(*[]byte)
|
||||||
|
// Update pool size counter when retrieving from pool
|
||||||
|
p.mutex.Lock()
|
||||||
|
if p.currentSize > 0 {
|
||||||
|
p.currentSize--
|
||||||
|
}
|
||||||
|
p.mutex.Unlock()
|
||||||
|
atomic.AddInt64(&p.hitCount, 1)
|
||||||
|
return (*bufPtr)[:0] // Reset length but keep capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: allocate new buffer
|
||||||
|
atomic.AddInt64(&p.missCount, 1)
|
||||||
return make([]byte, 0, p.bufferSize)
|
return make([]byte, 0, p.bufferSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioBufferPool) Put(buf []byte) {
|
func (p *AudioBufferPool) Put(buf []byte) {
|
||||||
if cap(buf) >= p.bufferSize {
|
if cap(buf) < p.bufferSize {
|
||||||
resetBuf := buf[:0]
|
return // Buffer too small, don't pool it
|
||||||
p.pool.Put(&resetBuf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset buffer for reuse
|
||||||
|
resetBuf := buf[:0]
|
||||||
|
|
||||||
|
// First try to return to pre-allocated pool for fastest reuse
|
||||||
|
p.mutex.Lock()
|
||||||
|
if len(p.preallocated) < p.preallocSize {
|
||||||
|
p.preallocated = append(p.preallocated, &resetBuf)
|
||||||
|
p.mutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mutex.Unlock()
|
||||||
|
|
||||||
|
// Check sync.Pool size limit to prevent excessive memory usage
|
||||||
|
p.mutex.RLock()
|
||||||
|
currentSize := p.currentSize
|
||||||
|
p.mutex.RUnlock()
|
||||||
|
|
||||||
|
if currentSize >= int64(p.maxPoolSize) {
|
||||||
|
return // Pool is full, let GC handle this buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return to sync.Pool
|
||||||
|
p.pool.Put(&resetBuf)
|
||||||
|
|
||||||
|
// Update pool size counter
|
||||||
|
p.mutex.Lock()
|
||||||
|
p.currentSize++
|
||||||
|
p.mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -54,3 +130,83 @@ func GetAudioControlBuffer() []byte {
|
||||||
func PutAudioControlBuffer(buf []byte) {
|
func PutAudioControlBuffer(buf []byte) {
|
||||||
audioControlPool.Put(buf)
|
audioControlPool.Put(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPoolStats returns detailed statistics about this buffer pool
|
||||||
|
func (p *AudioBufferPool) GetPoolStats() AudioBufferPoolDetailedStats {
|
||||||
|
p.mutex.RLock()
|
||||||
|
preallocatedCount := len(p.preallocated)
|
||||||
|
currentSize := p.currentSize
|
||||||
|
p.mutex.RUnlock()
|
||||||
|
|
||||||
|
hitCount := atomic.LoadInt64(&p.hitCount)
|
||||||
|
missCount := atomic.LoadInt64(&p.missCount)
|
||||||
|
totalRequests := hitCount + missCount
|
||||||
|
|
||||||
|
var hitRate float64
|
||||||
|
if totalRequests > 0 {
|
||||||
|
hitRate = float64(hitCount) / float64(totalRequests) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return AudioBufferPoolDetailedStats{
|
||||||
|
BufferSize: p.bufferSize,
|
||||||
|
MaxPoolSize: p.maxPoolSize,
|
||||||
|
CurrentPoolSize: currentSize,
|
||||||
|
PreallocatedCount: int64(preallocatedCount),
|
||||||
|
PreallocatedMax: int64(p.preallocSize),
|
||||||
|
HitCount: hitCount,
|
||||||
|
MissCount: missCount,
|
||||||
|
HitRate: hitRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioBufferPoolDetailedStats provides detailed pool statistics
|
||||||
|
type AudioBufferPoolDetailedStats struct {
|
||||||
|
BufferSize int
|
||||||
|
MaxPoolSize int
|
||||||
|
CurrentPoolSize int64
|
||||||
|
PreallocatedCount int64
|
||||||
|
PreallocatedMax int64
|
||||||
|
HitCount int64
|
||||||
|
MissCount int64
|
||||||
|
HitRate float64 // Percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAudioBufferPoolStats returns statistics about the audio buffer pools
|
||||||
|
type AudioBufferPoolStats struct {
|
||||||
|
FramePoolSize int64
|
||||||
|
FramePoolMax int
|
||||||
|
ControlPoolSize int64
|
||||||
|
ControlPoolMax int
|
||||||
|
// Enhanced statistics
|
||||||
|
FramePoolHitRate float64
|
||||||
|
ControlPoolHitRate float64
|
||||||
|
FramePoolDetails AudioBufferPoolDetailedStats
|
||||||
|
ControlPoolDetails AudioBufferPoolDetailedStats
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAudioBufferPoolStats() AudioBufferPoolStats {
|
||||||
|
audioFramePool.mutex.RLock()
|
||||||
|
frameSize := audioFramePool.currentSize
|
||||||
|
frameMax := audioFramePool.maxPoolSize
|
||||||
|
audioFramePool.mutex.RUnlock()
|
||||||
|
|
||||||
|
audioControlPool.mutex.RLock()
|
||||||
|
controlSize := audioControlPool.currentSize
|
||||||
|
controlMax := audioControlPool.maxPoolSize
|
||||||
|
audioControlPool.mutex.RUnlock()
|
||||||
|
|
||||||
|
// Get detailed statistics
|
||||||
|
frameDetails := audioFramePool.GetPoolStats()
|
||||||
|
controlDetails := audioControlPool.GetPoolStats()
|
||||||
|
|
||||||
|
return AudioBufferPoolStats{
|
||||||
|
FramePoolSize: frameSize,
|
||||||
|
FramePoolMax: frameMax,
|
||||||
|
ControlPoolSize: controlSize,
|
||||||
|
ControlPoolMax: controlMax,
|
||||||
|
FramePoolHitRate: frameDetails.HitRate,
|
||||||
|
ControlPoolHitRate: controlDetails.HitRate,
|
||||||
|
FramePoolDetails: frameDetails,
|
||||||
|
ControlPoolDetails: controlDetails,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -22,8 +22,14 @@ static snd_pcm_t *pcm_handle = NULL;
|
||||||
static snd_pcm_t *pcm_playback_handle = NULL;
|
static snd_pcm_t *pcm_playback_handle = NULL;
|
||||||
static OpusEncoder *encoder = NULL;
|
static OpusEncoder *encoder = NULL;
|
||||||
static OpusDecoder *decoder = NULL;
|
static OpusDecoder *decoder = NULL;
|
||||||
static int opus_bitrate = 64000;
|
// Optimized Opus encoder settings for ARM Cortex-A7
|
||||||
static int opus_complexity = 5;
|
static int opus_bitrate = 96000; // Increased for better quality
|
||||||
|
static int opus_complexity = 3; // Reduced for ARM performance
|
||||||
|
static int opus_vbr = 1; // Variable bitrate enabled
|
||||||
|
static int opus_vbr_constraint = 1; // Constrained VBR for consistent latency
|
||||||
|
static int opus_signal_type = OPUS_SIGNAL_MUSIC; // Optimized for general audio
|
||||||
|
static int opus_bandwidth = OPUS_BANDWIDTH_FULLBAND; // Full bandwidth
|
||||||
|
static int opus_dtx = 0; // Disable DTX for real-time audio
|
||||||
static int sample_rate = 48000;
|
static int sample_rate = 48000;
|
||||||
static int channels = 2;
|
static int channels = 2;
|
||||||
static int frame_size = 960; // 20ms for 48kHz
|
static int frame_size = 960; // 20ms for 48kHz
|
||||||
|
@ -164,7 +170,7 @@ int jetkvm_audio_init() {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Opus encoder
|
// Initialize Opus encoder with optimized settings
|
||||||
int opus_err = 0;
|
int opus_err = 0;
|
||||||
encoder = opus_encoder_create(sample_rate, channels, OPUS_APPLICATION_AUDIO, &opus_err);
|
encoder = opus_encoder_create(sample_rate, channels, OPUS_APPLICATION_AUDIO, &opus_err);
|
||||||
if (!encoder || opus_err != OPUS_OK) {
|
if (!encoder || opus_err != OPUS_OK) {
|
||||||
|
@ -173,8 +179,18 @@ int jetkvm_audio_init() {
|
||||||
return -2;
|
return -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply optimized Opus encoder settings
|
||||||
opus_encoder_ctl(encoder, OPUS_SET_BITRATE(opus_bitrate));
|
opus_encoder_ctl(encoder, OPUS_SET_BITRATE(opus_bitrate));
|
||||||
opus_encoder_ctl(encoder, OPUS_SET_COMPLEXITY(opus_complexity));
|
opus_encoder_ctl(encoder, OPUS_SET_COMPLEXITY(opus_complexity));
|
||||||
|
opus_encoder_ctl(encoder, OPUS_SET_VBR(opus_vbr));
|
||||||
|
opus_encoder_ctl(encoder, OPUS_SET_VBR_CONSTRAINT(opus_vbr_constraint));
|
||||||
|
opus_encoder_ctl(encoder, OPUS_SET_SIGNAL(opus_signal_type));
|
||||||
|
opus_encoder_ctl(encoder, OPUS_SET_BANDWIDTH(opus_bandwidth));
|
||||||
|
opus_encoder_ctl(encoder, OPUS_SET_DTX(opus_dtx));
|
||||||
|
// Enable packet loss concealment for better resilience
|
||||||
|
opus_encoder_ctl(encoder, OPUS_SET_PACKET_LOSS_PERC(5));
|
||||||
|
// Set prediction disabled for lower latency
|
||||||
|
opus_encoder_ctl(encoder, OPUS_SET_PREDICTION_DISABLED(1));
|
||||||
|
|
||||||
capture_initialized = 1;
|
capture_initialized = 1;
|
||||||
capture_initializing = 0;
|
capture_initializing = 0;
|
||||||
|
|
|
@ -99,6 +99,42 @@ func (aim *AudioInputManager) WriteOpusFrame(frame []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteOpusFrameZeroCopy writes an Opus frame using zero-copy optimization
|
||||||
|
func (aim *AudioInputManager) WriteOpusFrameZeroCopy(frame *ZeroCopyAudioFrame) error {
|
||||||
|
if !aim.IsRunning() {
|
||||||
|
return nil // Not running, silently drop
|
||||||
|
}
|
||||||
|
|
||||||
|
if frame == nil {
|
||||||
|
atomic.AddInt64(&aim.metrics.FramesDropped, 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track end-to-end latency from WebRTC to IPC
|
||||||
|
startTime := time.Now()
|
||||||
|
err := aim.ipcManager.WriteOpusFrameZeroCopy(frame)
|
||||||
|
processingTime := time.Since(startTime)
|
||||||
|
|
||||||
|
// Log high latency warnings
|
||||||
|
if processingTime > 10*time.Millisecond {
|
||||||
|
aim.logger.Warn().
|
||||||
|
Dur("latency_ms", processingTime).
|
||||||
|
Msg("High audio processing latency detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
atomic.AddInt64(&aim.metrics.FramesDropped, 1)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metrics
|
||||||
|
atomic.AddInt64(&aim.metrics.FramesSent, 1)
|
||||||
|
atomic.AddInt64(&aim.metrics.BytesProcessed, int64(frame.Length()))
|
||||||
|
aim.metrics.LastFrameTime = time.Now()
|
||||||
|
aim.metrics.AverageLatency = processingTime
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetMetrics returns current audio input metrics
|
// GetMetrics returns current audio input metrics
|
||||||
func (aim *AudioInputManager) GetMetrics() AudioInputMetrics {
|
func (aim *AudioInputManager) GetMetrics() AudioInputMetrics {
|
||||||
return AudioInputMetrics{
|
return AudioInputMetrics{
|
||||||
|
|
|
@ -8,17 +8,22 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
inputMagicNumber uint32 = 0x4A4B4D49 // "JKMI" (JetKVM Microphone Input)
|
inputMagicNumber uint32 = 0x4A4B4D49 // "JKMI" (JetKVM Microphone Input)
|
||||||
inputSocketName = "audio_input.sock"
|
inputSocketName = "audio_input.sock"
|
||||||
maxFrameSize = 4096 // Maximum Opus frame size
|
maxFrameSize = 4096 // Maximum Opus frame size
|
||||||
writeTimeout = 5 * time.Millisecond // Non-blocking write timeout
|
writeTimeout = 15 * time.Millisecond // Non-blocking write timeout (increased for high load)
|
||||||
maxDroppedFrames = 100 // Maximum consecutive dropped frames before reconnect
|
maxDroppedFrames = 100 // Maximum consecutive dropped frames before reconnect
|
||||||
|
headerSize = 17 // Fixed header size: 4+1+4+8 bytes
|
||||||
|
messagePoolSize = 256 // Pre-allocated message pool size
|
||||||
)
|
)
|
||||||
|
|
||||||
// InputMessageType represents the type of IPC message
|
// InputMessageType represents the type of IPC message
|
||||||
|
@ -41,6 +46,113 @@ type InputIPCMessage struct {
|
||||||
Data []byte
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimizedIPCMessage represents an optimized message with pre-allocated buffers
|
||||||
|
type OptimizedIPCMessage struct {
|
||||||
|
header [headerSize]byte // Pre-allocated header buffer
|
||||||
|
data []byte // Reusable data buffer
|
||||||
|
msg InputIPCMessage // Embedded message
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagePool manages a pool of reusable messages to reduce allocations
|
||||||
|
type MessagePool struct {
|
||||||
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
|
hitCount int64 // Pool hit counter (atomic)
|
||||||
|
missCount int64 // Pool miss counter (atomic)
|
||||||
|
|
||||||
|
// Other fields
|
||||||
|
pool chan *OptimizedIPCMessage
|
||||||
|
// Memory optimization fields
|
||||||
|
preallocated []*OptimizedIPCMessage // Pre-allocated messages for immediate use
|
||||||
|
preallocSize int // Number of pre-allocated messages
|
||||||
|
maxPoolSize int // Maximum pool size to prevent memory bloat
|
||||||
|
mutex sync.RWMutex // Protects preallocated slice
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global message pool instance
|
||||||
|
var globalMessagePool = &MessagePool{
|
||||||
|
pool: make(chan *OptimizedIPCMessage, messagePoolSize),
|
||||||
|
}
|
||||||
|
|
||||||
|
var messagePoolInitOnce sync.Once
|
||||||
|
|
||||||
|
// initializeMessagePool initializes the message pool with pre-allocated messages
|
||||||
|
func initializeMessagePool() {
|
||||||
|
messagePoolInitOnce.Do(func() {
|
||||||
|
// Pre-allocate 30% of pool size for immediate availability
|
||||||
|
preallocSize := messagePoolSize * 30 / 100
|
||||||
|
globalMessagePool.preallocSize = preallocSize
|
||||||
|
globalMessagePool.maxPoolSize = messagePoolSize * 2 // Allow growth up to 2x
|
||||||
|
globalMessagePool.preallocated = make([]*OptimizedIPCMessage, 0, preallocSize)
|
||||||
|
|
||||||
|
// Pre-allocate messages to reduce initial allocation overhead
|
||||||
|
for i := 0; i < preallocSize; i++ {
|
||||||
|
msg := &OptimizedIPCMessage{
|
||||||
|
data: make([]byte, 0, maxFrameSize),
|
||||||
|
}
|
||||||
|
globalMessagePool.preallocated = append(globalMessagePool.preallocated, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the channel pool with remaining messages
|
||||||
|
for i := preallocSize; i < messagePoolSize; i++ {
|
||||||
|
globalMessagePool.pool <- &OptimizedIPCMessage{
|
||||||
|
data: make([]byte, 0, maxFrameSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a message from the pool
|
||||||
|
func (mp *MessagePool) Get() *OptimizedIPCMessage {
|
||||||
|
initializeMessagePool()
|
||||||
|
// First try pre-allocated messages for fastest access
|
||||||
|
mp.mutex.Lock()
|
||||||
|
if len(mp.preallocated) > 0 {
|
||||||
|
msg := mp.preallocated[len(mp.preallocated)-1]
|
||||||
|
mp.preallocated = mp.preallocated[:len(mp.preallocated)-1]
|
||||||
|
mp.mutex.Unlock()
|
||||||
|
atomic.AddInt64(&mp.hitCount, 1)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
mp.mutex.Unlock()
|
||||||
|
|
||||||
|
// Try channel pool next
|
||||||
|
select {
|
||||||
|
case msg := <-mp.pool:
|
||||||
|
atomic.AddInt64(&mp.hitCount, 1)
|
||||||
|
return msg
|
||||||
|
default:
|
||||||
|
// Pool exhausted, create new message
|
||||||
|
atomic.AddInt64(&mp.missCount, 1)
|
||||||
|
return &OptimizedIPCMessage{
|
||||||
|
data: make([]byte, 0, maxFrameSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put returns a message to the pool
|
||||||
|
func (mp *MessagePool) Put(msg *OptimizedIPCMessage) {
|
||||||
|
// Reset the message for reuse
|
||||||
|
msg.data = msg.data[:0]
|
||||||
|
msg.msg = InputIPCMessage{}
|
||||||
|
|
||||||
|
// First try to return to pre-allocated pool for fastest reuse
|
||||||
|
mp.mutex.Lock()
|
||||||
|
if len(mp.preallocated) < mp.preallocSize {
|
||||||
|
mp.preallocated = append(mp.preallocated, msg)
|
||||||
|
mp.mutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mp.mutex.Unlock()
|
||||||
|
|
||||||
|
// Try channel pool next
|
||||||
|
select {
|
||||||
|
case mp.pool <- msg:
|
||||||
|
// Successfully returned to pool
|
||||||
|
default:
|
||||||
|
// Pool full, let GC handle it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// InputIPCConfig represents configuration for audio input
|
// InputIPCConfig represents configuration for audio input
|
||||||
type InputIPCConfig struct {
|
type InputIPCConfig struct {
|
||||||
SampleRate int
|
SampleRate int
|
||||||
|
@ -66,6 +178,9 @@ type AudioInputServer struct {
|
||||||
processChan chan *InputIPCMessage // Buffered channel for processing queue
|
processChan chan *InputIPCMessage // Buffered channel for processing queue
|
||||||
stopChan chan struct{} // Stop signal for all goroutines
|
stopChan chan struct{} // Stop signal for all goroutines
|
||||||
wg sync.WaitGroup // Wait group for goroutine coordination
|
wg sync.WaitGroup // Wait group for goroutine coordination
|
||||||
|
|
||||||
|
// Socket buffer configuration
|
||||||
|
socketBufferConfig SocketBufferConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAudioInputServer creates a new audio input server
|
// NewAudioInputServer creates a new audio input server
|
||||||
|
@ -79,15 +194,20 @@ func NewAudioInputServer() (*AudioInputServer, error) {
|
||||||
return nil, fmt.Errorf("failed to create unix socket: %w", err)
|
return nil, fmt.Errorf("failed to create unix socket: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize with adaptive buffer size (start with 1000 frames)
|
// Get initial buffer size from adaptive buffer manager
|
||||||
initialBufferSize := int64(1000)
|
adaptiveManager := GetAdaptiveBufferManager()
|
||||||
|
initialBufferSize := int64(adaptiveManager.GetInputBufferSize())
|
||||||
|
|
||||||
|
// Initialize socket buffer configuration
|
||||||
|
socketBufferConfig := DefaultSocketBufferConfig()
|
||||||
|
|
||||||
return &AudioInputServer{
|
return &AudioInputServer{
|
||||||
listener: listener,
|
listener: listener,
|
||||||
messageChan: make(chan *InputIPCMessage, initialBufferSize),
|
messageChan: make(chan *InputIPCMessage, initialBufferSize),
|
||||||
processChan: make(chan *InputIPCMessage, initialBufferSize),
|
processChan: make(chan *InputIPCMessage, initialBufferSize),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
bufferSize: initialBufferSize,
|
bufferSize: initialBufferSize,
|
||||||
|
socketBufferConfig: socketBufferConfig,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,6 +277,16 @@ func (ais *AudioInputServer) acceptConnections() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure socket buffers for optimal performance
|
||||||
|
if err := ConfigureSocketBuffers(conn, ais.socketBufferConfig); err != nil {
|
||||||
|
// Log warning but don't fail - socket buffer optimization is not critical
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-server").Logger()
|
||||||
|
logger.Warn().Err(err).Msg("Failed to configure socket buffers, continuing with defaults")
|
||||||
|
} else {
|
||||||
|
// Record socket buffer metrics for monitoring
|
||||||
|
RecordSocketBufferMetrics(conn, "audio-input")
|
||||||
|
}
|
||||||
|
|
||||||
ais.mtx.Lock()
|
ais.mtx.Lock()
|
||||||
// Close existing connection if any
|
// Close existing connection if any
|
||||||
if ais.conn != nil {
|
if ais.conn != nil {
|
||||||
|
@ -192,21 +322,22 @@ func (ais *AudioInputServer) handleConnection(conn net.Conn) {
|
||||||
|
|
||||||
// readMessage reads a complete message from the connection
|
// readMessage reads a complete message from the connection
|
||||||
func (ais *AudioInputServer) readMessage(conn net.Conn) (*InputIPCMessage, error) {
|
func (ais *AudioInputServer) readMessage(conn net.Conn) (*InputIPCMessage, error) {
|
||||||
// Read header (magic + type + length + timestamp)
|
// Get optimized message from pool
|
||||||
headerSize := 4 + 1 + 4 + 8 // uint32 + uint8 + uint32 + int64
|
optMsg := globalMessagePool.Get()
|
||||||
header := make([]byte, headerSize)
|
defer globalMessagePool.Put(optMsg)
|
||||||
|
|
||||||
_, err := io.ReadFull(conn, header)
|
// Read header directly into pre-allocated buffer
|
||||||
|
_, err := io.ReadFull(conn, optMsg.header[:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse header
|
// Parse header using optimized access
|
||||||
msg := &InputIPCMessage{}
|
msg := &optMsg.msg
|
||||||
msg.Magic = binary.LittleEndian.Uint32(header[0:4])
|
msg.Magic = binary.LittleEndian.Uint32(optMsg.header[0:4])
|
||||||
msg.Type = InputMessageType(header[4])
|
msg.Type = InputMessageType(optMsg.header[4])
|
||||||
msg.Length = binary.LittleEndian.Uint32(header[5:9])
|
msg.Length = binary.LittleEndian.Uint32(optMsg.header[5:9])
|
||||||
msg.Timestamp = int64(binary.LittleEndian.Uint64(header[9:17]))
|
msg.Timestamp = int64(binary.LittleEndian.Uint64(optMsg.header[9:17]))
|
||||||
|
|
||||||
// Validate magic number
|
// Validate magic number
|
||||||
if msg.Magic != inputMagicNumber {
|
if msg.Magic != inputMagicNumber {
|
||||||
|
@ -218,16 +349,37 @@ func (ais *AudioInputServer) readMessage(conn net.Conn) (*InputIPCMessage, error
|
||||||
return nil, fmt.Errorf("message too large: %d bytes", msg.Length)
|
return nil, fmt.Errorf("message too large: %d bytes", msg.Length)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read data if present
|
// Read data if present using pooled buffer
|
||||||
if msg.Length > 0 {
|
if msg.Length > 0 {
|
||||||
msg.Data = make([]byte, msg.Length)
|
// Ensure buffer capacity
|
||||||
_, err = io.ReadFull(conn, msg.Data)
|
if cap(optMsg.data) < int(msg.Length) {
|
||||||
|
optMsg.data = make([]byte, msg.Length)
|
||||||
|
} else {
|
||||||
|
optMsg.data = optMsg.data[:msg.Length]
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.ReadFull(conn, optMsg.data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
msg.Data = optMsg.data
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg, nil
|
// Return a copy of the message (data will be copied by caller if needed)
|
||||||
|
result := &InputIPCMessage{
|
||||||
|
Magic: msg.Magic,
|
||||||
|
Type: msg.Type,
|
||||||
|
Length: msg.Length,
|
||||||
|
Timestamp: msg.Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Length > 0 {
|
||||||
|
// Copy data to ensure it's not affected by buffer reuse
|
||||||
|
result.Data = make([]byte, msg.Length)
|
||||||
|
copy(result.Data, msg.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// processMessage processes a received message
|
// processMessage processes a received message
|
||||||
|
@ -282,19 +434,20 @@ func (ais *AudioInputServer) sendAck() error {
|
||||||
return ais.writeMessage(ais.conn, msg)
|
return ais.writeMessage(ais.conn, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeMessage writes a message to the connection
|
// writeMessage writes a message to the connection using optimized buffers
|
||||||
func (ais *AudioInputServer) writeMessage(conn net.Conn, msg *InputIPCMessage) error {
|
func (ais *AudioInputServer) writeMessage(conn net.Conn, msg *InputIPCMessage) error {
|
||||||
// Prepare header
|
// Get optimized message from pool for header preparation
|
||||||
headerSize := 4 + 1 + 4 + 8
|
optMsg := globalMessagePool.Get()
|
||||||
header := make([]byte, headerSize)
|
defer globalMessagePool.Put(optMsg)
|
||||||
|
|
||||||
binary.LittleEndian.PutUint32(header[0:4], msg.Magic)
|
// Prepare header in pre-allocated buffer
|
||||||
header[4] = byte(msg.Type)
|
binary.LittleEndian.PutUint32(optMsg.header[0:4], msg.Magic)
|
||||||
binary.LittleEndian.PutUint32(header[5:9], msg.Length)
|
optMsg.header[4] = byte(msg.Type)
|
||||||
binary.LittleEndian.PutUint64(header[9:17], uint64(msg.Timestamp))
|
binary.LittleEndian.PutUint32(optMsg.header[5:9], msg.Length)
|
||||||
|
binary.LittleEndian.PutUint64(optMsg.header[9:17], uint64(msg.Timestamp))
|
||||||
|
|
||||||
// Write header
|
// Write header
|
||||||
_, err := conn.Write(header)
|
_, err := conn.Write(optMsg.header[:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -312,7 +465,7 @@ func (ais *AudioInputServer) writeMessage(conn net.Conn, msg *InputIPCMessage) e
|
||||||
|
|
||||||
// AudioInputClient handles IPC communication from the main process
|
// AudioInputClient handles IPC communication from the main process
|
||||||
type AudioInputClient struct {
|
type AudioInputClient struct {
|
||||||
// Atomic fields must be first for proper alignment on ARM
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
droppedFrames int64 // Atomic counter for dropped frames
|
droppedFrames int64 // Atomic counter for dropped frames
|
||||||
totalFrames int64 // Atomic counter for total frames
|
totalFrames int64 // Atomic counter for total frames
|
||||||
|
|
||||||
|
@ -410,6 +563,35 @@ func (aic *AudioInputClient) SendFrame(frame []byte) error {
|
||||||
return aic.writeMessage(msg)
|
return aic.writeMessage(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendFrameZeroCopy sends a zero-copy Opus frame to the audio input server
|
||||||
|
func (aic *AudioInputClient) SendFrameZeroCopy(frame *ZeroCopyAudioFrame) error {
|
||||||
|
aic.mtx.Lock()
|
||||||
|
defer aic.mtx.Unlock()
|
||||||
|
|
||||||
|
if !aic.running || aic.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if frame == nil || frame.Length() == 0 {
|
||||||
|
return nil // Empty frame, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if frame.Length() > maxFrameSize {
|
||||||
|
return fmt.Errorf("frame too large: %d bytes", frame.Length())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use zero-copy data directly
|
||||||
|
msg := &InputIPCMessage{
|
||||||
|
Magic: inputMagicNumber,
|
||||||
|
Type: InputMessageTypeOpusFrame,
|
||||||
|
Length: uint32(frame.Length()),
|
||||||
|
Timestamp: time.Now().UnixNano(),
|
||||||
|
Data: frame.Data(), // Zero-copy data access
|
||||||
|
}
|
||||||
|
|
||||||
|
return aic.writeMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// SendConfig sends a configuration update to the audio input server
|
// SendConfig sends a configuration update to the audio input server
|
||||||
func (aic *AudioInputClient) SendConfig(config InputIPCConfig) error {
|
func (aic *AudioInputClient) SendConfig(config InputIPCConfig) error {
|
||||||
aic.mtx.Lock()
|
aic.mtx.Lock()
|
||||||
|
@ -460,14 +642,15 @@ func (aic *AudioInputClient) writeMessage(msg *InputIPCMessage) error {
|
||||||
// Increment total frames counter
|
// Increment total frames counter
|
||||||
atomic.AddInt64(&aic.totalFrames, 1)
|
atomic.AddInt64(&aic.totalFrames, 1)
|
||||||
|
|
||||||
// Prepare header
|
// Get optimized message from pool for header preparation
|
||||||
headerSize := 4 + 1 + 4 + 8
|
optMsg := globalMessagePool.Get()
|
||||||
header := make([]byte, headerSize)
|
defer globalMessagePool.Put(optMsg)
|
||||||
|
|
||||||
binary.LittleEndian.PutUint32(header[0:4], msg.Magic)
|
// Prepare header in pre-allocated buffer
|
||||||
header[4] = byte(msg.Type)
|
binary.LittleEndian.PutUint32(optMsg.header[0:4], msg.Magic)
|
||||||
binary.LittleEndian.PutUint32(header[5:9], msg.Length)
|
optMsg.header[4] = byte(msg.Type)
|
||||||
binary.LittleEndian.PutUint64(header[9:17], uint64(msg.Timestamp))
|
binary.LittleEndian.PutUint32(optMsg.header[5:9], msg.Length)
|
||||||
|
binary.LittleEndian.PutUint64(optMsg.header[9:17], uint64(msg.Timestamp))
|
||||||
|
|
||||||
// Use non-blocking write with timeout
|
// Use non-blocking write with timeout
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
||||||
|
@ -476,8 +659,8 @@ func (aic *AudioInputClient) writeMessage(msg *InputIPCMessage) error {
|
||||||
// Create a channel to signal write completion
|
// Create a channel to signal write completion
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
// Write header
|
// Write header using pre-allocated buffer
|
||||||
_, err := aic.conn.Write(header)
|
_, err := aic.conn.Write(optMsg.header[:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
done <- err
|
done <- err
|
||||||
return
|
return
|
||||||
|
@ -570,6 +753,20 @@ func (ais *AudioInputServer) startReaderGoroutine() {
|
||||||
func (ais *AudioInputServer) startProcessorGoroutine() {
|
func (ais *AudioInputServer) startProcessorGoroutine() {
|
||||||
ais.wg.Add(1)
|
ais.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
// Set high priority for audio processing
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-processor").Logger()
|
||||||
|
if err := SetAudioThreadPriority(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to set audio processing priority")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := ResetThreadPriority(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to reset thread priority")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
defer ais.wg.Done()
|
defer ais.wg.Done()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
@ -608,10 +805,28 @@ func (ais *AudioInputServer) startProcessorGoroutine() {
|
||||||
func (ais *AudioInputServer) startMonitorGoroutine() {
|
func (ais *AudioInputServer) startMonitorGoroutine() {
|
||||||
ais.wg.Add(1)
|
ais.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
// Set I/O priority for monitoring
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-monitor").Logger()
|
||||||
|
if err := SetAudioIOThreadPriority(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to set audio I/O priority")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := ResetThreadPriority(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to reset thread priority")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
defer ais.wg.Done()
|
defer ais.wg.Done()
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Buffer size update ticker (less frequent)
|
||||||
|
bufferUpdateTicker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer bufferUpdateTicker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ais.stopChan:
|
case <-ais.stopChan:
|
||||||
|
@ -623,52 +838,46 @@ func (ais *AudioInputServer) startMonitorGoroutine() {
|
||||||
case msg := <-ais.processChan:
|
case msg := <-ais.processChan:
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := ais.processMessage(msg)
|
err := ais.processMessage(msg)
|
||||||
processingTime := time.Since(start).Nanoseconds()
|
processingTime := time.Since(start)
|
||||||
|
|
||||||
// Calculate end-to-end latency using message timestamp
|
// Calculate end-to-end latency using message timestamp
|
||||||
|
var latency time.Duration
|
||||||
if msg.Type == InputMessageTypeOpusFrame && msg.Timestamp > 0 {
|
if msg.Type == InputMessageTypeOpusFrame && msg.Timestamp > 0 {
|
||||||
msgTime := time.Unix(0, msg.Timestamp)
|
msgTime := time.Unix(0, msg.Timestamp)
|
||||||
endToEndLatency := time.Since(msgTime).Nanoseconds()
|
latency = time.Since(msgTime)
|
||||||
// Use exponential moving average for end-to-end latency tracking
|
// Use exponential moving average for end-to-end latency tracking
|
||||||
currentAvg := atomic.LoadInt64(&ais.processingTime)
|
currentAvg := atomic.LoadInt64(&ais.processingTime)
|
||||||
// Weight: 90% historical, 10% current (for smoother averaging)
|
// Weight: 90% historical, 10% current (for smoother averaging)
|
||||||
newAvg := (currentAvg*9 + endToEndLatency) / 10
|
newAvg := (currentAvg*9 + latency.Nanoseconds()) / 10
|
||||||
atomic.StoreInt64(&ais.processingTime, newAvg)
|
atomic.StoreInt64(&ais.processingTime, newAvg)
|
||||||
} else {
|
} else {
|
||||||
// Fallback to processing time only
|
// Fallback to processing time only
|
||||||
|
latency = processingTime
|
||||||
currentAvg := atomic.LoadInt64(&ais.processingTime)
|
currentAvg := atomic.LoadInt64(&ais.processingTime)
|
||||||
newAvg := (currentAvg + processingTime) / 2
|
newAvg := (currentAvg + processingTime.Nanoseconds()) / 2
|
||||||
atomic.StoreInt64(&ais.processingTime, newAvg)
|
atomic.StoreInt64(&ais.processingTime, newAvg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report latency to adaptive buffer manager
|
||||||
|
ais.ReportLatency(latency)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
atomic.AddInt64(&ais.droppedFrames, 1)
|
atomic.AddInt64(&ais.droppedFrames, 1)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// No more messages to process
|
// No more messages to process
|
||||||
goto adaptiveBuffering
|
goto checkBufferUpdate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adaptiveBuffering:
|
checkBufferUpdate:
|
||||||
// Adaptive buffer sizing based on processing time
|
// Check if we need to update buffer size
|
||||||
avgTime := atomic.LoadInt64(&ais.processingTime)
|
select {
|
||||||
currentSize := atomic.LoadInt64(&ais.bufferSize)
|
case <-bufferUpdateTicker.C:
|
||||||
|
// Update buffer size from adaptive buffer manager
|
||||||
if avgTime > 10*1000*1000 { // > 10ms processing time
|
ais.UpdateBufferSize()
|
||||||
// Increase buffer size
|
default:
|
||||||
newSize := currentSize * 2
|
// No buffer update needed
|
||||||
if newSize > 1000 {
|
|
||||||
newSize = 1000
|
|
||||||
}
|
|
||||||
atomic.StoreInt64(&ais.bufferSize, newSize)
|
|
||||||
} else if avgTime < 1*1000*1000 { // < 1ms processing time
|
|
||||||
// Decrease buffer size
|
|
||||||
newSize := currentSize / 2
|
|
||||||
if newSize < 50 {
|
|
||||||
newSize = 50
|
|
||||||
}
|
|
||||||
atomic.StoreInt64(&ais.bufferSize, newSize)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -683,6 +892,64 @@ func (ais *AudioInputServer) GetServerStats() (total, dropped int64, avgProcessi
|
||||||
atomic.LoadInt64(&ais.bufferSize)
|
atomic.LoadInt64(&ais.bufferSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateBufferSize updates the buffer size from adaptive buffer manager
|
||||||
|
func (ais *AudioInputServer) UpdateBufferSize() {
|
||||||
|
adaptiveManager := GetAdaptiveBufferManager()
|
||||||
|
newSize := int64(adaptiveManager.GetInputBufferSize())
|
||||||
|
atomic.StoreInt64(&ais.bufferSize, newSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportLatency reports processing latency to adaptive buffer manager
|
||||||
|
func (ais *AudioInputServer) ReportLatency(latency time.Duration) {
|
||||||
|
adaptiveManager := GetAdaptiveBufferManager()
|
||||||
|
adaptiveManager.UpdateLatency(latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessagePoolStats returns detailed statistics about the message pool
|
||||||
|
func (mp *MessagePool) GetMessagePoolStats() MessagePoolStats {
|
||||||
|
mp.mutex.RLock()
|
||||||
|
preallocatedCount := len(mp.preallocated)
|
||||||
|
mp.mutex.RUnlock()
|
||||||
|
|
||||||
|
hitCount := atomic.LoadInt64(&mp.hitCount)
|
||||||
|
missCount := atomic.LoadInt64(&mp.missCount)
|
||||||
|
totalRequests := hitCount + missCount
|
||||||
|
|
||||||
|
var hitRate float64
|
||||||
|
if totalRequests > 0 {
|
||||||
|
hitRate = float64(hitCount) / float64(totalRequests) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate channel pool size
|
||||||
|
channelPoolSize := len(mp.pool)
|
||||||
|
|
||||||
|
return MessagePoolStats{
|
||||||
|
MaxPoolSize: mp.maxPoolSize,
|
||||||
|
ChannelPoolSize: channelPoolSize,
|
||||||
|
PreallocatedCount: int64(preallocatedCount),
|
||||||
|
PreallocatedMax: int64(mp.preallocSize),
|
||||||
|
HitCount: hitCount,
|
||||||
|
MissCount: missCount,
|
||||||
|
HitRate: hitRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagePoolStats provides detailed message pool statistics
|
||||||
|
type MessagePoolStats struct {
|
||||||
|
MaxPoolSize int
|
||||||
|
ChannelPoolSize int
|
||||||
|
PreallocatedCount int64
|
||||||
|
PreallocatedMax int64
|
||||||
|
HitCount int64
|
||||||
|
MissCount int64
|
||||||
|
HitRate float64 // Percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGlobalMessagePoolStats returns statistics for the global message pool
|
||||||
|
func GetGlobalMessagePoolStats() MessagePoolStats {
|
||||||
|
return globalMessagePool.GetMessagePoolStats()
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
// getInputSocketPath returns the path to the input socket
|
// getInputSocketPath returns the path to the input socket
|
||||||
|
|
|
@ -116,6 +116,40 @@ func (aim *AudioInputIPCManager) WriteOpusFrame(frame []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteOpusFrameZeroCopy sends an Opus frame via IPC using zero-copy optimization
|
||||||
|
func (aim *AudioInputIPCManager) WriteOpusFrameZeroCopy(frame *ZeroCopyAudioFrame) error {
|
||||||
|
if atomic.LoadInt32(&aim.running) == 0 {
|
||||||
|
return nil // Not running, silently ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if frame == nil || frame.Length() == 0 {
|
||||||
|
return nil // Empty frame, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start latency measurement
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Update metrics
|
||||||
|
atomic.AddInt64(&aim.metrics.FramesSent, 1)
|
||||||
|
atomic.AddInt64(&aim.metrics.BytesProcessed, int64(frame.Length()))
|
||||||
|
aim.metrics.LastFrameTime = startTime
|
||||||
|
|
||||||
|
// Send frame via IPC using zero-copy data
|
||||||
|
err := aim.supervisor.SendFrameZeroCopy(frame)
|
||||||
|
if err != nil {
|
||||||
|
// Count as dropped frame
|
||||||
|
atomic.AddInt64(&aim.metrics.FramesDropped, 1)
|
||||||
|
aim.logger.Debug().Err(err).Msg("Failed to send zero-copy frame via IPC")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate and update latency (end-to-end IPC transmission time)
|
||||||
|
latency := time.Since(startTime)
|
||||||
|
aim.updateLatencyMetrics(latency)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsRunning returns whether the IPC manager is running
|
// IsRunning returns whether the IPC manager is running
|
||||||
func (aim *AudioInputIPCManager) IsRunning() bool {
|
func (aim *AudioInputIPCManager) IsRunning() bool {
|
||||||
return atomic.LoadInt32(&aim.running) == 1
|
return atomic.LoadInt32(&aim.running) == 1
|
||||||
|
|
|
@ -16,6 +16,10 @@ func RunAudioInputServer() error {
|
||||||
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-server").Logger()
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-server").Logger()
|
||||||
logger.Info().Msg("Starting audio input server subprocess")
|
logger.Info().Msg("Starting audio input server subprocess")
|
||||||
|
|
||||||
|
// Start adaptive buffer management for optimal performance
|
||||||
|
StartAdaptiveBuffering()
|
||||||
|
defer StopAdaptiveBuffering()
|
||||||
|
|
||||||
// Initialize CGO audio system
|
// Initialize CGO audio system
|
||||||
err := CGOAudioPlaybackInit()
|
err := CGOAudioPlaybackInit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -244,6 +244,19 @@ func (ais *AudioInputSupervisor) SendFrame(frame []byte) error {
|
||||||
return ais.client.SendFrame(frame)
|
return ais.client.SendFrame(frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendFrameZeroCopy sends a zero-copy frame to the subprocess
|
||||||
|
func (ais *AudioInputSupervisor) SendFrameZeroCopy(frame *ZeroCopyAudioFrame) error {
|
||||||
|
if ais.client == nil {
|
||||||
|
return fmt.Errorf("client not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ais.client.IsConnected() {
|
||||||
|
return fmt.Errorf("client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ais.client.SendFrameZeroCopy(frame)
|
||||||
|
}
|
||||||
|
|
||||||
// SendConfig sends a configuration update to the subprocess (convenience method)
|
// SendConfig sends a configuration update to the subprocess (convenience method)
|
||||||
func (ais *AudioInputSupervisor) SendConfig(config InputIPCConfig) error {
|
func (ais *AudioInputSupervisor) SendConfig(config InputIPCConfig) error {
|
||||||
if ais.client == nil {
|
if ais.client == nil {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
@ -8,22 +9,123 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
magicNumber uint32 = 0x4A4B564D // "JKVM"
|
outputMagicNumber uint32 = 0x4A4B4F55 // "JKOU" (JetKVM Output)
|
||||||
socketName = "audio_output.sock"
|
outputSocketName = "audio_output.sock"
|
||||||
|
outputMaxFrameSize = 4096 // Maximum Opus frame size
|
||||||
|
outputWriteTimeout = 10 * time.Millisecond // Non-blocking write timeout (increased for high load)
|
||||||
|
outputMaxDroppedFrames = 50 // Maximum consecutive dropped frames
|
||||||
|
outputHeaderSize = 17 // Fixed header size: 4+1+4+8 bytes
|
||||||
|
outputMessagePoolSize = 128 // Pre-allocated message pool size
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OutputMessageType represents the type of IPC message
|
||||||
|
type OutputMessageType uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutputMessageTypeOpusFrame OutputMessageType = iota
|
||||||
|
OutputMessageTypeConfig
|
||||||
|
OutputMessageTypeStop
|
||||||
|
OutputMessageTypeHeartbeat
|
||||||
|
OutputMessageTypeAck
|
||||||
|
)
|
||||||
|
|
||||||
|
// OutputIPCMessage represents an IPC message for audio output
|
||||||
|
type OutputIPCMessage struct {
|
||||||
|
Magic uint32
|
||||||
|
Type OutputMessageType
|
||||||
|
Length uint32
|
||||||
|
Timestamp int64
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputOptimizedMessage represents a pre-allocated message for zero-allocation operations
|
||||||
|
type OutputOptimizedMessage struct {
|
||||||
|
header [outputHeaderSize]byte // Pre-allocated header buffer
|
||||||
|
data []byte // Reusable data buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputMessagePool manages pre-allocated messages for zero-allocation IPC
|
||||||
|
type OutputMessagePool struct {
|
||||||
|
pool chan *OutputOptimizedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOutputMessagePool creates a new message pool
|
||||||
|
func NewOutputMessagePool(size int) *OutputMessagePool {
|
||||||
|
pool := &OutputMessagePool{
|
||||||
|
pool: make(chan *OutputOptimizedMessage, size),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-allocate messages
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
msg := &OutputOptimizedMessage{
|
||||||
|
data: make([]byte, outputMaxFrameSize),
|
||||||
|
}
|
||||||
|
pool.pool <- msg
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a message from the pool
|
||||||
|
func (p *OutputMessagePool) Get() *OutputOptimizedMessage {
|
||||||
|
select {
|
||||||
|
case msg := <-p.pool:
|
||||||
|
return msg
|
||||||
|
default:
|
||||||
|
// Pool exhausted, create new message
|
||||||
|
return &OutputOptimizedMessage{
|
||||||
|
data: make([]byte, outputMaxFrameSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put returns a message to the pool
|
||||||
|
func (p *OutputMessagePool) Put(msg *OutputOptimizedMessage) {
|
||||||
|
select {
|
||||||
|
case p.pool <- msg:
|
||||||
|
// Successfully returned to pool
|
||||||
|
default:
|
||||||
|
// Pool full, let GC handle it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global message pool for output IPC
|
||||||
|
var globalOutputMessagePool = NewOutputMessagePool(outputMessagePoolSize)
|
||||||
|
|
||||||
type AudioServer struct {
|
type AudioServer struct {
|
||||||
|
// Atomic fields must be first for proper alignment on ARM
|
||||||
|
bufferSize int64 // Current buffer size (atomic)
|
||||||
|
droppedFrames int64 // Dropped frames counter (atomic)
|
||||||
|
totalFrames int64 // Total frames counter (atomic)
|
||||||
|
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
mtx sync.Mutex
|
mtx sync.Mutex
|
||||||
|
running bool
|
||||||
|
|
||||||
|
// Advanced message handling
|
||||||
|
messageChan chan *OutputIPCMessage // Buffered channel for incoming messages
|
||||||
|
stopChan chan struct{} // Stop signal
|
||||||
|
wg sync.WaitGroup // Wait group for goroutine coordination
|
||||||
|
|
||||||
|
// Latency monitoring
|
||||||
|
latencyMonitor *LatencyMonitor
|
||||||
|
adaptiveOptimizer *AdaptiveOptimizer
|
||||||
|
|
||||||
|
// Socket buffer configuration
|
||||||
|
socketBufferConfig SocketBufferConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAudioServer() (*AudioServer, error) {
|
func NewAudioServer() (*AudioServer, error) {
|
||||||
socketPath := filepath.Join("/var/run", socketName)
|
socketPath := getOutputSocketPath()
|
||||||
// Remove existing socket if any
|
// Remove existing socket if any
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
|
|
||||||
|
@ -32,26 +134,192 @@ func NewAudioServer() (*AudioServer, error) {
|
||||||
return nil, fmt.Errorf("failed to create unix socket: %w", err)
|
return nil, fmt.Errorf("failed to create unix socket: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AudioServer{listener: listener}, nil
|
// Initialize with adaptive buffer size (start with 500 frames)
|
||||||
|
initialBufferSize := int64(500)
|
||||||
|
|
||||||
|
// Initialize latency monitoring
|
||||||
|
latencyConfig := DefaultLatencyConfig()
|
||||||
|
logger := zerolog.New(os.Stderr).With().Timestamp().Str("component", "audio-server").Logger()
|
||||||
|
latencyMonitor := NewLatencyMonitor(latencyConfig, logger)
|
||||||
|
|
||||||
|
// Initialize adaptive buffer manager with default config
|
||||||
|
bufferConfig := DefaultAdaptiveBufferConfig()
|
||||||
|
bufferManager := NewAdaptiveBufferManager(bufferConfig)
|
||||||
|
|
||||||
|
// Initialize adaptive optimizer
|
||||||
|
optimizerConfig := DefaultOptimizerConfig()
|
||||||
|
adaptiveOptimizer := NewAdaptiveOptimizer(latencyMonitor, bufferManager, optimizerConfig, logger)
|
||||||
|
|
||||||
|
// Initialize socket buffer configuration
|
||||||
|
socketBufferConfig := DefaultSocketBufferConfig()
|
||||||
|
|
||||||
|
return &AudioServer{
|
||||||
|
listener: listener,
|
||||||
|
messageChan: make(chan *OutputIPCMessage, initialBufferSize),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
bufferSize: initialBufferSize,
|
||||||
|
latencyMonitor: latencyMonitor,
|
||||||
|
adaptiveOptimizer: adaptiveOptimizer,
|
||||||
|
socketBufferConfig: socketBufferConfig,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AudioServer) Start() error {
|
func (s *AudioServer) Start() error {
|
||||||
conn, err := s.listener.Accept()
|
s.mtx.Lock()
|
||||||
if err != nil {
|
defer s.mtx.Unlock()
|
||||||
return fmt.Errorf("failed to accept connection: %w", err)
|
|
||||||
|
if s.running {
|
||||||
|
return fmt.Errorf("server already running")
|
||||||
}
|
}
|
||||||
s.conn = conn
|
|
||||||
|
s.running = true
|
||||||
|
|
||||||
|
// Start latency monitoring and adaptive optimization
|
||||||
|
if s.latencyMonitor != nil {
|
||||||
|
s.latencyMonitor.Start()
|
||||||
|
}
|
||||||
|
if s.adaptiveOptimizer != nil {
|
||||||
|
s.adaptiveOptimizer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start message processor goroutine
|
||||||
|
s.startProcessorGoroutine()
|
||||||
|
|
||||||
|
// Accept connections in a goroutine
|
||||||
|
go s.acceptConnections()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AudioServer) Close() error {
|
// acceptConnections accepts incoming connections
|
||||||
|
func (s *AudioServer) acceptConnections() {
|
||||||
|
for s.running {
|
||||||
|
conn, err := s.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if s.running {
|
||||||
|
// Only log error if we're still supposed to be running
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure socket buffers for optimal performance
|
||||||
|
if err := ConfigureSocketBuffers(conn, s.socketBufferConfig); err != nil {
|
||||||
|
// Log warning but don't fail - socket buffer optimization is not critical
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-server").Logger()
|
||||||
|
logger.Warn().Err(err).Msg("Failed to configure socket buffers, continuing with defaults")
|
||||||
|
} else {
|
||||||
|
// Record socket buffer metrics for monitoring
|
||||||
|
RecordSocketBufferMetrics(conn, "audio-output")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mtx.Lock()
|
||||||
|
// Close existing connection if any
|
||||||
|
if s.conn != nil {
|
||||||
|
s.conn.Close()
|
||||||
|
}
|
||||||
|
s.conn = conn
|
||||||
|
s.mtx.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startProcessorGoroutine starts the message processor
|
||||||
|
func (s *AudioServer) startProcessorGoroutine() {
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg := <-s.messageChan:
|
||||||
|
// Process message (currently just frame sending)
|
||||||
|
if msg.Type == OutputMessageTypeOpusFrame {
|
||||||
|
if err := s.sendFrameToClient(msg.Data); err != nil {
|
||||||
|
// Log error but continue processing
|
||||||
|
atomic.AddInt64(&s.droppedFrames, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-s.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AudioServer) Stop() {
|
||||||
|
s.mtx.Lock()
|
||||||
|
defer s.mtx.Unlock()
|
||||||
|
|
||||||
|
if !s.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.running = false
|
||||||
|
|
||||||
|
// Stop latency monitoring and adaptive optimization
|
||||||
|
if s.adaptiveOptimizer != nil {
|
||||||
|
s.adaptiveOptimizer.Stop()
|
||||||
|
}
|
||||||
|
if s.latencyMonitor != nil {
|
||||||
|
s.latencyMonitor.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal processor to stop
|
||||||
|
close(s.stopChan)
|
||||||
|
s.wg.Wait()
|
||||||
|
|
||||||
if s.conn != nil {
|
if s.conn != nil {
|
||||||
s.conn.Close()
|
s.conn.Close()
|
||||||
|
s.conn = nil
|
||||||
}
|
}
|
||||||
return s.listener.Close()
|
}
|
||||||
|
|
||||||
|
func (s *AudioServer) Close() error {
|
||||||
|
s.Stop()
|
||||||
|
if s.listener != nil {
|
||||||
|
s.listener.Close()
|
||||||
|
}
|
||||||
|
// Remove socket file
|
||||||
|
os.Remove(getOutputSocketPath())
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AudioServer) SendFrame(frame []byte) error {
|
func (s *AudioServer) SendFrame(frame []byte) error {
|
||||||
|
if len(frame) > outputMaxFrameSize {
|
||||||
|
return fmt.Errorf("frame size %d exceeds maximum %d", len(frame), outputMaxFrameSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Create IPC message
|
||||||
|
msg := &OutputIPCMessage{
|
||||||
|
Magic: outputMagicNumber,
|
||||||
|
Type: OutputMessageTypeOpusFrame,
|
||||||
|
Length: uint32(len(frame)),
|
||||||
|
Timestamp: start.UnixNano(),
|
||||||
|
Data: frame,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to send via message channel (non-blocking)
|
||||||
|
select {
|
||||||
|
case s.messageChan <- msg:
|
||||||
|
atomic.AddInt64(&s.totalFrames, 1)
|
||||||
|
|
||||||
|
// Record latency for monitoring
|
||||||
|
if s.latencyMonitor != nil {
|
||||||
|
processingTime := time.Since(start)
|
||||||
|
s.latencyMonitor.RecordLatency(processingTime, "ipc_send")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
// Channel full, drop frame to prevent blocking
|
||||||
|
atomic.AddInt64(&s.droppedFrames, 1)
|
||||||
|
return fmt.Errorf("message channel full - frame dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendFrameToClient sends frame data directly to the connected client
|
||||||
|
func (s *AudioServer) sendFrameToClient(frame []byte) error {
|
||||||
s.mtx.Lock()
|
s.mtx.Lock()
|
||||||
defer s.mtx.Unlock()
|
defer s.mtx.Unlock()
|
||||||
|
|
||||||
|
@ -59,70 +327,199 @@ func (s *AudioServer) SendFrame(frame []byte) error {
|
||||||
return fmt.Errorf("no client connected")
|
return fmt.Errorf("no client connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write magic number
|
start := time.Now()
|
||||||
if err := binary.Write(s.conn, binary.BigEndian, magicNumber); err != nil {
|
|
||||||
return fmt.Errorf("failed to write magic number: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write frame size
|
// Get optimized message from pool
|
||||||
if err := binary.Write(s.conn, binary.BigEndian, uint32(len(frame))); err != nil {
|
optMsg := globalOutputMessagePool.Get()
|
||||||
return fmt.Errorf("failed to write frame size: %w", err)
|
defer globalOutputMessagePool.Put(optMsg)
|
||||||
}
|
|
||||||
|
|
||||||
// Write frame data
|
// Prepare header in pre-allocated buffer
|
||||||
if _, err := s.conn.Write(frame); err != nil {
|
binary.LittleEndian.PutUint32(optMsg.header[0:4], outputMagicNumber)
|
||||||
return fmt.Errorf("failed to write frame data: %w", err)
|
optMsg.header[4] = byte(OutputMessageTypeOpusFrame)
|
||||||
}
|
binary.LittleEndian.PutUint32(optMsg.header[5:9], uint32(len(frame)))
|
||||||
|
binary.LittleEndian.PutUint64(optMsg.header[9:17], uint64(start.UnixNano()))
|
||||||
|
|
||||||
return nil
|
// Use non-blocking write with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), outputWriteTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create a channel to signal write completion
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
// Write header using pre-allocated buffer
|
||||||
|
_, err := s.conn.Write(optMsg.header[:])
|
||||||
|
if err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write frame data
|
||||||
|
if len(frame) > 0 {
|
||||||
|
_, err = s.conn.Write(frame)
|
||||||
|
if err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for completion or timeout
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
atomic.AddInt64(&s.droppedFrames, 1)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Record latency for monitoring
|
||||||
|
if s.latencyMonitor != nil {
|
||||||
|
writeLatency := time.Since(start)
|
||||||
|
s.latencyMonitor.RecordLatency(writeLatency, "ipc_write")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Timeout occurred - drop frame to prevent blocking
|
||||||
|
atomic.AddInt64(&s.droppedFrames, 1)
|
||||||
|
return fmt.Errorf("write timeout - frame dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServerStats returns server performance statistics
|
||||||
|
func (s *AudioServer) GetServerStats() (total, dropped int64, bufferSize int64) {
|
||||||
|
return atomic.LoadInt64(&s.totalFrames),
|
||||||
|
atomic.LoadInt64(&s.droppedFrames),
|
||||||
|
atomic.LoadInt64(&s.bufferSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioClient struct {
|
type AudioClient struct {
|
||||||
conn net.Conn
|
// Atomic fields must be first for proper alignment on ARM
|
||||||
mtx sync.Mutex
|
droppedFrames int64 // Atomic counter for dropped frames
|
||||||
|
totalFrames int64 // Atomic counter for total frames
|
||||||
|
|
||||||
|
conn net.Conn
|
||||||
|
mtx sync.Mutex
|
||||||
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAudioClient() (*AudioClient, error) {
|
func NewAudioClient() *AudioClient {
|
||||||
socketPath := filepath.Join("/var/run", socketName)
|
return &AudioClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect connects to the audio output server
|
||||||
|
func (c *AudioClient) Connect() error {
|
||||||
|
c.mtx.Lock()
|
||||||
|
defer c.mtx.Unlock()
|
||||||
|
|
||||||
|
if c.running {
|
||||||
|
return nil // Already connected
|
||||||
|
}
|
||||||
|
|
||||||
|
socketPath := getOutputSocketPath()
|
||||||
// Try connecting multiple times as the server might not be ready
|
// Try connecting multiple times as the server might not be ready
|
||||||
for i := 0; i < 5; i++ {
|
// Reduced retry count and delay for faster startup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
conn, err := net.Dial("unix", socketPath)
|
conn, err := net.Dial("unix", socketPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return &AudioClient{conn: conn}, nil
|
c.conn = conn
|
||||||
|
c.running = true
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
time.Sleep(time.Second)
|
// Exponential backoff starting at 50ms
|
||||||
|
delay := time.Duration(50*(1<<uint(i/3))) * time.Millisecond
|
||||||
|
if delay > 400*time.Millisecond {
|
||||||
|
delay = 400 * time.Millisecond
|
||||||
|
}
|
||||||
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to connect to audio server")
|
|
||||||
|
return fmt.Errorf("failed to connect to audio output server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect disconnects from the audio output server
|
||||||
|
func (c *AudioClient) Disconnect() {
|
||||||
|
c.mtx.Lock()
|
||||||
|
defer c.mtx.Unlock()
|
||||||
|
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.running = false
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns whether the client is connected
|
||||||
|
func (c *AudioClient) IsConnected() bool {
|
||||||
|
c.mtx.Lock()
|
||||||
|
defer c.mtx.Unlock()
|
||||||
|
return c.running && c.conn != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AudioClient) Close() error {
|
func (c *AudioClient) Close() error {
|
||||||
return c.conn.Close()
|
c.Disconnect()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AudioClient) ReceiveFrame() ([]byte, error) {
|
func (c *AudioClient) ReceiveFrame() ([]byte, error) {
|
||||||
c.mtx.Lock()
|
c.mtx.Lock()
|
||||||
defer c.mtx.Unlock()
|
defer c.mtx.Unlock()
|
||||||
|
|
||||||
// Read magic number
|
if !c.running || c.conn == nil {
|
||||||
var magic uint32
|
return nil, fmt.Errorf("not connected")
|
||||||
if err := binary.Read(c.conn, binary.BigEndian, &magic); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read magic number: %w", err)
|
|
||||||
}
|
}
|
||||||
if magic != magicNumber {
|
|
||||||
|
// Get optimized message from pool for header reading
|
||||||
|
optMsg := globalOutputMessagePool.Get()
|
||||||
|
defer globalOutputMessagePool.Put(optMsg)
|
||||||
|
|
||||||
|
// Read header
|
||||||
|
if _, err := io.ReadFull(c.conn, optMsg.header[:]); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse header
|
||||||
|
magic := binary.LittleEndian.Uint32(optMsg.header[0:4])
|
||||||
|
if magic != outputMagicNumber {
|
||||||
return nil, fmt.Errorf("invalid magic number: %x", magic)
|
return nil, fmt.Errorf("invalid magic number: %x", magic)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read frame size
|
msgType := OutputMessageType(optMsg.header[4])
|
||||||
var size uint32
|
if msgType != OutputMessageTypeOpusFrame {
|
||||||
if err := binary.Read(c.conn, binary.BigEndian, &size); err != nil {
|
return nil, fmt.Errorf("unexpected message type: %d", msgType)
|
||||||
return nil, fmt.Errorf("failed to read frame size: %w", err)
|
}
|
||||||
|
|
||||||
|
size := binary.LittleEndian.Uint32(optMsg.header[5:9])
|
||||||
|
if size > outputMaxFrameSize {
|
||||||
|
return nil, fmt.Errorf("frame size %d exceeds maximum %d", size, outputMaxFrameSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read frame data
|
// Read frame data
|
||||||
frame := make([]byte, size)
|
frame := make([]byte, size)
|
||||||
if _, err := io.ReadFull(c.conn, frame); err != nil {
|
if size > 0 {
|
||||||
return nil, fmt.Errorf("failed to read frame data: %w", err)
|
if _, err := io.ReadFull(c.conn, frame); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read frame data: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
atomic.AddInt64(&c.totalFrames, 1)
|
||||||
return frame, nil
|
return frame, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClientStats returns client performance statistics
|
||||||
|
func (c *AudioClient) GetClientStats() (total, dropped int64) {
|
||||||
|
return atomic.LoadInt64(&c.totalFrames),
|
||||||
|
atomic.LoadInt64(&c.droppedFrames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
// getOutputSocketPath returns the path to the output socket
|
||||||
|
func getOutputSocketPath() string {
|
||||||
|
if path := os.Getenv("JETKVM_AUDIO_OUTPUT_SOCKET"); path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return filepath.Join("/var/run", outputSocketName)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,312 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LatencyMonitor tracks and optimizes audio latency in real-time
|
||||||
|
type LatencyMonitor struct {
|
||||||
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
|
currentLatency int64 // Current latency in nanoseconds (atomic)
|
||||||
|
averageLatency int64 // Rolling average latency in nanoseconds (atomic)
|
||||||
|
minLatency int64 // Minimum observed latency in nanoseconds (atomic)
|
||||||
|
maxLatency int64 // Maximum observed latency in nanoseconds (atomic)
|
||||||
|
latencySamples int64 // Number of latency samples collected (atomic)
|
||||||
|
jitterAccumulator int64 // Accumulated jitter for variance calculation (atomic)
|
||||||
|
lastOptimization int64 // Timestamp of last optimization in nanoseconds (atomic)
|
||||||
|
|
||||||
|
config LatencyConfig
|
||||||
|
logger zerolog.Logger
|
||||||
|
|
||||||
|
// Control channels
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Optimization callbacks
|
||||||
|
optimizationCallbacks []OptimizationCallback
|
||||||
|
mutex sync.RWMutex
|
||||||
|
|
||||||
|
// Performance tracking
|
||||||
|
latencyHistory []LatencyMeasurement
|
||||||
|
historyMutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatencyConfig holds configuration for latency monitoring
|
||||||
|
type LatencyConfig struct {
|
||||||
|
TargetLatency time.Duration // Target latency to maintain
|
||||||
|
MaxLatency time.Duration // Maximum acceptable latency
|
||||||
|
OptimizationInterval time.Duration // How often to run optimization
|
||||||
|
HistorySize int // Number of latency measurements to keep
|
||||||
|
JitterThreshold time.Duration // Jitter threshold for optimization
|
||||||
|
AdaptiveThreshold float64 // Threshold for adaptive adjustments (0.0-1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatencyMeasurement represents a single latency measurement
|
||||||
|
type LatencyMeasurement struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Latency time.Duration
|
||||||
|
Jitter time.Duration
|
||||||
|
Source string // Source of the measurement (e.g., "input", "output", "processing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizationCallback is called when latency optimization is triggered
|
||||||
|
type OptimizationCallback func(metrics LatencyMetrics) error
|
||||||
|
|
||||||
|
// LatencyMetrics provides comprehensive latency statistics
|
||||||
|
type LatencyMetrics struct {
|
||||||
|
Current time.Duration
|
||||||
|
Average time.Duration
|
||||||
|
Min time.Duration
|
||||||
|
Max time.Duration
|
||||||
|
Jitter time.Duration
|
||||||
|
SampleCount int64
|
||||||
|
Trend LatencyTrend
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatencyTrend indicates the direction of latency changes
|
||||||
|
type LatencyTrend int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LatencyTrendStable LatencyTrend = iota
|
||||||
|
LatencyTrendIncreasing
|
||||||
|
LatencyTrendDecreasing
|
||||||
|
LatencyTrendVolatile
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultLatencyConfig returns a sensible default configuration
|
||||||
|
func DefaultLatencyConfig() LatencyConfig {
|
||||||
|
return LatencyConfig{
|
||||||
|
TargetLatency: 50 * time.Millisecond,
|
||||||
|
MaxLatency: 200 * time.Millisecond,
|
||||||
|
OptimizationInterval: 5 * time.Second,
|
||||||
|
HistorySize: 100,
|
||||||
|
JitterThreshold: 20 * time.Millisecond,
|
||||||
|
AdaptiveThreshold: 0.8, // Trigger optimization when 80% above target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLatencyMonitor creates a new latency monitoring system
|
||||||
|
func NewLatencyMonitor(config LatencyConfig, logger zerolog.Logger) *LatencyMonitor {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
return &LatencyMonitor{
|
||||||
|
config: config,
|
||||||
|
logger: logger.With().Str("component", "latency-monitor").Logger(),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
latencyHistory: make([]LatencyMeasurement, 0, config.HistorySize),
|
||||||
|
minLatency: int64(time.Hour), // Initialize to high value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins latency monitoring and optimization
|
||||||
|
func (lm *LatencyMonitor) Start() {
|
||||||
|
lm.wg.Add(1)
|
||||||
|
go lm.monitoringLoop()
|
||||||
|
lm.logger.Info().Msg("Latency monitor started")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the latency monitor
|
||||||
|
func (lm *LatencyMonitor) Stop() {
|
||||||
|
lm.cancel()
|
||||||
|
lm.wg.Wait()
|
||||||
|
lm.logger.Info().Msg("Latency monitor stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLatency records a new latency measurement
|
||||||
|
func (lm *LatencyMonitor) RecordLatency(latency time.Duration, source string) {
|
||||||
|
now := time.Now()
|
||||||
|
latencyNanos := latency.Nanoseconds()
|
||||||
|
|
||||||
|
// Update atomic counters
|
||||||
|
atomic.StoreInt64(&lm.currentLatency, latencyNanos)
|
||||||
|
atomic.AddInt64(&lm.latencySamples, 1)
|
||||||
|
|
||||||
|
// Update min/max
|
||||||
|
for {
|
||||||
|
oldMin := atomic.LoadInt64(&lm.minLatency)
|
||||||
|
if latencyNanos >= oldMin || atomic.CompareAndSwapInt64(&lm.minLatency, oldMin, latencyNanos) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
oldMax := atomic.LoadInt64(&lm.maxLatency)
|
||||||
|
if latencyNanos <= oldMax || atomic.CompareAndSwapInt64(&lm.maxLatency, oldMax, latencyNanos) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rolling average using exponential moving average
|
||||||
|
oldAvg := atomic.LoadInt64(&lm.averageLatency)
|
||||||
|
newAvg := oldAvg + (latencyNanos-oldAvg)/10 // Alpha = 0.1
|
||||||
|
atomic.StoreInt64(&lm.averageLatency, newAvg)
|
||||||
|
|
||||||
|
// Calculate jitter (difference from average)
|
||||||
|
jitter := latencyNanos - newAvg
|
||||||
|
if jitter < 0 {
|
||||||
|
jitter = -jitter
|
||||||
|
}
|
||||||
|
atomic.AddInt64(&lm.jitterAccumulator, jitter)
|
||||||
|
|
||||||
|
// Store in history
|
||||||
|
lm.historyMutex.Lock()
|
||||||
|
measurement := LatencyMeasurement{
|
||||||
|
Timestamp: now,
|
||||||
|
Latency: latency,
|
||||||
|
Jitter: time.Duration(jitter),
|
||||||
|
Source: source,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lm.latencyHistory) >= lm.config.HistorySize {
|
||||||
|
// Remove oldest measurement
|
||||||
|
copy(lm.latencyHistory, lm.latencyHistory[1:])
|
||||||
|
lm.latencyHistory[len(lm.latencyHistory)-1] = measurement
|
||||||
|
} else {
|
||||||
|
lm.latencyHistory = append(lm.latencyHistory, measurement)
|
||||||
|
}
|
||||||
|
lm.historyMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetrics returns current latency metrics
|
||||||
|
func (lm *LatencyMonitor) GetMetrics() LatencyMetrics {
|
||||||
|
current := atomic.LoadInt64(&lm.currentLatency)
|
||||||
|
average := atomic.LoadInt64(&lm.averageLatency)
|
||||||
|
min := atomic.LoadInt64(&lm.minLatency)
|
||||||
|
max := atomic.LoadInt64(&lm.maxLatency)
|
||||||
|
samples := atomic.LoadInt64(&lm.latencySamples)
|
||||||
|
jitterSum := atomic.LoadInt64(&lm.jitterAccumulator)
|
||||||
|
|
||||||
|
var jitter time.Duration
|
||||||
|
if samples > 0 {
|
||||||
|
jitter = time.Duration(jitterSum / samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LatencyMetrics{
|
||||||
|
Current: time.Duration(current),
|
||||||
|
Average: time.Duration(average),
|
||||||
|
Min: time.Duration(min),
|
||||||
|
Max: time.Duration(max),
|
||||||
|
Jitter: jitter,
|
||||||
|
SampleCount: samples,
|
||||||
|
Trend: lm.calculateTrend(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOptimizationCallback adds a callback for latency optimization
|
||||||
|
func (lm *LatencyMonitor) AddOptimizationCallback(callback OptimizationCallback) {
|
||||||
|
lm.mutex.Lock()
|
||||||
|
lm.optimizationCallbacks = append(lm.optimizationCallbacks, callback)
|
||||||
|
lm.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitoringLoop runs the main monitoring and optimization loop
|
||||||
|
func (lm *LatencyMonitor) monitoringLoop() {
|
||||||
|
defer lm.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(lm.config.OptimizationInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-lm.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
lm.runOptimization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOptimization checks if optimization is needed and triggers callbacks
|
||||||
|
func (lm *LatencyMonitor) runOptimization() {
|
||||||
|
metrics := lm.GetMetrics()
|
||||||
|
|
||||||
|
// Check if optimization is needed
|
||||||
|
needsOptimization := false
|
||||||
|
|
||||||
|
// Check if current latency exceeds threshold
|
||||||
|
if metrics.Current > lm.config.MaxLatency {
|
||||||
|
needsOptimization = true
|
||||||
|
lm.logger.Warn().Dur("current_latency", metrics.Current).Dur("max_latency", lm.config.MaxLatency).Msg("Latency exceeds maximum threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if average latency is above adaptive threshold
|
||||||
|
adaptiveThreshold := time.Duration(float64(lm.config.TargetLatency.Nanoseconds()) * (1.0 + lm.config.AdaptiveThreshold))
|
||||||
|
if metrics.Average > adaptiveThreshold {
|
||||||
|
needsOptimization = true
|
||||||
|
lm.logger.Info().Dur("average_latency", metrics.Average).Dur("threshold", adaptiveThreshold).Msg("Average latency above adaptive threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if jitter is too high
|
||||||
|
if metrics.Jitter > lm.config.JitterThreshold {
|
||||||
|
needsOptimization = true
|
||||||
|
lm.logger.Info().Dur("jitter", metrics.Jitter).Dur("threshold", lm.config.JitterThreshold).Msg("Jitter above threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsOptimization {
|
||||||
|
atomic.StoreInt64(&lm.lastOptimization, time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Run optimization callbacks
|
||||||
|
lm.mutex.RLock()
|
||||||
|
callbacks := make([]OptimizationCallback, len(lm.optimizationCallbacks))
|
||||||
|
copy(callbacks, lm.optimizationCallbacks)
|
||||||
|
lm.mutex.RUnlock()
|
||||||
|
|
||||||
|
for _, callback := range callbacks {
|
||||||
|
if err := callback(metrics); err != nil {
|
||||||
|
lm.logger.Error().Err(err).Msg("Optimization callback failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lm.logger.Info().Interface("metrics", metrics).Msg("Latency optimization triggered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateTrend analyzes recent latency measurements to determine trend
|
||||||
|
func (lm *LatencyMonitor) calculateTrend() LatencyTrend {
|
||||||
|
lm.historyMutex.RLock()
|
||||||
|
defer lm.historyMutex.RUnlock()
|
||||||
|
|
||||||
|
if len(lm.latencyHistory) < 10 {
|
||||||
|
return LatencyTrendStable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze last 10 measurements
|
||||||
|
recentMeasurements := lm.latencyHistory[len(lm.latencyHistory)-10:]
|
||||||
|
|
||||||
|
var increasing, decreasing int
|
||||||
|
for i := 1; i < len(recentMeasurements); i++ {
|
||||||
|
if recentMeasurements[i].Latency > recentMeasurements[i-1].Latency {
|
||||||
|
increasing++
|
||||||
|
} else if recentMeasurements[i].Latency < recentMeasurements[i-1].Latency {
|
||||||
|
decreasing++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine trend based on direction changes
|
||||||
|
if increasing > 6 {
|
||||||
|
return LatencyTrendIncreasing
|
||||||
|
} else if decreasing > 6 {
|
||||||
|
return LatencyTrendDecreasing
|
||||||
|
} else if increasing+decreasing > 7 {
|
||||||
|
return LatencyTrendVolatile
|
||||||
|
}
|
||||||
|
|
||||||
|
return LatencyTrendStable
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatencyHistory returns a copy of recent latency measurements
|
||||||
|
func (lm *LatencyMonitor) GetLatencyHistory() []LatencyMeasurement {
|
||||||
|
lm.historyMutex.RLock()
|
||||||
|
defer lm.historyMutex.RUnlock()
|
||||||
|
|
||||||
|
history := make([]LatencyMeasurement, len(lm.latencyHistory))
|
||||||
|
copy(history, lm.latencyHistory)
|
||||||
|
return history
|
||||||
|
}
|
|
@ -0,0 +1,198 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemoryMetrics provides comprehensive memory allocation statistics
|
||||||
|
type MemoryMetrics struct {
|
||||||
|
// Runtime memory statistics
|
||||||
|
RuntimeStats RuntimeMemoryStats `json:"runtime_stats"`
|
||||||
|
// Audio buffer pool statistics
|
||||||
|
BufferPools AudioBufferPoolStats `json:"buffer_pools"`
|
||||||
|
// Zero-copy frame pool statistics
|
||||||
|
ZeroCopyPool ZeroCopyFramePoolStats `json:"zero_copy_pool"`
|
||||||
|
// Message pool statistics
|
||||||
|
MessagePool MessagePoolStats `json:"message_pool"`
|
||||||
|
// Batch processor statistics
|
||||||
|
BatchProcessor BatchProcessorMemoryStats `json:"batch_processor,omitempty"`
|
||||||
|
// Collection timestamp
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeMemoryStats provides Go runtime memory statistics
|
||||||
|
type RuntimeMemoryStats struct {
|
||||||
|
Alloc uint64 `json:"alloc"` // Bytes allocated and not yet freed
|
||||||
|
TotalAlloc uint64 `json:"total_alloc"` // Total bytes allocated (cumulative)
|
||||||
|
Sys uint64 `json:"sys"` // Total bytes obtained from OS
|
||||||
|
Lookups uint64 `json:"lookups"` // Number of pointer lookups
|
||||||
|
Mallocs uint64 `json:"mallocs"` // Number of mallocs
|
||||||
|
Frees uint64 `json:"frees"` // Number of frees
|
||||||
|
HeapAlloc uint64 `json:"heap_alloc"` // Bytes allocated and not yet freed (heap)
|
||||||
|
HeapSys uint64 `json:"heap_sys"` // Bytes obtained from OS for heap
|
||||||
|
HeapIdle uint64 `json:"heap_idle"` // Bytes in idle spans
|
||||||
|
HeapInuse uint64 `json:"heap_inuse"` // Bytes in non-idle spans
|
||||||
|
HeapReleased uint64 `json:"heap_released"` // Bytes released to OS
|
||||||
|
HeapObjects uint64 `json:"heap_objects"` // Total number of allocated objects
|
||||||
|
StackInuse uint64 `json:"stack_inuse"` // Bytes used by stack spans
|
||||||
|
StackSys uint64 `json:"stack_sys"` // Bytes obtained from OS for stack
|
||||||
|
MSpanInuse uint64 `json:"mspan_inuse"` // Bytes used by mspan structures
|
||||||
|
MSpanSys uint64 `json:"mspan_sys"` // Bytes obtained from OS for mspan
|
||||||
|
MCacheInuse uint64 `json:"mcache_inuse"` // Bytes used by mcache structures
|
||||||
|
MCacheSys uint64 `json:"mcache_sys"` // Bytes obtained from OS for mcache
|
||||||
|
BuckHashSys uint64 `json:"buck_hash_sys"` // Bytes used by profiling bucket hash table
|
||||||
|
GCSys uint64 `json:"gc_sys"` // Bytes used for garbage collection metadata
|
||||||
|
OtherSys uint64 `json:"other_sys"` // Bytes used for other system allocations
|
||||||
|
NextGC uint64 `json:"next_gc"` // Target heap size for next GC
|
||||||
|
LastGC uint64 `json:"last_gc"` // Time of last GC (nanoseconds since epoch)
|
||||||
|
PauseTotalNs uint64 `json:"pause_total_ns"` // Total GC pause time
|
||||||
|
NumGC uint32 `json:"num_gc"` // Number of completed GC cycles
|
||||||
|
NumForcedGC uint32 `json:"num_forced_gc"` // Number of forced GC cycles
|
||||||
|
GCCPUFraction float64 `json:"gc_cpu_fraction"` // Fraction of CPU time used by GC
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchProcessorMemoryStats provides batch processor memory statistics
|
||||||
|
type BatchProcessorMemoryStats struct {
|
||||||
|
Initialized bool `json:"initialized"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Stats BatchAudioStats `json:"stats"`
|
||||||
|
BufferPool AudioBufferPoolDetailedStats `json:"buffer_pool,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBatchAudioProcessor is defined in batch_audio.go
|
||||||
|
// BatchAudioStats is defined in batch_audio.go
|
||||||
|
|
||||||
|
var memoryMetricsLogger *zerolog.Logger
|
||||||
|
|
||||||
|
func getMemoryMetricsLogger() *zerolog.Logger {
|
||||||
|
if memoryMetricsLogger == nil {
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "memory-metrics").Logger()
|
||||||
|
memoryMetricsLogger = &logger
|
||||||
|
}
|
||||||
|
return memoryMetricsLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectMemoryMetrics gathers comprehensive memory allocation statistics
|
||||||
|
func CollectMemoryMetrics() MemoryMetrics {
|
||||||
|
// Collect runtime memory statistics
|
||||||
|
var m runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
|
||||||
|
runtimeStats := RuntimeMemoryStats{
|
||||||
|
Alloc: m.Alloc,
|
||||||
|
TotalAlloc: m.TotalAlloc,
|
||||||
|
Sys: m.Sys,
|
||||||
|
Lookups: m.Lookups,
|
||||||
|
Mallocs: m.Mallocs,
|
||||||
|
Frees: m.Frees,
|
||||||
|
HeapAlloc: m.HeapAlloc,
|
||||||
|
HeapSys: m.HeapSys,
|
||||||
|
HeapIdle: m.HeapIdle,
|
||||||
|
HeapInuse: m.HeapInuse,
|
||||||
|
HeapReleased: m.HeapReleased,
|
||||||
|
HeapObjects: m.HeapObjects,
|
||||||
|
StackInuse: m.StackInuse,
|
||||||
|
StackSys: m.StackSys,
|
||||||
|
MSpanInuse: m.MSpanInuse,
|
||||||
|
MSpanSys: m.MSpanSys,
|
||||||
|
MCacheInuse: m.MCacheInuse,
|
||||||
|
MCacheSys: m.MCacheSys,
|
||||||
|
BuckHashSys: m.BuckHashSys,
|
||||||
|
GCSys: m.GCSys,
|
||||||
|
OtherSys: m.OtherSys,
|
||||||
|
NextGC: m.NextGC,
|
||||||
|
LastGC: m.LastGC,
|
||||||
|
PauseTotalNs: m.PauseTotalNs,
|
||||||
|
NumGC: m.NumGC,
|
||||||
|
NumForcedGC: m.NumForcedGC,
|
||||||
|
GCCPUFraction: m.GCCPUFraction,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect audio buffer pool statistics
|
||||||
|
bufferPoolStats := GetAudioBufferPoolStats()
|
||||||
|
|
||||||
|
// Collect zero-copy frame pool statistics
|
||||||
|
zeroCopyStats := GetGlobalZeroCopyPoolStats()
|
||||||
|
|
||||||
|
// Collect message pool statistics
|
||||||
|
messagePoolStats := GetGlobalMessagePoolStats()
|
||||||
|
|
||||||
|
// Collect batch processor statistics if available
|
||||||
|
var batchStats BatchProcessorMemoryStats
|
||||||
|
if processor := GetBatchAudioProcessor(); processor != nil {
|
||||||
|
batchStats.Initialized = true
|
||||||
|
batchStats.Running = processor.IsRunning()
|
||||||
|
batchStats.Stats = processor.GetStats()
|
||||||
|
// Note: BatchAudioProcessor uses sync.Pool, detailed stats not available
|
||||||
|
}
|
||||||
|
|
||||||
|
return MemoryMetrics{
|
||||||
|
RuntimeStats: runtimeStats,
|
||||||
|
BufferPools: bufferPoolStats,
|
||||||
|
ZeroCopyPool: zeroCopyStats,
|
||||||
|
MessagePool: messagePoolStats,
|
||||||
|
BatchProcessor: batchStats,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMemoryMetrics provides an HTTP handler for memory metrics
|
||||||
|
func HandleMemoryMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logger := getMemoryMetricsLogger()
|
||||||
|
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics := CollectMemoryMetrics()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(metrics); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("failed to encode memory metrics")
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("memory metrics served")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogMemoryMetrics logs current memory metrics for debugging
|
||||||
|
func LogMemoryMetrics() {
|
||||||
|
logger := getMemoryMetricsLogger()
|
||||||
|
metrics := CollectMemoryMetrics()
|
||||||
|
|
||||||
|
logger.Info().
|
||||||
|
Uint64("heap_alloc_mb", metrics.RuntimeStats.HeapAlloc/1024/1024).
|
||||||
|
Uint64("heap_sys_mb", metrics.RuntimeStats.HeapSys/1024/1024).
|
||||||
|
Uint64("heap_objects", metrics.RuntimeStats.HeapObjects).
|
||||||
|
Uint32("num_gc", metrics.RuntimeStats.NumGC).
|
||||||
|
Float64("gc_cpu_fraction", metrics.RuntimeStats.GCCPUFraction).
|
||||||
|
Float64("buffer_pool_hit_rate", metrics.BufferPools.FramePoolHitRate).
|
||||||
|
Float64("zero_copy_hit_rate", metrics.ZeroCopyPool.HitRate).
|
||||||
|
Float64("message_pool_hit_rate", metrics.MessagePool.HitRate).
|
||||||
|
Msg("memory metrics snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartMemoryMetricsLogging starts periodic memory metrics logging
|
||||||
|
func StartMemoryMetricsLogging(interval time.Duration) {
|
||||||
|
logger := getMemoryMetricsLogger()
|
||||||
|
logger.Info().Dur("interval", interval).Msg("starting memory metrics logging")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
LogMemoryMetrics()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
|
@ -10,6 +10,67 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// Adaptive buffer metrics
|
||||||
|
adaptiveInputBufferSize = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_adaptive_input_buffer_size_bytes",
|
||||||
|
Help: "Current adaptive input buffer size in bytes",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
adaptiveOutputBufferSize = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_adaptive_output_buffer_size_bytes",
|
||||||
|
Help: "Current adaptive output buffer size in bytes",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
adaptiveBufferAdjustmentsTotal = promauto.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "jetkvm_adaptive_buffer_adjustments_total",
|
||||||
|
Help: "Total number of adaptive buffer size adjustments",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
adaptiveSystemCpuPercent = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_adaptive_system_cpu_percent",
|
||||||
|
Help: "System CPU usage percentage used by adaptive buffer manager",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
adaptiveSystemMemoryPercent = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_adaptive_system_memory_percent",
|
||||||
|
Help: "System memory usage percentage used by adaptive buffer manager",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Socket buffer metrics
|
||||||
|
socketBufferSizeGauge = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_audio_socket_buffer_size_bytes",
|
||||||
|
Help: "Current socket buffer size in bytes",
|
||||||
|
},
|
||||||
|
[]string{"component", "buffer_type"}, // buffer_type: send, receive
|
||||||
|
)
|
||||||
|
|
||||||
|
socketBufferUtilizationGauge = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_audio_socket_buffer_utilization_percent",
|
||||||
|
Help: "Socket buffer utilization percentage",
|
||||||
|
},
|
||||||
|
[]string{"component", "buffer_type"}, // buffer_type: send, receive
|
||||||
|
)
|
||||||
|
|
||||||
|
socketBufferOverflowCounter = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "jetkvm_audio_socket_buffer_overflow_total",
|
||||||
|
Help: "Total number of socket buffer overflows",
|
||||||
|
},
|
||||||
|
[]string{"component", "buffer_type"}, // buffer_type: send, receive
|
||||||
|
)
|
||||||
|
|
||||||
// Audio output metrics
|
// Audio output metrics
|
||||||
audioFramesReceivedTotal = promauto.NewCounter(
|
audioFramesReceivedTotal = promauto.NewCounter(
|
||||||
prometheus.CounterOpts{
|
prometheus.CounterOpts{
|
||||||
|
@ -364,6 +425,23 @@ func UpdateMicrophoneConfigMetrics(config AudioConfig) {
|
||||||
atomic.StoreInt64(&lastMetricsUpdate, time.Now().Unix())
|
atomic.StoreInt64(&lastMetricsUpdate, time.Now().Unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAdaptiveBufferMetrics updates Prometheus metrics with adaptive buffer information
|
||||||
|
func UpdateAdaptiveBufferMetrics(inputBufferSize, outputBufferSize int, cpuPercent, memoryPercent float64, adjustmentMade bool) {
|
||||||
|
metricsUpdateMutex.Lock()
|
||||||
|
defer metricsUpdateMutex.Unlock()
|
||||||
|
|
||||||
|
adaptiveInputBufferSize.Set(float64(inputBufferSize))
|
||||||
|
adaptiveOutputBufferSize.Set(float64(outputBufferSize))
|
||||||
|
adaptiveSystemCpuPercent.Set(cpuPercent)
|
||||||
|
adaptiveSystemMemoryPercent.Set(memoryPercent)
|
||||||
|
|
||||||
|
if adjustmentMade {
|
||||||
|
adaptiveBufferAdjustmentsTotal.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.StoreInt64(&lastMetricsUpdate, time.Now().Unix())
|
||||||
|
}
|
||||||
|
|
||||||
// GetLastMetricsUpdate returns the timestamp of the last metrics update
|
// GetLastMetricsUpdate returns the timestamp of the last metrics update
|
||||||
func GetLastMetricsUpdate() time.Time {
|
func GetLastMetricsUpdate() time.Time {
|
||||||
timestamp := atomic.LoadInt64(&lastMetricsUpdate)
|
timestamp := atomic.LoadInt64(&lastMetricsUpdate)
|
||||||
|
|
|
@ -8,10 +8,12 @@ import (
|
||||||
|
|
||||||
// MicrophoneContentionManager manages microphone access with cooldown periods
|
// MicrophoneContentionManager manages microphone access with cooldown periods
|
||||||
type MicrophoneContentionManager struct {
|
type MicrophoneContentionManager struct {
|
||||||
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
lastOpNano int64
|
lastOpNano int64
|
||||||
cooldownNanos int64
|
cooldownNanos int64
|
||||||
operationID int64
|
operationID int64
|
||||||
lockPtr unsafe.Pointer
|
|
||||||
|
lockPtr unsafe.Pointer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMicrophoneContentionManager(cooldown time.Duration) *MicrophoneContentionManager {
|
func NewMicrophoneContentionManager(cooldown time.Duration) *MicrophoneContentionManager {
|
||||||
|
|
|
@ -2,6 +2,9 @@ package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -9,6 +12,28 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OutputStreamer manages high-performance audio output streaming
|
||||||
|
type OutputStreamer struct {
|
||||||
|
// Atomic fields must be first for proper alignment on ARM
|
||||||
|
processedFrames int64 // Total processed frames counter (atomic)
|
||||||
|
droppedFrames int64 // Dropped frames counter (atomic)
|
||||||
|
processingTime int64 // Average processing time in nanoseconds (atomic)
|
||||||
|
lastStatsTime int64 // Last statistics update time (atomic)
|
||||||
|
|
||||||
|
client *AudioClient
|
||||||
|
bufferPool *AudioBufferPool
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
running bool
|
||||||
|
mtx sync.Mutex
|
||||||
|
|
||||||
|
// Performance optimization fields
|
||||||
|
batchSize int // Adaptive batch size for frame processing
|
||||||
|
processingChan chan []byte // Buffered channel for frame processing
|
||||||
|
statsInterval time.Duration // Statistics reporting interval
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
outputStreamingRunning int32
|
outputStreamingRunning int32
|
||||||
outputStreamingCancel context.CancelFunc
|
outputStreamingCancel context.CancelFunc
|
||||||
|
@ -23,6 +48,253 @@ func getOutputStreamingLogger() *zerolog.Logger {
|
||||||
return outputStreamingLogger
|
return outputStreamingLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewOutputStreamer() (*OutputStreamer, error) {
|
||||||
|
client := NewAudioClient()
|
||||||
|
|
||||||
|
// Get initial batch size from adaptive buffer manager
|
||||||
|
adaptiveManager := GetAdaptiveBufferManager()
|
||||||
|
initialBatchSize := adaptiveManager.GetOutputBufferSize()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &OutputStreamer{
|
||||||
|
client: client,
|
||||||
|
bufferPool: NewAudioBufferPool(MaxAudioFrameSize), // Use existing buffer pool
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
batchSize: initialBatchSize, // Use adaptive batch size
|
||||||
|
processingChan: make(chan []byte, 500), // Large buffer for smooth processing
|
||||||
|
statsInterval: 5 * time.Second, // Statistics every 5 seconds
|
||||||
|
lastStatsTime: time.Now().UnixNano(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OutputStreamer) Start() error {
|
||||||
|
s.mtx.Lock()
|
||||||
|
defer s.mtx.Unlock()
|
||||||
|
|
||||||
|
if s.running {
|
||||||
|
return fmt.Errorf("output streamer already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to audio output server
|
||||||
|
if err := s.client.Connect(); err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to audio output server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.running = true
|
||||||
|
|
||||||
|
// Start multiple goroutines for optimal performance
|
||||||
|
s.wg.Add(3)
|
||||||
|
go s.streamLoop() // Main streaming loop
|
||||||
|
go s.processingLoop() // Frame processing loop
|
||||||
|
go s.statisticsLoop() // Performance monitoring loop
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OutputStreamer) Stop() {
|
||||||
|
s.mtx.Lock()
|
||||||
|
defer s.mtx.Unlock()
|
||||||
|
|
||||||
|
if !s.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.running = false
|
||||||
|
s.cancel()
|
||||||
|
|
||||||
|
// Close processing channel to signal goroutines
|
||||||
|
close(s.processingChan)
|
||||||
|
|
||||||
|
// Wait for all goroutines to finish
|
||||||
|
s.wg.Wait()
|
||||||
|
|
||||||
|
if s.client != nil {
|
||||||
|
s.client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OutputStreamer) streamLoop() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
|
||||||
|
// Pin goroutine to OS thread for consistent performance
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
// Adaptive timing for frame reading
|
||||||
|
frameInterval := time.Duration(20) * time.Millisecond // 50 FPS base rate
|
||||||
|
ticker := time.NewTicker(frameInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Batch size update ticker
|
||||||
|
batchUpdateTicker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer batchUpdateTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-batchUpdateTicker.C:
|
||||||
|
// Update batch size from adaptive buffer manager
|
||||||
|
s.UpdateBatchSize()
|
||||||
|
case <-ticker.C:
|
||||||
|
// Read audio data from CGO with timing measurement
|
||||||
|
startTime := time.Now()
|
||||||
|
frameBuf := s.bufferPool.Get()
|
||||||
|
n, err := CGOAudioReadEncode(frameBuf)
|
||||||
|
processingDuration := time.Since(startTime)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
getOutputStreamingLogger().Warn().Err(err).Msg("Failed to read audio data")
|
||||||
|
s.bufferPool.Put(frameBuf)
|
||||||
|
atomic.AddInt64(&s.droppedFrames, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if n > 0 {
|
||||||
|
// Send frame for processing (non-blocking)
|
||||||
|
frameData := make([]byte, n)
|
||||||
|
copy(frameData, frameBuf[:n])
|
||||||
|
|
||||||
|
select {
|
||||||
|
case s.processingChan <- frameData:
|
||||||
|
atomic.AddInt64(&s.processedFrames, 1)
|
||||||
|
// Update processing time statistics
|
||||||
|
atomic.StoreInt64(&s.processingTime, int64(processingDuration))
|
||||||
|
// Report latency to adaptive buffer manager
|
||||||
|
s.ReportLatency(processingDuration)
|
||||||
|
default:
|
||||||
|
// Processing channel full, drop frame
|
||||||
|
atomic.AddInt64(&s.droppedFrames, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.bufferPool.Put(frameBuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processingLoop handles frame processing in a separate goroutine
|
||||||
|
func (s *OutputStreamer) processingLoop() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
|
||||||
|
// Pin goroutine to OS thread for consistent performance
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
// Set high priority for audio output processing
|
||||||
|
if err := SetAudioThreadPriority(); err != nil {
|
||||||
|
getOutputStreamingLogger().Warn().Err(err).Msg("Failed to set audio output processing priority")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := ResetThreadPriority(); err != nil {
|
||||||
|
getOutputStreamingLogger().Warn().Err(err).Msg("Failed to reset thread priority")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for range s.processingChan {
|
||||||
|
// Process frame (currently just receiving, but can be extended)
|
||||||
|
if _, err := s.client.ReceiveFrame(); err != nil {
|
||||||
|
if s.client.IsConnected() {
|
||||||
|
getOutputStreamingLogger().Warn().Err(err).Msg("Failed to receive frame")
|
||||||
|
atomic.AddInt64(&s.droppedFrames, 1)
|
||||||
|
}
|
||||||
|
// Try to reconnect if disconnected
|
||||||
|
if !s.client.IsConnected() {
|
||||||
|
if err := s.client.Connect(); err != nil {
|
||||||
|
getOutputStreamingLogger().Warn().Err(err).Msg("Failed to reconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statisticsLoop monitors and reports performance statistics
|
||||||
|
func (s *OutputStreamer) statisticsLoop() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(s.statsInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.reportStatistics()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportStatistics logs current performance statistics
|
||||||
|
func (s *OutputStreamer) reportStatistics() {
|
||||||
|
processed := atomic.LoadInt64(&s.processedFrames)
|
||||||
|
dropped := atomic.LoadInt64(&s.droppedFrames)
|
||||||
|
processingTime := atomic.LoadInt64(&s.processingTime)
|
||||||
|
|
||||||
|
if processed > 0 {
|
||||||
|
dropRate := float64(dropped) / float64(processed+dropped) * 100
|
||||||
|
avgProcessingTime := time.Duration(processingTime)
|
||||||
|
|
||||||
|
getOutputStreamingLogger().Info().Int64("processed", processed).Int64("dropped", dropped).Float64("drop_rate", dropRate).Dur("avg_processing", avgProcessingTime).Msg("Output Audio Stats")
|
||||||
|
|
||||||
|
// Get client statistics
|
||||||
|
clientTotal, clientDropped := s.client.GetClientStats()
|
||||||
|
getOutputStreamingLogger().Info().Int64("total", clientTotal).Int64("dropped", clientDropped).Msg("Client Stats")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns streaming statistics
|
||||||
|
func (s *OutputStreamer) GetStats() (processed, dropped int64, avgProcessingTime time.Duration) {
|
||||||
|
processed = atomic.LoadInt64(&s.processedFrames)
|
||||||
|
dropped = atomic.LoadInt64(&s.droppedFrames)
|
||||||
|
processingTimeNs := atomic.LoadInt64(&s.processingTime)
|
||||||
|
avgProcessingTime = time.Duration(processingTimeNs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDetailedStats returns comprehensive streaming statistics
|
||||||
|
func (s *OutputStreamer) GetDetailedStats() map[string]interface{} {
|
||||||
|
processed := atomic.LoadInt64(&s.processedFrames)
|
||||||
|
dropped := atomic.LoadInt64(&s.droppedFrames)
|
||||||
|
processingTime := atomic.LoadInt64(&s.processingTime)
|
||||||
|
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"processed_frames": processed,
|
||||||
|
"dropped_frames": dropped,
|
||||||
|
"avg_processing_time_ns": processingTime,
|
||||||
|
"batch_size": s.batchSize,
|
||||||
|
"channel_buffer_size": cap(s.processingChan),
|
||||||
|
"channel_current_size": len(s.processingChan),
|
||||||
|
"connected": s.client.IsConnected(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if processed+dropped > 0 {
|
||||||
|
stats["drop_rate_percent"] = float64(dropped) / float64(processed+dropped) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add client statistics
|
||||||
|
clientTotal, clientDropped := s.client.GetClientStats()
|
||||||
|
stats["client_total_frames"] = clientTotal
|
||||||
|
stats["client_dropped_frames"] = clientDropped
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBatchSize updates the batch size from adaptive buffer manager
|
||||||
|
func (s *OutputStreamer) UpdateBatchSize() {
|
||||||
|
s.mtx.Lock()
|
||||||
|
adaptiveManager := GetAdaptiveBufferManager()
|
||||||
|
s.batchSize = adaptiveManager.GetOutputBufferSize()
|
||||||
|
s.mtx.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportLatency reports processing latency to adaptive buffer manager
|
||||||
|
func (s *OutputStreamer) ReportLatency(latency time.Duration) {
|
||||||
|
adaptiveManager := GetAdaptiveBufferManager()
|
||||||
|
adaptiveManager.UpdateLatency(latency)
|
||||||
|
}
|
||||||
|
|
||||||
// StartAudioOutputStreaming starts audio output streaming (capturing system audio)
|
// StartAudioOutputStreaming starts audio output streaming (capturing system audio)
|
||||||
func StartAudioOutputStreaming(send func([]byte)) error {
|
func StartAudioOutputStreaming(send func([]byte)) error {
|
||||||
if !atomic.CompareAndSwapInt32(&outputStreamingRunning, 0, 1) {
|
if !atomic.CompareAndSwapInt32(&outputStreamingRunning, 0, 1) {
|
||||||
|
@ -61,10 +333,13 @@ func StartAudioOutputStreaming(send func([]byte)) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
// Send frame to callback
|
// Get frame buffer from pool to reduce allocations
|
||||||
frame := make([]byte, n)
|
frame := GetAudioFrameBuffer()
|
||||||
|
frame = frame[:n] // Resize to actual frame size
|
||||||
copy(frame, buffer[:n])
|
copy(frame, buffer[:n])
|
||||||
send(frame)
|
send(frame)
|
||||||
|
// Return buffer to pool after sending
|
||||||
|
PutAudioFrameBuffer(frame)
|
||||||
RecordFrameReceived(n)
|
RecordFrameReceived(n)
|
||||||
}
|
}
|
||||||
// Small delay to prevent busy waiting
|
// Small delay to prevent busy waiting
|
||||||
|
|
|
@ -0,0 +1,165 @@
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SchedParam represents scheduling parameters for Linux
|
||||||
|
type SchedParam struct {
|
||||||
|
Priority int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority levels for audio processing
|
||||||
|
const (
|
||||||
|
// SCHED_FIFO priorities (1-99, higher = more priority)
|
||||||
|
AudioHighPriority = 80 // High priority for critical audio processing
|
||||||
|
AudioMediumPriority = 60 // Medium priority for regular audio processing
|
||||||
|
AudioLowPriority = 40 // Low priority for background audio tasks
|
||||||
|
|
||||||
|
// SCHED_NORMAL is the default (priority 0)
|
||||||
|
NormalPriority = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scheduling policies
|
||||||
|
const (
|
||||||
|
SCHED_NORMAL = 0
|
||||||
|
SCHED_FIFO = 1
|
||||||
|
SCHED_RR = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// PriorityScheduler manages thread priorities for audio processing
|
||||||
|
type PriorityScheduler struct {
|
||||||
|
logger zerolog.Logger
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPriorityScheduler creates a new priority scheduler
|
||||||
|
func NewPriorityScheduler() *PriorityScheduler {
|
||||||
|
return &PriorityScheduler{
|
||||||
|
logger: logging.GetDefaultLogger().With().Str("component", "priority-scheduler").Logger(),
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetThreadPriority sets the priority of the current thread
|
||||||
|
func (ps *PriorityScheduler) SetThreadPriority(priority int, policy int) error {
|
||||||
|
if !ps.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock to OS thread to ensure we're setting priority for the right thread
|
||||||
|
runtime.LockOSThread()
|
||||||
|
|
||||||
|
// Get current thread ID
|
||||||
|
tid := syscall.Gettid()
|
||||||
|
|
||||||
|
// Set scheduling parameters
|
||||||
|
param := &SchedParam{
|
||||||
|
Priority: int32(priority),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use syscall to set scheduler
|
||||||
|
_, _, errno := syscall.Syscall(syscall.SYS_SCHED_SETSCHEDULER,
|
||||||
|
uintptr(tid),
|
||||||
|
uintptr(policy),
|
||||||
|
uintptr(unsafe.Pointer(param)))
|
||||||
|
|
||||||
|
if errno != 0 {
|
||||||
|
// If we can't set real-time priority, try nice value instead
|
||||||
|
if policy != SCHED_NORMAL {
|
||||||
|
ps.logger.Warn().Int("errno", int(errno)).Msg("Failed to set real-time priority, falling back to nice")
|
||||||
|
return ps.setNicePriority(priority)
|
||||||
|
}
|
||||||
|
return errno
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.logger.Debug().Int("tid", tid).Int("priority", priority).Int("policy", policy).Msg("Thread priority set")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNicePriority sets nice value as fallback when real-time scheduling is not available
|
||||||
|
func (ps *PriorityScheduler) setNicePriority(rtPriority int) error {
|
||||||
|
// Convert real-time priority to nice value (inverse relationship)
|
||||||
|
// RT priority 80 -> nice -10, RT priority 40 -> nice 0
|
||||||
|
niceValue := (40 - rtPriority) / 4
|
||||||
|
if niceValue < -20 {
|
||||||
|
niceValue = -20
|
||||||
|
}
|
||||||
|
if niceValue > 19 {
|
||||||
|
niceValue = 19
|
||||||
|
}
|
||||||
|
|
||||||
|
err := syscall.Setpriority(syscall.PRIO_PROCESS, 0, niceValue)
|
||||||
|
if err != nil {
|
||||||
|
ps.logger.Warn().Err(err).Int("nice", niceValue).Msg("Failed to set nice priority")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.logger.Debug().Int("nice", niceValue).Msg("Nice priority set as fallback")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudioProcessingPriority sets high priority for audio processing threads
|
||||||
|
func (ps *PriorityScheduler) SetAudioProcessingPriority() error {
|
||||||
|
return ps.SetThreadPriority(AudioHighPriority, SCHED_FIFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudioIOPriority sets medium priority for audio I/O threads
|
||||||
|
func (ps *PriorityScheduler) SetAudioIOPriority() error {
|
||||||
|
return ps.SetThreadPriority(AudioMediumPriority, SCHED_FIFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudioBackgroundPriority sets low priority for background audio tasks
|
||||||
|
func (ps *PriorityScheduler) SetAudioBackgroundPriority() error {
|
||||||
|
return ps.SetThreadPriority(AudioLowPriority, SCHED_FIFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPriority resets thread to normal scheduling
|
||||||
|
func (ps *PriorityScheduler) ResetPriority() error {
|
||||||
|
return ps.SetThreadPriority(NormalPriority, SCHED_NORMAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable disables priority scheduling (useful for testing or fallback)
|
||||||
|
func (ps *PriorityScheduler) Disable() {
|
||||||
|
ps.enabled = false
|
||||||
|
ps.logger.Info().Msg("Priority scheduling disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable enables priority scheduling
|
||||||
|
func (ps *PriorityScheduler) Enable() {
|
||||||
|
ps.enabled = true
|
||||||
|
ps.logger.Info().Msg("Priority scheduling enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global priority scheduler instance
|
||||||
|
var globalPriorityScheduler *PriorityScheduler
|
||||||
|
|
||||||
|
// GetPriorityScheduler returns the global priority scheduler instance
|
||||||
|
func GetPriorityScheduler() *PriorityScheduler {
|
||||||
|
if globalPriorityScheduler == nil {
|
||||||
|
globalPriorityScheduler = NewPriorityScheduler()
|
||||||
|
}
|
||||||
|
return globalPriorityScheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudioThreadPriority is a convenience function to set audio processing priority
|
||||||
|
func SetAudioThreadPriority() error {
|
||||||
|
return GetPriorityScheduler().SetAudioProcessingPriority()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudioIOThreadPriority is a convenience function to set audio I/O priority
|
||||||
|
func SetAudioIOThreadPriority() error {
|
||||||
|
return GetPriorityScheduler().SetAudioIOPriority()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetThreadPriority is a convenience function to reset thread priority
|
||||||
|
func ResetThreadPriority() error {
|
||||||
|
return GetPriorityScheduler().ResetPriority()
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -13,6 +14,10 @@ import (
|
||||||
// AudioRelay handles forwarding audio frames from the audio server subprocess
|
// AudioRelay handles forwarding audio frames from the audio server subprocess
|
||||||
// to WebRTC without any CGO audio processing. This runs in the main process.
|
// to WebRTC without any CGO audio processing. This runs in the main process.
|
||||||
type AudioRelay struct {
|
type AudioRelay struct {
|
||||||
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
|
framesRelayed int64
|
||||||
|
framesDropped int64
|
||||||
|
|
||||||
client *AudioClient
|
client *AudioClient
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
@ -25,10 +30,6 @@ type AudioRelay struct {
|
||||||
audioTrack AudioTrackWriter
|
audioTrack AudioTrackWriter
|
||||||
config AudioConfig
|
config AudioConfig
|
||||||
muted bool
|
muted bool
|
||||||
|
|
||||||
// Statistics
|
|
||||||
framesRelayed int64
|
|
||||||
framesDropped int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioTrackWriter interface for WebRTC audio track
|
// AudioTrackWriter interface for WebRTC audio track
|
||||||
|
@ -58,14 +59,16 @@ func (r *AudioRelay) Start(audioTrack AudioTrackWriter, config AudioConfig) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create audio client to connect to subprocess
|
// Create audio client to connect to subprocess
|
||||||
client, err := NewAudioClient()
|
client := NewAudioClient()
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
r.client = client
|
r.client = client
|
||||||
r.audioTrack = audioTrack
|
r.audioTrack = audioTrack
|
||||||
r.config = config
|
r.config = config
|
||||||
|
|
||||||
|
// Connect to the audio output server
|
||||||
|
if err := client.Connect(); err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to audio output server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Start relay goroutine
|
// Start relay goroutine
|
||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go r.relayLoop()
|
go r.relayLoop()
|
||||||
|
@ -88,7 +91,7 @@ func (r *AudioRelay) Stop() {
|
||||||
r.wg.Wait()
|
r.wg.Wait()
|
||||||
|
|
||||||
if r.client != nil {
|
if r.client != nil {
|
||||||
r.client.Close()
|
r.client.Disconnect()
|
||||||
r.client = nil
|
r.client = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,160 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Socket buffer sizes optimized for JetKVM's audio workload
|
||||||
|
OptimalSocketBuffer = 128 * 1024 // 128KB (32 frames @ 4KB each)
|
||||||
|
MaxSocketBuffer = 256 * 1024 // 256KB for high-load scenarios
|
||||||
|
MinSocketBuffer = 32 * 1024 // 32KB minimum for basic functionality
|
||||||
|
)
|
||||||
|
|
||||||
|
// SocketBufferConfig holds socket buffer configuration
|
||||||
|
type SocketBufferConfig struct {
|
||||||
|
SendBufferSize int
|
||||||
|
RecvBufferSize int
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSocketBufferConfig returns the default socket buffer configuration
|
||||||
|
func DefaultSocketBufferConfig() SocketBufferConfig {
|
||||||
|
return SocketBufferConfig{
|
||||||
|
SendBufferSize: OptimalSocketBuffer,
|
||||||
|
RecvBufferSize: OptimalSocketBuffer,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HighLoadSocketBufferConfig returns configuration for high-load scenarios
|
||||||
|
func HighLoadSocketBufferConfig() SocketBufferConfig {
|
||||||
|
return SocketBufferConfig{
|
||||||
|
SendBufferSize: MaxSocketBuffer,
|
||||||
|
RecvBufferSize: MaxSocketBuffer,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureSocketBuffers applies socket buffer configuration to a Unix socket connection
|
||||||
|
func ConfigureSocketBuffers(conn net.Conn, config SocketBufferConfig) error {
|
||||||
|
if !config.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateSocketBufferConfig(config); err != nil {
|
||||||
|
return fmt.Errorf("invalid socket buffer config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unixConn, ok := conn.(*net.UnixConn)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("connection is not a Unix socket")
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := unixConn.File()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get socket file descriptor: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fd := int(file.Fd())
|
||||||
|
|
||||||
|
if config.SendBufferSize > 0 {
|
||||||
|
if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, config.SendBufferSize); err != nil {
|
||||||
|
return fmt.Errorf("failed to set SO_SNDBUF to %d: %w", config.SendBufferSize, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.RecvBufferSize > 0 {
|
||||||
|
if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, config.RecvBufferSize); err != nil {
|
||||||
|
return fmt.Errorf("failed to set SO_RCVBUF to %d: %w", config.RecvBufferSize, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSocketBufferSizes retrieves current socket buffer sizes
|
||||||
|
func GetSocketBufferSizes(conn net.Conn) (sendSize, recvSize int, err error) {
|
||||||
|
unixConn, ok := conn.(*net.UnixConn)
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, fmt.Errorf("socket buffer query only supported for Unix sockets")
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := unixConn.File()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to get socket file descriptor: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fd := int(file.Fd())
|
||||||
|
|
||||||
|
// Get send buffer size
|
||||||
|
sendSize, err = syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to get SO_SNDBUF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get receive buffer size
|
||||||
|
recvSize, err = syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to get SO_RCVBUF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendSize, recvSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSocketBufferConfig validates socket buffer configuration
|
||||||
|
func ValidateSocketBufferConfig(config SocketBufferConfig) error {
|
||||||
|
if !config.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.SendBufferSize < MinSocketBuffer {
|
||||||
|
return fmt.Errorf("send buffer size %d is below minimum %d", config.SendBufferSize, MinSocketBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.RecvBufferSize < MinSocketBuffer {
|
||||||
|
return fmt.Errorf("receive buffer size %d is below minimum %d", config.RecvBufferSize, MinSocketBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.SendBufferSize > MaxSocketBuffer {
|
||||||
|
return fmt.Errorf("send buffer size %d exceeds maximum %d", config.SendBufferSize, MaxSocketBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.RecvBufferSize > MaxSocketBuffer {
|
||||||
|
return fmt.Errorf("receive buffer size %d exceeds maximum %d", config.RecvBufferSize, MaxSocketBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSocketBufferMetrics records socket buffer metrics for monitoring
|
||||||
|
func RecordSocketBufferMetrics(conn net.Conn, component string) {
|
||||||
|
if conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current socket buffer sizes
|
||||||
|
sendSize, recvSize, err := GetSocketBufferSizes(conn)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but don't fail
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record buffer sizes
|
||||||
|
socketBufferSizeGauge.WithLabelValues(component, "send").Set(float64(sendSize))
|
||||||
|
socketBufferSizeGauge.WithLabelValues(component, "receive").Set(float64(recvSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSocketBufferOverflow records a socket buffer overflow event
|
||||||
|
func RecordSocketBufferOverflow(component, bufferType string) {
|
||||||
|
socketBufferOverflowCounter.WithLabelValues(component, bufferType).Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSocketBufferUtilization updates socket buffer utilization metrics
|
||||||
|
func UpdateSocketBufferUtilization(component, bufferType string, utilizationPercent float64) {
|
||||||
|
socketBufferUtilizationGauge.WithLabelValues(component, bufferType).Set(utilizationPercent)
|
||||||
|
}
|
|
@ -0,0 +1,314 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ZeroCopyAudioFrame represents an audio frame that can be passed between
|
||||||
|
// components without copying the underlying data
|
||||||
|
type ZeroCopyAudioFrame struct {
|
||||||
|
data []byte
|
||||||
|
length int
|
||||||
|
capacity int
|
||||||
|
refCount int32
|
||||||
|
mutex sync.RWMutex
|
||||||
|
pooled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZeroCopyFramePool manages reusable zero-copy audio frames
|
||||||
|
type ZeroCopyFramePool struct {
|
||||||
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
|
counter int64 // Frame counter (atomic)
|
||||||
|
hitCount int64 // Pool hit counter (atomic)
|
||||||
|
missCount int64 // Pool miss counter (atomic)
|
||||||
|
|
||||||
|
// Other fields
|
||||||
|
pool sync.Pool
|
||||||
|
maxSize int
|
||||||
|
mutex sync.RWMutex
|
||||||
|
// Memory optimization fields
|
||||||
|
preallocated []*ZeroCopyAudioFrame // Pre-allocated frames for immediate use
|
||||||
|
preallocSize int // Number of pre-allocated frames
|
||||||
|
maxPoolSize int // Maximum pool size to prevent memory bloat
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewZeroCopyFramePool creates a new zero-copy frame pool
|
||||||
|
func NewZeroCopyFramePool(maxFrameSize int) *ZeroCopyFramePool {
|
||||||
|
// Pre-allocate 15 frames for immediate availability
|
||||||
|
preallocSize := 15
|
||||||
|
maxPoolSize := 50 // Limit total pool size
|
||||||
|
preallocated := make([]*ZeroCopyAudioFrame, 0, preallocSize)
|
||||||
|
|
||||||
|
// Pre-allocate frames to reduce initial allocation overhead
|
||||||
|
for i := 0; i < preallocSize; i++ {
|
||||||
|
frame := &ZeroCopyAudioFrame{
|
||||||
|
data: make([]byte, 0, maxFrameSize),
|
||||||
|
capacity: maxFrameSize,
|
||||||
|
pooled: true,
|
||||||
|
}
|
||||||
|
preallocated = append(preallocated, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ZeroCopyFramePool{
|
||||||
|
maxSize: maxFrameSize,
|
||||||
|
preallocated: preallocated,
|
||||||
|
preallocSize: preallocSize,
|
||||||
|
maxPoolSize: maxPoolSize,
|
||||||
|
pool: sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return &ZeroCopyAudioFrame{
|
||||||
|
data: make([]byte, 0, maxFrameSize),
|
||||||
|
capacity: maxFrameSize,
|
||||||
|
pooled: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a zero-copy frame from the pool
|
||||||
|
func (p *ZeroCopyFramePool) Get() *ZeroCopyAudioFrame {
|
||||||
|
// First try pre-allocated frames for fastest access
|
||||||
|
p.mutex.Lock()
|
||||||
|
if len(p.preallocated) > 0 {
|
||||||
|
frame := p.preallocated[len(p.preallocated)-1]
|
||||||
|
p.preallocated = p.preallocated[:len(p.preallocated)-1]
|
||||||
|
p.mutex.Unlock()
|
||||||
|
|
||||||
|
frame.mutex.Lock()
|
||||||
|
frame.refCount = 1
|
||||||
|
frame.length = 0
|
||||||
|
frame.data = frame.data[:0]
|
||||||
|
frame.mutex.Unlock()
|
||||||
|
|
||||||
|
atomic.AddInt64(&p.hitCount, 1)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
p.mutex.Unlock()
|
||||||
|
|
||||||
|
// Try sync.Pool next
|
||||||
|
frame := p.pool.Get().(*ZeroCopyAudioFrame)
|
||||||
|
frame.mutex.Lock()
|
||||||
|
frame.refCount = 1
|
||||||
|
frame.length = 0
|
||||||
|
frame.data = frame.data[:0]
|
||||||
|
frame.mutex.Unlock()
|
||||||
|
|
||||||
|
atomic.AddInt64(&p.hitCount, 1)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put returns a zero-copy frame to the pool
|
||||||
|
func (p *ZeroCopyFramePool) Put(frame *ZeroCopyAudioFrame) {
|
||||||
|
if frame == nil || !frame.pooled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.mutex.Lock()
|
||||||
|
frame.refCount--
|
||||||
|
if frame.refCount <= 0 {
|
||||||
|
frame.refCount = 0
|
||||||
|
frame.length = 0
|
||||||
|
frame.data = frame.data[:0]
|
||||||
|
frame.mutex.Unlock()
|
||||||
|
|
||||||
|
// First try to return to pre-allocated pool for fastest reuse
|
||||||
|
p.mutex.Lock()
|
||||||
|
if len(p.preallocated) < p.preallocSize {
|
||||||
|
p.preallocated = append(p.preallocated, frame)
|
||||||
|
p.mutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mutex.Unlock()
|
||||||
|
|
||||||
|
// Check pool size limit to prevent excessive memory usage
|
||||||
|
p.mutex.RLock()
|
||||||
|
currentCount := atomic.LoadInt64(&p.counter)
|
||||||
|
p.mutex.RUnlock()
|
||||||
|
|
||||||
|
if currentCount >= int64(p.maxPoolSize) {
|
||||||
|
return // Pool is full, let GC handle this frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return to sync.Pool
|
||||||
|
p.pool.Put(frame)
|
||||||
|
atomic.AddInt64(&p.counter, 1)
|
||||||
|
} else {
|
||||||
|
frame.mutex.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data returns the frame data as a slice (zero-copy view)
|
||||||
|
func (f *ZeroCopyAudioFrame) Data() []byte {
|
||||||
|
f.mutex.RLock()
|
||||||
|
defer f.mutex.RUnlock()
|
||||||
|
return f.data[:f.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetData sets the frame data (zero-copy if possible)
|
||||||
|
func (f *ZeroCopyAudioFrame) SetData(data []byte) error {
|
||||||
|
f.mutex.Lock()
|
||||||
|
defer f.mutex.Unlock()
|
||||||
|
|
||||||
|
if len(data) > f.capacity {
|
||||||
|
// Need to reallocate - not zero-copy but necessary
|
||||||
|
f.data = make([]byte, len(data))
|
||||||
|
f.capacity = len(data)
|
||||||
|
f.pooled = false // Can't return to pool anymore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-copy assignment when data fits in existing buffer
|
||||||
|
if cap(f.data) >= len(data) {
|
||||||
|
f.data = f.data[:len(data)]
|
||||||
|
copy(f.data, data)
|
||||||
|
} else {
|
||||||
|
f.data = append(f.data[:0], data...)
|
||||||
|
}
|
||||||
|
f.length = len(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDataDirect sets frame data using direct buffer assignment (true zero-copy)
|
||||||
|
// WARNING: The caller must ensure the buffer remains valid for the frame's lifetime
|
||||||
|
func (f *ZeroCopyAudioFrame) SetDataDirect(data []byte) {
|
||||||
|
f.mutex.Lock()
|
||||||
|
defer f.mutex.Unlock()
|
||||||
|
f.data = data
|
||||||
|
f.length = len(data)
|
||||||
|
f.capacity = cap(data)
|
||||||
|
f.pooled = false // Direct assignment means we can't pool this frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRef increments the reference count for shared access
|
||||||
|
func (f *ZeroCopyAudioFrame) AddRef() {
|
||||||
|
f.mutex.Lock()
|
||||||
|
f.refCount++
|
||||||
|
f.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release decrements the reference count
|
||||||
|
func (f *ZeroCopyAudioFrame) Release() {
|
||||||
|
f.mutex.Lock()
|
||||||
|
f.refCount--
|
||||||
|
f.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length returns the current data length
|
||||||
|
func (f *ZeroCopyAudioFrame) Length() int {
|
||||||
|
f.mutex.RLock()
|
||||||
|
defer f.mutex.RUnlock()
|
||||||
|
return f.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacity returns the buffer capacity
|
||||||
|
func (f *ZeroCopyAudioFrame) Capacity() int {
|
||||||
|
f.mutex.RLock()
|
||||||
|
defer f.mutex.RUnlock()
|
||||||
|
return f.capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsafePointer returns an unsafe pointer to the data for CGO calls
|
||||||
|
// WARNING: Only use this for CGO interop, ensure frame lifetime
|
||||||
|
func (f *ZeroCopyAudioFrame) UnsafePointer() unsafe.Pointer {
|
||||||
|
f.mutex.RLock()
|
||||||
|
defer f.mutex.RUnlock()
|
||||||
|
if len(f.data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return unsafe.Pointer(&f.data[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global zero-copy frame pool
|
||||||
|
// GetZeroCopyPoolStats returns detailed statistics about the zero-copy frame pool
|
||||||
|
func (p *ZeroCopyFramePool) GetZeroCopyPoolStats() ZeroCopyFramePoolStats {
|
||||||
|
p.mutex.RLock()
|
||||||
|
preallocatedCount := len(p.preallocated)
|
||||||
|
currentCount := atomic.LoadInt64(&p.counter)
|
||||||
|
p.mutex.RUnlock()
|
||||||
|
|
||||||
|
hitCount := atomic.LoadInt64(&p.hitCount)
|
||||||
|
missCount := atomic.LoadInt64(&p.missCount)
|
||||||
|
totalRequests := hitCount + missCount
|
||||||
|
|
||||||
|
var hitRate float64
|
||||||
|
if totalRequests > 0 {
|
||||||
|
hitRate = float64(hitCount) / float64(totalRequests) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZeroCopyFramePoolStats{
|
||||||
|
MaxFrameSize: p.maxSize,
|
||||||
|
MaxPoolSize: p.maxPoolSize,
|
||||||
|
CurrentPoolSize: currentCount,
|
||||||
|
PreallocatedCount: int64(preallocatedCount),
|
||||||
|
PreallocatedMax: int64(p.preallocSize),
|
||||||
|
HitCount: hitCount,
|
||||||
|
MissCount: missCount,
|
||||||
|
HitRate: hitRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZeroCopyFramePoolStats provides detailed zero-copy pool statistics
|
||||||
|
type ZeroCopyFramePoolStats struct {
|
||||||
|
MaxFrameSize int
|
||||||
|
MaxPoolSize int
|
||||||
|
CurrentPoolSize int64
|
||||||
|
PreallocatedCount int64
|
||||||
|
PreallocatedMax int64
|
||||||
|
HitCount int64
|
||||||
|
MissCount int64
|
||||||
|
HitRate float64 // Percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalZeroCopyPool = NewZeroCopyFramePool(MaxAudioFrameSize)
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetZeroCopyFrame gets a frame from the global pool
|
||||||
|
func GetZeroCopyFrame() *ZeroCopyAudioFrame {
|
||||||
|
return globalZeroCopyPool.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGlobalZeroCopyPoolStats returns statistics for the global zero-copy pool
|
||||||
|
func GetGlobalZeroCopyPoolStats() ZeroCopyFramePoolStats {
|
||||||
|
return globalZeroCopyPool.GetZeroCopyPoolStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutZeroCopyFrame returns a frame to the global pool
|
||||||
|
func PutZeroCopyFrame(frame *ZeroCopyAudioFrame) {
|
||||||
|
globalZeroCopyPool.Put(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZeroCopyAudioReadEncode performs audio read and encode with zero-copy optimization
|
||||||
|
func ZeroCopyAudioReadEncode() (*ZeroCopyAudioFrame, error) {
|
||||||
|
frame := GetZeroCopyFrame()
|
||||||
|
|
||||||
|
// Ensure frame has enough capacity
|
||||||
|
if frame.Capacity() < MaxAudioFrameSize {
|
||||||
|
// Reallocate if needed
|
||||||
|
frame.data = make([]byte, MaxAudioFrameSize)
|
||||||
|
frame.capacity = MaxAudioFrameSize
|
||||||
|
frame.pooled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use unsafe pointer for direct CGO call
|
||||||
|
n, err := CGOAudioReadEncode(frame.data[:MaxAudioFrameSize])
|
||||||
|
if err != nil {
|
||||||
|
PutZeroCopyFrame(frame)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
PutZeroCopyFrame(frame)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the actual data length
|
||||||
|
frame.mutex.Lock()
|
||||||
|
frame.length = n
|
||||||
|
frame.data = frame.data[:n]
|
||||||
|
frame.mutex.Unlock()
|
||||||
|
|
||||||
|
return frame, nil
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tmpl = `// Code generated by "go run gen.go". DO NOT EDIT.
|
||||||
|
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
||||||
|
package tzdata
|
||||||
|
var TimeZones = []string{
|
||||||
|
{{- range . }}
|
||||||
|
"{{.}}",
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var filename = flag.String("output", "tzdata.go", "output file name")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
path := os.Getenv("ZONEINFO")
|
||||||
|
if path == "" {
|
||||||
|
fmt.Println("ZONEINFO is not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
fmt.Printf("ZONEINFO %s does not exist\n", path)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
zipfile, err := zip.OpenReader(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error opening ZONEINFO %s: %v\n", path, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer zipfile.Close()
|
||||||
|
|
||||||
|
timezones := []string{}
|
||||||
|
|
||||||
|
for _, file := range zipfile.File {
|
||||||
|
timezones = append(timezones, file.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
tmpl, err := template.New("tzdata").Parse(tmpl)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error parsing template: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tmpl.Execute(&buf, timezones)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error executing template: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(*filename, buf.Bytes(), 0644)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error writing file %s: %v\n", *filename, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,602 @@
|
||||||
|
// Code generated by "go run gen.go". DO NOT EDIT.
|
||||||
|
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
||||||
|
package tzdata
|
||||||
|
var TimeZones = []string{
|
||||||
|
"Africa/Abidjan",
|
||||||
|
"Africa/Accra",
|
||||||
|
"Africa/Addis_Ababa",
|
||||||
|
"Africa/Algiers",
|
||||||
|
"Africa/Asmara",
|
||||||
|
"Africa/Asmera",
|
||||||
|
"Africa/Bamako",
|
||||||
|
"Africa/Bangui",
|
||||||
|
"Africa/Banjul",
|
||||||
|
"Africa/Bissau",
|
||||||
|
"Africa/Blantyre",
|
||||||
|
"Africa/Brazzaville",
|
||||||
|
"Africa/Bujumbura",
|
||||||
|
"Africa/Cairo",
|
||||||
|
"Africa/Casablanca",
|
||||||
|
"Africa/Ceuta",
|
||||||
|
"Africa/Conakry",
|
||||||
|
"Africa/Dakar",
|
||||||
|
"Africa/Dar_es_Salaam",
|
||||||
|
"Africa/Djibouti",
|
||||||
|
"Africa/Douala",
|
||||||
|
"Africa/El_Aaiun",
|
||||||
|
"Africa/Freetown",
|
||||||
|
"Africa/Gaborone",
|
||||||
|
"Africa/Harare",
|
||||||
|
"Africa/Johannesburg",
|
||||||
|
"Africa/Juba",
|
||||||
|
"Africa/Kampala",
|
||||||
|
"Africa/Khartoum",
|
||||||
|
"Africa/Kigali",
|
||||||
|
"Africa/Kinshasa",
|
||||||
|
"Africa/Lagos",
|
||||||
|
"Africa/Libreville",
|
||||||
|
"Africa/Lome",
|
||||||
|
"Africa/Luanda",
|
||||||
|
"Africa/Lubumbashi",
|
||||||
|
"Africa/Lusaka",
|
||||||
|
"Africa/Malabo",
|
||||||
|
"Africa/Maputo",
|
||||||
|
"Africa/Maseru",
|
||||||
|
"Africa/Mbabane",
|
||||||
|
"Africa/Mogadishu",
|
||||||
|
"Africa/Monrovia",
|
||||||
|
"Africa/Nairobi",
|
||||||
|
"Africa/Ndjamena",
|
||||||
|
"Africa/Niamey",
|
||||||
|
"Africa/Nouakchott",
|
||||||
|
"Africa/Ouagadougou",
|
||||||
|
"Africa/Porto-Novo",
|
||||||
|
"Africa/Sao_Tome",
|
||||||
|
"Africa/Timbuktu",
|
||||||
|
"Africa/Tripoli",
|
||||||
|
"Africa/Tunis",
|
||||||
|
"Africa/Windhoek",
|
||||||
|
"America/Adak",
|
||||||
|
"America/Anchorage",
|
||||||
|
"America/Anguilla",
|
||||||
|
"America/Antigua",
|
||||||
|
"America/Araguaina",
|
||||||
|
"America/Argentina/Buenos_Aires",
|
||||||
|
"America/Argentina/Catamarca",
|
||||||
|
"America/Argentina/ComodRivadavia",
|
||||||
|
"America/Argentina/Cordoba",
|
||||||
|
"America/Argentina/Jujuy",
|
||||||
|
"America/Argentina/La_Rioja",
|
||||||
|
"America/Argentina/Mendoza",
|
||||||
|
"America/Argentina/Rio_Gallegos",
|
||||||
|
"America/Argentina/Salta",
|
||||||
|
"America/Argentina/San_Juan",
|
||||||
|
"America/Argentina/San_Luis",
|
||||||
|
"America/Argentina/Tucuman",
|
||||||
|
"America/Argentina/Ushuaia",
|
||||||
|
"America/Aruba",
|
||||||
|
"America/Asuncion",
|
||||||
|
"America/Atikokan",
|
||||||
|
"America/Atka",
|
||||||
|
"America/Bahia",
|
||||||
|
"America/Bahia_Banderas",
|
||||||
|
"America/Barbados",
|
||||||
|
"America/Belem",
|
||||||
|
"America/Belize",
|
||||||
|
"America/Blanc-Sablon",
|
||||||
|
"America/Boa_Vista",
|
||||||
|
"America/Bogota",
|
||||||
|
"America/Boise",
|
||||||
|
"America/Buenos_Aires",
|
||||||
|
"America/Cambridge_Bay",
|
||||||
|
"America/Campo_Grande",
|
||||||
|
"America/Cancun",
|
||||||
|
"America/Caracas",
|
||||||
|
"America/Catamarca",
|
||||||
|
"America/Cayenne",
|
||||||
|
"America/Cayman",
|
||||||
|
"America/Chicago",
|
||||||
|
"America/Chihuahua",
|
||||||
|
"America/Ciudad_Juarez",
|
||||||
|
"America/Coral_Harbour",
|
||||||
|
"America/Cordoba",
|
||||||
|
"America/Costa_Rica",
|
||||||
|
"America/Creston",
|
||||||
|
"America/Cuiaba",
|
||||||
|
"America/Curacao",
|
||||||
|
"America/Danmarkshavn",
|
||||||
|
"America/Dawson",
|
||||||
|
"America/Dawson_Creek",
|
||||||
|
"America/Denver",
|
||||||
|
"America/Detroit",
|
||||||
|
"America/Dominica",
|
||||||
|
"America/Edmonton",
|
||||||
|
"America/Eirunepe",
|
||||||
|
"America/El_Salvador",
|
||||||
|
"America/Ensenada",
|
||||||
|
"America/Fort_Nelson",
|
||||||
|
"America/Fort_Wayne",
|
||||||
|
"America/Fortaleza",
|
||||||
|
"America/Glace_Bay",
|
||||||
|
"America/Godthab",
|
||||||
|
"America/Goose_Bay",
|
||||||
|
"America/Grand_Turk",
|
||||||
|
"America/Grenada",
|
||||||
|
"America/Guadeloupe",
|
||||||
|
"America/Guatemala",
|
||||||
|
"America/Guayaquil",
|
||||||
|
"America/Guyana",
|
||||||
|
"America/Halifax",
|
||||||
|
"America/Havana",
|
||||||
|
"America/Hermosillo",
|
||||||
|
"America/Indiana/Indianapolis",
|
||||||
|
"America/Indiana/Knox",
|
||||||
|
"America/Indiana/Marengo",
|
||||||
|
"America/Indiana/Petersburg",
|
||||||
|
"America/Indiana/Tell_City",
|
||||||
|
"America/Indiana/Vevay",
|
||||||
|
"America/Indiana/Vincennes",
|
||||||
|
"America/Indiana/Winamac",
|
||||||
|
"America/Indianapolis",
|
||||||
|
"America/Inuvik",
|
||||||
|
"America/Iqaluit",
|
||||||
|
"America/Jamaica",
|
||||||
|
"America/Jujuy",
|
||||||
|
"America/Juneau",
|
||||||
|
"America/Kentucky/Louisville",
|
||||||
|
"America/Kentucky/Monticello",
|
||||||
|
"America/Knox_IN",
|
||||||
|
"America/Kralendijk",
|
||||||
|
"America/La_Paz",
|
||||||
|
"America/Lima",
|
||||||
|
"America/Los_Angeles",
|
||||||
|
"America/Louisville",
|
||||||
|
"America/Lower_Princes",
|
||||||
|
"America/Maceio",
|
||||||
|
"America/Managua",
|
||||||
|
"America/Manaus",
|
||||||
|
"America/Marigot",
|
||||||
|
"America/Martinique",
|
||||||
|
"America/Matamoros",
|
||||||
|
"America/Mazatlan",
|
||||||
|
"America/Mendoza",
|
||||||
|
"America/Menominee",
|
||||||
|
"America/Merida",
|
||||||
|
"America/Metlakatla",
|
||||||
|
"America/Mexico_City",
|
||||||
|
"America/Miquelon",
|
||||||
|
"America/Moncton",
|
||||||
|
"America/Monterrey",
|
||||||
|
"America/Montevideo",
|
||||||
|
"America/Montreal",
|
||||||
|
"America/Montserrat",
|
||||||
|
"America/Nassau",
|
||||||
|
"America/New_York",
|
||||||
|
"America/Nipigon",
|
||||||
|
"America/Nome",
|
||||||
|
"America/Noronha",
|
||||||
|
"America/North_Dakota/Beulah",
|
||||||
|
"America/North_Dakota/Center",
|
||||||
|
"America/North_Dakota/New_Salem",
|
||||||
|
"America/Nuuk",
|
||||||
|
"America/Ojinaga",
|
||||||
|
"America/Panama",
|
||||||
|
"America/Pangnirtung",
|
||||||
|
"America/Paramaribo",
|
||||||
|
"America/Phoenix",
|
||||||
|
"America/Port-au-Prince",
|
||||||
|
"America/Port_of_Spain",
|
||||||
|
"America/Porto_Acre",
|
||||||
|
"America/Porto_Velho",
|
||||||
|
"America/Puerto_Rico",
|
||||||
|
"America/Punta_Arenas",
|
||||||
|
"America/Rainy_River",
|
||||||
|
"America/Rankin_Inlet",
|
||||||
|
"America/Recife",
|
||||||
|
"America/Regina",
|
||||||
|
"America/Resolute",
|
||||||
|
"America/Rio_Branco",
|
||||||
|
"America/Rosario",
|
||||||
|
"America/Santa_Isabel",
|
||||||
|
"America/Santarem",
|
||||||
|
"America/Santiago",
|
||||||
|
"America/Santo_Domingo",
|
||||||
|
"America/Sao_Paulo",
|
||||||
|
"America/Scoresbysund",
|
||||||
|
"America/Shiprock",
|
||||||
|
"America/Sitka",
|
||||||
|
"America/St_Barthelemy",
|
||||||
|
"America/St_Johns",
|
||||||
|
"America/St_Kitts",
|
||||||
|
"America/St_Lucia",
|
||||||
|
"America/St_Thomas",
|
||||||
|
"America/St_Vincent",
|
||||||
|
"America/Swift_Current",
|
||||||
|
"America/Tegucigalpa",
|
||||||
|
"America/Thule",
|
||||||
|
"America/Thunder_Bay",
|
||||||
|
"America/Tijuana",
|
||||||
|
"America/Toronto",
|
||||||
|
"America/Tortola",
|
||||||
|
"America/Vancouver",
|
||||||
|
"America/Virgin",
|
||||||
|
"America/Whitehorse",
|
||||||
|
"America/Winnipeg",
|
||||||
|
"America/Yakutat",
|
||||||
|
"America/Yellowknife",
|
||||||
|
"Antarctica/Casey",
|
||||||
|
"Antarctica/Davis",
|
||||||
|
"Antarctica/DumontDUrville",
|
||||||
|
"Antarctica/Macquarie",
|
||||||
|
"Antarctica/Mawson",
|
||||||
|
"Antarctica/McMurdo",
|
||||||
|
"Antarctica/Palmer",
|
||||||
|
"Antarctica/Rothera",
|
||||||
|
"Antarctica/South_Pole",
|
||||||
|
"Antarctica/Syowa",
|
||||||
|
"Antarctica/Troll",
|
||||||
|
"Antarctica/Vostok",
|
||||||
|
"Arctic/Longyearbyen",
|
||||||
|
"Asia/Aden",
|
||||||
|
"Asia/Almaty",
|
||||||
|
"Asia/Amman",
|
||||||
|
"Asia/Anadyr",
|
||||||
|
"Asia/Aqtau",
|
||||||
|
"Asia/Aqtobe",
|
||||||
|
"Asia/Ashgabat",
|
||||||
|
"Asia/Ashkhabad",
|
||||||
|
"Asia/Atyrau",
|
||||||
|
"Asia/Baghdad",
|
||||||
|
"Asia/Bahrain",
|
||||||
|
"Asia/Baku",
|
||||||
|
"Asia/Bangkok",
|
||||||
|
"Asia/Barnaul",
|
||||||
|
"Asia/Beirut",
|
||||||
|
"Asia/Bishkek",
|
||||||
|
"Asia/Brunei",
|
||||||
|
"Asia/Calcutta",
|
||||||
|
"Asia/Chita",
|
||||||
|
"Asia/Choibalsan",
|
||||||
|
"Asia/Chongqing",
|
||||||
|
"Asia/Chungking",
|
||||||
|
"Asia/Colombo",
|
||||||
|
"Asia/Dacca",
|
||||||
|
"Asia/Damascus",
|
||||||
|
"Asia/Dhaka",
|
||||||
|
"Asia/Dili",
|
||||||
|
"Asia/Dubai",
|
||||||
|
"Asia/Dushanbe",
|
||||||
|
"Asia/Famagusta",
|
||||||
|
"Asia/Gaza",
|
||||||
|
"Asia/Harbin",
|
||||||
|
"Asia/Hebron",
|
||||||
|
"Asia/Ho_Chi_Minh",
|
||||||
|
"Asia/Hong_Kong",
|
||||||
|
"Asia/Hovd",
|
||||||
|
"Asia/Irkutsk",
|
||||||
|
"Asia/Istanbul",
|
||||||
|
"Asia/Jakarta",
|
||||||
|
"Asia/Jayapura",
|
||||||
|
"Asia/Jerusalem",
|
||||||
|
"Asia/Kabul",
|
||||||
|
"Asia/Kamchatka",
|
||||||
|
"Asia/Karachi",
|
||||||
|
"Asia/Kashgar",
|
||||||
|
"Asia/Kathmandu",
|
||||||
|
"Asia/Katmandu",
|
||||||
|
"Asia/Khandyga",
|
||||||
|
"Asia/Kolkata",
|
||||||
|
"Asia/Krasnoyarsk",
|
||||||
|
"Asia/Kuala_Lumpur",
|
||||||
|
"Asia/Kuching",
|
||||||
|
"Asia/Kuwait",
|
||||||
|
"Asia/Macao",
|
||||||
|
"Asia/Macau",
|
||||||
|
"Asia/Magadan",
|
||||||
|
"Asia/Makassar",
|
||||||
|
"Asia/Manila",
|
||||||
|
"Asia/Muscat",
|
||||||
|
"Asia/Nicosia",
|
||||||
|
"Asia/Novokuznetsk",
|
||||||
|
"Asia/Novosibirsk",
|
||||||
|
"Asia/Omsk",
|
||||||
|
"Asia/Oral",
|
||||||
|
"Asia/Phnom_Penh",
|
||||||
|
"Asia/Pontianak",
|
||||||
|
"Asia/Pyongyang",
|
||||||
|
"Asia/Qatar",
|
||||||
|
"Asia/Qostanay",
|
||||||
|
"Asia/Qyzylorda",
|
||||||
|
"Asia/Rangoon",
|
||||||
|
"Asia/Riyadh",
|
||||||
|
"Asia/Saigon",
|
||||||
|
"Asia/Sakhalin",
|
||||||
|
"Asia/Samarkand",
|
||||||
|
"Asia/Seoul",
|
||||||
|
"Asia/Shanghai",
|
||||||
|
"Asia/Singapore",
|
||||||
|
"Asia/Srednekolymsk",
|
||||||
|
"Asia/Taipei",
|
||||||
|
"Asia/Tashkent",
|
||||||
|
"Asia/Tbilisi",
|
||||||
|
"Asia/Tehran",
|
||||||
|
"Asia/Tel_Aviv",
|
||||||
|
"Asia/Thimbu",
|
||||||
|
"Asia/Thimphu",
|
||||||
|
"Asia/Tokyo",
|
||||||
|
"Asia/Tomsk",
|
||||||
|
"Asia/Ujung_Pandang",
|
||||||
|
"Asia/Ulaanbaatar",
|
||||||
|
"Asia/Ulan_Bator",
|
||||||
|
"Asia/Urumqi",
|
||||||
|
"Asia/Ust-Nera",
|
||||||
|
"Asia/Vientiane",
|
||||||
|
"Asia/Vladivostok",
|
||||||
|
"Asia/Yakutsk",
|
||||||
|
"Asia/Yangon",
|
||||||
|
"Asia/Yekaterinburg",
|
||||||
|
"Asia/Yerevan",
|
||||||
|
"Atlantic/Azores",
|
||||||
|
"Atlantic/Bermuda",
|
||||||
|
"Atlantic/Canary",
|
||||||
|
"Atlantic/Cape_Verde",
|
||||||
|
"Atlantic/Faeroe",
|
||||||
|
"Atlantic/Faroe",
|
||||||
|
"Atlantic/Jan_Mayen",
|
||||||
|
"Atlantic/Madeira",
|
||||||
|
"Atlantic/Reykjavik",
|
||||||
|
"Atlantic/South_Georgia",
|
||||||
|
"Atlantic/St_Helena",
|
||||||
|
"Atlantic/Stanley",
|
||||||
|
"Australia/ACT",
|
||||||
|
"Australia/Adelaide",
|
||||||
|
"Australia/Brisbane",
|
||||||
|
"Australia/Broken_Hill",
|
||||||
|
"Australia/Canberra",
|
||||||
|
"Australia/Currie",
|
||||||
|
"Australia/Darwin",
|
||||||
|
"Australia/Eucla",
|
||||||
|
"Australia/Hobart",
|
||||||
|
"Australia/LHI",
|
||||||
|
"Australia/Lindeman",
|
||||||
|
"Australia/Lord_Howe",
|
||||||
|
"Australia/Melbourne",
|
||||||
|
"Australia/NSW",
|
||||||
|
"Australia/North",
|
||||||
|
"Australia/Perth",
|
||||||
|
"Australia/Queensland",
|
||||||
|
"Australia/South",
|
||||||
|
"Australia/Sydney",
|
||||||
|
"Australia/Tasmania",
|
||||||
|
"Australia/Victoria",
|
||||||
|
"Australia/West",
|
||||||
|
"Australia/Yancowinna",
|
||||||
|
"Brazil/Acre",
|
||||||
|
"Brazil/DeNoronha",
|
||||||
|
"Brazil/East",
|
||||||
|
"Brazil/West",
|
||||||
|
"CET",
|
||||||
|
"CST6CDT",
|
||||||
|
"Canada/Atlantic",
|
||||||
|
"Canada/Central",
|
||||||
|
"Canada/Eastern",
|
||||||
|
"Canada/Mountain",
|
||||||
|
"Canada/Newfoundland",
|
||||||
|
"Canada/Pacific",
|
||||||
|
"Canada/Saskatchewan",
|
||||||
|
"Canada/Yukon",
|
||||||
|
"Chile/Continental",
|
||||||
|
"Chile/EasterIsland",
|
||||||
|
"Cuba",
|
||||||
|
"EET",
|
||||||
|
"EST",
|
||||||
|
"EST5EDT",
|
||||||
|
"Egypt",
|
||||||
|
"Eire",
|
||||||
|
"Etc/GMT",
|
||||||
|
"Etc/GMT+0",
|
||||||
|
"Etc/GMT+1",
|
||||||
|
"Etc/GMT+10",
|
||||||
|
"Etc/GMT+11",
|
||||||
|
"Etc/GMT+12",
|
||||||
|
"Etc/GMT+2",
|
||||||
|
"Etc/GMT+3",
|
||||||
|
"Etc/GMT+4",
|
||||||
|
"Etc/GMT+5",
|
||||||
|
"Etc/GMT+6",
|
||||||
|
"Etc/GMT+7",
|
||||||
|
"Etc/GMT+8",
|
||||||
|
"Etc/GMT+9",
|
||||||
|
"Etc/GMT-0",
|
||||||
|
"Etc/GMT-1",
|
||||||
|
"Etc/GMT-10",
|
||||||
|
"Etc/GMT-11",
|
||||||
|
"Etc/GMT-12",
|
||||||
|
"Etc/GMT-13",
|
||||||
|
"Etc/GMT-14",
|
||||||
|
"Etc/GMT-2",
|
||||||
|
"Etc/GMT-3",
|
||||||
|
"Etc/GMT-4",
|
||||||
|
"Etc/GMT-5",
|
||||||
|
"Etc/GMT-6",
|
||||||
|
"Etc/GMT-7",
|
||||||
|
"Etc/GMT-8",
|
||||||
|
"Etc/GMT-9",
|
||||||
|
"Etc/GMT0",
|
||||||
|
"Etc/Greenwich",
|
||||||
|
"Etc/UCT",
|
||||||
|
"Etc/UTC",
|
||||||
|
"Etc/Universal",
|
||||||
|
"Etc/Zulu",
|
||||||
|
"Europe/Amsterdam",
|
||||||
|
"Europe/Andorra",
|
||||||
|
"Europe/Astrakhan",
|
||||||
|
"Europe/Athens",
|
||||||
|
"Europe/Belfast",
|
||||||
|
"Europe/Belgrade",
|
||||||
|
"Europe/Berlin",
|
||||||
|
"Europe/Bratislava",
|
||||||
|
"Europe/Brussels",
|
||||||
|
"Europe/Bucharest",
|
||||||
|
"Europe/Budapest",
|
||||||
|
"Europe/Busingen",
|
||||||
|
"Europe/Chisinau",
|
||||||
|
"Europe/Copenhagen",
|
||||||
|
"Europe/Dublin",
|
||||||
|
"Europe/Gibraltar",
|
||||||
|
"Europe/Guernsey",
|
||||||
|
"Europe/Helsinki",
|
||||||
|
"Europe/Isle_of_Man",
|
||||||
|
"Europe/Istanbul",
|
||||||
|
"Europe/Jersey",
|
||||||
|
"Europe/Kaliningrad",
|
||||||
|
"Europe/Kiev",
|
||||||
|
"Europe/Kirov",
|
||||||
|
"Europe/Kyiv",
|
||||||
|
"Europe/Lisbon",
|
||||||
|
"Europe/Ljubljana",
|
||||||
|
"Europe/London",
|
||||||
|
"Europe/Luxembourg",
|
||||||
|
"Europe/Madrid",
|
||||||
|
"Europe/Malta",
|
||||||
|
"Europe/Mariehamn",
|
||||||
|
"Europe/Minsk",
|
||||||
|
"Europe/Monaco",
|
||||||
|
"Europe/Moscow",
|
||||||
|
"Europe/Nicosia",
|
||||||
|
"Europe/Oslo",
|
||||||
|
"Europe/Paris",
|
||||||
|
"Europe/Podgorica",
|
||||||
|
"Europe/Prague",
|
||||||
|
"Europe/Riga",
|
||||||
|
"Europe/Rome",
|
||||||
|
"Europe/Samara",
|
||||||
|
"Europe/San_Marino",
|
||||||
|
"Europe/Sarajevo",
|
||||||
|
"Europe/Saratov",
|
||||||
|
"Europe/Simferopol",
|
||||||
|
"Europe/Skopje",
|
||||||
|
"Europe/Sofia",
|
||||||
|
"Europe/Stockholm",
|
||||||
|
"Europe/Tallinn",
|
||||||
|
"Europe/Tirane",
|
||||||
|
"Europe/Tiraspol",
|
||||||
|
"Europe/Ulyanovsk",
|
||||||
|
"Europe/Uzhgorod",
|
||||||
|
"Europe/Vaduz",
|
||||||
|
"Europe/Vatican",
|
||||||
|
"Europe/Vienna",
|
||||||
|
"Europe/Vilnius",
|
||||||
|
"Europe/Volgograd",
|
||||||
|
"Europe/Warsaw",
|
||||||
|
"Europe/Zagreb",
|
||||||
|
"Europe/Zaporozhye",
|
||||||
|
"Europe/Zurich",
|
||||||
|
"Factory",
|
||||||
|
"GB",
|
||||||
|
"GB-Eire",
|
||||||
|
"GMT",
|
||||||
|
"GMT+0",
|
||||||
|
"GMT-0",
|
||||||
|
"GMT0",
|
||||||
|
"Greenwich",
|
||||||
|
"HST",
|
||||||
|
"Hongkong",
|
||||||
|
"Iceland",
|
||||||
|
"Indian/Antananarivo",
|
||||||
|
"Indian/Chagos",
|
||||||
|
"Indian/Christmas",
|
||||||
|
"Indian/Cocos",
|
||||||
|
"Indian/Comoro",
|
||||||
|
"Indian/Kerguelen",
|
||||||
|
"Indian/Mahe",
|
||||||
|
"Indian/Maldives",
|
||||||
|
"Indian/Mauritius",
|
||||||
|
"Indian/Mayotte",
|
||||||
|
"Indian/Reunion",
|
||||||
|
"Iran",
|
||||||
|
"Israel",
|
||||||
|
"Jamaica",
|
||||||
|
"Japan",
|
||||||
|
"Kwajalein",
|
||||||
|
"Libya",
|
||||||
|
"MET",
|
||||||
|
"MST",
|
||||||
|
"MST7MDT",
|
||||||
|
"Mexico/BajaNorte",
|
||||||
|
"Mexico/BajaSur",
|
||||||
|
"Mexico/General",
|
||||||
|
"NZ",
|
||||||
|
"NZ-CHAT",
|
||||||
|
"Navajo",
|
||||||
|
"PRC",
|
||||||
|
"PST8PDT",
|
||||||
|
"Pacific/Apia",
|
||||||
|
"Pacific/Auckland",
|
||||||
|
"Pacific/Bougainville",
|
||||||
|
"Pacific/Chatham",
|
||||||
|
"Pacific/Chuuk",
|
||||||
|
"Pacific/Easter",
|
||||||
|
"Pacific/Efate",
|
||||||
|
"Pacific/Enderbury",
|
||||||
|
"Pacific/Fakaofo",
|
||||||
|
"Pacific/Fiji",
|
||||||
|
"Pacific/Funafuti",
|
||||||
|
"Pacific/Galapagos",
|
||||||
|
"Pacific/Gambier",
|
||||||
|
"Pacific/Guadalcanal",
|
||||||
|
"Pacific/Guam",
|
||||||
|
"Pacific/Honolulu",
|
||||||
|
"Pacific/Johnston",
|
||||||
|
"Pacific/Kanton",
|
||||||
|
"Pacific/Kiritimati",
|
||||||
|
"Pacific/Kosrae",
|
||||||
|
"Pacific/Kwajalein",
|
||||||
|
"Pacific/Majuro",
|
||||||
|
"Pacific/Marquesas",
|
||||||
|
"Pacific/Midway",
|
||||||
|
"Pacific/Nauru",
|
||||||
|
"Pacific/Niue",
|
||||||
|
"Pacific/Norfolk",
|
||||||
|
"Pacific/Noumea",
|
||||||
|
"Pacific/Pago_Pago",
|
||||||
|
"Pacific/Palau",
|
||||||
|
"Pacific/Pitcairn",
|
||||||
|
"Pacific/Pohnpei",
|
||||||
|
"Pacific/Ponape",
|
||||||
|
"Pacific/Port_Moresby",
|
||||||
|
"Pacific/Rarotonga",
|
||||||
|
"Pacific/Saipan",
|
||||||
|
"Pacific/Samoa",
|
||||||
|
"Pacific/Tahiti",
|
||||||
|
"Pacific/Tarawa",
|
||||||
|
"Pacific/Tongatapu",
|
||||||
|
"Pacific/Truk",
|
||||||
|
"Pacific/Wake",
|
||||||
|
"Pacific/Wallis",
|
||||||
|
"Pacific/Yap",
|
||||||
|
"Poland",
|
||||||
|
"Portugal",
|
||||||
|
"ROC",
|
||||||
|
"ROK",
|
||||||
|
"Singapore",
|
||||||
|
"Turkey",
|
||||||
|
"UCT",
|
||||||
|
"US/Alaska",
|
||||||
|
"US/Aleutian",
|
||||||
|
"US/Arizona",
|
||||||
|
"US/Central",
|
||||||
|
"US/East-Indiana",
|
||||||
|
"US/Eastern",
|
||||||
|
"US/Hawaii",
|
||||||
|
"US/Indiana-Starke",
|
||||||
|
"US/Michigan",
|
||||||
|
"US/Mountain",
|
||||||
|
"US/Pacific",
|
||||||
|
"US/Samoa",
|
||||||
|
"UTC",
|
||||||
|
"Universal",
|
||||||
|
"W-SU",
|
||||||
|
"WET",
|
||||||
|
"Zulu",
|
||||||
|
}
|
147
jiggler.go
147
jiggler.go
|
@ -1,39 +1,156 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
"github.com/jetkvm/kvm/internal/tzdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lastUserInput = time.Now()
|
type JigglerConfig struct {
|
||||||
|
InactivityLimitSeconds int `json:"inactivity_limit_seconds"`
|
||||||
|
JitterPercentage int `json:"jitter_percentage"`
|
||||||
|
ScheduleCronTab string `json:"schedule_cron_tab"`
|
||||||
|
Timezone string `json:"timezone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
var jigglerEnabled = false
|
var jigglerEnabled = false
|
||||||
|
var jobDelta time.Duration = 0
|
||||||
|
var scheduler gocron.Scheduler = nil
|
||||||
|
|
||||||
func rpcSetJigglerState(enabled bool) {
|
func rpcSetJigglerState(enabled bool) {
|
||||||
jigglerEnabled = enabled
|
jigglerEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetJigglerState() bool {
|
func rpcGetJigglerState() bool {
|
||||||
return jigglerEnabled
|
return jigglerEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetTimezones() []string {
|
||||||
|
return tzdata.TimeZones
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetJigglerConfig() (JigglerConfig, error) {
|
||||||
|
return *config.JigglerConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetJigglerConfig(jigglerConfig JigglerConfig) error {
|
||||||
|
logger.Info().Msgf("jigglerConfig: %v, %v, %v, %v", jigglerConfig.InactivityLimitSeconds, jigglerConfig.JitterPercentage, jigglerConfig.ScheduleCronTab, jigglerConfig.Timezone)
|
||||||
|
config.JigglerConfig = &jigglerConfig
|
||||||
|
err := removeExistingCrobJobs(scheduler)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error removing cron jobs from scheduler %v", err)
|
||||||
|
}
|
||||||
|
err = runJigglerCronTab()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error scheduling jiggler crontab: %v", err)
|
||||||
|
}
|
||||||
|
err = SaveConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeExistingCrobJobs(s gocron.Scheduler) error {
|
||||||
|
for _, j := range s.Jobs() {
|
||||||
|
err := s.RemoveJob(j.ID())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func initJiggler() {
|
func initJiggler() {
|
||||||
go runJiggler()
|
ensureConfigLoaded()
|
||||||
|
err := runJigglerCronTab()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Msgf("Error scheduling jiggler crontab: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runJigglerCronTab() error {
|
||||||
|
cronTab := config.JigglerConfig.ScheduleCronTab
|
||||||
|
|
||||||
|
// Apply timezone if specified and valid
|
||||||
|
if config.JigglerConfig.Timezone != "" && config.JigglerConfig.Timezone != "UTC" {
|
||||||
|
// Validate timezone before applying
|
||||||
|
if _, err := time.LoadLocation(config.JigglerConfig.Timezone); err != nil {
|
||||||
|
logger.Warn().Msgf("Invalid timezone '%s', falling back to UTC: %v", config.JigglerConfig.Timezone, err)
|
||||||
|
// Don't add TZ prefix, let it run in UTC
|
||||||
|
} else {
|
||||||
|
cronTab = fmt.Sprintf("TZ=%s %s", config.JigglerConfig.Timezone, cronTab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := gocron.NewScheduler()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scheduler = s
|
||||||
|
_, err = s.NewJob(
|
||||||
|
gocron.CronJob(
|
||||||
|
cronTab,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
gocron.NewTask(
|
||||||
|
func() {
|
||||||
|
runJiggler()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Start()
|
||||||
|
delta, err := calculateJobDelta(s)
|
||||||
|
jobDelta = delta
|
||||||
|
logger.Info().Msgf("Time between jiggler runs: %v", jobDelta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runJiggler() {
|
func runJiggler() {
|
||||||
for {
|
if jigglerEnabled {
|
||||||
if jigglerEnabled {
|
if config.JigglerConfig.JitterPercentage != 0 {
|
||||||
if time.Since(lastUserInput) > 20*time.Second {
|
jitter := calculateJitterDuration(jobDelta)
|
||||||
//TODO: change to rel mouse
|
time.Sleep(jitter)
|
||||||
err := rpcAbsMouseReport(1, 1, 0)
|
}
|
||||||
if err != nil {
|
inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds
|
||||||
logger.Warn().Err(err).Msg("Failed to jiggle mouse")
|
timeSinceLastInput := time.Since(gadget.GetLastUserInputTime())
|
||||||
}
|
logger.Debug().Msgf("Time since last user input %v", timeSinceLastInput)
|
||||||
err = rpcAbsMouseReport(0, 0, 0)
|
if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second {
|
||||||
if err != nil {
|
logger.Debug().Msg("Jiggling mouse...")
|
||||||
logger.Warn().Err(err).Msg("Failed to reset mouse position")
|
//TODO: change to rel mouse
|
||||||
}
|
err := rpcAbsMouseReport(1, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Msgf("Failed to jiggle mouse: %v", err)
|
||||||
|
}
|
||||||
|
err = rpcAbsMouseReport(0, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Msgf("Failed to reset mouse position: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(20 * time.Second)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func calculateJobDelta(s gocron.Scheduler) (time.Duration, error) {
|
||||||
|
j := s.Jobs()[0]
|
||||||
|
runs, err := j.NextRuns(2)
|
||||||
|
if err != nil {
|
||||||
|
return 0.0, err
|
||||||
|
}
|
||||||
|
return runs[1].Sub(runs[0]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateJitterDuration(delta time.Duration) time.Duration {
|
||||||
|
jitter := rand.Float64() * float64(config.JigglerConfig.JitterPercentage) / 100 * delta.Seconds()
|
||||||
|
return time.Duration(jitter * float64(time.Second))
|
||||||
|
}
|
||||||
|
|
|
@ -1091,6 +1091,9 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||||
"getJigglerState": {Func: rpcGetJigglerState},
|
"getJigglerState": {Func: rpcGetJigglerState},
|
||||||
|
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
|
||||||
|
"getJigglerConfig": {Func: rpcGetJigglerConfig},
|
||||||
|
"getTimezones": {Func: rpcGetTimezones},
|
||||||
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
||||||
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
||||||
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
||||||
|
|
5
main.go
5
main.go
|
@ -31,6 +31,9 @@ func runAudioServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func startAudioSubprocess() error {
|
func startAudioSubprocess() error {
|
||||||
|
// Start adaptive buffer management for optimal performance
|
||||||
|
audio.StartAdaptiveBuffering()
|
||||||
|
|
||||||
// Create audio server supervisor
|
// Create audio server supervisor
|
||||||
audioSupervisor = audio.NewAudioServerSupervisor()
|
audioSupervisor = audio.NewAudioServerSupervisor()
|
||||||
|
|
||||||
|
@ -59,6 +62,8 @@ func startAudioSubprocess() error {
|
||||||
|
|
||||||
// Stop audio relay when process exits
|
// Stop audio relay when process exits
|
||||||
audio.StopAudioRelay()
|
audio.StopAudioRelay()
|
||||||
|
// Stop adaptive buffering
|
||||||
|
audio.StopAdaptiveBuffering()
|
||||||
},
|
},
|
||||||
// onRestart
|
// onRestart
|
||||||
func(attempt int, delay time.Duration) {
|
func(attempt int, delay time.Duration) {
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "kvm-ui",
|
"name": "kvm-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "2025.08.07.001",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "22.15.0"
|
"node": "22.15.0"
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.4",
|
"@headlessui/react": "^2.2.7",
|
||||||
"@headlessui/tailwindcss": "^0.2.2",
|
"@headlessui/tailwindcss": "^0.2.2",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||||
|
@ -33,16 +33,16 @@
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"eslint-import-resolver-alias": "^1.1.2",
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
"focus-trap-react": "^11.0.4",
|
"focus-trap-react": "^11.0.4",
|
||||||
"framer-motion": "^12.23.3",
|
"framer-motion": "^12.23.12",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"mini-svg-data-uri": "^1.4.4",
|
"mini-svg-data-uri": "^1.4.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.1",
|
||||||
"react-animate-height": "^3.2.3",
|
"react-animate-height": "^3.2.3",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.1",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"react-simple-keyboard": "^3.8.93",
|
"react-simple-keyboard": "^3.8.106",
|
||||||
"react-use-websocket": "^4.13.0",
|
"react-use-websocket": "^4.13.0",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
|
@ -54,21 +54,21 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.3.1",
|
"@eslint/compat": "^1.3.1",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.32.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.9",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@types/semver": "^7.7.0",
|
"@types/semver": "^7.7.0",
|
||||||
"@types/validator": "^13.15.2",
|
"@types/validator": "^13.15.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.36.0",
|
"@typescript-eslint/eslint-plugin": "^8.39.0",
|
||||||
"@typescript-eslint/parser": "^8.36.0",
|
"@typescript-eslint/parser": "^8.39.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.32.0",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
@ -78,7 +78,7 @@
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ export default function FieldLabel({
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{description && (
|
{description && (
|
||||||
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
<span className="mb-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||||
{description}
|
{description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
@ -36,11 +36,11 @@ export default function FieldLabel({
|
||||||
} else if (as === "span") {
|
} else if (as === "span") {
|
||||||
return (
|
return (
|
||||||
<div className="flex select-none flex-col">
|
<div className="flex select-none flex-col">
|
||||||
<span className="font-display text-[13px] font-medium leading-snug text-black dark:text-white">
|
<span className="font-display text-[13px] font-semibold leading-snug text-black dark:text-white">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{description && (
|
{description && (
|
||||||
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
<span className="mb-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||||
{description}
|
{description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
@ -49,4 +49,4 @@ export default function FieldLabel({
|
||||||
} else {
|
} else {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ type InputFieldProps = {
|
||||||
|
|
||||||
type InputFieldWithLabelProps = InputFieldProps & {
|
type InputFieldWithLabelProps = InputFieldProps & {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
description?: string | null;
|
description?: React.ReactNode | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
|
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
|
||||||
|
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Button, LinkButton } from "@components/Button";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
|
import { InputFieldWithLabel } from "./InputField";
|
||||||
|
import { SelectMenuBasic } from "./SelectMenuBasic";
|
||||||
|
|
||||||
|
export interface JigglerConfig {
|
||||||
|
inactivity_limit_seconds: number;
|
||||||
|
jitter_percentage: number;
|
||||||
|
schedule_cron_tab: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JigglerSetting({
|
||||||
|
onSave,
|
||||||
|
defaultJigglerState,
|
||||||
|
}: {
|
||||||
|
onSave: (jigglerConfig: JigglerConfig) => void;
|
||||||
|
defaultJigglerState?: JigglerConfig;
|
||||||
|
}) {
|
||||||
|
const [jigglerConfigState, setJigglerConfigState] = useState<JigglerConfig>(
|
||||||
|
defaultJigglerState || {
|
||||||
|
inactivity_limit_seconds: 20,
|
||||||
|
jitter_percentage: 0,
|
||||||
|
schedule_cron_tab: "*/20 * * * * *",
|
||||||
|
timezone: "UTC",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
const [timezones, setTimezones] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getTimezones", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setTimezones(resp.result as string[]);
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const timezoneOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
timezones.map((timezone: string) => ({
|
||||||
|
value: timezone,
|
||||||
|
label: timezone,
|
||||||
|
})),
|
||||||
|
[timezones],
|
||||||
|
);
|
||||||
|
|
||||||
|
const exampleConfigs = [
|
||||||
|
{
|
||||||
|
name: "Business Hours 9-17",
|
||||||
|
config: {
|
||||||
|
inactivity_limit_seconds: 60,
|
||||||
|
jitter_percentage: 25,
|
||||||
|
schedule_cron_tab: "0 * 9-17 * * 1-5",
|
||||||
|
timezone: jigglerConfigState.timezone || "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Business Hours 8-17",
|
||||||
|
config: {
|
||||||
|
inactivity_limit_seconds: 60,
|
||||||
|
jitter_percentage: 25,
|
||||||
|
schedule_cron_tab: "0 * 8-17 * * 1-5",
|
||||||
|
timezone: jigglerConfigState.timezone || "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Examples
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{exampleConfigs.map((example, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text={example.name}
|
||||||
|
onClick={() => setJigglerConfigState(example.config)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LinkButton
|
||||||
|
to="https://crontab.guru/examples.html"
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text="More examples"
|
||||||
|
LeadingIcon={LuExternalLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 items-end gap-4 md:grid-cols-2">
|
||||||
|
<InputFieldWithLabel
|
||||||
|
required
|
||||||
|
size="SM"
|
||||||
|
label="Cron Schedule"
|
||||||
|
description="Cron expression for scheduling"
|
||||||
|
placeholder="*/20 * * * * *"
|
||||||
|
value={jigglerConfigState.schedule_cron_tab}
|
||||||
|
onChange={e =>
|
||||||
|
setJigglerConfigState({
|
||||||
|
...jigglerConfigState,
|
||||||
|
schedule_cron_tab: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputFieldWithLabel
|
||||||
|
size="SM"
|
||||||
|
label="Inactivity Limit Seconds"
|
||||||
|
description="Inactivity time before jiggle"
|
||||||
|
value={jigglerConfigState.inactivity_limit_seconds}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
onChange={e =>
|
||||||
|
setJigglerConfigState({
|
||||||
|
...jigglerConfigState,
|
||||||
|
inactivity_limit_seconds: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputFieldWithLabel
|
||||||
|
required
|
||||||
|
size="SM"
|
||||||
|
label="Random delay"
|
||||||
|
description="To avoid recognizable patterns"
|
||||||
|
placeholder="25"
|
||||||
|
TrailingElm={<span className="px-2 text-xs text-slate-500">%</span>}
|
||||||
|
value={jigglerConfigState.jitter_percentage}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
onChange={e =>
|
||||||
|
setJigglerConfigState({
|
||||||
|
...jigglerConfigState,
|
||||||
|
jitter_percentage: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
label="Timezone"
|
||||||
|
description="Timezone for cron schedule"
|
||||||
|
value={jigglerConfigState.timezone || "UTC"}
|
||||||
|
disabled={timezones.length === 0}
|
||||||
|
onChange={e =>
|
||||||
|
setJigglerConfigState({
|
||||||
|
...jigglerConfigState,
|
||||||
|
timezone: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={timezoneOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-x-2">
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
text="Save Jiggler Config"
|
||||||
|
onClick={() => onSave(jigglerConfigState)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
export default function MacroBar() {
|
export default function MacroBar() {
|
||||||
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
||||||
const { executeMacro } = useKeyboard();
|
const { executeMacro } = useKeyboard();
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSendFn(send);
|
setSendFn(send);
|
||||||
|
|
|
@ -26,7 +26,7 @@ type SelectMenuProps = Pick<
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
||||||
SM: "h-[32px] pl-3 pr-8 text-[13px]",
|
SM: "h-[36px] pl-3 pr-8 text-[13px]",
|
||||||
MD: "h-[40px] pl-4 pr-10 text-sm",
|
MD: "h-[40px] pl-4 pr-10 text-sm",
|
||||||
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
|
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
|
||||||
};
|
};
|
||||||
|
@ -62,7 +62,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
||||||
"text-sm",
|
"text-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label && <FieldLabel label={label} id={id} as="span" />}
|
{label && <FieldLabel label={label} id={id} />}
|
||||||
<Card className="w-auto border! border-solid border-slate-800/30! shadow-xs outline-0 dark:border-slate-300/30!">
|
<Card className="w-auto border! border-solid border-slate-800/30! shadow-xs outline-0 dark:border-slate-300/30!">
|
||||||
<select
|
<select
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
export default function SettingsNestedSection({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="ml-2 border-l border-slate-800/30 pl-4 dark:border-slate-300/30">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback , useEffect, useState } from "react";
|
import { useCallback , useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { SettingsItem } from "../routes/devices.$id.settings";
|
import { SettingsItem } from "../routes/devices.$id.settings";
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ const usbPresets = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export function UsbDeviceSetting() {
|
export function UsbDeviceSetting() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const [usbDeviceConfig, setUsbDeviceConfig] =
|
const [usbDeviceConfig, setUsbDeviceConfig] =
|
||||||
|
@ -67,7 +67,7 @@ export function UsbDeviceSetting() {
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string>("default");
|
const [selectedPreset, setSelectedPreset] = useState<string>("default");
|
||||||
|
|
||||||
const syncUsbDeviceConfig = useCallback(() => {
|
const syncUsbDeviceConfig = useCallback(() => {
|
||||||
send("getUsbDevices", {}, resp => {
|
send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
console.error("Failed to load USB devices:", resp.error);
|
console.error("Failed to load USB devices:", resp.error);
|
||||||
notifications.error(
|
notifications.error(
|
||||||
|
@ -97,7 +97,7 @@ export function UsbDeviceSetting() {
|
||||||
const handleUsbConfigChange = useCallback(
|
const handleUsbConfigChange = useCallback(
|
||||||
(devices: UsbDeviceConfig) => {
|
(devices: UsbDeviceConfig) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
send("setUsbDevices", { devices }, async resp => {
|
send("setUsbDevices", { devices }, async (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
|
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Button } from "@components/Button";
|
||||||
|
|
||||||
|
|
||||||
import { UsbConfigState } from "../hooks/stores";
|
import { UsbConfigState } from "../hooks/stores";
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { SettingsItem } from "../routes/devices.$id.settings";
|
import { SettingsItem } from "../routes/devices.$id.settings";
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ const usbConfigs = [
|
||||||
type UsbConfigMap = Record<string, USBConfig>;
|
type UsbConfigMap = Record<string, USBConfig>;
|
||||||
|
|
||||||
export function UsbInfoSetting() {
|
export function UsbInfoSetting() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const [usbConfigProduct, setUsbConfigProduct] = useState("");
|
const [usbConfigProduct, setUsbConfigProduct] = useState("");
|
||||||
|
@ -94,7 +94,7 @@ export function UsbInfoSetting() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncUsbConfigProduct = useCallback(() => {
|
const syncUsbConfigProduct = useCallback(() => {
|
||||||
send("getUsbConfig", {}, resp => {
|
send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
console.error("Failed to load USB Config:", resp.error);
|
console.error("Failed to load USB Config:", resp.error);
|
||||||
notifications.error(
|
notifications.error(
|
||||||
|
@ -114,7 +114,7 @@ export function UsbInfoSetting() {
|
||||||
const handleUsbConfigChange = useCallback(
|
const handleUsbConfigChange = useCallback(
|
||||||
(usbConfig: USBConfig) => {
|
(usbConfig: USBConfig) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
send("setUsbConfig", { usbConfig }, async resp => {
|
send("setUsbConfig", { usbConfig }, async (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
|
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -137,7 +137,7 @@ export function UsbInfoSetting() {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getDeviceID", {}, async resp => {
|
send("getDeviceID", {}, async (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
return notifications.error(
|
return notifications.error(
|
||||||
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
|
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -205,10 +205,10 @@ function USBConfigDialog({
|
||||||
product: "",
|
product: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
const syncUsbConfig = useCallback(() => {
|
const syncUsbConfig = useCallback(() => {
|
||||||
send("getUsbConfig", {}, resp => {
|
send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
console.error("Failed to load USB Config:", resp.error);
|
console.error("Failed to load USB Config:", resp.error);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -99,7 +99,7 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
|
||||||
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
|
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
|
||||||
|
|
||||||
// Misc states and hooks
|
// Misc states and hooks
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
// Video-related
|
// Video-related
|
||||||
useResizeObserver({
|
useResizeObserver({
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
|
|
||||||
import { useJsonRpc } from "../../hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "../../hooks/useJsonRpc";
|
||||||
|
|
||||||
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
|
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ export function ATXPowerControl() {
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
const [atxState, setAtxState] = useState<ATXState | null>(null);
|
const [atxState, setAtxState] = useState<ATXState | null>(null);
|
||||||
|
|
||||||
const [send] = useJsonRpc(function onRequest(resp) {
|
const { send } = useJsonRpc(function onRequest(resp) {
|
||||||
if (resp.method === "atxState") {
|
if (resp.method === "atxState") {
|
||||||
setAtxState(resp.params as ATXState);
|
setAtxState(resp.params as ATXState);
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,7 @@ export function ATXPowerControl() {
|
||||||
|
|
||||||
// Request initial state
|
// Request initial state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getATXState", {}, resp => {
|
send("getATXState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
|
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -54,7 +54,7 @@ export function ATXPowerControl() {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
// Send long press action
|
// Send long press action
|
||||||
console.log("Sending long press ATX power action");
|
console.log("Sending long press ATX power action");
|
||||||
send("setATXPowerAction", { action: "power-long" }, resp => {
|
send("setATXPowerAction", { action: "power-long" }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -75,7 +75,7 @@ export function ATXPowerControl() {
|
||||||
|
|
||||||
// Send short press action
|
// Send short press action
|
||||||
console.log("Sending short press ATX power action");
|
console.log("Sending short press ATX power action");
|
||||||
send("setATXPowerAction", { action: "power-short" }, resp => {
|
send("setATXPowerAction", { action: "power-short" }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -127,7 +127,7 @@ export function ATXPowerControl() {
|
||||||
LeadingIcon={LuRotateCcw}
|
LeadingIcon={LuRotateCcw}
|
||||||
text="Reset"
|
text="Reset"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
send("setATXPowerAction", { action: "reset" }, resp => {
|
send("setATXPowerAction", { action: "reset" }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import FieldLabel from "@components/FieldLabel";
|
import FieldLabel from "@components/FieldLabel";
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
|
@ -19,11 +19,11 @@ interface DCPowerState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DCPowerControl() {
|
export function DCPowerControl() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const [powerState, setPowerState] = useState<DCPowerState | null>(null);
|
const [powerState, setPowerState] = useState<DCPowerState | null>(null);
|
||||||
|
|
||||||
const getDCPowerState = useCallback(() => {
|
const getDCPowerState = useCallback(() => {
|
||||||
send("getDCPowerState", {}, resp => {
|
send("getDCPowerState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`,
|
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -35,7 +35,7 @@ export function DCPowerControl() {
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const handlePowerToggle = (enabled: boolean) => {
|
const handlePowerToggle = (enabled: boolean) => {
|
||||||
send("setDCPowerState", { enabled }, resp => {
|
send("setDCPowerState", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
|
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -47,7 +47,7 @@ export function DCPowerControl() {
|
||||||
};
|
};
|
||||||
const handleRestoreChange = (state: number) => {
|
const handleRestoreChange = (state: number) => {
|
||||||
// const state = powerState?.restoreState === 0 ? 1 : powerState?.restoreState === 1 ? 2 : 0;
|
// const state = powerState?.restoreState === 0 ? 1 : powerState?.restoreState === 1 ? 2 : 0;
|
||||||
send("setDCRestoreState", { state }, resp => {
|
send("setDCRestoreState", { state }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
|
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { useUiStore } from "@/hooks/stores";
|
import { useUiStore } from "@/hooks/stores";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
|
@ -17,7 +17,7 @@ interface SerialSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SerialConsole() {
|
export function SerialConsole() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const [settings, setSettings] = useState<SerialSettings>({
|
const [settings, setSettings] = useState<SerialSettings>({
|
||||||
baudRate: "9600",
|
baudRate: "9600",
|
||||||
dataBits: "8",
|
dataBits: "8",
|
||||||
|
@ -26,7 +26,7 @@ export function SerialConsole() {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getSerialSettings", {}, resp => {
|
send("getSerialSettings", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to get serial settings: ${resp.error.data || "Unknown error"}`,
|
`Failed to get serial settings: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -39,7 +39,7 @@ export function SerialConsole() {
|
||||||
|
|
||||||
const handleSettingChange = (setting: keyof SerialSettings, value: string) => {
|
const handleSettingChange = (setting: keyof SerialSettings, value: string) => {
|
||||||
const newSettings = { ...settings, [setting]: value };
|
const newSettings = { ...settings, [setting]: value };
|
||||||
send("setSerialSettings", { settings: newSettings }, resp => {
|
send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`,
|
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
|
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
|
||||||
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import Card, { GridCard } from "@components/Card";
|
import Card, { GridCard } from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
||||||
|
@ -39,12 +39,12 @@ const AVAILABLE_EXTENSIONS: Extension[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ExtensionPopover() {
|
export default function ExtensionPopover() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
|
const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
|
||||||
|
|
||||||
// Load active extension on component mount
|
// Load active extension on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getActiveExtension", {}, resp => {
|
send("getActiveExtension", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
const extensionId = resp.result as string;
|
const extensionId = resp.result as string;
|
||||||
if (extensionId) {
|
if (extensionId) {
|
||||||
|
@ -57,7 +57,7 @@ export default function ExtensionPopover() {
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const handleSetActiveExtension = (extension: Extension | null) => {
|
const handleSetActiveExtension = (extension: Extension | null) => {
|
||||||
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
|
send("setActiveExtension", { extensionId: extension?.id || "" }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set active extension: ${resp.error.data || "Unknown error"}`,
|
`Failed to set active extension: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
|
@ -16,13 +16,13 @@ import Card, { GridCard } from "@components/Card";
|
||||||
import { formatters } from "@/utils";
|
import { formatters } from "@/utils";
|
||||||
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
|
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
||||||
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
|
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
|
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
|
||||||
useMountMediaStore();
|
useMountMediaStore();
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
||||||
}, [diskDataChannelStats]);
|
}, [diskDataChannelStats]);
|
||||||
|
|
||||||
const syncRemoteVirtualMediaState = useCallback(() => {
|
const syncRemoteVirtualMediaState = useCallback(() => {
|
||||||
send("getVirtualMediaState", {}, response => {
|
send("getVirtualMediaState", {}, (response: JsonRpcResponse) => {
|
||||||
if ("error" in response) {
|
if ("error" in response) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to get virtual media state: ${response.error.message}`,
|
`Failed to get virtual media state: ${response.error.message}`,
|
||||||
|
@ -59,7 +59,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
||||||
}, [send, setRemoteVirtualMediaState]);
|
}, [send, setRemoteVirtualMediaState]);
|
||||||
|
|
||||||
const handleUnmount = () => {
|
const handleUnmount = () => {
|
||||||
send("unmountImage", {}, response => {
|
send("unmountImage", {}, (response: JsonRpcResponse) => {
|
||||||
if ("error" in response) {
|
if ("error" in response) {
|
||||||
notifications.error(`Failed to unmount image: ${response.error.message}`);
|
notifications.error(`Failed to unmount image: ${response.error.message}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { Button } from "@components/Button";
|
||||||
import { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { KeyStroke, KeyboardLayout, selectedKeyboard } from "@/keyboardLayouts";
|
import { KeyStroke, KeyboardLayout, selectedKeyboard } from "@/keyboardLayouts";
|
||||||
|
@ -28,7 +28,7 @@ export default function PasteModal() {
|
||||||
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
|
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
|
||||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||||
|
|
||||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||||
|
@ -47,7 +47,7 @@ export default function PasteModal() {
|
||||||
}, [keyboardLayout]);
|
}, [keyboardLayout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getKeyboardLayout", {}, resp => {
|
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setKeyboardLayout(resp.result as string);
|
setKeyboardLayout(resp.result as string);
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { useClose } from "@headlessui/react";
|
||||||
|
|
||||||
import { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ export default function WakeOnLanModal() {
|
||||||
|
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const close = useClose();
|
const close = useClose();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
|
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
|
||||||
|
@ -33,7 +33,7 @@ export default function WakeOnLanModal() {
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
|
|
||||||
send("sendWOLMagicPacket", { macAddress }, resp => {
|
send("sendWOLMagicPacket", { macAddress }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
const isInvalid = resp.error.data?.includes("invalid MAC address");
|
const isInvalid = resp.error.data?.includes("invalid MAC address");
|
||||||
if (isInvalid) {
|
if (isInvalid) {
|
||||||
|
@ -52,7 +52,7 @@ export default function WakeOnLanModal() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncStoredDevices = useCallback(() => {
|
const syncStoredDevices = useCallback(() => {
|
||||||
send("getWakeOnLanDevices", {}, resp => {
|
send("getWakeOnLanDevices", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("result" in resp) {
|
if ("result" in resp) {
|
||||||
setStoredDevices(resp.result as StoredDevice[]);
|
setStoredDevices(resp.result as StoredDevice[]);
|
||||||
} else {
|
} else {
|
||||||
|
@ -70,7 +70,7 @@ export default function WakeOnLanModal() {
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
const updatedDevices = storedDevices.filter((_, i) => i !== index);
|
const updatedDevices = storedDevices.filter((_, i) => i !== index);
|
||||||
|
|
||||||
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
|
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
console.error("Failed to update Wake-on-LAN devices:", resp.error);
|
console.error("Failed to update Wake-on-LAN devices:", resp.error);
|
||||||
} else {
|
} else {
|
||||||
|
@ -86,7 +86,7 @@ export default function WakeOnLanModal() {
|
||||||
if (!name || !macAddress) return;
|
if (!name || !macAddress) return;
|
||||||
const updatedDevices = [...storedDevices, { name, macAddress }];
|
const updatedDevices = [...storedDevices, { name, macAddress }];
|
||||||
console.log("updatedDevices", updatedDevices);
|
console.log("updatedDevices", updatedDevices);
|
||||||
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
|
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
console.error("Failed to add Wake-on-LAN device:", resp.error);
|
console.error("Failed to add Wake-on-LAN device:", resp.error);
|
||||||
setAddDeviceErrorMessage("Failed to add device");
|
setAddDeviceErrorMessage("Failed to add device");
|
||||||
|
|
|
@ -853,7 +853,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
sendFn("getKeyboardMacros", {}, response => {
|
sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
console.error("Error loading macros:", response.error);
|
console.error("Error loading macros:", response.error);
|
||||||
reject(new Error(response.error.message));
|
reject(new Error(response.error.message));
|
||||||
|
|
|
@ -78,5 +78,5 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||||
};
|
};
|
||||||
}, [rpcDataChannel, onRequest]);
|
}, [rpcDataChannel, onRequest]);
|
||||||
|
|
||||||
return [send];
|
return { send };
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
|
||||||
export default function useKeyboard() {
|
export default function useKeyboard() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||||
const updateActiveKeysAndModifiers = useHidStore(
|
const updateActiveKeysAndModifiers = useHidStore(
|
||||||
|
|
|
@ -27,7 +27,7 @@ import NetBootIcon from "@/assets/netboot-icon.svg";
|
||||||
import Fieldset from "@/components/Fieldset";
|
import Fieldset from "@/components/Fieldset";
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
|
@ -64,10 +64,10 @@ export function Dialog({ onClose }: { onClose: () => void }) {
|
||||||
setRemoteVirtualMediaState(null);
|
setRemoteVirtualMediaState(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
async function syncRemoteVirtualMediaState() {
|
async function syncRemoteVirtualMediaState() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
send("getVirtualMediaState", {}, resp => {
|
send("getVirtualMediaState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
reject(new Error(resp.error.message));
|
reject(new Error(resp.error.message));
|
||||||
} else {
|
} else {
|
||||||
|
@ -89,7 +89,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
|
||||||
console.log(`Mounting ${url} as ${mode}`);
|
console.log(`Mounting ${url} as ${mode}`);
|
||||||
|
|
||||||
setMountInProgress(true);
|
setMountInProgress(true);
|
||||||
send("mountWithHTTP", { url, mode }, async resp => {
|
send("mountWithHTTP", { url, mode }, async (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) triggerError(resp.error.message);
|
if ("error" in resp) triggerError(resp.error.message);
|
||||||
|
|
||||||
clearMountMediaState();
|
clearMountMediaState();
|
||||||
|
@ -108,7 +108,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
|
||||||
console.log(`Mounting ${fileName} as ${mode}`);
|
console.log(`Mounting ${fileName} as ${mode}`);
|
||||||
|
|
||||||
setMountInProgress(true);
|
setMountInProgress(true);
|
||||||
send("mountWithStorage", { filename: fileName, mode }, async resp => {
|
send("mountWithStorage", { filename: fileName, mode }, async (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) triggerError(resp.error.message);
|
if ("error" in resp) triggerError(resp.error.message);
|
||||||
|
|
||||||
clearMountMediaState();
|
clearMountMediaState();
|
||||||
|
@ -526,8 +526,13 @@ function UrlView({
|
||||||
icon: UbuntuIcon,
|
icon: UbuntuIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Debian 12",
|
name: "Debian 13 Trixie",
|
||||||
url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.9.0-amd64-netinst.iso",
|
url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-13.0.0-amd64-netinst.iso",
|
||||||
|
icon: DebianIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Debian 12 Bookworm (old-stable)",
|
||||||
|
url: "https://cdimage.debian.org/mirror/cdimage/archive/12.11.0/amd64/iso-cd/debian-12.11.0-amd64-netinst.iso",
|
||||||
icon: DebianIcon,
|
icon: DebianIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -684,7 +689,7 @@ function DeviceFileView({
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const filesPerPage = 5;
|
const filesPerPage = 5;
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
interface StorageSpace {
|
interface StorageSpace {
|
||||||
bytesUsed: number;
|
bytesUsed: number;
|
||||||
|
@ -713,12 +718,12 @@ function DeviceFileView({
|
||||||
}, [storageSpace]);
|
}, [storageSpace]);
|
||||||
|
|
||||||
const syncStorage = useCallback(() => {
|
const syncStorage = useCallback(() => {
|
||||||
send("listStorageFiles", {}, res => {
|
send("listStorageFiles", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in res) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Error listing storage files: ${res.error}`);
|
notifications.error(`Error listing storage files: ${resp.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { files } = res.result as StorageFiles;
|
const { files } = resp.result as StorageFiles;
|
||||||
const formattedFiles = files.map(file => ({
|
const formattedFiles = files.map(file => ({
|
||||||
name: file.filename,
|
name: file.filename,
|
||||||
size: formatters.bytes(file.size),
|
size: formatters.bytes(file.size),
|
||||||
|
@ -728,13 +733,13 @@ function DeviceFileView({
|
||||||
setOnStorageFiles(formattedFiles);
|
setOnStorageFiles(formattedFiles);
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getStorageSpace", {}, res => {
|
send("getStorageSpace", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in res) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Error getting storage space: ${res.error}`);
|
notifications.error(`Error getting storage space: ${resp.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const space = res.result as StorageSpace;
|
const space = resp.result as StorageSpace;
|
||||||
setStorageSpace(space);
|
setStorageSpace(space);
|
||||||
});
|
});
|
||||||
}, [send, setOnStorageFiles, setStorageSpace]);
|
}, [send, setOnStorageFiles, setStorageSpace]);
|
||||||
|
@ -757,9 +762,9 @@ function DeviceFileView({
|
||||||
|
|
||||||
function handleDeleteFile(file: { name: string; size: string; createdAt: string }) {
|
function handleDeleteFile(file: { name: string; size: string; createdAt: string }) {
|
||||||
console.log("Deleting file:", file);
|
console.log("Deleting file:", file);
|
||||||
send("deleteStorageFile", { filename: file.name }, res => {
|
send("deleteStorageFile", { filename: file.name }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in res) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Error deleting file: ${res.error}`);
|
notifications.error(`Error deleting file: ${resp.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -996,7 +1001,7 @@ function UploadFileView({
|
||||||
const [fileError, setFileError] = useState<string | null>(null);
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
|
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1211,7 +1216,7 @@ function UploadFileView({
|
||||||
setUploadState("uploading");
|
setUploadState("uploading");
|
||||||
console.log("Upload state set to 'uploading'");
|
console.log("Upload state set to 'uploading'");
|
||||||
|
|
||||||
send("startStorageFileUpload", { filename: file.name, size: file.size }, resp => {
|
send("startStorageFileUpload", { filename: file.name, size: file.size }, (resp: JsonRpcResponse) => {
|
||||||
console.log("startStorageFileUpload response:", resp);
|
console.log("startStorageFileUpload response:", resp);
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
console.error("Upload error:", resp.error.message);
|
console.error("Upload error:", resp.error.message);
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { SettingsSectionHeader } from "@/components/SettingsSectionHeader";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { isOnDevice } from "@/main";
|
import { isOnDevice } from "@/main";
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
const [isAdopted, setAdopted] = useState(false);
|
const [isAdopted, setAdopted] = useState(false);
|
||||||
const [deviceId, setDeviceId] = useState<string | null>(null);
|
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||||
|
@ -56,7 +56,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
const [tlsKey, setTlsKey] = useState<string>("");
|
const [tlsKey, setTlsKey] = useState<string>("");
|
||||||
|
|
||||||
const getCloudState = useCallback(() => {
|
const getCloudState = useCallback(() => {
|
||||||
send("getCloudState", {}, resp => {
|
send("getCloudState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return console.error(resp.error);
|
if ("error" in resp) return console.error(resp.error);
|
||||||
const cloudState = resp.result as CloudState;
|
const cloudState = resp.result as CloudState;
|
||||||
setAdopted(cloudState.connected);
|
setAdopted(cloudState.connected);
|
||||||
|
@ -77,7 +77,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const getTLSState = useCallback(() => {
|
const getTLSState = useCallback(() => {
|
||||||
send("getTLSState", {}, resp => {
|
send("getTLSState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return console.error(resp.error);
|
if ("error" in resp) return console.error(resp.error);
|
||||||
const tlsState = resp.result as TLSState;
|
const tlsState = resp.result as TLSState;
|
||||||
|
|
||||||
|
@ -88,7 +88,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const deregisterDevice = async () => {
|
const deregisterDevice = async () => {
|
||||||
send("deregisterDevice", {}, resp => {
|
send("deregisterDevice", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
|
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -110,7 +110,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => {
|
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
|
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -156,7 +156,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
state.privateKey = key;
|
state.privateKey = key;
|
||||||
}
|
}
|
||||||
|
|
||||||
send("setTLSState", { state }, resp => {
|
send("setTLSState", { state }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to update TLS settings: ${resp.error.data || "Unknown error"}`,
|
`Failed to update TLS settings: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -198,7 +198,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
getCloudState();
|
getCloudState();
|
||||||
getTLSState();
|
getTLSState();
|
||||||
|
|
||||||
send("getDeviceID", {}, async resp => {
|
send("getDeviceID", {}, async (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return console.error(resp.error);
|
if ("error" in resp) return console.error(resp.error);
|
||||||
setDeviceId(resp.result as string);
|
setDeviceId(resp.result as string);
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,14 +8,14 @@ import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||||
import { TextAreaWithLabel } from "../components/TextArea";
|
import { TextAreaWithLabel } from "../components/TextArea";
|
||||||
import { useSettingsStore } from "../hooks/stores";
|
import { useSettingsStore } from "../hooks/stores";
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
export default function SettingsAdvancedRoute() {
|
export default function SettingsAdvancedRoute() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
const [sshKey, setSSHKey] = useState<string>("");
|
const [sshKey, setSSHKey] = useState<string>("");
|
||||||
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
|
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
|
||||||
|
@ -27,35 +27,35 @@ export default function SettingsAdvancedRoute() {
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getDevModeState", {}, resp => {
|
send("getDevModeState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
const result = resp.result as { enabled: boolean };
|
const result = resp.result as { enabled: boolean };
|
||||||
setDeveloperMode(result.enabled);
|
setDeveloperMode(result.enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getSSHKeyState", {}, resp => {
|
send("getSSHKeyState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setSSHKey(resp.result as string);
|
setSSHKey(resp.result as string);
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getUsbEmulationState", {}, resp => {
|
send("getUsbEmulationState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setUsbEmulationEnabled(resp.result as boolean);
|
setUsbEmulationEnabled(resp.result as boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getDevChannelState", {}, resp => {
|
send("getDevChannelState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setDevChannel(resp.result as boolean);
|
setDevChannel(resp.result as boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getLocalLoopbackOnly", {}, resp => {
|
send("getLocalLoopbackOnly", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setLocalLoopbackOnly(resp.result as boolean);
|
setLocalLoopbackOnly(resp.result as boolean);
|
||||||
});
|
});
|
||||||
}, [send, setDeveloperMode]);
|
}, [send, setDeveloperMode]);
|
||||||
|
|
||||||
const getUsbEmulationState = useCallback(() => {
|
const getUsbEmulationState = useCallback(() => {
|
||||||
send("getUsbEmulationState", {}, resp => {
|
send("getUsbEmulationState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setUsbEmulationEnabled(resp.result as boolean);
|
setUsbEmulationEnabled(resp.result as boolean);
|
||||||
});
|
});
|
||||||
|
@ -63,7 +63,7 @@ export default function SettingsAdvancedRoute() {
|
||||||
|
|
||||||
const handleUsbEmulationToggle = useCallback(
|
const handleUsbEmulationToggle = useCallback(
|
||||||
(enabled: boolean) => {
|
(enabled: boolean) => {
|
||||||
send("setUsbEmulationState", { enabled: enabled }, resp => {
|
send("setUsbEmulationState", { enabled: enabled }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
|
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -78,7 +78,7 @@ export default function SettingsAdvancedRoute() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResetConfig = useCallback(() => {
|
const handleResetConfig = useCallback(() => {
|
||||||
send("resetConfig", {}, resp => {
|
send("resetConfig", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
|
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -90,7 +90,7 @@ export default function SettingsAdvancedRoute() {
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const handleUpdateSSHKey = useCallback(() => {
|
const handleUpdateSSHKey = useCallback(() => {
|
||||||
send("setSSHKeyState", { sshKey }, resp => {
|
send("setSSHKeyState", { sshKey }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
|
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -103,7 +103,7 @@ export default function SettingsAdvancedRoute() {
|
||||||
|
|
||||||
const handleDevModeChange = useCallback(
|
const handleDevModeChange = useCallback(
|
||||||
(developerMode: boolean) => {
|
(developerMode: boolean) => {
|
||||||
send("setDevModeState", { enabled: developerMode }, resp => {
|
send("setDevModeState", { enabled: developerMode }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
|
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -118,7 +118,7 @@ export default function SettingsAdvancedRoute() {
|
||||||
|
|
||||||
const handleDevChannelChange = useCallback(
|
const handleDevChannelChange = useCallback(
|
||||||
(enabled: boolean) => {
|
(enabled: boolean) => {
|
||||||
send("setDevChannelState", { enabled }, resp => {
|
send("setDevChannelState", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
|
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -133,7 +133,7 @@ export default function SettingsAdvancedRoute() {
|
||||||
|
|
||||||
const applyLoopbackOnlyMode = useCallback(
|
const applyLoopbackOnlyMode = useCallback(
|
||||||
(enabled: boolean) => {
|
(enabled: boolean) => {
|
||||||
send("setLocalLoopbackOnly", { enabled }, resp => {
|
send("setLocalLoopbackOnly", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`,
|
`Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
import { useState , useEffect } from "react";
|
import { useState , useEffect } from "react";
|
||||||
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
|
@ -13,7 +13,7 @@ import { useDeviceStore } from "../hooks/stores";
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
export default function SettingsGeneralRoute() {
|
export default function SettingsGeneralRoute() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||||
|
|
||||||
|
@ -24,14 +24,14 @@ export default function SettingsGeneralRoute() {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getAutoUpdateState", {}, resp => {
|
send("getAutoUpdateState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setAutoUpdate(resp.result as boolean);
|
setAutoUpdate(resp.result as boolean);
|
||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const handleAutoUpdateChange = (enabled: boolean) => {
|
const handleAutoUpdateChange = (enabled: boolean) => {
|
||||||
send("setAutoUpdateState", { enabled }, resp => {
|
send("setAutoUpdateState", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
|
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { Button } from "@components/Button";
|
||||||
|
|
||||||
export default function SettingsGeneralRebootRoute() {
|
export default function SettingsGeneralRebootRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
const onConfirmUpdate = useCallback(() => {
|
const onConfirmUpdate = useCallback(() => {
|
||||||
// This is where we send the RPC to the golang binary
|
// This is where we send the RPC to the golang binary
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
|
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
@ -16,7 +16,7 @@ export default function SettingsGeneralUpdateRoute() {
|
||||||
const { updateSuccess } = location.state || {};
|
const { updateSuccess } = location.state || {};
|
||||||
|
|
||||||
const { setModalView, otaState } = useUpdateStore();
|
const { setModalView, otaState } = useUpdateStore();
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
const onConfirmUpdate = useCallback(() => {
|
const onConfirmUpdate = useCallback(() => {
|
||||||
send("tryUpdate", {});
|
send("tryUpdate", {});
|
||||||
|
@ -134,14 +134,14 @@ function LoadingState({
|
||||||
}) {
|
}) {
|
||||||
const [progressWidth, setProgressWidth] = useState("0%");
|
const [progressWidth, setProgressWidth] = useState("0%");
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
const setAppVersion = useDeviceStore(state => state.setAppVersion);
|
const setAppVersion = useDeviceStore(state => state.setAppVersion);
|
||||||
const setSystemVersion = useDeviceStore(state => state.setSystemVersion);
|
const setSystemVersion = useDeviceStore(state => state.setSystemVersion);
|
||||||
|
|
||||||
const getVersionInfo = useCallback(() => {
|
const getVersionInfo = useCallback(() => {
|
||||||
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
||||||
send("getUpdateStatus", {}, async resp => {
|
send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Failed to check for updates: ${resp.error}`);
|
notifications.error(`Failed to check for updates: ${resp.error}`);
|
||||||
reject(new Error("Failed to check for updates"));
|
reject(new Error("Failed to check for updates"));
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { useEffect } from "react";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SettingsItem } from "@routes/devices.$id.settings";
|
import { SettingsItem } from "@routes/devices.$id.settings";
|
||||||
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
||||||
import { FeatureFlag } from "../components/FeatureFlag";
|
import { FeatureFlag } from "../components/FeatureFlag";
|
||||||
|
|
||||||
export default function SettingsHardwareRoute() {
|
export default function SettingsHardwareRoute() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
const setDisplayRotation = useSettingsStore(state => state.setDisplayRotation);
|
const setDisplayRotation = useSettingsStore(state => state.setDisplayRotation);
|
||||||
|
@ -23,7 +23,7 @@ export default function SettingsHardwareRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisplayRotationSave = () => {
|
const handleDisplayRotationSave = () => {
|
||||||
send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, resp => {
|
send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set display orientation: ${resp.error.data || "Unknown error"}`,
|
`Failed to set display orientation: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -48,7 +48,7 @@ export default function SettingsHardwareRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBacklightSettingsSave = () => {
|
const handleBacklightSettingsSave = () => {
|
||||||
send("setBacklightSettings", { params: settings.backlightSettings }, resp => {
|
send("setBacklightSettings", { params: settings.backlightSettings }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
|
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -60,7 +60,7 @@ export default function SettingsHardwareRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getBacklightSettings", {}, resp => {
|
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
return notifications.error(
|
return notifications.error(
|
||||||
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
|
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
|
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { keyboardOptions } from "@/keyboardLayouts";
|
import { keyboardOptions } from "@/keyboardLayouts";
|
||||||
|
@ -39,10 +39,10 @@ export default function SettingsKeyboardRoute() {
|
||||||
{ value: "host", label: "Host Only" },
|
{ value: "host", label: "Host Only" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getKeyboardLayout", {}, resp => {
|
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setKeyboardLayout(resp.result as string);
|
setKeyboardLayout(resp.result as string);
|
||||||
});
|
});
|
||||||
|
@ -51,7 +51,7 @@ export default function SettingsKeyboardRoute() {
|
||||||
const onKeyboardLayoutChange = useCallback(
|
const onKeyboardLayoutChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const layout = e.target.value;
|
const layout = e.target.value;
|
||||||
send("setKeyboardLayout", { layout }, resp => {
|
send("setKeyboardLayout", { layout }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set keyboard layout: ${resp.error.data || "Unknown error"}`,
|
`Failed to set keyboard layout: ${resp.error.data || "Unknown error"}`,
|
||||||
|
|
|
@ -1,21 +1,68 @@
|
||||||
import { CheckCircleIcon } from "@heroicons/react/16/solid";
|
import { CheckCircleIcon } from "@heroicons/react/16/solid";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import MouseIcon from "@/assets/mouse-icon.svg";
|
import MouseIcon from "@/assets/mouse-icon.svg";
|
||||||
import PointingFinger from "@/assets/pointing-finger.svg";
|
import PointingFinger from "@/assets/pointing-finger.svg";
|
||||||
import { GridCard } from "@/components/Card";
|
import { GridCard } from "@/components/Card";
|
||||||
import { Checkbox } from "@/components/Checkbox";
|
import { Checkbox } from "@/components/Checkbox";
|
||||||
import { useSettingsStore } from "@/hooks/stores";
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
|
import { JigglerSetting } from "@components/JigglerSetting";
|
||||||
|
|
||||||
import { useFeatureFlag } from "../hooks/useFeatureFlag";
|
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
|
import notifications from "../notifications";
|
||||||
|
import SettingsNestedSection from "../components/SettingsNestedSection";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
|
export interface JigglerConfig {
|
||||||
|
inactivity_limit_seconds: number;
|
||||||
|
jitter_percentage: number;
|
||||||
|
schedule_cron_tab: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jigglerOptions = [
|
||||||
|
{ value: "disabled", label: "Disabled", config: null },
|
||||||
|
{
|
||||||
|
value: "frequent",
|
||||||
|
label: "Frequent - 30s",
|
||||||
|
config: {
|
||||||
|
inactivity_limit_seconds: 30,
|
||||||
|
jitter_percentage: 25,
|
||||||
|
schedule_cron_tab: "*/30 * * * * *",
|
||||||
|
// We don't care about the timezone for this preset
|
||||||
|
// timezone: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "standard",
|
||||||
|
label: "Standard - 1m",
|
||||||
|
config: {
|
||||||
|
inactivity_limit_seconds: 60,
|
||||||
|
jitter_percentage: 25,
|
||||||
|
schedule_cron_tab: "0 * * * * *",
|
||||||
|
// We don't care about the timezone for this preset
|
||||||
|
// timezone: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "light",
|
||||||
|
label: "Light - 5m",
|
||||||
|
config: {
|
||||||
|
inactivity_limit_seconds: 300,
|
||||||
|
jitter_percentage: 25,
|
||||||
|
schedule_cron_tab: "0 */5 * * * *",
|
||||||
|
// We don't care about the timezone for this preset
|
||||||
|
// timezone: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type JigglerValues = (typeof jigglerOptions)[number]["value"] | "custom";
|
||||||
|
|
||||||
export default function SettingsMouseRoute() {
|
export default function SettingsMouseRoute() {
|
||||||
const hideCursor = useSettingsStore(state => state.isCursorHidden);
|
const hideCursor = useSettingsStore(state => state.isCursorHidden);
|
||||||
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
|
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
|
||||||
|
@ -23,13 +70,13 @@ export default function SettingsMouseRoute() {
|
||||||
const mouseMode = useSettingsStore(state => state.mouseMode);
|
const mouseMode = useSettingsStore(state => state.mouseMode);
|
||||||
const setMouseMode = useSettingsStore(state => state.setMouseMode);
|
const setMouseMode = useSettingsStore(state => state.setMouseMode);
|
||||||
|
|
||||||
const { isEnabled: isScrollSensitivityEnabled } = useFeatureFlag("0.3.8");
|
|
||||||
|
|
||||||
const [jiggler, setJiggler] = useState(false);
|
|
||||||
|
|
||||||
const scrollThrottling = useSettingsStore(state => state.scrollThrottling);
|
const scrollThrottling = useSettingsStore(state => state.scrollThrottling);
|
||||||
const setScrollThrottling = useSettingsStore(
|
const setScrollThrottling = useSettingsStore(state => state.setScrollThrottling);
|
||||||
state => state.setScrollThrottling,
|
|
||||||
|
const [selectedJigglerOption, setSelectedJigglerOption] =
|
||||||
|
useState<JigglerValues | null>(null);
|
||||||
|
const [currentJigglerConfig, setCurrentJigglerConfig] = useState<JigglerConfig | null>(
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const scrollThrottlingOptions = [
|
const scrollThrottlingOptions = [
|
||||||
|
@ -40,25 +87,100 @@ export default function SettingsMouseRoute() {
|
||||||
{ value: "100", label: "Very High" },
|
{ value: "100", label: "Very High" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
const syncJigglerSettings = useCallback(() => {
|
||||||
|
send("getJigglerState", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
const isEnabled = resp.result as boolean;
|
||||||
|
|
||||||
|
// If the jiggler is disabled, set the selected option to "disabled" and nothing else
|
||||||
|
if (!isEnabled) return setSelectedJigglerOption("disabled");
|
||||||
|
|
||||||
|
send("getJigglerConfig", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
const result = resp.result as JigglerConfig;
|
||||||
|
setCurrentJigglerConfig(result);
|
||||||
|
|
||||||
|
const value = jigglerOptions.find(
|
||||||
|
o =>
|
||||||
|
o?.config?.inactivity_limit_seconds === result.inactivity_limit_seconds &&
|
||||||
|
o?.config?.jitter_percentage === result.jitter_percentage &&
|
||||||
|
o?.config?.schedule_cron_tab === result.schedule_cron_tab,
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
setSelectedJigglerOption(value || "custom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getJigglerState", {}, resp => {
|
syncJigglerSettings();
|
||||||
if ("error" in resp) return;
|
}, [syncJigglerSettings]);
|
||||||
setJiggler(resp.result as boolean);
|
|
||||||
});
|
|
||||||
}, [isScrollSensitivityEnabled, send]);
|
|
||||||
|
|
||||||
const handleJigglerChange = (enabled: boolean) => {
|
const saveJigglerConfig = useCallback(
|
||||||
send("setJigglerState", { enabled }, resp => {
|
(jigglerConfig: JigglerConfig) => {
|
||||||
if ("error" in resp) {
|
// We assume the jiggler should be set to enabled if the config is being updated
|
||||||
notifications.error(
|
send("setJigglerState", { enabled: true }, async (resp: JsonRpcResponse) => {
|
||||||
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
|
if ("error" in resp) {
|
||||||
);
|
return notifications.error(
|
||||||
return;
|
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
|
||||||
}
|
);
|
||||||
setJiggler(enabled);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
send("setJigglerConfig", { jigglerConfig }, async (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
const errorMsg = resp.error.data || "Unknown error";
|
||||||
|
|
||||||
|
// Check for cron syntax errors and provide user-friendly message
|
||||||
|
if (
|
||||||
|
errorMsg.includes("invalid syntax") ||
|
||||||
|
errorMsg.includes("parse failure") ||
|
||||||
|
errorMsg.includes("invalid cron")
|
||||||
|
) {
|
||||||
|
return notifications.error(
|
||||||
|
"Invalid cron expression. Please check your schedule format (e.g., '0 * * * * *' for every minute).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifications.error(`Failed to set jiggler config: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.success(`Jiggler Config successfully updated`);
|
||||||
|
syncJigglerSettings();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[send, syncJigglerSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJigglerChange = (option: JigglerValues) => {
|
||||||
|
if (option === "custom") {
|
||||||
|
setSelectedJigglerOption("custom");
|
||||||
|
// We don't need to sync the jiggler settings when the option is "custom". The user will press "Save" to save the custom settings.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't need to update the device jiggler state when the option is "disabled"
|
||||||
|
if (option === "disabled") {
|
||||||
|
send("setJigglerState", { enabled: false }, async (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
return notifications.error(
|
||||||
|
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
notifications.success(`Jiggler Config successfully updated`);
|
||||||
|
return setSelectedJigglerOption("disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const jigglerConfig = jigglerOptions.find(o => o.value === option)?.config;
|
||||||
|
if (!jigglerConfig) {
|
||||||
|
return notifications.error("There was an error setting the jiggler config");
|
||||||
|
}
|
||||||
|
|
||||||
|
saveJigglerConfig(jigglerConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -79,30 +201,49 @@ export default function SettingsMouseRoute() {
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
|
||||||
title="Scroll Throttling"
|
|
||||||
description="Reduce the frequency of scroll events"
|
|
||||||
>
|
|
||||||
<SelectMenuBasic
|
|
||||||
size="SM"
|
|
||||||
label=""
|
|
||||||
className="max-w-[292px]"
|
|
||||||
value={scrollThrottling}
|
|
||||||
fullWidth
|
|
||||||
onChange={e => setScrollThrottling(parseInt(e.target.value))}
|
|
||||||
options={scrollThrottlingOptions}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title="Jiggler"
|
title="Scroll Throttling"
|
||||||
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
|
description="Reduce the frequency of scroll events"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<SelectMenuBasic
|
||||||
checked={jiggler}
|
size="SM"
|
||||||
onChange={e => handleJigglerChange(e.target.checked)}
|
label=""
|
||||||
|
className="max-w-[292px]"
|
||||||
|
value={scrollThrottling}
|
||||||
|
fullWidth
|
||||||
|
onChange={e => setScrollThrottling(parseInt(e.target.value))}
|
||||||
|
options={scrollThrottlingOptions}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem title="Jiggler" description="Simulate movement of a computer mouse">
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
label=""
|
||||||
|
value={selectedJigglerOption || "disabled"}
|
||||||
|
options={[
|
||||||
|
...jigglerOptions.map(option => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
})),
|
||||||
|
{ value: "custom", label: "Custom" },
|
||||||
|
]}
|
||||||
|
onChange={e => {
|
||||||
|
handleJigglerChange(
|
||||||
|
e.target.value as (typeof jigglerOptions)[number]["value"],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
{selectedJigglerOption === "custom" && (
|
||||||
|
<SettingsNestedSection>
|
||||||
|
<JigglerSetting
|
||||||
|
onSave={saveJigglerConfig}
|
||||||
|
defaultJigglerState={currentJigglerConfig || undefined}
|
||||||
|
/>
|
||||||
|
</SettingsNestedSection>
|
||||||
|
)}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsItem title="Modes" description="Choose the mouse input mode" />
|
<SettingsItem title="Modes" description="Choose the mouse input mode" />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
TimeSyncMode,
|
TimeSyncMode,
|
||||||
useNetworkStateStore,
|
useNetworkStateStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import InputField, { InputFieldWithLabel } from "@components/InputField";
|
import InputField, { InputFieldWithLabel } from "@components/InputField";
|
||||||
|
@ -72,7 +72,7 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsNetworkRoute() {
|
export default function SettingsNetworkRoute() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const [networkState, setNetworkState] = useNetworkStateStore(state => [
|
const [networkState, setNetworkState] = useNetworkStateStore(state => [
|
||||||
state,
|
state,
|
||||||
state.setNetworkState,
|
state.setNetworkState,
|
||||||
|
@ -104,7 +104,7 @@ export default function SettingsNetworkRoute() {
|
||||||
|
|
||||||
const getNetworkSettings = useCallback(() => {
|
const getNetworkSettings = useCallback(() => {
|
||||||
setNetworkSettingsLoaded(false);
|
setNetworkSettingsLoaded(false);
|
||||||
send("getNetworkSettings", {}, resp => {
|
send("getNetworkSettings", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
console.log(resp.result);
|
console.log(resp.result);
|
||||||
setNetworkSettings(resp.result as NetworkSettings);
|
setNetworkSettings(resp.result as NetworkSettings);
|
||||||
|
@ -117,7 +117,7 @@ export default function SettingsNetworkRoute() {
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const getNetworkState = useCallback(() => {
|
const getNetworkState = useCallback(() => {
|
||||||
send("getNetworkState", {}, resp => {
|
send("getNetworkState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
console.log(resp.result);
|
console.log(resp.result);
|
||||||
setNetworkState(resp.result as NetworkState);
|
setNetworkState(resp.result as NetworkState);
|
||||||
|
@ -127,7 +127,7 @@ export default function SettingsNetworkRoute() {
|
||||||
const setNetworkSettingsRemote = useCallback(
|
const setNetworkSettingsRemote = useCallback(
|
||||||
(settings: NetworkSettings) => {
|
(settings: NetworkSettings) => {
|
||||||
setNetworkSettingsLoaded(false);
|
setNetworkSettingsLoaded(false);
|
||||||
send("setNetworkSettings", { settings }, resp => {
|
send("setNetworkSettings", { settings }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
"Failed to save network settings: " +
|
"Failed to save network settings: " +
|
||||||
|
@ -148,7 +148,7 @@ export default function SettingsNetworkRoute() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenewLease = useCallback(() => {
|
const handleRenewLease = useCallback(() => {
|
||||||
send("renewDHCPLease", {}, resp => {
|
send("renewDHCPLease", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error("Failed to renew lease: " + resp.error.message);
|
notifications.error("Failed to renew lease: " + resp.error.message);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { TextAreaWithLabel } from "@/components/TextArea";
|
import { TextAreaWithLabel } from "@/components/TextArea";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useSettingsStore } from "@/hooks/stores";
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ const streamQualityOptions = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsVideoRoute() {
|
export default function SettingsVideoRoute() {
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const [streamQuality, setStreamQuality] = useState("1");
|
const [streamQuality, setStreamQuality] = useState("1");
|
||||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||||
const [edid, setEdid] = useState<string | null>(null);
|
const [edid, setEdid] = useState<string | null>(null);
|
||||||
|
@ -55,12 +55,12 @@ export default function SettingsVideoRoute() {
|
||||||
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
|
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getStreamQualityFactor", {}, resp => {
|
send("getStreamQualityFactor", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setStreamQuality(String(resp.result));
|
setStreamQuality(String(resp.result));
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getEDID", {}, resp => {
|
send("getEDID", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
||||||
return;
|
return;
|
||||||
|
@ -85,7 +85,7 @@ export default function SettingsVideoRoute() {
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const handleStreamQualityChange = (factor: string) => {
|
const handleStreamQualityChange = (factor: string) => {
|
||||||
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
|
send("setStreamQualityFactor", { factor: Number(factor) }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
|
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
|
||||||
|
@ -99,7 +99,7 @@ export default function SettingsVideoRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEDIDChange = (newEdid: string) => {
|
const handleEDIDChange = (newEdid: string) => {
|
||||||
send("setEDID", { edid: newEdid }, resp => {
|
send("setEDID", { edid: newEdid }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
|
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -39,7 +39,7 @@ import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||||
import DashboardNavbar from "@components/Header";
|
import DashboardNavbar from "@components/Header";
|
||||||
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
|
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
|
||||||
import AudioMetricsSidebar from "@/components/sidebar/AudioMetricsSidebar";
|
import AudioMetricsSidebar from "@/components/sidebar/AudioMetricsSidebar";
|
||||||
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcRequest, JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import Terminal from "@components/Terminal";
|
import Terminal from "@components/Terminal";
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
|
@ -653,11 +653,11 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||||
const [send] = useJsonRpc(onJsonRpcRequest);
|
const { send } = useJsonRpc(onJsonRpcRequest);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
send("getVideoState", {}, resp => {
|
send("getVideoState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
|
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
|
||||||
});
|
});
|
||||||
|
@ -669,7 +669,7 @@ export default function KvmIdRoute() {
|
||||||
if (keyboardLedState !== undefined) return;
|
if (keyboardLedState !== undefined) return;
|
||||||
console.log("Requesting keyboard led state");
|
console.log("Requesting keyboard led state");
|
||||||
|
|
||||||
send("getKeyboardLedState", {}, resp => {
|
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
// -32601 means the method is not supported
|
// -32601 means the method is not supported
|
||||||
if (resp.error.code === -32601) {
|
if (resp.error.code === -32601) {
|
||||||
|
@ -742,7 +742,7 @@ export default function KvmIdRoute() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appVersion) return;
|
if (appVersion) return;
|
||||||
|
|
||||||
send("getUpdateStatus", {}, async resp => {
|
send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Failed to get device version: ${resp.error}`);
|
notifications.error(`Failed to get device version: ${resp.error}`);
|
||||||
return
|
return
|
||||||
|
|
3
web.go
3
web.go
|
@ -457,6 +457,9 @@ func setupRouter() *gin.Engine {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Audio memory allocation metrics endpoint
|
||||||
|
protected.GET("/audio/memory-metrics", gin.WrapF(audio.HandleMemoryMetrics))
|
||||||
|
|
||||||
protected.GET("/microphone/process-metrics", func(c *gin.Context) {
|
protected.GET("/microphone/process-metrics", func(c *gin.Context) {
|
||||||
if currentSession == nil || currentSession.AudioInputManager == nil {
|
if currentSession == nil || currentSession.AudioInputManager == nil {
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
|
|
19
webrtc.go
19
webrtc.go
|
@ -29,14 +29,13 @@ type Session struct {
|
||||||
DiskChannel *webrtc.DataChannel
|
DiskChannel *webrtc.DataChannel
|
||||||
AudioInputManager *audio.AudioInputManager
|
AudioInputManager *audio.AudioInputManager
|
||||||
shouldUmountVirtualMedia bool
|
shouldUmountVirtualMedia bool
|
||||||
|
|
||||||
// Microphone operation throttling
|
// Microphone operation throttling
|
||||||
micCooldown time.Duration
|
micCooldown time.Duration
|
||||||
|
|
||||||
// Audio frame processing
|
// Audio frame processing
|
||||||
audioFrameChan chan []byte
|
audioFrameChan chan []byte
|
||||||
audioStopChan chan struct{}
|
audioStopChan chan struct{}
|
||||||
audioWg sync.WaitGroup
|
audioWg sync.WaitGroup
|
||||||
|
rpcQueue chan webrtc.DataChannelMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionConfig struct {
|
type SessionConfig struct {
|
||||||
|
@ -118,6 +117,7 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
session := &Session{
|
session := &Session{
|
||||||
peerConnection: peerConnection,
|
peerConnection: peerConnection,
|
||||||
AudioInputManager: audio.NewAudioInputManager(),
|
AudioInputManager: audio.NewAudioInputManager(),
|
||||||
|
@ -129,13 +129,21 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
// Start audio processing goroutine
|
// Start audio processing goroutine
|
||||||
session.startAudioProcessor(*logger)
|
session.startAudioProcessor(*logger)
|
||||||
|
|
||||||
|
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
|
||||||
|
go func() {
|
||||||
|
for msg := range session.rpcQueue {
|
||||||
|
onRPCMessage(msg, session)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
|
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||||
scopedLogger.Info().Str("label", d.Label()).Uint16("id", *d.ID()).Msg("New DataChannel")
|
scopedLogger.Info().Str("label", d.Label()).Uint16("id", *d.ID()).Msg("New DataChannel")
|
||||||
switch d.Label() {
|
switch d.Label() {
|
||||||
case "rpc":
|
case "rpc":
|
||||||
session.RPCChannel = d
|
session.RPCChannel = d
|
||||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
go onRPCMessage(msg, session)
|
// Enqueue to ensure ordered processing
|
||||||
|
session.rpcQueue <- msg
|
||||||
})
|
})
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
triggerVideoStateUpdate()
|
triggerVideoStateUpdate()
|
||||||
|
@ -259,6 +267,11 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
if session == currentSession {
|
if session == currentSession {
|
||||||
currentSession = nil
|
currentSession = nil
|
||||||
}
|
}
|
||||||
|
// Stop RPC processor
|
||||||
|
if session.rpcQueue != nil {
|
||||||
|
close(session.rpcQueue)
|
||||||
|
session.rpcQueue = nil
|
||||||
|
}
|
||||||
if session.shouldUmountVirtualMedia {
|
if session.shouldUmountVirtualMedia {
|
||||||
err := rpcUnmountImage()
|
err := rpcUnmountImage()
|
||||||
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")
|
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")
|
||||||
|
|
Loading…
Reference in New Issue