Compare commits

...

21 Commits

Author SHA1 Message Date
Alex P 44a35aa5c2 feat(audio): add socket buffer configuration and monitoring
Add socket buffer configuration support with metrics collection for audio IPC connections. This improves performance monitoring and allows tuning socket buffers for optimal audio streaming performance.

- Introduce SocketBufferConfig struct with default and high-load presets
- Add socket buffer configuration to AudioServer and AudioInputServer
- Implement socket buffer metrics collection (size, utilization, overflow)
- Add new Prometheus metrics for socket buffer monitoring
2025-08-24 23:56:58 +00:00
Alex P 3a28105f56 Fix: linting errors 2025-08-24 23:36:29 +00:00
Alex P a9a1082bcc Docs: Updated documentation 2025-08-24 23:15:46 +00:00
Alex e0f7b1d930
Merge branch 'dev' into feat/audio-support 2025-08-25 02:08:12 +03:00
Marc Brooks 5188717bb9 fix: useJsonRpc "any" issue
PR #743 didn't have all the files included in the commit.
Mea culpa, many apologies.
2025-08-24 22:58:35 +00:00
Alex Ballas 70e49a1cac chore: ensure that rpc messages get processed sequentially and avoid phantom and repeated key presses (#744) 2025-08-24 22:57:37 +00:00
Marc Brooks 9d40263eed fix: compiler error (#743)
Using { send } gives the resp a type instead of any
2025-08-24 22:54:26 +00:00
Adam Shiervani 0651faeceb feat: improve custom jiggler settings and add timezone support (#742)
* feat: add timezone support to jiggler and fix custom settings persistence

- Add timezone field to JigglerConfig with comprehensive IANA timezone list
- Fix custom settings not loading current values
- Remove business hours preset, add as examples in custom settings
- Improve error handling for invalid cron expressions

* fix: format jiggler.go with gofmt

* fix: add embedded timezone data and validation

- Import time/tzdata to embed timezone database in binary
- Add timezone validation in runJigglerCronTab() to gracefully fallback to UTC
- Add timezone to debug logging in rpcSetJigglerConfig
- Fixes 'unknown time zone' errors when system lacks timezone data

* refactor: add timezone field comments from jiggler options

* chore: move tzdata to backend

* refactor: fix JigglerSetting linting

- Adjusted useEffect dependency to include send function for better data fetching
- Modified layout classes for improved responsiveness and consistency
- Cleaned up code formatting for better readability

---------

Co-authored-by: Siyuan Miao <i@xswan.net>
2025-08-24 22:54:26 +00:00
Serhii 199cca83ed Update mount list for new Debian 13 release (#739)
* Update mount list for new Debian 13 release

* Keep Debian 12 Bookworm as old-stable release
2025-08-24 22:54:26 +00:00
Marc Brooks a3b2b46f49 Lint fix from last merge. (#733) 2025-08-24 22:54:26 +00:00
jackislanding f729675a3f Added crontab scheduler for jiggler (#316) 2025-08-24 22:54:26 +00:00
Marc Brooks 785a68d923 chore(ui)/package upgrades (#724)
| Package                          | From     | To           |
| -------------------------------- | ----------- | ------------ |
| @headlessui/react        | 2.2.4     | 2.2.7        |
| framer-motion              | 12.23.3 | 12.23.12 |
| react                                | 19.1.0   | 19.1.1     |
| react-dom                       | 19.1.0   | 19.1.1    |
| react-simple-keyboard | 3.8.93   | 3.8.106  |
|@eslint/js                         | 9.30.1   | 9.32.0    |
| @types/react                  | 19.1.8   | 19.1.9    |
| @types/react-dom         | 19.1.8   | 19.1.9   |
|eslint                                 | 9.30.1   | 9.32.0    |
|eslint-config-prettier       | 10.1.5   | 10.1.8   |
| typescript                         |  5.8.3    | 5.9.2     |
2025-08-24 22:54:26 +00:00
Alex P 57b7bafcc1 feat(audio): implement comprehensive audio optimization system
- Add AdaptiveOptimizer for real-time parameter adjustment based on latency metrics
- Add AdaptiveBufferConfig for dynamic buffer sizing based on system load
- Implement BatchAudioProcessor for reduced CGO call overhead
- Add AudioBufferPool with sync.Pool for optimized memory allocation
- Implement LatencyMonitor with exponential moving averages
- Add MemoryMetrics for comprehensive memory usage tracking
- Implement PriorityScheduler with SCHED_FIFO for real-time audio processing
- Add zero-copy operations to minimize memory copying in audio pipeline
- Enhance IPC architecture with intelligent frame dropping
- Add comprehensive Prometheus metrics for performance monitoring
- Implement triple-goroutine architecture for audio input processing
- Add adaptive buffering and performance feedback loops
2025-08-24 22:33:49 +00:00
Marc Brooks d952480c2a
fix: useJsonRpc "any" issue
PR #743 didn't have all the files included in the commit.
Mea culpa, many apologies.
2025-08-24 11:40:43 +02:00
Alex Ballas 8e27cd6b60
chore: ensure that rpc messages get processed sequentially and avoid phantom and repeated key presses (#744) 2025-08-22 20:15:46 +02:00
Marc Brooks bb87fb5a1a
fix: compiler error (#743)
Using { send } gives the resp a type instead of any
2025-08-22 12:15:27 +02:00
Adam Shiervani 8527b1eff1
feat: improve custom jiggler settings and add timezone support (#742)
* feat: add timezone support to jiggler and fix custom settings persistence

- Add timezone field to JigglerConfig with comprehensive IANA timezone list
- Fix custom settings not loading current values
- Remove business hours preset, add as examples in custom settings
- Improve error handling for invalid cron expressions

* fix: format jiggler.go with gofmt

* fix: add embedded timezone data and validation

- Import time/tzdata to embed timezone database in binary
- Add timezone validation in runJigglerCronTab() to gracefully fallback to UTC
- Add timezone to debug logging in rpcSetJigglerConfig
- Fixes 'unknown time zone' errors when system lacks timezone data

* refactor: add timezone field comments from jiggler options

* chore: move tzdata to backend

* refactor: fix JigglerSetting linting

- Adjusted useEffect dependency to include send function for better data fetching
- Modified layout classes for improved responsiveness and consistency
- Cleaned up code formatting for better readability

---------

Co-authored-by: Siyuan Miao <i@xswan.net>
2025-08-19 16:50:42 +02:00
Serhii 9f573200b1
Update mount list for new Debian 13 release (#739)
* Update mount list for new Debian 13 release

* Keep Debian 12 Bookworm as old-stable release
2025-08-18 11:20:20 +02:00
Marc Brooks 608f69db13
Lint fix from last merge. (#733) 2025-08-13 11:13:42 +02:00
jackislanding f7b8efde7c
Added crontab scheduler for jiggler (#316) 2025-08-12 20:50:03 +02:00
Marc Brooks 33ac9fe0b6
chore(ui)/package upgrades (#724)
| Package                          | From     | To           |
| -------------------------------- | ----------- | ------------ |
| @headlessui/react        | 2.2.4     | 2.2.7        |
| framer-motion              | 12.23.3 | 12.23.12 |
| react                                | 19.1.0   | 19.1.1     |
| react-dom                       | 19.1.0   | 19.1.1    |
| react-simple-keyboard | 3.8.93   | 3.8.106  |
|@eslint/js                         | 9.30.1   | 9.32.0    |
| @types/react                  | 19.1.8   | 19.1.9    |
| @types/react-dom         | 19.1.8   | 19.1.9   |
|eslint                                 | 9.30.1   | 9.32.0    |
|eslint-config-prettier       | 10.1.5   | 10.1.8   |
| typescript                         |  5.8.3    | 5.9.2     |
2025-08-12 11:24:05 +02:00
66 changed files with 4940 additions and 809 deletions

View File

@ -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
- **[SSH access](https://jetkvm.com/docs/advanced-usage/developing#developer-mode)** to your JetKVM device
- **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
@ -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
# 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)
@ -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`
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/components/ # UI components
├── 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
└── Makefile # Build and dev automation (see audio targets)
```
**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
- `config.go` - Add new settings here
- `ui/src/routes/` - Add new pages here
@ -174,7 +174,7 @@ npm install
### 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
# Skip frontend build for faster deployment
@ -353,7 +353,7 @@ go clean -modcache
go mod tidy
make build_dev
# 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"

View File

@ -34,7 +34,7 @@ OPTIM_CFLAGS := -O3 -mcpu=cortex-a7 -mfpu=neon -mfloat-abi=hard -ftree-vectorize
PROMETHEUS_TAG := github.com/prometheus/common/version
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_LDFLAGS := \
-s -w \

View File

@ -22,7 +22,7 @@ JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse, *
## Features
- **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.
- **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
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.
@ -53,7 +53,7 @@ For quick device development, use the `./dev_deploy.sh` script. It will build th
## 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

View File

@ -82,6 +82,7 @@ type Config struct {
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
JigglerConfig *JigglerConfig `json:"jiggler_config"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
@ -117,7 +118,14 @@ var defaultConfig = &Config{
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 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{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget

9
go.mod
View File

@ -11,6 +11,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6
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/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341
@ -28,9 +29,9 @@ require (
github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.1
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/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
@ -50,6 +51,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // 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/klauspost/cpuid/v2 v2.2.10 // 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/pmezard/go-difflib v1.0.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
@ -82,7 +85,7 @@ require (
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.18.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
gopkg.in/yaml.v3 v3.0.1 // indirect
)

20
go.sum
View File

@ -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-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
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/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
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/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
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/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
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/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
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/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
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.10.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.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
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/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -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()
}
}

View File

@ -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)

View File

@ -199,7 +199,16 @@ func (bap *BatchAudioProcessor) processBatchRead(batch []batchReadRequest) {
start := time.Now()
if atomic.CompareAndSwapInt32(&bap.threadPinned, 0, 1) {
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() {
if err := ResetThreadPriority(); err != nil {
bap.logger.Warn().Err(err).Msg("Failed to reset thread priority")
}
runtime.UnlockOSThread()
atomic.StoreInt32(&bap.threadPinned, 0)
bap.stats.OSThreadPinTime += time.Since(start)

View File

@ -2,16 +2,41 @@ package audio
import (
"sync"
"sync/atomic"
)
type AudioBufferPool struct {
pool sync.Pool
bufferSize int
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
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 {
// 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{
bufferSize: bufferSize,
bufferSize: bufferSize,
maxPoolSize: 100, // Limit pool size to prevent excessive memory usage
preallocated: preallocated,
preallocSize: preallocSize,
pool: sync.Pool{
New: func() interface{} {
return make([]byte, 0, bufferSize)
@ -21,17 +46,68 @@ func NewAudioBufferPool(bufferSize int) *AudioBufferPool {
}
func (p *AudioBufferPool) Get() []byte {
if buf := p.pool.Get(); buf != nil {
return *buf.(*[]byte)
// First try pre-allocated buffers for fastest access
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)
}
func (p *AudioBufferPool) Put(buf []byte) {
if cap(buf) >= p.bufferSize {
resetBuf := buf[:0]
p.pool.Put(&resetBuf)
if cap(buf) < p.bufferSize {
return // Buffer too small, don't pool it
}
// 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 (
@ -54,3 +130,83 @@ func GetAudioControlBuffer() []byte {
func PutAudioControlBuffer(buf []byte) {
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,
}
}

View File

@ -22,8 +22,14 @@ static snd_pcm_t *pcm_handle = NULL;
static snd_pcm_t *pcm_playback_handle = NULL;
static OpusEncoder *encoder = NULL;
static OpusDecoder *decoder = NULL;
static int opus_bitrate = 64000;
static int opus_complexity = 5;
// Optimized Opus encoder settings for ARM Cortex-A7
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 channels = 2;
static int frame_size = 960; // 20ms for 48kHz
@ -164,7 +170,7 @@ int jetkvm_audio_init() {
return -1;
}
// Initialize Opus encoder
// Initialize Opus encoder with optimized settings
int opus_err = 0;
encoder = opus_encoder_create(sample_rate, channels, OPUS_APPLICATION_AUDIO, &opus_err);
if (!encoder || opus_err != OPUS_OK) {
@ -173,8 +179,18 @@ int jetkvm_audio_init() {
return -2;
}
// Apply optimized Opus encoder settings
opus_encoder_ctl(encoder, OPUS_SET_BITRATE(opus_bitrate));
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_initializing = 0;

View File

@ -99,6 +99,42 @@ func (aim *AudioInputManager) WriteOpusFrame(frame []byte) error {
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
func (aim *AudioInputManager) GetMetrics() AudioInputMetrics {
return AudioInputMetrics{

View File

@ -8,17 +8,22 @@ import (
"net"
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"time"
"github.com/jetkvm/kvm/internal/logging"
)
const (
inputMagicNumber uint32 = 0x4A4B4D49 // "JKMI" (JetKVM Microphone Input)
inputSocketName = "audio_input.sock"
maxFrameSize = 4096 // Maximum Opus frame size
writeTimeout = 5 * time.Millisecond // Non-blocking write timeout
maxDroppedFrames = 100 // Maximum consecutive dropped frames before reconnect
maxFrameSize = 4096 // Maximum Opus frame size
writeTimeout = 15 * time.Millisecond // Non-blocking write timeout (increased for high load)
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
@ -41,6 +46,113 @@ type InputIPCMessage struct {
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
type InputIPCConfig struct {
SampleRate int
@ -66,6 +178,9 @@ type AudioInputServer struct {
processChan chan *InputIPCMessage // Buffered channel for processing queue
stopChan chan struct{} // Stop signal for all goroutines
wg sync.WaitGroup // Wait group for goroutine coordination
// Socket buffer configuration
socketBufferConfig SocketBufferConfig
}
// 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)
}
// Initialize with adaptive buffer size (start with 1000 frames)
initialBufferSize := int64(1000)
// Get initial buffer size from adaptive buffer manager
adaptiveManager := GetAdaptiveBufferManager()
initialBufferSize := int64(adaptiveManager.GetInputBufferSize())
// Initialize socket buffer configuration
socketBufferConfig := DefaultSocketBufferConfig()
return &AudioInputServer{
listener: listener,
messageChan: make(chan *InputIPCMessage, initialBufferSize),
processChan: make(chan *InputIPCMessage, initialBufferSize),
stopChan: make(chan struct{}),
bufferSize: initialBufferSize,
listener: listener,
messageChan: make(chan *InputIPCMessage, initialBufferSize),
processChan: make(chan *InputIPCMessage, initialBufferSize),
stopChan: make(chan struct{}),
bufferSize: initialBufferSize,
socketBufferConfig: socketBufferConfig,
}, nil
}
@ -157,6 +277,16 @@ func (ais *AudioInputServer) acceptConnections() {
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()
// Close existing connection if any
if ais.conn != nil {
@ -192,21 +322,22 @@ func (ais *AudioInputServer) handleConnection(conn net.Conn) {
// readMessage reads a complete message from the connection
func (ais *AudioInputServer) readMessage(conn net.Conn) (*InputIPCMessage, error) {
// Read header (magic + type + length + timestamp)
headerSize := 4 + 1 + 4 + 8 // uint32 + uint8 + uint32 + int64
header := make([]byte, headerSize)
// Get optimized message from pool
optMsg := globalMessagePool.Get()
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 {
return nil, err
}
// Parse header
msg := &InputIPCMessage{}
msg.Magic = binary.LittleEndian.Uint32(header[0:4])
msg.Type = InputMessageType(header[4])
msg.Length = binary.LittleEndian.Uint32(header[5:9])
msg.Timestamp = int64(binary.LittleEndian.Uint64(header[9:17]))
// Parse header using optimized access
msg := &optMsg.msg
msg.Magic = binary.LittleEndian.Uint32(optMsg.header[0:4])
msg.Type = InputMessageType(optMsg.header[4])
msg.Length = binary.LittleEndian.Uint32(optMsg.header[5:9])
msg.Timestamp = int64(binary.LittleEndian.Uint64(optMsg.header[9:17]))
// Validate magic number
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)
}
// Read data if present
// Read data if present using pooled buffer
if msg.Length > 0 {
msg.Data = make([]byte, msg.Length)
_, err = io.ReadFull(conn, msg.Data)
// Ensure buffer capacity
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 {
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
@ -282,19 +434,20 @@ func (ais *AudioInputServer) sendAck() error {
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 {
// Prepare header
headerSize := 4 + 1 + 4 + 8
header := make([]byte, headerSize)
// Get optimized message from pool for header preparation
optMsg := globalMessagePool.Get()
defer globalMessagePool.Put(optMsg)
binary.LittleEndian.PutUint32(header[0:4], msg.Magic)
header[4] = byte(msg.Type)
binary.LittleEndian.PutUint32(header[5:9], msg.Length)
binary.LittleEndian.PutUint64(header[9:17], uint64(msg.Timestamp))
// Prepare header in pre-allocated buffer
binary.LittleEndian.PutUint32(optMsg.header[0:4], msg.Magic)
optMsg.header[4] = byte(msg.Type)
binary.LittleEndian.PutUint32(optMsg.header[5:9], msg.Length)
binary.LittleEndian.PutUint64(optMsg.header[9:17], uint64(msg.Timestamp))
// Write header
_, err := conn.Write(header)
_, err := conn.Write(optMsg.header[:])
if err != nil {
return err
}
@ -312,7 +465,7 @@ func (ais *AudioInputServer) writeMessage(conn net.Conn, msg *InputIPCMessage) e
// AudioInputClient handles IPC communication from the main process
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
totalFrames int64 // Atomic counter for total frames
@ -410,6 +563,35 @@ func (aic *AudioInputClient) SendFrame(frame []byte) error {
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
func (aic *AudioInputClient) SendConfig(config InputIPCConfig) error {
aic.mtx.Lock()
@ -460,14 +642,15 @@ func (aic *AudioInputClient) writeMessage(msg *InputIPCMessage) error {
// Increment total frames counter
atomic.AddInt64(&aic.totalFrames, 1)
// Prepare header
headerSize := 4 + 1 + 4 + 8
header := make([]byte, headerSize)
// Get optimized message from pool for header preparation
optMsg := globalMessagePool.Get()
defer globalMessagePool.Put(optMsg)
binary.LittleEndian.PutUint32(header[0:4], msg.Magic)
header[4] = byte(msg.Type)
binary.LittleEndian.PutUint32(header[5:9], msg.Length)
binary.LittleEndian.PutUint64(header[9:17], uint64(msg.Timestamp))
// Prepare header in pre-allocated buffer
binary.LittleEndian.PutUint32(optMsg.header[0:4], msg.Magic)
optMsg.header[4] = byte(msg.Type)
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
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
done := make(chan error, 1)
go func() {
// Write header
_, err := aic.conn.Write(header)
// Write header using pre-allocated buffer
_, err := aic.conn.Write(optMsg.header[:])
if err != nil {
done <- err
return
@ -570,6 +753,20 @@ func (ais *AudioInputServer) startReaderGoroutine() {
func (ais *AudioInputServer) startProcessorGoroutine() {
ais.wg.Add(1)
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()
for {
select {
@ -608,10 +805,28 @@ func (ais *AudioInputServer) startProcessorGoroutine() {
func (ais *AudioInputServer) startMonitorGoroutine() {
ais.wg.Add(1)
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()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
// Buffer size update ticker (less frequent)
bufferUpdateTicker := time.NewTicker(500 * time.Millisecond)
defer bufferUpdateTicker.Stop()
for {
select {
case <-ais.stopChan:
@ -623,52 +838,46 @@ func (ais *AudioInputServer) startMonitorGoroutine() {
case msg := <-ais.processChan:
start := time.Now()
err := ais.processMessage(msg)
processingTime := time.Since(start).Nanoseconds()
processingTime := time.Since(start)
// Calculate end-to-end latency using message timestamp
var latency time.Duration
if msg.Type == InputMessageTypeOpusFrame && msg.Timestamp > 0 {
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
currentAvg := atomic.LoadInt64(&ais.processingTime)
// Weight: 90% historical, 10% current (for smoother averaging)
newAvg := (currentAvg*9 + endToEndLatency) / 10
newAvg := (currentAvg*9 + latency.Nanoseconds()) / 10
atomic.StoreInt64(&ais.processingTime, newAvg)
} else {
// Fallback to processing time only
latency = processingTime
currentAvg := atomic.LoadInt64(&ais.processingTime)
newAvg := (currentAvg + processingTime) / 2
newAvg := (currentAvg + processingTime.Nanoseconds()) / 2
atomic.StoreInt64(&ais.processingTime, newAvg)
}
// Report latency to adaptive buffer manager
ais.ReportLatency(latency)
if err != nil {
atomic.AddInt64(&ais.droppedFrames, 1)
}
default:
// No more messages to process
goto adaptiveBuffering
goto checkBufferUpdate
}
}
adaptiveBuffering:
// Adaptive buffer sizing based on processing time
avgTime := atomic.LoadInt64(&ais.processingTime)
currentSize := atomic.LoadInt64(&ais.bufferSize)
if avgTime > 10*1000*1000 { // > 10ms processing time
// Increase buffer size
newSize := currentSize * 2
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)
checkBufferUpdate:
// Check if we need to update buffer size
select {
case <-bufferUpdateTicker.C:
// Update buffer size from adaptive buffer manager
ais.UpdateBufferSize()
default:
// No buffer update needed
}
}
}
@ -683,6 +892,64 @@ func (ais *AudioInputServer) GetServerStats() (total, dropped int64, avgProcessi
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
// getInputSocketPath returns the path to the input socket

View File

@ -116,6 +116,40 @@ func (aim *AudioInputIPCManager) WriteOpusFrame(frame []byte) error {
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
func (aim *AudioInputIPCManager) IsRunning() bool {
return atomic.LoadInt32(&aim.running) == 1

View File

@ -16,6 +16,10 @@ func RunAudioInputServer() error {
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-server").Logger()
logger.Info().Msg("Starting audio input server subprocess")
// Start adaptive buffer management for optimal performance
StartAdaptiveBuffering()
defer StopAdaptiveBuffering()
// Initialize CGO audio system
err := CGOAudioPlaybackInit()
if err != nil {

View File

@ -244,6 +244,19 @@ func (ais *AudioInputSupervisor) SendFrame(frame []byte) error {
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)
func (ais *AudioInputSupervisor) SendConfig(config InputIPCConfig) error {
if ais.client == nil {

View File

@ -1,6 +1,7 @@
package audio
import (
"context"
"encoding/binary"
"fmt"
"io"
@ -8,22 +9,123 @@ import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
)
const (
magicNumber uint32 = 0x4A4B564D // "JKVM"
socketName = "audio_output.sock"
outputMagicNumber uint32 = 0x4A4B4F55 // "JKOU" (JetKVM Output)
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 {
// 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
conn net.Conn
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) {
socketPath := filepath.Join("/var/run", socketName)
socketPath := getOutputSocketPath()
// Remove existing socket if any
os.Remove(socketPath)
@ -32,26 +134,192 @@ func NewAudioServer() (*AudioServer, error) {
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 {
conn, err := s.listener.Accept()
if err != nil {
return fmt.Errorf("failed to accept connection: %w", err)
s.mtx.Lock()
defer s.mtx.Unlock()
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
}
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 {
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 {
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()
defer s.mtx.Unlock()
@ -59,70 +327,199 @@ func (s *AudioServer) SendFrame(frame []byte) error {
return fmt.Errorf("no client connected")
}
// Write magic number
if err := binary.Write(s.conn, binary.BigEndian, magicNumber); err != nil {
return fmt.Errorf("failed to write magic number: %w", err)
}
start := time.Now()
// Write frame size
if err := binary.Write(s.conn, binary.BigEndian, uint32(len(frame))); err != nil {
return fmt.Errorf("failed to write frame size: %w", err)
}
// Get optimized message from pool
optMsg := globalOutputMessagePool.Get()
defer globalOutputMessagePool.Put(optMsg)
// Write frame data
if _, err := s.conn.Write(frame); err != nil {
return fmt.Errorf("failed to write frame data: %w", err)
}
// Prepare header in pre-allocated buffer
binary.LittleEndian.PutUint32(optMsg.header[0:4], outputMagicNumber)
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 {
conn net.Conn
mtx sync.Mutex
// Atomic fields must be first for proper alignment on ARM
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) {
socketPath := filepath.Join("/var/run", socketName)
func NewAudioClient() *AudioClient {
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
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)
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 {
return c.conn.Close()
c.Disconnect()
return nil
}
func (c *AudioClient) ReceiveFrame() ([]byte, error) {
c.mtx.Lock()
defer c.mtx.Unlock()
// Read magic number
var magic uint32
if err := binary.Read(c.conn, binary.BigEndian, &magic); err != nil {
return nil, fmt.Errorf("failed to read magic number: %w", err)
if !c.running || c.conn == nil {
return nil, fmt.Errorf("not connected")
}
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)
}
// Read frame size
var size uint32
if err := binary.Read(c.conn, binary.BigEndian, &size); err != nil {
return nil, fmt.Errorf("failed to read frame size: %w", err)
msgType := OutputMessageType(optMsg.header[4])
if msgType != OutputMessageTypeOpusFrame {
return nil, fmt.Errorf("unexpected message type: %d", msgType)
}
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
frame := make([]byte, size)
if _, err := io.ReadFull(c.conn, frame); err != nil {
return nil, fmt.Errorf("failed to read frame data: %w", err)
if size > 0 {
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
}
// 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)
}

View File

@ -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
}

View File

@ -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()
}
}()
}

View File

@ -10,6 +10,67 @@ import (
)
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
audioFramesReceivedTotal = promauto.NewCounter(
prometheus.CounterOpts{
@ -364,6 +425,23 @@ func UpdateMicrophoneConfigMetrics(config AudioConfig) {
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
func GetLastMetricsUpdate() time.Time {
timestamp := atomic.LoadInt64(&lastMetricsUpdate)

View File

@ -8,10 +8,12 @@ import (
// MicrophoneContentionManager manages microphone access with cooldown periods
type MicrophoneContentionManager struct {
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
lastOpNano int64
cooldownNanos int64
operationID int64
lockPtr unsafe.Pointer
lockPtr unsafe.Pointer
}
func NewMicrophoneContentionManager(cooldown time.Duration) *MicrophoneContentionManager {

View File

@ -2,6 +2,9 @@ package audio
import (
"context"
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
@ -9,6 +12,28 @@ import (
"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 (
outputStreamingRunning int32
outputStreamingCancel context.CancelFunc
@ -23,6 +48,253 @@ func getOutputStreamingLogger() *zerolog.Logger {
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)
func StartAudioOutputStreaming(send func([]byte)) error {
if !atomic.CompareAndSwapInt32(&outputStreamingRunning, 0, 1) {
@ -61,10 +333,13 @@ func StartAudioOutputStreaming(send func([]byte)) error {
continue
}
if n > 0 {
// Send frame to callback
frame := make([]byte, n)
// Get frame buffer from pool to reduce allocations
frame := GetAudioFrameBuffer()
frame = frame[:n] // Resize to actual frame size
copy(frame, buffer[:n])
send(frame)
// Return buffer to pool after sending
PutAudioFrameBuffer(frame)
RecordFrameReceived(n)
}
// Small delay to prevent busy waiting

View File

@ -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()
}

View File

@ -2,6 +2,7 @@ package audio
import (
"context"
"fmt"
"sync"
"time"
@ -13,6 +14,10 @@ import (
// AudioRelay handles forwarding audio frames from the audio server subprocess
// to WebRTC without any CGO audio processing. This runs in the main process.
type AudioRelay struct {
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
framesRelayed int64
framesDropped int64
client *AudioClient
ctx context.Context
cancel context.CancelFunc
@ -25,10 +30,6 @@ type AudioRelay struct {
audioTrack AudioTrackWriter
config AudioConfig
muted bool
// Statistics
framesRelayed int64
framesDropped int64
}
// 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
client, err := NewAudioClient()
if err != nil {
return err
}
client := NewAudioClient()
r.client = client
r.audioTrack = audioTrack
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
r.wg.Add(1)
go r.relayLoop()
@ -88,7 +91,7 @@ func (r *AudioRelay) Stop() {
r.wg.Wait()
if r.client != nil {
r.client.Close()
r.client.Disconnect()
r.client = nil
}

View File

@ -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)
}

314
internal/audio/zero_copy.go Normal file
View File

@ -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
}

72
internal/tzdata/gen.go Normal file
View File

@ -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)
}
}

602
internal/tzdata/tzdata.go Normal file
View File

@ -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",
}

View File

@ -1,39 +1,156 @@
package kvm
import (
"fmt"
"math/rand"
"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 jobDelta time.Duration = 0
var scheduler gocron.Scheduler = nil
func rpcSetJigglerState(enabled bool) {
jigglerEnabled = enabled
}
func rpcGetJigglerState() bool {
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() {
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() {
for {
if jigglerEnabled {
if time.Since(lastUserInput) > 20*time.Second {
//TODO: change to rel mouse
err := rpcAbsMouseReport(1, 1, 0)
if err != nil {
logger.Warn().Err(err).Msg("Failed to jiggle mouse")
}
err = rpcAbsMouseReport(0, 0, 0)
if err != nil {
logger.Warn().Err(err).Msg("Failed to reset mouse position")
}
if jigglerEnabled {
if config.JigglerConfig.JitterPercentage != 0 {
jitter := calculateJitterDuration(jobDelta)
time.Sleep(jitter)
}
inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds
timeSinceLastInput := time.Since(gadget.GetLastUserInputTime())
logger.Debug().Msgf("Time since last user input %v", timeSinceLastInput)
if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second {
logger.Debug().Msg("Jiggling mouse...")
//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))
}

View File

@ -1091,6 +1091,9 @@ var rpcHandlers = map[string]RPCHandler{
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
"getJigglerConfig": {Func: rpcGetJigglerConfig},
"getTimezones": {Func: rpcGetTimezones},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},

View File

@ -31,6 +31,9 @@ func runAudioServer() {
}
func startAudioSubprocess() error {
// Start adaptive buffer management for optimal performance
audio.StartAdaptiveBuffering()
// Create audio server supervisor
audioSupervisor = audio.NewAudioServerSupervisor()
@ -59,6 +62,8 @@ func startAudioSubprocess() error {
// Stop audio relay when process exits
audio.StopAudioRelay()
// Stop adaptive buffering
audio.StopAdaptiveBuffering()
},
// onRestart
func(attempt int, delay time.Duration) {

877
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "kvm-ui",
"private": true,
"version": "0.0.0",
"version": "2025.08.07.001",
"type": "module",
"engines": {
"node": "22.15.0"
@ -19,7 +19,7 @@
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^2.2.4",
"@headlessui/react": "^2.2.7",
"@headlessui/tailwindcss": "^0.2.2",
"@heroicons/react": "^2.2.0",
"@vitejs/plugin-basic-ssl": "^2.1.0",
@ -33,16 +33,16 @@
"dayjs": "^1.11.13",
"eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^11.0.4",
"framer-motion": "^12.23.3",
"framer-motion": "^12.23.12",
"lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4",
"react": "^19.1.0",
"react": "^19.1.1",
"react-animate-height": "^3.2.3",
"react-dom": "^19.1.0",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0",
"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-xtermjs": "^1.0.10",
"recharts": "^2.15.3",
@ -54,21 +54,21 @@
"devDependencies": {
"@eslint/compat": "^1.3.1",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.30.1",
"@eslint/js": "^9.32.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/semver": "^7.7.0",
"@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.0",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
@ -78,7 +78,7 @@
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3",
"typescript": "^5.9.2",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4"
}

View File

@ -27,7 +27,7 @@ export default function FieldLabel({
>
{label}
{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}
</span>
)}
@ -36,11 +36,11 @@ export default function FieldLabel({
} else if (as === "span") {
return (
<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}
</span>
{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}
</span>
)}
@ -49,4 +49,4 @@ export default function FieldLabel({
} else {
return <></>;
}
}
}

View File

@ -26,7 +26,7 @@ type InputFieldProps = {
type InputFieldWithLabelProps = InputFieldProps & {
label: React.ReactNode;
description?: string | null;
description?: React.ReactNode | string | null;
};
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(

View File

@ -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>
);
}

View File

@ -10,7 +10,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function MacroBar() {
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
const { executeMacro } = useKeyboard();
const [send] = useJsonRpc();
const { send } = useJsonRpc();
useEffect(() => {
setSendFn(send);

View File

@ -26,7 +26,7 @@ type SelectMenuProps = Pick<
const sizes = {
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",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
};
@ -62,7 +62,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
"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!">
<select
ref={ref}

View File

@ -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>
);
}

View File

@ -1,6 +1,6 @@
import { useCallback , useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
@ -59,7 +59,7 @@ const usbPresets = [
];
export function UsbDeviceSetting() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbDeviceConfig, setUsbDeviceConfig] =
@ -67,7 +67,7 @@ export function UsbDeviceSetting() {
const [selectedPreset, setSelectedPreset] = useState<string>("default");
const syncUsbDeviceConfig = useCallback(() => {
send("getUsbDevices", {}, resp => {
send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error);
notifications.error(
@ -97,7 +97,7 @@ export function UsbDeviceSetting() {
const handleUsbConfigChange = useCallback(
(devices: UsbDeviceConfig) => {
setLoading(true);
send("setUsbDevices", { devices }, async resp => {
send("setUsbDevices", { devices }, async (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`,

View File

@ -4,7 +4,7 @@ import { Button } from "@components/Button";
import { UsbConfigState } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
@ -54,7 +54,7 @@ const usbConfigs = [
type UsbConfigMap = Record<string, USBConfig>;
export function UsbInfoSetting() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbConfigProduct, setUsbConfigProduct] = useState("");
@ -94,7 +94,7 @@ export function UsbInfoSetting() {
);
const syncUsbConfigProduct = useCallback(() => {
send("getUsbConfig", {}, resp => {
send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error);
notifications.error(
@ -114,7 +114,7 @@ export function UsbInfoSetting() {
const handleUsbConfigChange = useCallback(
(usbConfig: USBConfig) => {
setLoading(true);
send("setUsbConfig", { usbConfig }, async resp => {
send("setUsbConfig", { usbConfig }, async (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
@ -137,7 +137,7 @@ export function UsbInfoSetting() {
);
useEffect(() => {
send("getDeviceID", {}, async resp => {
send("getDeviceID", {}, async (resp: JsonRpcResponse) => {
if ("error" in resp) {
return notifications.error(
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
@ -205,10 +205,10 @@ function USBConfigDialog({
product: "",
});
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const syncUsbConfig = useCallback(() => {
send("getUsbConfig", {}, resp => {
send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error);
} else {

View File

@ -99,7 +99,7 @@ export default function WebRTCVideo({ microphone }: WebRTCVideoProps) {
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
// Misc states and hooks
const [send] = useJsonRpc();
const { send } = useJsonRpc();
// Video-related
useResizeObserver({

View File

@ -7,7 +7,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications";
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
@ -23,7 +23,7 @@ export function ATXPowerControl() {
> | 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") {
setAtxState(resp.params as ATXState);
}
@ -31,7 +31,7 @@ export function ATXPowerControl() {
// Request initial state
useEffect(() => {
send("getATXState", {}, resp => {
send("getATXState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
@ -54,7 +54,7 @@ export function ATXPowerControl() {
const timer = setTimeout(() => {
// Send long press 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) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
@ -75,7 +75,7 @@ export function ATXPowerControl() {
// Send short press 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) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
@ -127,7 +127,7 @@ export function ATXPowerControl() {
LeadingIcon={LuRotateCcw}
text="Reset"
onClick={() => {
send("setATXPowerAction", { action: "reset" }, resp => {
send("setATXPowerAction", { action: "reset" }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,

View File

@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import FieldLabel from "@components/FieldLabel";
import LoadingSpinner from "@components/LoadingSpinner";
@ -19,11 +19,11 @@ interface DCPowerState {
}
export function DCPowerControl() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [powerState, setPowerState] = useState<DCPowerState | null>(null);
const getDCPowerState = useCallback(() => {
send("getDCPowerState", {}, resp => {
send("getDCPowerState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`,
@ -35,7 +35,7 @@ export function DCPowerControl() {
}, [send]);
const handlePowerToggle = (enabled: boolean) => {
send("setDCPowerState", { enabled }, resp => {
send("setDCPowerState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
@ -47,7 +47,7 @@ export function DCPowerControl() {
};
const handleRestoreChange = (state: number) => {
// const state = powerState?.restoreState === 0 ? 1 : powerState?.restoreState === 1 ? 2 : 0;
send("setDCRestoreState", { state }, resp => {
send("setDCRestoreState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { useUiStore } from "@/hooks/stores";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
@ -17,7 +17,7 @@ interface SerialSettings {
}
export function SerialConsole() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [settings, setSettings] = useState<SerialSettings>({
baudRate: "9600",
dataBits: "8",
@ -26,7 +26,7 @@ export function SerialConsole() {
});
useEffect(() => {
send("getSerialSettings", {}, resp => {
send("getSerialSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.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 newSettings = { ...settings, [setting]: value };
send("setSerialSettings", { settings: newSettings }, resp => {
send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`,

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
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 { SettingsPageHeader } from "@components/SettingsPageheader";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
@ -39,12 +39,12 @@ const AVAILABLE_EXTENSIONS: Extension[] = [
];
export default function ExtensionPopover() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
// Load active extension on component mount
useEffect(() => {
send("getActiveExtension", {}, resp => {
send("getActiveExtension", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
const extensionId = resp.result as string;
if (extensionId) {
@ -57,7 +57,7 @@ export default function ExtensionPopover() {
}, [send]);
const handleSetActiveExtension = (extension: Extension | null) => {
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
send("setActiveExtension", { extensionId: extension?.id || "" }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set active extension: ${resp.error.data || "Unknown error"}`,

View File

@ -16,13 +16,13 @@ import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore();
@ -47,7 +47,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
}, [diskDataChannelStats]);
const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, response => {
send("getVirtualMediaState", {}, (response: JsonRpcResponse) => {
if ("error" in response) {
notifications.error(
`Failed to get virtual media state: ${response.error.message}`,
@ -59,7 +59,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
}, [send, setRemoteVirtualMediaState]);
const handleUnmount = () => {
send("unmountImage", {}, response => {
send("unmountImage", {}, (response: JsonRpcResponse) => {
if ("error" in response) {
notifications.error(`Failed to unmount image: ${response.error.message}`);
} else {

View File

@ -7,7 +7,7 @@ import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea";
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 { keys, modifiers } from "@/keyboardMappings";
import { KeyStroke, KeyboardLayout, selectedKeyboard } from "@/keyboardLayouts";
@ -28,7 +28,7 @@ export default function PasteModal() {
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]);
@ -47,7 +47,7 @@ export default function PasteModal() {
}, [keyboardLayout]);
useEffect(() => {
send("getKeyboardLayout", {}, resp => {
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setKeyboardLayout(resp.result as string);
});

View File

@ -3,7 +3,7 @@ import { useClose } from "@headlessui/react";
import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications";
@ -18,7 +18,7 @@ export default function WakeOnLanModal() {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
@ -33,7 +33,7 @@ export default function WakeOnLanModal() {
setErrorMessage(null);
if (rpcDataChannel?.readyState !== "open") return;
send("sendWOLMagicPacket", { macAddress }, resp => {
send("sendWOLMagicPacket", { macAddress }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
const isInvalid = resp.error.data?.includes("invalid MAC address");
if (isInvalid) {
@ -52,7 +52,7 @@ export default function WakeOnLanModal() {
);
const syncStoredDevices = useCallback(() => {
send("getWakeOnLanDevices", {}, resp => {
send("getWakeOnLanDevices", {}, (resp: JsonRpcResponse) => {
if ("result" in resp) {
setStoredDevices(resp.result as StoredDevice[]);
} else {
@ -70,7 +70,7 @@ export default function WakeOnLanModal() {
(index: number) => {
const updatedDevices = storedDevices.filter((_, i) => i !== index);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to update Wake-on-LAN devices:", resp.error);
} else {
@ -86,7 +86,7 @@ export default function WakeOnLanModal() {
if (!name || !macAddress) return;
const updatedDevices = [...storedDevices, { name, macAddress }];
console.log("updatedDevices", updatedDevices);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to add Wake-on-LAN device:", resp.error);
setAddDeviceErrorMessage("Failed to add device");

View File

@ -853,7 +853,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try {
await new Promise<void>((resolve, reject) => {
sendFn("getKeyboardMacros", {}, response => {
sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => {
if (response.error) {
console.error("Error loading macros:", response.error);
reject(new Error(response.error.message));

View File

@ -78,5 +78,5 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
};
}, [rpcDataChannel, onRequest]);
return [send];
return { send };
}

View File

@ -5,7 +5,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import { keys, modifiers } from "@/keyboardMappings";
export default function useKeyboard() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const updateActiveKeysAndModifiers = useHidStore(

View File

@ -27,7 +27,7 @@ import NetBootIcon from "@/assets/netboot-icon.svg";
import Fieldset from "@/components/Fieldset";
import { DEVICE_API } from "@/ui.config";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { isOnDevice } from "../main";
import { cx } from "../cva.config";
@ -64,10 +64,10 @@ export function Dialog({ onClose }: { onClose: () => void }) {
setRemoteVirtualMediaState(null);
}
const [send] = useJsonRpc();
const { send } = useJsonRpc();
async function syncRemoteVirtualMediaState() {
return new Promise((resolve, reject) => {
send("getVirtualMediaState", {}, resp => {
send("getVirtualMediaState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
reject(new Error(resp.error.message));
} else {
@ -89,7 +89,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
console.log(`Mounting ${url} as ${mode}`);
setMountInProgress(true);
send("mountWithHTTP", { url, mode }, async resp => {
send("mountWithHTTP", { url, mode }, async (resp: JsonRpcResponse) => {
if ("error" in resp) triggerError(resp.error.message);
clearMountMediaState();
@ -108,7 +108,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
console.log(`Mounting ${fileName} as ${mode}`);
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);
clearMountMediaState();
@ -526,8 +526,13 @@ function UrlView({
icon: UbuntuIcon,
},
{
name: "Debian 12",
url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.9.0-amd64-netinst.iso",
name: "Debian 13 Trixie",
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,
},
{
@ -684,7 +689,7 @@ function DeviceFileView({
const [currentPage, setCurrentPage] = useState(1);
const filesPerPage = 5;
const [send] = useJsonRpc();
const { send } = useJsonRpc();
interface StorageSpace {
bytesUsed: number;
@ -713,12 +718,12 @@ function DeviceFileView({
}, [storageSpace]);
const syncStorage = useCallback(() => {
send("listStorageFiles", {}, res => {
if ("error" in res) {
notifications.error(`Error listing storage files: ${res.error}`);
send("listStorageFiles", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Error listing storage files: ${resp.error}`);
return;
}
const { files } = res.result as StorageFiles;
const { files } = resp.result as StorageFiles;
const formattedFiles = files.map(file => ({
name: file.filename,
size: formatters.bytes(file.size),
@ -728,13 +733,13 @@ function DeviceFileView({
setOnStorageFiles(formattedFiles);
});
send("getStorageSpace", {}, res => {
if ("error" in res) {
notifications.error(`Error getting storage space: ${res.error}`);
send("getStorageSpace", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Error getting storage space: ${resp.error}`);
return;
}
const space = res.result as StorageSpace;
const space = resp.result as StorageSpace;
setStorageSpace(space);
});
}, [send, setOnStorageFiles, setStorageSpace]);
@ -757,9 +762,9 @@ function DeviceFileView({
function handleDeleteFile(file: { name: string; size: string; createdAt: string }) {
console.log("Deleting file:", file);
send("deleteStorageFile", { filename: file.name }, res => {
if ("error" in res) {
notifications.error(`Error deleting file: ${res.error}`);
send("deleteStorageFile", { filename: file.name }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Error deleting file: ${resp.error}`);
return;
}
@ -996,7 +1001,7 @@ function UploadFileView({
const [fileError, setFileError] = useState<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
useEffect(() => {
@ -1211,7 +1216,7 @@ function UploadFileView({
setUploadState("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);
if ("error" in resp) {
console.error("Upload error:", resp.error.message);

View File

@ -12,7 +12,7 @@ import { SettingsSectionHeader } from "@/components/SettingsSectionHeader";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications";
import { DEVICE_API } from "@/ui.config";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { isOnDevice } from "@/main";
import { TextAreaWithLabel } from "@components/TextArea";
@ -42,7 +42,7 @@ export default function SettingsAccessIndexRoute() {
const { navigateTo } = useDeviceUiNavigation();
const navigate = useNavigate();
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
@ -56,7 +56,7 @@ export default function SettingsAccessIndexRoute() {
const [tlsKey, setTlsKey] = useState<string>("");
const getCloudState = useCallback(() => {
send("getCloudState", {}, resp => {
send("getCloudState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return console.error(resp.error);
const cloudState = resp.result as CloudState;
setAdopted(cloudState.connected);
@ -77,7 +77,7 @@ export default function SettingsAccessIndexRoute() {
}, [send]);
const getTLSState = useCallback(() => {
send("getTLSState", {}, resp => {
send("getTLSState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return console.error(resp.error);
const tlsState = resp.result as TLSState;
@ -88,7 +88,7 @@ export default function SettingsAccessIndexRoute() {
}, [send]);
const deregisterDevice = async () => {
send("deregisterDevice", {}, resp => {
send("deregisterDevice", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
@ -110,7 +110,7 @@ export default function SettingsAccessIndexRoute() {
return;
}
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => {
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
@ -156,7 +156,7 @@ export default function SettingsAccessIndexRoute() {
state.privateKey = key;
}
send("setTLSState", { state }, resp => {
send("setTLSState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update TLS settings: ${resp.error.data || "Unknown error"}`,
@ -198,7 +198,7 @@ export default function SettingsAccessIndexRoute() {
getCloudState();
getTLSState();
send("getDeviceID", {}, async resp => {
send("getDeviceID", {}, async (resp: JsonRpcResponse) => {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});

View File

@ -8,14 +8,14 @@ import { ConfirmDialog } from "../components/ConfirmDialog";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { TextAreaWithLabel } from "../components/TextArea";
import { useSettingsStore } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import { isOnDevice } from "../main";
import notifications from "../notifications";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAdvancedRoute() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [sshKey, setSSHKey] = useState<string>("");
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
@ -27,35 +27,35 @@ export default function SettingsAdvancedRoute() {
const settings = useSettingsStore();
useEffect(() => {
send("getDevModeState", {}, resp => {
send("getDevModeState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
setDeveloperMode(result.enabled);
});
send("getSSHKeyState", {}, resp => {
send("getSSHKeyState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setSSHKey(resp.result as string);
});
send("getUsbEmulationState", {}, resp => {
send("getUsbEmulationState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
send("getDevChannelState", {}, resp => {
send("getDevChannelState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
send("getLocalLoopbackOnly", {}, resp => {
send("getLocalLoopbackOnly", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setLocalLoopbackOnly(resp.result as boolean);
});
}, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => {
send("getUsbEmulationState", {}, resp => {
send("getUsbEmulationState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
@ -63,7 +63,7 @@ export default function SettingsAdvancedRoute() {
const handleUsbEmulationToggle = useCallback(
(enabled: boolean) => {
send("setUsbEmulationState", { enabled: enabled }, resp => {
send("setUsbEmulationState", { enabled: enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
@ -78,7 +78,7 @@ export default function SettingsAdvancedRoute() {
);
const handleResetConfig = useCallback(() => {
send("resetConfig", {}, resp => {
send("resetConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
@ -90,7 +90,7 @@ export default function SettingsAdvancedRoute() {
}, [send]);
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, resp => {
send("setSSHKeyState", { sshKey }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
@ -103,7 +103,7 @@ export default function SettingsAdvancedRoute() {
const handleDevModeChange = useCallback(
(developerMode: boolean) => {
send("setDevModeState", { enabled: developerMode }, resp => {
send("setDevModeState", { enabled: developerMode }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
@ -118,7 +118,7 @@ export default function SettingsAdvancedRoute() {
const handleDevChannelChange = useCallback(
(enabled: boolean) => {
send("setDevChannelState", { enabled }, resp => {
send("setDevChannelState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
@ -133,7 +133,7 @@ export default function SettingsAdvancedRoute() {
const applyLoopbackOnlyMode = useCallback(
(enabled: boolean) => {
send("setLocalLoopbackOnly", { enabled }, resp => {
send("setLocalLoopbackOnly", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`,

View File

@ -1,7 +1,7 @@
import { useState , useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { Button } from "../components/Button";
@ -13,7 +13,7 @@ import { useDeviceStore } from "../hooks/stores";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsGeneralRoute() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [autoUpdate, setAutoUpdate] = useState(true);
@ -24,14 +24,14 @@ export default function SettingsGeneralRoute() {
});
useEffect(() => {
send("getAutoUpdateState", {}, resp => {
send("getAutoUpdateState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setAutoUpdate(resp.result as boolean);
});
}, [send]);
const handleAutoUpdateChange = (enabled: boolean) => {
send("setAutoUpdateState", { enabled }, resp => {
send("setAutoUpdateState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,

View File

@ -6,7 +6,7 @@ import { Button } from "@components/Button";
export default function SettingsGeneralRebootRoute() {
const navigate = useNavigate();
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
// This is where we send the RPC to the golang binary

View File

@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
@ -16,7 +16,7 @@ export default function SettingsGeneralUpdateRoute() {
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
@ -134,14 +134,14 @@ function LoadingState({
}) {
const [progressWidth, setProgressWidth] = useState("0%");
const abortControllerRef = useRef<AbortController | null>(null);
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const setAppVersion = useDeviceStore(state => state.setAppVersion);
const setSystemVersion = useDeviceStore(state => state.setSystemVersion);
const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, async resp => {
send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to check for updates: ${resp.error}`);
reject(new Error("Failed to check for updates"));

View File

@ -3,7 +3,7 @@ import { useEffect } from "react";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
@ -12,7 +12,7 @@ import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsHardwareRoute() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const settings = useSettingsStore();
const setDisplayRotation = useSettingsStore(state => state.setDisplayRotation);
@ -23,7 +23,7 @@ export default function SettingsHardwareRoute() {
};
const handleDisplayRotationSave = () => {
send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, resp => {
send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set display orientation: ${resp.error.data || "Unknown error"}`,
@ -48,7 +48,7 @@ export default function SettingsHardwareRoute() {
};
const handleBacklightSettingsSave = () => {
send("setBacklightSettings", { params: settings.backlightSettings }, resp => {
send("setBacklightSettings", { params: settings.backlightSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
@ -60,7 +60,7 @@ export default function SettingsHardwareRoute() {
};
useEffect(() => {
send("getBacklightSettings", {}, resp => {
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
return notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo } from "react";
import { KeyboardLedSync, 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 { keyboardOptions } from "@/keyboardLayouts";
@ -39,10 +39,10 @@ export default function SettingsKeyboardRoute() {
{ value: "host", label: "Host Only" },
];
const [send] = useJsonRpc();
const { send } = useJsonRpc();
useEffect(() => {
send("getKeyboardLayout", {}, resp => {
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setKeyboardLayout(resp.result as string);
});
@ -51,7 +51,7 @@ export default function SettingsKeyboardRoute() {
const onKeyboardLayoutChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const layout = e.target.value;
send("setKeyboardLayout", { layout }, resp => {
send("setKeyboardLayout", { layout }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set keyboard layout: ${resp.error.data || "Unknown error"}`,

View File

@ -1,21 +1,68 @@
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 PointingFinger from "@/assets/pointing-finger.svg";
import { GridCard } from "@/components/Card";
import { Checkbox } from "@/components/Checkbox";
import { useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { JigglerSetting } from "@components/JigglerSetting";
import { useFeatureFlag } from "../hooks/useFeatureFlag";
import { cx } from "../cva.config";
import notifications from "../notifications";
import SettingsNestedSection from "../components/SettingsNestedSection";
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() {
const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
@ -23,13 +70,13 @@ export default function SettingsMouseRoute() {
const mouseMode = useSettingsStore(state => state.mouseMode);
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 setScrollThrottling = useSettingsStore(
state => state.setScrollThrottling,
const setScrollThrottling = useSettingsStore(state => state.setScrollThrottling);
const [selectedJigglerOption, setSelectedJigglerOption] =
useState<JigglerValues | null>(null);
const [currentJigglerConfig, setCurrentJigglerConfig] = useState<JigglerConfig | null>(
null,
);
const scrollThrottlingOptions = [
@ -40,25 +87,100 @@ export default function SettingsMouseRoute() {
{ 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(() => {
send("getJigglerState", {}, resp => {
if ("error" in resp) return;
setJiggler(resp.result as boolean);
});
}, [isScrollSensitivityEnabled, send]);
syncJigglerSettings();
}, [syncJigglerSettings]);
const handleJigglerChange = (enabled: boolean) => {
send("setJigglerState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setJiggler(enabled);
});
const saveJigglerConfig = useCallback(
(jigglerConfig: JigglerConfig) => {
// We assume the jiggler should be set to enabled if the config is being updated
send("setJigglerState", { enabled: true }, async (resp: JsonRpcResponse) => {
if ("error" in resp) {
return notifications.error(
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
);
}
});
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 (
@ -79,30 +201,49 @@ export default function SettingsMouseRoute() {
/>
</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
title="Jiggler"
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
title="Scroll Throttling"
description="Reduce the frequency of scroll events"
>
<Checkbox
checked={jiggler}
onChange={e => handleJigglerChange(e.target.checked)}
<SelectMenuBasic
size="SM"
label=""
className="max-w-[292px]"
value={scrollThrottling}
fullWidth
onChange={e => setScrollThrottling(parseInt(e.target.value))}
options={scrollThrottlingOptions}
/>
</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">
<SettingsItem title="Modes" description="Choose the mouse input mode" />
<div className="flex items-center gap-4">

View File

@ -13,7 +13,7 @@ import {
TimeSyncMode,
useNetworkStateStore,
} from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import InputField, { InputFieldWithLabel } from "@components/InputField";
@ -72,7 +72,7 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
}
export default function SettingsNetworkRoute() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [networkState, setNetworkState] = useNetworkStateStore(state => [
state,
state.setNetworkState,
@ -104,7 +104,7 @@ export default function SettingsNetworkRoute() {
const getNetworkSettings = useCallback(() => {
setNetworkSettingsLoaded(false);
send("getNetworkSettings", {}, resp => {
send("getNetworkSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
console.log(resp.result);
setNetworkSettings(resp.result as NetworkSettings);
@ -117,7 +117,7 @@ export default function SettingsNetworkRoute() {
}, [send]);
const getNetworkState = useCallback(() => {
send("getNetworkState", {}, resp => {
send("getNetworkState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
console.log(resp.result);
setNetworkState(resp.result as NetworkState);
@ -127,7 +127,7 @@ export default function SettingsNetworkRoute() {
const setNetworkSettingsRemote = useCallback(
(settings: NetworkSettings) => {
setNetworkSettingsLoaded(false);
send("setNetworkSettings", { settings }, resp => {
send("setNetworkSettings", { settings }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
"Failed to save network settings: " +
@ -148,7 +148,7 @@ export default function SettingsNetworkRoute() {
);
const handleRenewLease = useCallback(() => {
send("renewDHCPLease", {}, resp => {
send("renewDHCPLease", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error("Failed to renew lease: " + resp.error.message);
} else {

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { Button } from "@/components/Button";
import { TextAreaWithLabel } from "@/components/TextArea";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useSettingsStore } from "@/hooks/stores";
@ -41,7 +41,7 @@ const streamQualityOptions = [
];
export default function SettingsVideoRoute() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = 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);
useEffect(() => {
send("getStreamQualityFactor", {}, resp => {
send("getStreamQualityFactor", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));
});
send("getEDID", {}, resp => {
send("getEDID", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
return;
@ -85,7 +85,7 @@ export default function SettingsVideoRoute() {
}, [send]);
const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
send("setStreamQualityFactor", { factor: Number(factor) }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
@ -99,7 +99,7 @@ export default function SettingsVideoRoute() {
};
const handleEDIDChange = (newEdid: string) => {
send("setEDID", { edid: newEdid }, resp => {
send("setEDID", { edid: newEdid }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
return;

View File

@ -39,7 +39,7 @@ import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
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 { CLOUD_API, DEVICE_API } from "@/ui.config";
@ -653,11 +653,11 @@ export default function KvmIdRoute() {
}
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc(onJsonRpcRequest);
const { send } = useJsonRpc(onJsonRpcRequest);
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
send("getVideoState", {}, resp => {
send("getVideoState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
});
@ -669,7 +669,7 @@ export default function KvmIdRoute() {
if (keyboardLedState !== undefined) return;
console.log("Requesting keyboard led state");
send("getKeyboardLedState", {}, resp => {
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
// -32601 means the method is not supported
if (resp.error.code === -32601) {
@ -742,7 +742,7 @@ export default function KvmIdRoute() {
useEffect(() => {
if (appVersion) return;
send("getUpdateStatus", {}, async resp => {
send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to get device version: ${resp.error}`);
return

3
web.go
View File

@ -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) {
if currentSession == nil || currentSession.AudioInputManager == nil {
c.JSON(200, gin.H{

View File

@ -29,14 +29,13 @@ type Session struct {
DiskChannel *webrtc.DataChannel
AudioInputManager *audio.AudioInputManager
shouldUmountVirtualMedia bool
// Microphone operation throttling
micCooldown time.Duration
// Audio frame processing
audioFrameChan chan []byte
audioStopChan chan struct{}
audioWg sync.WaitGroup
rpcQueue chan webrtc.DataChannelMessage
}
type SessionConfig struct {
@ -118,6 +117,7 @@ func newSession(config SessionConfig) (*Session, error) {
if err != nil {
return nil, err
}
session := &Session{
peerConnection: peerConnection,
AudioInputManager: audio.NewAudioInputManager(),
@ -129,13 +129,21 @@ func newSession(config SessionConfig) (*Session, error) {
// Start audio processing goroutine
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) {
scopedLogger.Info().Str("label", d.Label()).Uint16("id", *d.ID()).Msg("New DataChannel")
switch d.Label() {
case "rpc":
session.RPCChannel = d
d.OnMessage(func(msg webrtc.DataChannelMessage) {
go onRPCMessage(msg, session)
// Enqueue to ensure ordered processing
session.rpcQueue <- msg
})
triggerOTAStateUpdate()
triggerVideoStateUpdate()
@ -259,6 +267,11 @@ func newSession(config SessionConfig) (*Session, error) {
if session == currentSession {
currentSession = nil
}
// Stop RPC processor
if session.rpcQueue != nil {
close(session.rpcQueue)
session.rpcQueue = nil
}
if session.shouldUmountVirtualMedia {
err := rpcUnmountImage()
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")