mirror of https://github.com/jetkvm/kvm.git
Compare commits
21 Commits
88679cda2f
...
44a35aa5c2
Author | SHA1 | Date |
---|---|---|
|
44a35aa5c2 | |
|
3a28105f56 | |
|
a9a1082bcc | |
|
e0f7b1d930 | |
|
5188717bb9 | |
|
70e49a1cac | |
|
9d40263eed | |
|
0651faeceb | |
|
199cca83ed | |
|
a3b2b46f49 | |
|
f729675a3f | |
|
785a68d923 | |
|
57b7bafcc1 | |
|
d952480c2a | |
|
8e27cd6b60 | |
|
bb87fb5a1a | |
|
8527b1eff1 | |
|
9f573200b1 | |
|
608f69db13 | |
|
f7b8efde7c | |
|
33ac9fe0b6 |
|
@ -26,7 +26,7 @@ Welcome to JetKVM development! This guide will help you get started quickly, whe
|
|||
- **[Git](https://git-scm.com/downloads)** for version control
|
||||
- **[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"
|
||||
|
|
2
Makefile
2
Makefile
|
@ -34,7 +34,7 @@ OPTIM_CFLAGS := -O3 -mcpu=cortex-a7 -mfpu=neon -mfloat-abi=hard -ftree-vectorize
|
|||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||
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 \
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
10
config.go
10
config.go
|
@ -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
9
go.mod
|
@ -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
20
go.sum
|
@ -38,6 +38,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-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=
|
||||
|
|
|
@ -0,0 +1,338 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// AdaptiveBufferConfig holds configuration for adaptive buffer sizing
|
||||
type AdaptiveBufferConfig struct {
|
||||
// Buffer size limits (in frames)
|
||||
MinBufferSize int
|
||||
MaxBufferSize int
|
||||
DefaultBufferSize int
|
||||
|
||||
// System load thresholds
|
||||
LowCPUThreshold float64 // Below this, increase buffer size
|
||||
HighCPUThreshold float64 // Above this, decrease buffer size
|
||||
LowMemoryThreshold float64 // Below this, increase buffer size
|
||||
HighMemoryThreshold float64 // Above this, decrease buffer size
|
||||
|
||||
// Latency thresholds (in milliseconds)
|
||||
TargetLatency time.Duration
|
||||
MaxLatency time.Duration
|
||||
|
||||
// Adaptation parameters
|
||||
AdaptationInterval time.Duration
|
||||
SmoothingFactor float64 // 0.0-1.0, higher = more responsive
|
||||
}
|
||||
|
||||
// DefaultAdaptiveBufferConfig returns optimized config for JetKVM hardware
|
||||
func DefaultAdaptiveBufferConfig() AdaptiveBufferConfig {
|
||||
return AdaptiveBufferConfig{
|
||||
// Conservative buffer sizes for 256MB RAM constraint
|
||||
MinBufferSize: 3, // Minimum 3 frames (slightly higher for stability)
|
||||
MaxBufferSize: 20, // Maximum 20 frames (increased for high load scenarios)
|
||||
DefaultBufferSize: 6, // Default 6 frames (increased for better stability)
|
||||
|
||||
// CPU thresholds optimized for single-core ARM Cortex A7 under load
|
||||
LowCPUThreshold: 20.0, // Below 20% CPU
|
||||
HighCPUThreshold: 60.0, // Above 60% CPU (lowered to be more responsive)
|
||||
|
||||
// Memory thresholds for 256MB total RAM
|
||||
LowMemoryThreshold: 35.0, // Below 35% memory usage
|
||||
HighMemoryThreshold: 75.0, // Above 75% memory usage (lowered for earlier response)
|
||||
|
||||
// Latency targets
|
||||
TargetLatency: 20 * time.Millisecond, // Target 20ms latency
|
||||
MaxLatency: 50 * time.Millisecond, // Max acceptable 50ms
|
||||
|
||||
// Adaptation settings
|
||||
AdaptationInterval: 500 * time.Millisecond, // Check every 500ms
|
||||
SmoothingFactor: 0.3, // Moderate responsiveness
|
||||
}
|
||||
}
|
||||
|
||||
// AdaptiveBufferManager manages dynamic buffer sizing based on system conditions
|
||||
type AdaptiveBufferManager struct {
|
||||
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||
currentInputBufferSize int64 // Current input buffer size (atomic)
|
||||
currentOutputBufferSize int64 // Current output buffer size (atomic)
|
||||
averageLatency int64 // Average latency in nanoseconds (atomic)
|
||||
systemCPUPercent int64 // System CPU percentage * 100 (atomic)
|
||||
systemMemoryPercent int64 // System memory percentage * 100 (atomic)
|
||||
adaptationCount int64 // Metrics tracking (atomic)
|
||||
|
||||
config AdaptiveBufferConfig
|
||||
logger zerolog.Logger
|
||||
processMonitor *ProcessMonitor
|
||||
|
||||
// Control channels
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Metrics tracking
|
||||
lastAdaptation time.Time
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAdaptiveBufferManager creates a new adaptive buffer manager
|
||||
func NewAdaptiveBufferManager(config AdaptiveBufferConfig) *AdaptiveBufferManager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &AdaptiveBufferManager{
|
||||
currentInputBufferSize: int64(config.DefaultBufferSize),
|
||||
currentOutputBufferSize: int64(config.DefaultBufferSize),
|
||||
config: config,
|
||||
logger: logging.GetDefaultLogger().With().Str("component", "adaptive-buffer").Logger(),
|
||||
processMonitor: GetProcessMonitor(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
lastAdaptation: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the adaptive buffer management
|
||||
func (abm *AdaptiveBufferManager) Start() {
|
||||
abm.wg.Add(1)
|
||||
go abm.adaptationLoop()
|
||||
abm.logger.Info().Msg("Adaptive buffer manager started")
|
||||
}
|
||||
|
||||
// Stop stops the adaptive buffer management
|
||||
func (abm *AdaptiveBufferManager) Stop() {
|
||||
abm.cancel()
|
||||
abm.wg.Wait()
|
||||
abm.logger.Info().Msg("Adaptive buffer manager stopped")
|
||||
}
|
||||
|
||||
// GetInputBufferSize returns the current recommended input buffer size
|
||||
func (abm *AdaptiveBufferManager) GetInputBufferSize() int {
|
||||
return int(atomic.LoadInt64(&abm.currentInputBufferSize))
|
||||
}
|
||||
|
||||
// GetOutputBufferSize returns the current recommended output buffer size
|
||||
func (abm *AdaptiveBufferManager) GetOutputBufferSize() int {
|
||||
return int(atomic.LoadInt64(&abm.currentOutputBufferSize))
|
||||
}
|
||||
|
||||
// UpdateLatency updates the current latency measurement
|
||||
func (abm *AdaptiveBufferManager) UpdateLatency(latency time.Duration) {
|
||||
// Use exponential moving average for latency
|
||||
currentAvg := atomic.LoadInt64(&abm.averageLatency)
|
||||
newLatency := latency.Nanoseconds()
|
||||
|
||||
if currentAvg == 0 {
|
||||
atomic.StoreInt64(&abm.averageLatency, newLatency)
|
||||
} else {
|
||||
// Exponential moving average: 70% historical, 30% current
|
||||
newAvg := int64(float64(currentAvg)*0.7 + float64(newLatency)*0.3)
|
||||
atomic.StoreInt64(&abm.averageLatency, newAvg)
|
||||
}
|
||||
}
|
||||
|
||||
// adaptationLoop is the main loop that adjusts buffer sizes
|
||||
func (abm *AdaptiveBufferManager) adaptationLoop() {
|
||||
defer abm.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(abm.config.AdaptationInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-abm.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
abm.adaptBufferSizes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// adaptBufferSizes analyzes system conditions and adjusts buffer sizes
|
||||
func (abm *AdaptiveBufferManager) adaptBufferSizes() {
|
||||
// Collect current system metrics
|
||||
metrics := abm.processMonitor.GetCurrentMetrics()
|
||||
if len(metrics) == 0 {
|
||||
return // No metrics available
|
||||
}
|
||||
|
||||
// Calculate system-wide CPU and memory usage
|
||||
totalCPU := 0.0
|
||||
totalMemory := 0.0
|
||||
processCount := 0
|
||||
|
||||
for _, metric := range metrics {
|
||||
totalCPU += metric.CPUPercent
|
||||
totalMemory += metric.MemoryPercent
|
||||
processCount++
|
||||
}
|
||||
|
||||
if processCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Store system metrics atomically
|
||||
systemCPU := totalCPU // Total CPU across all monitored processes
|
||||
systemMemory := totalMemory / float64(processCount) // Average memory usage
|
||||
|
||||
atomic.StoreInt64(&abm.systemCPUPercent, int64(systemCPU*100))
|
||||
atomic.StoreInt64(&abm.systemMemoryPercent, int64(systemMemory*100))
|
||||
|
||||
// Get current latency
|
||||
currentLatencyNs := atomic.LoadInt64(&abm.averageLatency)
|
||||
currentLatency := time.Duration(currentLatencyNs)
|
||||
|
||||
// Calculate adaptation factors
|
||||
cpuFactor := abm.calculateCPUFactor(systemCPU)
|
||||
memoryFactor := abm.calculateMemoryFactor(systemMemory)
|
||||
latencyFactor := abm.calculateLatencyFactor(currentLatency)
|
||||
|
||||
// Combine factors with weights (CPU has highest priority for KVM coexistence)
|
||||
combinedFactor := 0.5*cpuFactor + 0.3*memoryFactor + 0.2*latencyFactor
|
||||
|
||||
// Apply adaptation with smoothing
|
||||
currentInput := float64(atomic.LoadInt64(&abm.currentInputBufferSize))
|
||||
currentOutput := float64(atomic.LoadInt64(&abm.currentOutputBufferSize))
|
||||
|
||||
// Calculate new buffer sizes
|
||||
newInputSize := abm.applyAdaptation(currentInput, combinedFactor)
|
||||
newOutputSize := abm.applyAdaptation(currentOutput, combinedFactor)
|
||||
|
||||
// Update buffer sizes if they changed significantly
|
||||
adjustmentMade := false
|
||||
if math.Abs(newInputSize-currentInput) >= 0.5 || math.Abs(newOutputSize-currentOutput) >= 0.5 {
|
||||
atomic.StoreInt64(&abm.currentInputBufferSize, int64(math.Round(newInputSize)))
|
||||
atomic.StoreInt64(&abm.currentOutputBufferSize, int64(math.Round(newOutputSize)))
|
||||
|
||||
atomic.AddInt64(&abm.adaptationCount, 1)
|
||||
abm.mutex.Lock()
|
||||
abm.lastAdaptation = time.Now()
|
||||
abm.mutex.Unlock()
|
||||
adjustmentMade = true
|
||||
|
||||
abm.logger.Debug().
|
||||
Float64("cpu_percent", systemCPU).
|
||||
Float64("memory_percent", systemMemory).
|
||||
Dur("latency", currentLatency).
|
||||
Float64("combined_factor", combinedFactor).
|
||||
Int("new_input_size", int(newInputSize)).
|
||||
Int("new_output_size", int(newOutputSize)).
|
||||
Msg("Adapted buffer sizes")
|
||||
}
|
||||
|
||||
// Update metrics with current state
|
||||
currentInputSize := int(atomic.LoadInt64(&abm.currentInputBufferSize))
|
||||
currentOutputSize := int(atomic.LoadInt64(&abm.currentOutputBufferSize))
|
||||
UpdateAdaptiveBufferMetrics(currentInputSize, currentOutputSize, systemCPU, systemMemory, adjustmentMade)
|
||||
}
|
||||
|
||||
// calculateCPUFactor returns adaptation factor based on CPU usage
|
||||
// Returns: -1.0 (decrease buffers) to +1.0 (increase buffers)
|
||||
func (abm *AdaptiveBufferManager) calculateCPUFactor(cpuPercent float64) float64 {
|
||||
if cpuPercent > abm.config.HighCPUThreshold {
|
||||
// High CPU: decrease buffers to reduce latency and give CPU to KVM
|
||||
return -1.0
|
||||
} else if cpuPercent < abm.config.LowCPUThreshold {
|
||||
// Low CPU: increase buffers for better quality
|
||||
return 1.0
|
||||
}
|
||||
// Medium CPU: linear interpolation
|
||||
midpoint := (abm.config.HighCPUThreshold + abm.config.LowCPUThreshold) / 2
|
||||
return (midpoint - cpuPercent) / (midpoint - abm.config.LowCPUThreshold)
|
||||
}
|
||||
|
||||
// calculateMemoryFactor returns adaptation factor based on memory usage
|
||||
func (abm *AdaptiveBufferManager) calculateMemoryFactor(memoryPercent float64) float64 {
|
||||
if memoryPercent > abm.config.HighMemoryThreshold {
|
||||
// High memory: decrease buffers to free memory
|
||||
return -1.0
|
||||
} else if memoryPercent < abm.config.LowMemoryThreshold {
|
||||
// Low memory: increase buffers for better performance
|
||||
return 1.0
|
||||
}
|
||||
// Medium memory: linear interpolation
|
||||
midpoint := (abm.config.HighMemoryThreshold + abm.config.LowMemoryThreshold) / 2
|
||||
return (midpoint - memoryPercent) / (midpoint - abm.config.LowMemoryThreshold)
|
||||
}
|
||||
|
||||
// calculateLatencyFactor returns adaptation factor based on latency
|
||||
func (abm *AdaptiveBufferManager) calculateLatencyFactor(latency time.Duration) float64 {
|
||||
if latency > abm.config.MaxLatency {
|
||||
// High latency: decrease buffers
|
||||
return -1.0
|
||||
} else if latency < abm.config.TargetLatency {
|
||||
// Low latency: can increase buffers
|
||||
return 1.0
|
||||
}
|
||||
// Medium latency: linear interpolation
|
||||
midLatency := (abm.config.MaxLatency + abm.config.TargetLatency) / 2
|
||||
return float64(midLatency-latency) / float64(midLatency-abm.config.TargetLatency)
|
||||
}
|
||||
|
||||
// applyAdaptation applies the adaptation factor to current buffer size
|
||||
func (abm *AdaptiveBufferManager) applyAdaptation(currentSize, factor float64) float64 {
|
||||
// Calculate target size based on factor
|
||||
var targetSize float64
|
||||
if factor > 0 {
|
||||
// Increase towards max
|
||||
targetSize = currentSize + factor*(float64(abm.config.MaxBufferSize)-currentSize)
|
||||
} else {
|
||||
// Decrease towards min
|
||||
targetSize = currentSize + factor*(currentSize-float64(abm.config.MinBufferSize))
|
||||
}
|
||||
|
||||
// Apply smoothing
|
||||
newSize := currentSize + abm.config.SmoothingFactor*(targetSize-currentSize)
|
||||
|
||||
// Clamp to valid range
|
||||
return math.Max(float64(abm.config.MinBufferSize),
|
||||
math.Min(float64(abm.config.MaxBufferSize), newSize))
|
||||
}
|
||||
|
||||
// GetStats returns current adaptation statistics
|
||||
func (abm *AdaptiveBufferManager) GetStats() map[string]interface{} {
|
||||
abm.mutex.RLock()
|
||||
lastAdaptation := abm.lastAdaptation
|
||||
abm.mutex.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"input_buffer_size": abm.GetInputBufferSize(),
|
||||
"output_buffer_size": abm.GetOutputBufferSize(),
|
||||
"average_latency_ms": float64(atomic.LoadInt64(&abm.averageLatency)) / 1e6,
|
||||
"system_cpu_percent": float64(atomic.LoadInt64(&abm.systemCPUPercent)) / 100,
|
||||
"system_memory_percent": float64(atomic.LoadInt64(&abm.systemMemoryPercent)) / 100,
|
||||
"adaptation_count": atomic.LoadInt64(&abm.adaptationCount),
|
||||
"last_adaptation": lastAdaptation,
|
||||
}
|
||||
}
|
||||
|
||||
// Global adaptive buffer manager instance
|
||||
var globalAdaptiveBufferManager *AdaptiveBufferManager
|
||||
var adaptiveBufferOnce sync.Once
|
||||
|
||||
// GetAdaptiveBufferManager returns the global adaptive buffer manager instance
|
||||
func GetAdaptiveBufferManager() *AdaptiveBufferManager {
|
||||
adaptiveBufferOnce.Do(func() {
|
||||
globalAdaptiveBufferManager = NewAdaptiveBufferManager(DefaultAdaptiveBufferConfig())
|
||||
})
|
||||
return globalAdaptiveBufferManager
|
||||
}
|
||||
|
||||
// StartAdaptiveBuffering starts the global adaptive buffer manager
|
||||
func StartAdaptiveBuffering() {
|
||||
GetAdaptiveBufferManager().Start()
|
||||
}
|
||||
|
||||
// StopAdaptiveBuffering stops the global adaptive buffer manager
|
||||
func StopAdaptiveBuffering() {
|
||||
if globalAdaptiveBufferManager != nil {
|
||||
globalAdaptiveBufferManager.Stop()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// AdaptiveOptimizer automatically adjusts audio parameters based on latency metrics
|
||||
type AdaptiveOptimizer struct {
|
||||
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||
optimizationCount int64 // Number of optimizations performed (atomic)
|
||||
lastOptimization int64 // Timestamp of last optimization (atomic)
|
||||
optimizationLevel int64 // Current optimization level (0-10) (atomic)
|
||||
|
||||
latencyMonitor *LatencyMonitor
|
||||
bufferManager *AdaptiveBufferManager
|
||||
logger zerolog.Logger
|
||||
|
||||
// Control channels
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Configuration
|
||||
config OptimizerConfig
|
||||
}
|
||||
|
||||
// OptimizerConfig holds configuration for the adaptive optimizer
|
||||
type OptimizerConfig struct {
|
||||
MaxOptimizationLevel int // Maximum optimization level (0-10)
|
||||
CooldownPeriod time.Duration // Minimum time between optimizations
|
||||
Aggressiveness float64 // How aggressively to optimize (0.0-1.0)
|
||||
RollbackThreshold time.Duration // Latency threshold to rollback optimizations
|
||||
StabilityPeriod time.Duration // Time to wait for stability after optimization
|
||||
}
|
||||
|
||||
// DefaultOptimizerConfig returns a sensible default configuration
|
||||
func DefaultOptimizerConfig() OptimizerConfig {
|
||||
return OptimizerConfig{
|
||||
MaxOptimizationLevel: 8,
|
||||
CooldownPeriod: 30 * time.Second,
|
||||
Aggressiveness: 0.7,
|
||||
RollbackThreshold: 300 * time.Millisecond,
|
||||
StabilityPeriod: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdaptiveOptimizer creates a new adaptive optimizer
|
||||
func NewAdaptiveOptimizer(latencyMonitor *LatencyMonitor, bufferManager *AdaptiveBufferManager, config OptimizerConfig, logger zerolog.Logger) *AdaptiveOptimizer {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
optimizer := &AdaptiveOptimizer{
|
||||
latencyMonitor: latencyMonitor,
|
||||
bufferManager: bufferManager,
|
||||
config: config,
|
||||
logger: logger.With().Str("component", "adaptive-optimizer").Logger(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Register as latency monitor callback
|
||||
latencyMonitor.AddOptimizationCallback(optimizer.handleLatencyOptimization)
|
||||
|
||||
return optimizer
|
||||
}
|
||||
|
||||
// Start begins the adaptive optimization process
|
||||
func (ao *AdaptiveOptimizer) Start() {
|
||||
ao.wg.Add(1)
|
||||
go ao.optimizationLoop()
|
||||
ao.logger.Info().Msg("Adaptive optimizer started")
|
||||
}
|
||||
|
||||
// Stop stops the adaptive optimizer
|
||||
func (ao *AdaptiveOptimizer) Stop() {
|
||||
ao.cancel()
|
||||
ao.wg.Wait()
|
||||
ao.logger.Info().Msg("Adaptive optimizer stopped")
|
||||
}
|
||||
|
||||
// initializeStrategies sets up the available optimization strategies
|
||||
|
||||
// handleLatencyOptimization is called when latency optimization is needed
|
||||
func (ao *AdaptiveOptimizer) handleLatencyOptimization(metrics LatencyMetrics) error {
|
||||
currentLevel := atomic.LoadInt64(&ao.optimizationLevel)
|
||||
lastOpt := atomic.LoadInt64(&ao.lastOptimization)
|
||||
|
||||
// Check cooldown period
|
||||
if time.Since(time.Unix(0, lastOpt)) < ao.config.CooldownPeriod {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine if we need to increase or decrease optimization level
|
||||
targetLevel := ao.calculateTargetOptimizationLevel(metrics)
|
||||
|
||||
if targetLevel > currentLevel {
|
||||
return ao.increaseOptimization(int(targetLevel))
|
||||
} else if targetLevel < currentLevel {
|
||||
return ao.decreaseOptimization(int(targetLevel))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateTargetOptimizationLevel determines the appropriate optimization level
|
||||
func (ao *AdaptiveOptimizer) calculateTargetOptimizationLevel(metrics LatencyMetrics) int64 {
|
||||
// Base calculation on current latency vs target
|
||||
latencyRatio := float64(metrics.Current) / float64(50*time.Millisecond) // 50ms target
|
||||
|
||||
// Adjust based on trend
|
||||
switch metrics.Trend {
|
||||
case LatencyTrendIncreasing:
|
||||
latencyRatio *= 1.2 // Be more aggressive
|
||||
case LatencyTrendDecreasing:
|
||||
latencyRatio *= 0.8 // Be less aggressive
|
||||
case LatencyTrendVolatile:
|
||||
latencyRatio *= 1.1 // Slightly more aggressive
|
||||
}
|
||||
|
||||
// Apply aggressiveness factor
|
||||
latencyRatio *= ao.config.Aggressiveness
|
||||
|
||||
// Convert to optimization level
|
||||
targetLevel := int64(latencyRatio * 2) // Scale to 0-10 range
|
||||
if targetLevel > int64(ao.config.MaxOptimizationLevel) {
|
||||
targetLevel = int64(ao.config.MaxOptimizationLevel)
|
||||
}
|
||||
if targetLevel < 0 {
|
||||
targetLevel = 0
|
||||
}
|
||||
|
||||
return targetLevel
|
||||
}
|
||||
|
||||
// increaseOptimization applies optimization strategies up to the target level
|
||||
func (ao *AdaptiveOptimizer) increaseOptimization(targetLevel int) error {
|
||||
atomic.StoreInt64(&ao.optimizationLevel, int64(targetLevel))
|
||||
atomic.StoreInt64(&ao.lastOptimization, time.Now().UnixNano())
|
||||
atomic.AddInt64(&ao.optimizationCount, 1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decreaseOptimization rolls back optimization strategies to the target level
|
||||
func (ao *AdaptiveOptimizer) decreaseOptimization(targetLevel int) error {
|
||||
atomic.StoreInt64(&ao.optimizationLevel, int64(targetLevel))
|
||||
atomic.StoreInt64(&ao.lastOptimization, time.Now().UnixNano())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// optimizationLoop runs the main optimization monitoring loop
|
||||
func (ao *AdaptiveOptimizer) optimizationLoop() {
|
||||
defer ao.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(ao.config.StabilityPeriod)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ao.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
ao.checkStability()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkStability monitors system stability and rolls back if needed
|
||||
func (ao *AdaptiveOptimizer) checkStability() {
|
||||
metrics := ao.latencyMonitor.GetMetrics()
|
||||
|
||||
// Check if we need to rollback due to excessive latency
|
||||
if metrics.Current > ao.config.RollbackThreshold {
|
||||
currentLevel := int(atomic.LoadInt64(&ao.optimizationLevel))
|
||||
if currentLevel > 0 {
|
||||
ao.logger.Warn().Dur("current_latency", metrics.Current).Dur("threshold", ao.config.RollbackThreshold).Msg("Rolling back optimizations due to excessive latency")
|
||||
if err := ao.decreaseOptimization(currentLevel - 1); err != nil {
|
||||
ao.logger.Error().Err(err).Msg("Failed to decrease optimization level")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetOptimizationStats returns current optimization statistics
|
||||
func (ao *AdaptiveOptimizer) GetOptimizationStats() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"optimization_level": atomic.LoadInt64(&ao.optimizationLevel),
|
||||
"optimization_count": atomic.LoadInt64(&ao.optimizationCount),
|
||||
"last_optimization": time.Unix(0, atomic.LoadInt64(&ao.lastOptimization)),
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy implementation methods (stubs for now)
|
|
@ -199,7 +199,16 @@ func (bap *BatchAudioProcessor) processBatchRead(batch []batchReadRequest) {
|
|||
start := time.Now()
|
||||
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)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,312 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// LatencyMonitor tracks and optimizes audio latency in real-time
|
||||
type LatencyMonitor struct {
|
||||
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||
currentLatency int64 // Current latency in nanoseconds (atomic)
|
||||
averageLatency int64 // Rolling average latency in nanoseconds (atomic)
|
||||
minLatency int64 // Minimum observed latency in nanoseconds (atomic)
|
||||
maxLatency int64 // Maximum observed latency in nanoseconds (atomic)
|
||||
latencySamples int64 // Number of latency samples collected (atomic)
|
||||
jitterAccumulator int64 // Accumulated jitter for variance calculation (atomic)
|
||||
lastOptimization int64 // Timestamp of last optimization in nanoseconds (atomic)
|
||||
|
||||
config LatencyConfig
|
||||
logger zerolog.Logger
|
||||
|
||||
// Control channels
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Optimization callbacks
|
||||
optimizationCallbacks []OptimizationCallback
|
||||
mutex sync.RWMutex
|
||||
|
||||
// Performance tracking
|
||||
latencyHistory []LatencyMeasurement
|
||||
historyMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// LatencyConfig holds configuration for latency monitoring
|
||||
type LatencyConfig struct {
|
||||
TargetLatency time.Duration // Target latency to maintain
|
||||
MaxLatency time.Duration // Maximum acceptable latency
|
||||
OptimizationInterval time.Duration // How often to run optimization
|
||||
HistorySize int // Number of latency measurements to keep
|
||||
JitterThreshold time.Duration // Jitter threshold for optimization
|
||||
AdaptiveThreshold float64 // Threshold for adaptive adjustments (0.0-1.0)
|
||||
}
|
||||
|
||||
// LatencyMeasurement represents a single latency measurement
|
||||
type LatencyMeasurement struct {
|
||||
Timestamp time.Time
|
||||
Latency time.Duration
|
||||
Jitter time.Duration
|
||||
Source string // Source of the measurement (e.g., "input", "output", "processing")
|
||||
}
|
||||
|
||||
// OptimizationCallback is called when latency optimization is triggered
|
||||
type OptimizationCallback func(metrics LatencyMetrics) error
|
||||
|
||||
// LatencyMetrics provides comprehensive latency statistics
|
||||
type LatencyMetrics struct {
|
||||
Current time.Duration
|
||||
Average time.Duration
|
||||
Min time.Duration
|
||||
Max time.Duration
|
||||
Jitter time.Duration
|
||||
SampleCount int64
|
||||
Trend LatencyTrend
|
||||
}
|
||||
|
||||
// LatencyTrend indicates the direction of latency changes
|
||||
type LatencyTrend int
|
||||
|
||||
const (
|
||||
LatencyTrendStable LatencyTrend = iota
|
||||
LatencyTrendIncreasing
|
||||
LatencyTrendDecreasing
|
||||
LatencyTrendVolatile
|
||||
)
|
||||
|
||||
// DefaultLatencyConfig returns a sensible default configuration
|
||||
func DefaultLatencyConfig() LatencyConfig {
|
||||
return LatencyConfig{
|
||||
TargetLatency: 50 * time.Millisecond,
|
||||
MaxLatency: 200 * time.Millisecond,
|
||||
OptimizationInterval: 5 * time.Second,
|
||||
HistorySize: 100,
|
||||
JitterThreshold: 20 * time.Millisecond,
|
||||
AdaptiveThreshold: 0.8, // Trigger optimization when 80% above target
|
||||
}
|
||||
}
|
||||
|
||||
// NewLatencyMonitor creates a new latency monitoring system
|
||||
func NewLatencyMonitor(config LatencyConfig, logger zerolog.Logger) *LatencyMonitor {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &LatencyMonitor{
|
||||
config: config,
|
||||
logger: logger.With().Str("component", "latency-monitor").Logger(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
latencyHistory: make([]LatencyMeasurement, 0, config.HistorySize),
|
||||
minLatency: int64(time.Hour), // Initialize to high value
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins latency monitoring and optimization
|
||||
func (lm *LatencyMonitor) Start() {
|
||||
lm.wg.Add(1)
|
||||
go lm.monitoringLoop()
|
||||
lm.logger.Info().Msg("Latency monitor started")
|
||||
}
|
||||
|
||||
// Stop stops the latency monitor
|
||||
func (lm *LatencyMonitor) Stop() {
|
||||
lm.cancel()
|
||||
lm.wg.Wait()
|
||||
lm.logger.Info().Msg("Latency monitor stopped")
|
||||
}
|
||||
|
||||
// RecordLatency records a new latency measurement
|
||||
func (lm *LatencyMonitor) RecordLatency(latency time.Duration, source string) {
|
||||
now := time.Now()
|
||||
latencyNanos := latency.Nanoseconds()
|
||||
|
||||
// Update atomic counters
|
||||
atomic.StoreInt64(&lm.currentLatency, latencyNanos)
|
||||
atomic.AddInt64(&lm.latencySamples, 1)
|
||||
|
||||
// Update min/max
|
||||
for {
|
||||
oldMin := atomic.LoadInt64(&lm.minLatency)
|
||||
if latencyNanos >= oldMin || atomic.CompareAndSwapInt64(&lm.minLatency, oldMin, latencyNanos) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
oldMax := atomic.LoadInt64(&lm.maxLatency)
|
||||
if latencyNanos <= oldMax || atomic.CompareAndSwapInt64(&lm.maxLatency, oldMax, latencyNanos) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Update rolling average using exponential moving average
|
||||
oldAvg := atomic.LoadInt64(&lm.averageLatency)
|
||||
newAvg := oldAvg + (latencyNanos-oldAvg)/10 // Alpha = 0.1
|
||||
atomic.StoreInt64(&lm.averageLatency, newAvg)
|
||||
|
||||
// Calculate jitter (difference from average)
|
||||
jitter := latencyNanos - newAvg
|
||||
if jitter < 0 {
|
||||
jitter = -jitter
|
||||
}
|
||||
atomic.AddInt64(&lm.jitterAccumulator, jitter)
|
||||
|
||||
// Store in history
|
||||
lm.historyMutex.Lock()
|
||||
measurement := LatencyMeasurement{
|
||||
Timestamp: now,
|
||||
Latency: latency,
|
||||
Jitter: time.Duration(jitter),
|
||||
Source: source,
|
||||
}
|
||||
|
||||
if len(lm.latencyHistory) >= lm.config.HistorySize {
|
||||
// Remove oldest measurement
|
||||
copy(lm.latencyHistory, lm.latencyHistory[1:])
|
||||
lm.latencyHistory[len(lm.latencyHistory)-1] = measurement
|
||||
} else {
|
||||
lm.latencyHistory = append(lm.latencyHistory, measurement)
|
||||
}
|
||||
lm.historyMutex.Unlock()
|
||||
}
|
||||
|
||||
// GetMetrics returns current latency metrics
|
||||
func (lm *LatencyMonitor) GetMetrics() LatencyMetrics {
|
||||
current := atomic.LoadInt64(&lm.currentLatency)
|
||||
average := atomic.LoadInt64(&lm.averageLatency)
|
||||
min := atomic.LoadInt64(&lm.minLatency)
|
||||
max := atomic.LoadInt64(&lm.maxLatency)
|
||||
samples := atomic.LoadInt64(&lm.latencySamples)
|
||||
jitterSum := atomic.LoadInt64(&lm.jitterAccumulator)
|
||||
|
||||
var jitter time.Duration
|
||||
if samples > 0 {
|
||||
jitter = time.Duration(jitterSum / samples)
|
||||
}
|
||||
|
||||
return LatencyMetrics{
|
||||
Current: time.Duration(current),
|
||||
Average: time.Duration(average),
|
||||
Min: time.Duration(min),
|
||||
Max: time.Duration(max),
|
||||
Jitter: jitter,
|
||||
SampleCount: samples,
|
||||
Trend: lm.calculateTrend(),
|
||||
}
|
||||
}
|
||||
|
||||
// AddOptimizationCallback adds a callback for latency optimization
|
||||
func (lm *LatencyMonitor) AddOptimizationCallback(callback OptimizationCallback) {
|
||||
lm.mutex.Lock()
|
||||
lm.optimizationCallbacks = append(lm.optimizationCallbacks, callback)
|
||||
lm.mutex.Unlock()
|
||||
}
|
||||
|
||||
// monitoringLoop runs the main monitoring and optimization loop
|
||||
func (lm *LatencyMonitor) monitoringLoop() {
|
||||
defer lm.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(lm.config.OptimizationInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-lm.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
lm.runOptimization()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runOptimization checks if optimization is needed and triggers callbacks
|
||||
func (lm *LatencyMonitor) runOptimization() {
|
||||
metrics := lm.GetMetrics()
|
||||
|
||||
// Check if optimization is needed
|
||||
needsOptimization := false
|
||||
|
||||
// Check if current latency exceeds threshold
|
||||
if metrics.Current > lm.config.MaxLatency {
|
||||
needsOptimization = true
|
||||
lm.logger.Warn().Dur("current_latency", metrics.Current).Dur("max_latency", lm.config.MaxLatency).Msg("Latency exceeds maximum threshold")
|
||||
}
|
||||
|
||||
// Check if average latency is above adaptive threshold
|
||||
adaptiveThreshold := time.Duration(float64(lm.config.TargetLatency.Nanoseconds()) * (1.0 + lm.config.AdaptiveThreshold))
|
||||
if metrics.Average > adaptiveThreshold {
|
||||
needsOptimization = true
|
||||
lm.logger.Info().Dur("average_latency", metrics.Average).Dur("threshold", adaptiveThreshold).Msg("Average latency above adaptive threshold")
|
||||
}
|
||||
|
||||
// Check if jitter is too high
|
||||
if metrics.Jitter > lm.config.JitterThreshold {
|
||||
needsOptimization = true
|
||||
lm.logger.Info().Dur("jitter", metrics.Jitter).Dur("threshold", lm.config.JitterThreshold).Msg("Jitter above threshold")
|
||||
}
|
||||
|
||||
if needsOptimization {
|
||||
atomic.StoreInt64(&lm.lastOptimization, time.Now().UnixNano())
|
||||
|
||||
// Run optimization callbacks
|
||||
lm.mutex.RLock()
|
||||
callbacks := make([]OptimizationCallback, len(lm.optimizationCallbacks))
|
||||
copy(callbacks, lm.optimizationCallbacks)
|
||||
lm.mutex.RUnlock()
|
||||
|
||||
for _, callback := range callbacks {
|
||||
if err := callback(metrics); err != nil {
|
||||
lm.logger.Error().Err(err).Msg("Optimization callback failed")
|
||||
}
|
||||
}
|
||||
|
||||
lm.logger.Info().Interface("metrics", metrics).Msg("Latency optimization triggered")
|
||||
}
|
||||
}
|
||||
|
||||
// calculateTrend analyzes recent latency measurements to determine trend
|
||||
func (lm *LatencyMonitor) calculateTrend() LatencyTrend {
|
||||
lm.historyMutex.RLock()
|
||||
defer lm.historyMutex.RUnlock()
|
||||
|
||||
if len(lm.latencyHistory) < 10 {
|
||||
return LatencyTrendStable
|
||||
}
|
||||
|
||||
// Analyze last 10 measurements
|
||||
recentMeasurements := lm.latencyHistory[len(lm.latencyHistory)-10:]
|
||||
|
||||
var increasing, decreasing int
|
||||
for i := 1; i < len(recentMeasurements); i++ {
|
||||
if recentMeasurements[i].Latency > recentMeasurements[i-1].Latency {
|
||||
increasing++
|
||||
} else if recentMeasurements[i].Latency < recentMeasurements[i-1].Latency {
|
||||
decreasing++
|
||||
}
|
||||
}
|
||||
|
||||
// Determine trend based on direction changes
|
||||
if increasing > 6 {
|
||||
return LatencyTrendIncreasing
|
||||
} else if decreasing > 6 {
|
||||
return LatencyTrendDecreasing
|
||||
} else if increasing+decreasing > 7 {
|
||||
return LatencyTrendVolatile
|
||||
}
|
||||
|
||||
return LatencyTrendStable
|
||||
}
|
||||
|
||||
// GetLatencyHistory returns a copy of recent latency measurements
|
||||
func (lm *LatencyMonitor) GetLatencyHistory() []LatencyMeasurement {
|
||||
lm.historyMutex.RLock()
|
||||
defer lm.historyMutex.RUnlock()
|
||||
|
||||
history := make([]LatencyMeasurement, len(lm.latencyHistory))
|
||||
copy(history, lm.latencyHistory)
|
||||
return history
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// MemoryMetrics provides comprehensive memory allocation statistics
|
||||
type MemoryMetrics struct {
|
||||
// Runtime memory statistics
|
||||
RuntimeStats RuntimeMemoryStats `json:"runtime_stats"`
|
||||
// Audio buffer pool statistics
|
||||
BufferPools AudioBufferPoolStats `json:"buffer_pools"`
|
||||
// Zero-copy frame pool statistics
|
||||
ZeroCopyPool ZeroCopyFramePoolStats `json:"zero_copy_pool"`
|
||||
// Message pool statistics
|
||||
MessagePool MessagePoolStats `json:"message_pool"`
|
||||
// Batch processor statistics
|
||||
BatchProcessor BatchProcessorMemoryStats `json:"batch_processor,omitempty"`
|
||||
// Collection timestamp
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// RuntimeMemoryStats provides Go runtime memory statistics
|
||||
type RuntimeMemoryStats struct {
|
||||
Alloc uint64 `json:"alloc"` // Bytes allocated and not yet freed
|
||||
TotalAlloc uint64 `json:"total_alloc"` // Total bytes allocated (cumulative)
|
||||
Sys uint64 `json:"sys"` // Total bytes obtained from OS
|
||||
Lookups uint64 `json:"lookups"` // Number of pointer lookups
|
||||
Mallocs uint64 `json:"mallocs"` // Number of mallocs
|
||||
Frees uint64 `json:"frees"` // Number of frees
|
||||
HeapAlloc uint64 `json:"heap_alloc"` // Bytes allocated and not yet freed (heap)
|
||||
HeapSys uint64 `json:"heap_sys"` // Bytes obtained from OS for heap
|
||||
HeapIdle uint64 `json:"heap_idle"` // Bytes in idle spans
|
||||
HeapInuse uint64 `json:"heap_inuse"` // Bytes in non-idle spans
|
||||
HeapReleased uint64 `json:"heap_released"` // Bytes released to OS
|
||||
HeapObjects uint64 `json:"heap_objects"` // Total number of allocated objects
|
||||
StackInuse uint64 `json:"stack_inuse"` // Bytes used by stack spans
|
||||
StackSys uint64 `json:"stack_sys"` // Bytes obtained from OS for stack
|
||||
MSpanInuse uint64 `json:"mspan_inuse"` // Bytes used by mspan structures
|
||||
MSpanSys uint64 `json:"mspan_sys"` // Bytes obtained from OS for mspan
|
||||
MCacheInuse uint64 `json:"mcache_inuse"` // Bytes used by mcache structures
|
||||
MCacheSys uint64 `json:"mcache_sys"` // Bytes obtained from OS for mcache
|
||||
BuckHashSys uint64 `json:"buck_hash_sys"` // Bytes used by profiling bucket hash table
|
||||
GCSys uint64 `json:"gc_sys"` // Bytes used for garbage collection metadata
|
||||
OtherSys uint64 `json:"other_sys"` // Bytes used for other system allocations
|
||||
NextGC uint64 `json:"next_gc"` // Target heap size for next GC
|
||||
LastGC uint64 `json:"last_gc"` // Time of last GC (nanoseconds since epoch)
|
||||
PauseTotalNs uint64 `json:"pause_total_ns"` // Total GC pause time
|
||||
NumGC uint32 `json:"num_gc"` // Number of completed GC cycles
|
||||
NumForcedGC uint32 `json:"num_forced_gc"` // Number of forced GC cycles
|
||||
GCCPUFraction float64 `json:"gc_cpu_fraction"` // Fraction of CPU time used by GC
|
||||
}
|
||||
|
||||
// BatchProcessorMemoryStats provides batch processor memory statistics
|
||||
type BatchProcessorMemoryStats struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
Running bool `json:"running"`
|
||||
Stats BatchAudioStats `json:"stats"`
|
||||
BufferPool AudioBufferPoolDetailedStats `json:"buffer_pool,omitempty"`
|
||||
}
|
||||
|
||||
// GetBatchAudioProcessor is defined in batch_audio.go
|
||||
// BatchAudioStats is defined in batch_audio.go
|
||||
|
||||
var memoryMetricsLogger *zerolog.Logger
|
||||
|
||||
func getMemoryMetricsLogger() *zerolog.Logger {
|
||||
if memoryMetricsLogger == nil {
|
||||
logger := logging.GetDefaultLogger().With().Str("component", "memory-metrics").Logger()
|
||||
memoryMetricsLogger = &logger
|
||||
}
|
||||
return memoryMetricsLogger
|
||||
}
|
||||
|
||||
// CollectMemoryMetrics gathers comprehensive memory allocation statistics
|
||||
func CollectMemoryMetrics() MemoryMetrics {
|
||||
// Collect runtime memory statistics
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
runtimeStats := RuntimeMemoryStats{
|
||||
Alloc: m.Alloc,
|
||||
TotalAlloc: m.TotalAlloc,
|
||||
Sys: m.Sys,
|
||||
Lookups: m.Lookups,
|
||||
Mallocs: m.Mallocs,
|
||||
Frees: m.Frees,
|
||||
HeapAlloc: m.HeapAlloc,
|
||||
HeapSys: m.HeapSys,
|
||||
HeapIdle: m.HeapIdle,
|
||||
HeapInuse: m.HeapInuse,
|
||||
HeapReleased: m.HeapReleased,
|
||||
HeapObjects: m.HeapObjects,
|
||||
StackInuse: m.StackInuse,
|
||||
StackSys: m.StackSys,
|
||||
MSpanInuse: m.MSpanInuse,
|
||||
MSpanSys: m.MSpanSys,
|
||||
MCacheInuse: m.MCacheInuse,
|
||||
MCacheSys: m.MCacheSys,
|
||||
BuckHashSys: m.BuckHashSys,
|
||||
GCSys: m.GCSys,
|
||||
OtherSys: m.OtherSys,
|
||||
NextGC: m.NextGC,
|
||||
LastGC: m.LastGC,
|
||||
PauseTotalNs: m.PauseTotalNs,
|
||||
NumGC: m.NumGC,
|
||||
NumForcedGC: m.NumForcedGC,
|
||||
GCCPUFraction: m.GCCPUFraction,
|
||||
}
|
||||
|
||||
// Collect audio buffer pool statistics
|
||||
bufferPoolStats := GetAudioBufferPoolStats()
|
||||
|
||||
// Collect zero-copy frame pool statistics
|
||||
zeroCopyStats := GetGlobalZeroCopyPoolStats()
|
||||
|
||||
// Collect message pool statistics
|
||||
messagePoolStats := GetGlobalMessagePoolStats()
|
||||
|
||||
// Collect batch processor statistics if available
|
||||
var batchStats BatchProcessorMemoryStats
|
||||
if processor := GetBatchAudioProcessor(); processor != nil {
|
||||
batchStats.Initialized = true
|
||||
batchStats.Running = processor.IsRunning()
|
||||
batchStats.Stats = processor.GetStats()
|
||||
// Note: BatchAudioProcessor uses sync.Pool, detailed stats not available
|
||||
}
|
||||
|
||||
return MemoryMetrics{
|
||||
RuntimeStats: runtimeStats,
|
||||
BufferPools: bufferPoolStats,
|
||||
ZeroCopyPool: zeroCopyStats,
|
||||
MessagePool: messagePoolStats,
|
||||
BatchProcessor: batchStats,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMemoryMetrics provides an HTTP handler for memory metrics
|
||||
func HandleMemoryMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
logger := getMemoryMetricsLogger()
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
metrics := CollectMemoryMetrics()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(metrics); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to encode memory metrics")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Msg("memory metrics served")
|
||||
}
|
||||
|
||||
// LogMemoryMetrics logs current memory metrics for debugging
|
||||
func LogMemoryMetrics() {
|
||||
logger := getMemoryMetricsLogger()
|
||||
metrics := CollectMemoryMetrics()
|
||||
|
||||
logger.Info().
|
||||
Uint64("heap_alloc_mb", metrics.RuntimeStats.HeapAlloc/1024/1024).
|
||||
Uint64("heap_sys_mb", metrics.RuntimeStats.HeapSys/1024/1024).
|
||||
Uint64("heap_objects", metrics.RuntimeStats.HeapObjects).
|
||||
Uint32("num_gc", metrics.RuntimeStats.NumGC).
|
||||
Float64("gc_cpu_fraction", metrics.RuntimeStats.GCCPUFraction).
|
||||
Float64("buffer_pool_hit_rate", metrics.BufferPools.FramePoolHitRate).
|
||||
Float64("zero_copy_hit_rate", metrics.ZeroCopyPool.HitRate).
|
||||
Float64("message_pool_hit_rate", metrics.MessagePool.HitRate).
|
||||
Msg("memory metrics snapshot")
|
||||
}
|
||||
|
||||
// StartMemoryMetricsLogging starts periodic memory metrics logging
|
||||
func StartMemoryMetricsLogging(interval time.Duration) {
|
||||
logger := getMemoryMetricsLogger()
|
||||
logger.Info().Dur("interval", interval).Msg("starting memory metrics logging")
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
LogMemoryMetrics()
|
||||
}
|
||||
}()
|
||||
}
|
|
@ -10,6 +10,67 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
// 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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,165 @@
|
|||
//go:build linux
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// SchedParam represents scheduling parameters for Linux
|
||||
type SchedParam struct {
|
||||
Priority int32
|
||||
}
|
||||
|
||||
// Priority levels for audio processing
|
||||
const (
|
||||
// SCHED_FIFO priorities (1-99, higher = more priority)
|
||||
AudioHighPriority = 80 // High priority for critical audio processing
|
||||
AudioMediumPriority = 60 // Medium priority for regular audio processing
|
||||
AudioLowPriority = 40 // Low priority for background audio tasks
|
||||
|
||||
// SCHED_NORMAL is the default (priority 0)
|
||||
NormalPriority = 0
|
||||
)
|
||||
|
||||
// Scheduling policies
|
||||
const (
|
||||
SCHED_NORMAL = 0
|
||||
SCHED_FIFO = 1
|
||||
SCHED_RR = 2
|
||||
)
|
||||
|
||||
// PriorityScheduler manages thread priorities for audio processing
|
||||
type PriorityScheduler struct {
|
||||
logger zerolog.Logger
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewPriorityScheduler creates a new priority scheduler
|
||||
func NewPriorityScheduler() *PriorityScheduler {
|
||||
return &PriorityScheduler{
|
||||
logger: logging.GetDefaultLogger().With().Str("component", "priority-scheduler").Logger(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// SetThreadPriority sets the priority of the current thread
|
||||
func (ps *PriorityScheduler) SetThreadPriority(priority int, policy int) error {
|
||||
if !ps.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lock to OS thread to ensure we're setting priority for the right thread
|
||||
runtime.LockOSThread()
|
||||
|
||||
// Get current thread ID
|
||||
tid := syscall.Gettid()
|
||||
|
||||
// Set scheduling parameters
|
||||
param := &SchedParam{
|
||||
Priority: int32(priority),
|
||||
}
|
||||
|
||||
// Use syscall to set scheduler
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_SCHED_SETSCHEDULER,
|
||||
uintptr(tid),
|
||||
uintptr(policy),
|
||||
uintptr(unsafe.Pointer(param)))
|
||||
|
||||
if errno != 0 {
|
||||
// If we can't set real-time priority, try nice value instead
|
||||
if policy != SCHED_NORMAL {
|
||||
ps.logger.Warn().Int("errno", int(errno)).Msg("Failed to set real-time priority, falling back to nice")
|
||||
return ps.setNicePriority(priority)
|
||||
}
|
||||
return errno
|
||||
}
|
||||
|
||||
ps.logger.Debug().Int("tid", tid).Int("priority", priority).Int("policy", policy).Msg("Thread priority set")
|
||||
return nil
|
||||
}
|
||||
|
||||
// setNicePriority sets nice value as fallback when real-time scheduling is not available
|
||||
func (ps *PriorityScheduler) setNicePriority(rtPriority int) error {
|
||||
// Convert real-time priority to nice value (inverse relationship)
|
||||
// RT priority 80 -> nice -10, RT priority 40 -> nice 0
|
||||
niceValue := (40 - rtPriority) / 4
|
||||
if niceValue < -20 {
|
||||
niceValue = -20
|
||||
}
|
||||
if niceValue > 19 {
|
||||
niceValue = 19
|
||||
}
|
||||
|
||||
err := syscall.Setpriority(syscall.PRIO_PROCESS, 0, niceValue)
|
||||
if err != nil {
|
||||
ps.logger.Warn().Err(err).Int("nice", niceValue).Msg("Failed to set nice priority")
|
||||
return err
|
||||
}
|
||||
|
||||
ps.logger.Debug().Int("nice", niceValue).Msg("Nice priority set as fallback")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAudioProcessingPriority sets high priority for audio processing threads
|
||||
func (ps *PriorityScheduler) SetAudioProcessingPriority() error {
|
||||
return ps.SetThreadPriority(AudioHighPriority, SCHED_FIFO)
|
||||
}
|
||||
|
||||
// SetAudioIOPriority sets medium priority for audio I/O threads
|
||||
func (ps *PriorityScheduler) SetAudioIOPriority() error {
|
||||
return ps.SetThreadPriority(AudioMediumPriority, SCHED_FIFO)
|
||||
}
|
||||
|
||||
// SetAudioBackgroundPriority sets low priority for background audio tasks
|
||||
func (ps *PriorityScheduler) SetAudioBackgroundPriority() error {
|
||||
return ps.SetThreadPriority(AudioLowPriority, SCHED_FIFO)
|
||||
}
|
||||
|
||||
// ResetPriority resets thread to normal scheduling
|
||||
func (ps *PriorityScheduler) ResetPriority() error {
|
||||
return ps.SetThreadPriority(NormalPriority, SCHED_NORMAL)
|
||||
}
|
||||
|
||||
// Disable disables priority scheduling (useful for testing or fallback)
|
||||
func (ps *PriorityScheduler) Disable() {
|
||||
ps.enabled = false
|
||||
ps.logger.Info().Msg("Priority scheduling disabled")
|
||||
}
|
||||
|
||||
// Enable enables priority scheduling
|
||||
func (ps *PriorityScheduler) Enable() {
|
||||
ps.enabled = true
|
||||
ps.logger.Info().Msg("Priority scheduling enabled")
|
||||
}
|
||||
|
||||
// Global priority scheduler instance
|
||||
var globalPriorityScheduler *PriorityScheduler
|
||||
|
||||
// GetPriorityScheduler returns the global priority scheduler instance
|
||||
func GetPriorityScheduler() *PriorityScheduler {
|
||||
if globalPriorityScheduler == nil {
|
||||
globalPriorityScheduler = NewPriorityScheduler()
|
||||
}
|
||||
return globalPriorityScheduler
|
||||
}
|
||||
|
||||
// SetAudioThreadPriority is a convenience function to set audio processing priority
|
||||
func SetAudioThreadPriority() error {
|
||||
return GetPriorityScheduler().SetAudioProcessingPriority()
|
||||
}
|
||||
|
||||
// SetAudioIOThreadPriority is a convenience function to set audio I/O priority
|
||||
func SetAudioIOThreadPriority() error {
|
||||
return GetPriorityScheduler().SetAudioIOPriority()
|
||||
}
|
||||
|
||||
// ResetThreadPriority is a convenience function to reset thread priority
|
||||
func ResetThreadPriority() error {
|
||||
return GetPriorityScheduler().ResetPriority()
|
||||
}
|
|
@ -2,6 +2,7 @@ package audio
|
|||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const (
|
||||
// Socket buffer sizes optimized for JetKVM's audio workload
|
||||
OptimalSocketBuffer = 128 * 1024 // 128KB (32 frames @ 4KB each)
|
||||
MaxSocketBuffer = 256 * 1024 // 256KB for high-load scenarios
|
||||
MinSocketBuffer = 32 * 1024 // 32KB minimum for basic functionality
|
||||
)
|
||||
|
||||
// SocketBufferConfig holds socket buffer configuration
|
||||
type SocketBufferConfig struct {
|
||||
SendBufferSize int
|
||||
RecvBufferSize int
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// DefaultSocketBufferConfig returns the default socket buffer configuration
|
||||
func DefaultSocketBufferConfig() SocketBufferConfig {
|
||||
return SocketBufferConfig{
|
||||
SendBufferSize: OptimalSocketBuffer,
|
||||
RecvBufferSize: OptimalSocketBuffer,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// HighLoadSocketBufferConfig returns configuration for high-load scenarios
|
||||
func HighLoadSocketBufferConfig() SocketBufferConfig {
|
||||
return SocketBufferConfig{
|
||||
SendBufferSize: MaxSocketBuffer,
|
||||
RecvBufferSize: MaxSocketBuffer,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureSocketBuffers applies socket buffer configuration to a Unix socket connection
|
||||
func ConfigureSocketBuffers(conn net.Conn, config SocketBufferConfig) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ValidateSocketBufferConfig(config); err != nil {
|
||||
return fmt.Errorf("invalid socket buffer config: %w", err)
|
||||
}
|
||||
|
||||
unixConn, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("connection is not a Unix socket")
|
||||
}
|
||||
|
||||
file, err := unixConn.File()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get socket file descriptor: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fd := int(file.Fd())
|
||||
|
||||
if config.SendBufferSize > 0 {
|
||||
if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, config.SendBufferSize); err != nil {
|
||||
return fmt.Errorf("failed to set SO_SNDBUF to %d: %w", config.SendBufferSize, err)
|
||||
}
|
||||
}
|
||||
|
||||
if config.RecvBufferSize > 0 {
|
||||
if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, config.RecvBufferSize); err != nil {
|
||||
return fmt.Errorf("failed to set SO_RCVBUF to %d: %w", config.RecvBufferSize, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSocketBufferSizes retrieves current socket buffer sizes
|
||||
func GetSocketBufferSizes(conn net.Conn) (sendSize, recvSize int, err error) {
|
||||
unixConn, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("socket buffer query only supported for Unix sockets")
|
||||
}
|
||||
|
||||
file, err := unixConn.File()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to get socket file descriptor: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fd := int(file.Fd())
|
||||
|
||||
// Get send buffer size
|
||||
sendSize, err = syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to get SO_SNDBUF: %w", err)
|
||||
}
|
||||
|
||||
// Get receive buffer size
|
||||
recvSize, err = syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to get SO_RCVBUF: %w", err)
|
||||
}
|
||||
|
||||
return sendSize, recvSize, nil
|
||||
}
|
||||
|
||||
// ValidateSocketBufferConfig validates socket buffer configuration
|
||||
func ValidateSocketBufferConfig(config SocketBufferConfig) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.SendBufferSize < MinSocketBuffer {
|
||||
return fmt.Errorf("send buffer size %d is below minimum %d", config.SendBufferSize, MinSocketBuffer)
|
||||
}
|
||||
|
||||
if config.RecvBufferSize < MinSocketBuffer {
|
||||
return fmt.Errorf("receive buffer size %d is below minimum %d", config.RecvBufferSize, MinSocketBuffer)
|
||||
}
|
||||
|
||||
if config.SendBufferSize > MaxSocketBuffer {
|
||||
return fmt.Errorf("send buffer size %d exceeds maximum %d", config.SendBufferSize, MaxSocketBuffer)
|
||||
}
|
||||
|
||||
if config.RecvBufferSize > MaxSocketBuffer {
|
||||
return fmt.Errorf("receive buffer size %d exceeds maximum %d", config.RecvBufferSize, MaxSocketBuffer)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSocketBufferMetrics records socket buffer metrics for monitoring
|
||||
func RecordSocketBufferMetrics(conn net.Conn, component string) {
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get current socket buffer sizes
|
||||
sendSize, recvSize, err := GetSocketBufferSizes(conn)
|
||||
if err != nil {
|
||||
// Log error but don't fail
|
||||
return
|
||||
}
|
||||
|
||||
// Record buffer sizes
|
||||
socketBufferSizeGauge.WithLabelValues(component, "send").Set(float64(sendSize))
|
||||
socketBufferSizeGauge.WithLabelValues(component, "receive").Set(float64(recvSize))
|
||||
}
|
||||
|
||||
// RecordSocketBufferOverflow records a socket buffer overflow event
|
||||
func RecordSocketBufferOverflow(component, bufferType string) {
|
||||
socketBufferOverflowCounter.WithLabelValues(component, bufferType).Inc()
|
||||
}
|
||||
|
||||
// UpdateSocketBufferUtilization updates socket buffer utilization metrics
|
||||
func UpdateSocketBufferUtilization(component, bufferType string, utilizationPercent float64) {
|
||||
socketBufferUtilizationGauge.WithLabelValues(component, bufferType).Set(utilizationPercent)
|
||||
}
|
|
@ -0,0 +1,314 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ZeroCopyAudioFrame represents an audio frame that can be passed between
|
||||
// components without copying the underlying data
|
||||
type ZeroCopyAudioFrame struct {
|
||||
data []byte
|
||||
length int
|
||||
capacity int
|
||||
refCount int32
|
||||
mutex sync.RWMutex
|
||||
pooled bool
|
||||
}
|
||||
|
||||
// ZeroCopyFramePool manages reusable zero-copy audio frames
|
||||
type ZeroCopyFramePool struct {
|
||||
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||
counter int64 // Frame counter (atomic)
|
||||
hitCount int64 // Pool hit counter (atomic)
|
||||
missCount int64 // Pool miss counter (atomic)
|
||||
|
||||
// Other fields
|
||||
pool sync.Pool
|
||||
maxSize int
|
||||
mutex sync.RWMutex
|
||||
// Memory optimization fields
|
||||
preallocated []*ZeroCopyAudioFrame // Pre-allocated frames for immediate use
|
||||
preallocSize int // Number of pre-allocated frames
|
||||
maxPoolSize int // Maximum pool size to prevent memory bloat
|
||||
}
|
||||
|
||||
// NewZeroCopyFramePool creates a new zero-copy frame pool
|
||||
func NewZeroCopyFramePool(maxFrameSize int) *ZeroCopyFramePool {
|
||||
// Pre-allocate 15 frames for immediate availability
|
||||
preallocSize := 15
|
||||
maxPoolSize := 50 // Limit total pool size
|
||||
preallocated := make([]*ZeroCopyAudioFrame, 0, preallocSize)
|
||||
|
||||
// Pre-allocate frames to reduce initial allocation overhead
|
||||
for i := 0; i < preallocSize; i++ {
|
||||
frame := &ZeroCopyAudioFrame{
|
||||
data: make([]byte, 0, maxFrameSize),
|
||||
capacity: maxFrameSize,
|
||||
pooled: true,
|
||||
}
|
||||
preallocated = append(preallocated, frame)
|
||||
}
|
||||
|
||||
return &ZeroCopyFramePool{
|
||||
maxSize: maxFrameSize,
|
||||
preallocated: preallocated,
|
||||
preallocSize: preallocSize,
|
||||
maxPoolSize: maxPoolSize,
|
||||
pool: sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &ZeroCopyAudioFrame{
|
||||
data: make([]byte, 0, maxFrameSize),
|
||||
capacity: maxFrameSize,
|
||||
pooled: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a zero-copy frame from the pool
|
||||
func (p *ZeroCopyFramePool) Get() *ZeroCopyAudioFrame {
|
||||
// First try pre-allocated frames for fastest access
|
||||
p.mutex.Lock()
|
||||
if len(p.preallocated) > 0 {
|
||||
frame := p.preallocated[len(p.preallocated)-1]
|
||||
p.preallocated = p.preallocated[:len(p.preallocated)-1]
|
||||
p.mutex.Unlock()
|
||||
|
||||
frame.mutex.Lock()
|
||||
frame.refCount = 1
|
||||
frame.length = 0
|
||||
frame.data = frame.data[:0]
|
||||
frame.mutex.Unlock()
|
||||
|
||||
atomic.AddInt64(&p.hitCount, 1)
|
||||
return frame
|
||||
}
|
||||
p.mutex.Unlock()
|
||||
|
||||
// Try sync.Pool next
|
||||
frame := p.pool.Get().(*ZeroCopyAudioFrame)
|
||||
frame.mutex.Lock()
|
||||
frame.refCount = 1
|
||||
frame.length = 0
|
||||
frame.data = frame.data[:0]
|
||||
frame.mutex.Unlock()
|
||||
|
||||
atomic.AddInt64(&p.hitCount, 1)
|
||||
return frame
|
||||
}
|
||||
|
||||
// Put returns a zero-copy frame to the pool
|
||||
func (p *ZeroCopyFramePool) Put(frame *ZeroCopyAudioFrame) {
|
||||
if frame == nil || !frame.pooled {
|
||||
return
|
||||
}
|
||||
|
||||
frame.mutex.Lock()
|
||||
frame.refCount--
|
||||
if frame.refCount <= 0 {
|
||||
frame.refCount = 0
|
||||
frame.length = 0
|
||||
frame.data = frame.data[:0]
|
||||
frame.mutex.Unlock()
|
||||
|
||||
// First try to return to pre-allocated pool for fastest reuse
|
||||
p.mutex.Lock()
|
||||
if len(p.preallocated) < p.preallocSize {
|
||||
p.preallocated = append(p.preallocated, frame)
|
||||
p.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
p.mutex.Unlock()
|
||||
|
||||
// Check pool size limit to prevent excessive memory usage
|
||||
p.mutex.RLock()
|
||||
currentCount := atomic.LoadInt64(&p.counter)
|
||||
p.mutex.RUnlock()
|
||||
|
||||
if currentCount >= int64(p.maxPoolSize) {
|
||||
return // Pool is full, let GC handle this frame
|
||||
}
|
||||
|
||||
// Return to sync.Pool
|
||||
p.pool.Put(frame)
|
||||
atomic.AddInt64(&p.counter, 1)
|
||||
} else {
|
||||
frame.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Data returns the frame data as a slice (zero-copy view)
|
||||
func (f *ZeroCopyAudioFrame) Data() []byte {
|
||||
f.mutex.RLock()
|
||||
defer f.mutex.RUnlock()
|
||||
return f.data[:f.length]
|
||||
}
|
||||
|
||||
// SetData sets the frame data (zero-copy if possible)
|
||||
func (f *ZeroCopyAudioFrame) SetData(data []byte) error {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
|
||||
if len(data) > f.capacity {
|
||||
// Need to reallocate - not zero-copy but necessary
|
||||
f.data = make([]byte, len(data))
|
||||
f.capacity = len(data)
|
||||
f.pooled = false // Can't return to pool anymore
|
||||
}
|
||||
|
||||
// Zero-copy assignment when data fits in existing buffer
|
||||
if cap(f.data) >= len(data) {
|
||||
f.data = f.data[:len(data)]
|
||||
copy(f.data, data)
|
||||
} else {
|
||||
f.data = append(f.data[:0], data...)
|
||||
}
|
||||
f.length = len(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDataDirect sets frame data using direct buffer assignment (true zero-copy)
|
||||
// WARNING: The caller must ensure the buffer remains valid for the frame's lifetime
|
||||
func (f *ZeroCopyAudioFrame) SetDataDirect(data []byte) {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
f.data = data
|
||||
f.length = len(data)
|
||||
f.capacity = cap(data)
|
||||
f.pooled = false // Direct assignment means we can't pool this frame
|
||||
}
|
||||
|
||||
// AddRef increments the reference count for shared access
|
||||
func (f *ZeroCopyAudioFrame) AddRef() {
|
||||
f.mutex.Lock()
|
||||
f.refCount++
|
||||
f.mutex.Unlock()
|
||||
}
|
||||
|
||||
// Release decrements the reference count
|
||||
func (f *ZeroCopyAudioFrame) Release() {
|
||||
f.mutex.Lock()
|
||||
f.refCount--
|
||||
f.mutex.Unlock()
|
||||
}
|
||||
|
||||
// Length returns the current data length
|
||||
func (f *ZeroCopyAudioFrame) Length() int {
|
||||
f.mutex.RLock()
|
||||
defer f.mutex.RUnlock()
|
||||
return f.length
|
||||
}
|
||||
|
||||
// Capacity returns the buffer capacity
|
||||
func (f *ZeroCopyAudioFrame) Capacity() int {
|
||||
f.mutex.RLock()
|
||||
defer f.mutex.RUnlock()
|
||||
return f.capacity
|
||||
}
|
||||
|
||||
// UnsafePointer returns an unsafe pointer to the data for CGO calls
|
||||
// WARNING: Only use this for CGO interop, ensure frame lifetime
|
||||
func (f *ZeroCopyAudioFrame) UnsafePointer() unsafe.Pointer {
|
||||
f.mutex.RLock()
|
||||
defer f.mutex.RUnlock()
|
||||
if len(f.data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return unsafe.Pointer(&f.data[0])
|
||||
}
|
||||
|
||||
// Global zero-copy frame pool
|
||||
// GetZeroCopyPoolStats returns detailed statistics about the zero-copy frame pool
|
||||
func (p *ZeroCopyFramePool) GetZeroCopyPoolStats() ZeroCopyFramePoolStats {
|
||||
p.mutex.RLock()
|
||||
preallocatedCount := len(p.preallocated)
|
||||
currentCount := atomic.LoadInt64(&p.counter)
|
||||
p.mutex.RUnlock()
|
||||
|
||||
hitCount := atomic.LoadInt64(&p.hitCount)
|
||||
missCount := atomic.LoadInt64(&p.missCount)
|
||||
totalRequests := hitCount + missCount
|
||||
|
||||
var hitRate float64
|
||||
if totalRequests > 0 {
|
||||
hitRate = float64(hitCount) / float64(totalRequests) * 100
|
||||
}
|
||||
|
||||
return ZeroCopyFramePoolStats{
|
||||
MaxFrameSize: p.maxSize,
|
||||
MaxPoolSize: p.maxPoolSize,
|
||||
CurrentPoolSize: currentCount,
|
||||
PreallocatedCount: int64(preallocatedCount),
|
||||
PreallocatedMax: int64(p.preallocSize),
|
||||
HitCount: hitCount,
|
||||
MissCount: missCount,
|
||||
HitRate: hitRate,
|
||||
}
|
||||
}
|
||||
|
||||
// ZeroCopyFramePoolStats provides detailed zero-copy pool statistics
|
||||
type ZeroCopyFramePoolStats struct {
|
||||
MaxFrameSize int
|
||||
MaxPoolSize int
|
||||
CurrentPoolSize int64
|
||||
PreallocatedCount int64
|
||||
PreallocatedMax int64
|
||||
HitCount int64
|
||||
MissCount int64
|
||||
HitRate float64 // Percentage
|
||||
}
|
||||
|
||||
var (
|
||||
globalZeroCopyPool = NewZeroCopyFramePool(MaxAudioFrameSize)
|
||||
)
|
||||
|
||||
// GetZeroCopyFrame gets a frame from the global pool
|
||||
func GetZeroCopyFrame() *ZeroCopyAudioFrame {
|
||||
return globalZeroCopyPool.Get()
|
||||
}
|
||||
|
||||
// GetGlobalZeroCopyPoolStats returns statistics for the global zero-copy pool
|
||||
func GetGlobalZeroCopyPoolStats() ZeroCopyFramePoolStats {
|
||||
return globalZeroCopyPool.GetZeroCopyPoolStats()
|
||||
}
|
||||
|
||||
// PutZeroCopyFrame returns a frame to the global pool
|
||||
func PutZeroCopyFrame(frame *ZeroCopyAudioFrame) {
|
||||
globalZeroCopyPool.Put(frame)
|
||||
}
|
||||
|
||||
// ZeroCopyAudioReadEncode performs audio read and encode with zero-copy optimization
|
||||
func ZeroCopyAudioReadEncode() (*ZeroCopyAudioFrame, error) {
|
||||
frame := GetZeroCopyFrame()
|
||||
|
||||
// Ensure frame has enough capacity
|
||||
if frame.Capacity() < MaxAudioFrameSize {
|
||||
// Reallocate if needed
|
||||
frame.data = make([]byte, MaxAudioFrameSize)
|
||||
frame.capacity = MaxAudioFrameSize
|
||||
frame.pooled = false
|
||||
}
|
||||
|
||||
// Use unsafe pointer for direct CGO call
|
||||
n, err := CGOAudioReadEncode(frame.data[:MaxAudioFrameSize])
|
||||
if err != nil {
|
||||
PutZeroCopyFrame(frame)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
PutZeroCopyFrame(frame)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Set the actual data length
|
||||
frame.mutex.Lock()
|
||||
frame.length = n
|
||||
frame.data = frame.data[:n]
|
||||
frame.mutex.Unlock()
|
||||
|
||||
return frame, nil
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var tmpl = `// Code generated by "go run gen.go". DO NOT EDIT.
|
||||
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
||||
package tzdata
|
||||
var TimeZones = []string{
|
||||
{{- range . }}
|
||||
"{{.}}",
|
||||
{{- end }}
|
||||
}
|
||||
`
|
||||
|
||||
var filename = flag.String("output", "tzdata.go", "output file name")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
path := os.Getenv("ZONEINFO")
|
||||
if path == "" {
|
||||
fmt.Println("ZONEINFO is not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
fmt.Printf("ZONEINFO %s does not exist\n", path)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
zipfile, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
fmt.Printf("Error opening ZONEINFO %s: %v\n", path, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer zipfile.Close()
|
||||
|
||||
timezones := []string{}
|
||||
|
||||
for _, file := range zipfile.File {
|
||||
timezones = append(timezones, file.Name)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
tmpl, err := template.New("tzdata").Parse(tmpl)
|
||||
if err != nil {
|
||||
fmt.Printf("Error parsing template: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = tmpl.Execute(&buf, timezones)
|
||||
if err != nil {
|
||||
fmt.Printf("Error executing template: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = os.WriteFile(*filename, buf.Bytes(), 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("Error writing file %s: %v\n", *filename, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,602 @@
|
|||
// Code generated by "go run gen.go". DO NOT EDIT.
|
||||
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
||||
package tzdata
|
||||
var TimeZones = []string{
|
||||
"Africa/Abidjan",
|
||||
"Africa/Accra",
|
||||
"Africa/Addis_Ababa",
|
||||
"Africa/Algiers",
|
||||
"Africa/Asmara",
|
||||
"Africa/Asmera",
|
||||
"Africa/Bamako",
|
||||
"Africa/Bangui",
|
||||
"Africa/Banjul",
|
||||
"Africa/Bissau",
|
||||
"Africa/Blantyre",
|
||||
"Africa/Brazzaville",
|
||||
"Africa/Bujumbura",
|
||||
"Africa/Cairo",
|
||||
"Africa/Casablanca",
|
||||
"Africa/Ceuta",
|
||||
"Africa/Conakry",
|
||||
"Africa/Dakar",
|
||||
"Africa/Dar_es_Salaam",
|
||||
"Africa/Djibouti",
|
||||
"Africa/Douala",
|
||||
"Africa/El_Aaiun",
|
||||
"Africa/Freetown",
|
||||
"Africa/Gaborone",
|
||||
"Africa/Harare",
|
||||
"Africa/Johannesburg",
|
||||
"Africa/Juba",
|
||||
"Africa/Kampala",
|
||||
"Africa/Khartoum",
|
||||
"Africa/Kigali",
|
||||
"Africa/Kinshasa",
|
||||
"Africa/Lagos",
|
||||
"Africa/Libreville",
|
||||
"Africa/Lome",
|
||||
"Africa/Luanda",
|
||||
"Africa/Lubumbashi",
|
||||
"Africa/Lusaka",
|
||||
"Africa/Malabo",
|
||||
"Africa/Maputo",
|
||||
"Africa/Maseru",
|
||||
"Africa/Mbabane",
|
||||
"Africa/Mogadishu",
|
||||
"Africa/Monrovia",
|
||||
"Africa/Nairobi",
|
||||
"Africa/Ndjamena",
|
||||
"Africa/Niamey",
|
||||
"Africa/Nouakchott",
|
||||
"Africa/Ouagadougou",
|
||||
"Africa/Porto-Novo",
|
||||
"Africa/Sao_Tome",
|
||||
"Africa/Timbuktu",
|
||||
"Africa/Tripoli",
|
||||
"Africa/Tunis",
|
||||
"Africa/Windhoek",
|
||||
"America/Adak",
|
||||
"America/Anchorage",
|
||||
"America/Anguilla",
|
||||
"America/Antigua",
|
||||
"America/Araguaina",
|
||||
"America/Argentina/Buenos_Aires",
|
||||
"America/Argentina/Catamarca",
|
||||
"America/Argentina/ComodRivadavia",
|
||||
"America/Argentina/Cordoba",
|
||||
"America/Argentina/Jujuy",
|
||||
"America/Argentina/La_Rioja",
|
||||
"America/Argentina/Mendoza",
|
||||
"America/Argentina/Rio_Gallegos",
|
||||
"America/Argentina/Salta",
|
||||
"America/Argentina/San_Juan",
|
||||
"America/Argentina/San_Luis",
|
||||
"America/Argentina/Tucuman",
|
||||
"America/Argentina/Ushuaia",
|
||||
"America/Aruba",
|
||||
"America/Asuncion",
|
||||
"America/Atikokan",
|
||||
"America/Atka",
|
||||
"America/Bahia",
|
||||
"America/Bahia_Banderas",
|
||||
"America/Barbados",
|
||||
"America/Belem",
|
||||
"America/Belize",
|
||||
"America/Blanc-Sablon",
|
||||
"America/Boa_Vista",
|
||||
"America/Bogota",
|
||||
"America/Boise",
|
||||
"America/Buenos_Aires",
|
||||
"America/Cambridge_Bay",
|
||||
"America/Campo_Grande",
|
||||
"America/Cancun",
|
||||
"America/Caracas",
|
||||
"America/Catamarca",
|
||||
"America/Cayenne",
|
||||
"America/Cayman",
|
||||
"America/Chicago",
|
||||
"America/Chihuahua",
|
||||
"America/Ciudad_Juarez",
|
||||
"America/Coral_Harbour",
|
||||
"America/Cordoba",
|
||||
"America/Costa_Rica",
|
||||
"America/Creston",
|
||||
"America/Cuiaba",
|
||||
"America/Curacao",
|
||||
"America/Danmarkshavn",
|
||||
"America/Dawson",
|
||||
"America/Dawson_Creek",
|
||||
"America/Denver",
|
||||
"America/Detroit",
|
||||
"America/Dominica",
|
||||
"America/Edmonton",
|
||||
"America/Eirunepe",
|
||||
"America/El_Salvador",
|
||||
"America/Ensenada",
|
||||
"America/Fort_Nelson",
|
||||
"America/Fort_Wayne",
|
||||
"America/Fortaleza",
|
||||
"America/Glace_Bay",
|
||||
"America/Godthab",
|
||||
"America/Goose_Bay",
|
||||
"America/Grand_Turk",
|
||||
"America/Grenada",
|
||||
"America/Guadeloupe",
|
||||
"America/Guatemala",
|
||||
"America/Guayaquil",
|
||||
"America/Guyana",
|
||||
"America/Halifax",
|
||||
"America/Havana",
|
||||
"America/Hermosillo",
|
||||
"America/Indiana/Indianapolis",
|
||||
"America/Indiana/Knox",
|
||||
"America/Indiana/Marengo",
|
||||
"America/Indiana/Petersburg",
|
||||
"America/Indiana/Tell_City",
|
||||
"America/Indiana/Vevay",
|
||||
"America/Indiana/Vincennes",
|
||||
"America/Indiana/Winamac",
|
||||
"America/Indianapolis",
|
||||
"America/Inuvik",
|
||||
"America/Iqaluit",
|
||||
"America/Jamaica",
|
||||
"America/Jujuy",
|
||||
"America/Juneau",
|
||||
"America/Kentucky/Louisville",
|
||||
"America/Kentucky/Monticello",
|
||||
"America/Knox_IN",
|
||||
"America/Kralendijk",
|
||||
"America/La_Paz",
|
||||
"America/Lima",
|
||||
"America/Los_Angeles",
|
||||
"America/Louisville",
|
||||
"America/Lower_Princes",
|
||||
"America/Maceio",
|
||||
"America/Managua",
|
||||
"America/Manaus",
|
||||
"America/Marigot",
|
||||
"America/Martinique",
|
||||
"America/Matamoros",
|
||||
"America/Mazatlan",
|
||||
"America/Mendoza",
|
||||
"America/Menominee",
|
||||
"America/Merida",
|
||||
"America/Metlakatla",
|
||||
"America/Mexico_City",
|
||||
"America/Miquelon",
|
||||
"America/Moncton",
|
||||
"America/Monterrey",
|
||||
"America/Montevideo",
|
||||
"America/Montreal",
|
||||
"America/Montserrat",
|
||||
"America/Nassau",
|
||||
"America/New_York",
|
||||
"America/Nipigon",
|
||||
"America/Nome",
|
||||
"America/Noronha",
|
||||
"America/North_Dakota/Beulah",
|
||||
"America/North_Dakota/Center",
|
||||
"America/North_Dakota/New_Salem",
|
||||
"America/Nuuk",
|
||||
"America/Ojinaga",
|
||||
"America/Panama",
|
||||
"America/Pangnirtung",
|
||||
"America/Paramaribo",
|
||||
"America/Phoenix",
|
||||
"America/Port-au-Prince",
|
||||
"America/Port_of_Spain",
|
||||
"America/Porto_Acre",
|
||||
"America/Porto_Velho",
|
||||
"America/Puerto_Rico",
|
||||
"America/Punta_Arenas",
|
||||
"America/Rainy_River",
|
||||
"America/Rankin_Inlet",
|
||||
"America/Recife",
|
||||
"America/Regina",
|
||||
"America/Resolute",
|
||||
"America/Rio_Branco",
|
||||
"America/Rosario",
|
||||
"America/Santa_Isabel",
|
||||
"America/Santarem",
|
||||
"America/Santiago",
|
||||
"America/Santo_Domingo",
|
||||
"America/Sao_Paulo",
|
||||
"America/Scoresbysund",
|
||||
"America/Shiprock",
|
||||
"America/Sitka",
|
||||
"America/St_Barthelemy",
|
||||
"America/St_Johns",
|
||||
"America/St_Kitts",
|
||||
"America/St_Lucia",
|
||||
"America/St_Thomas",
|
||||
"America/St_Vincent",
|
||||
"America/Swift_Current",
|
||||
"America/Tegucigalpa",
|
||||
"America/Thule",
|
||||
"America/Thunder_Bay",
|
||||
"America/Tijuana",
|
||||
"America/Toronto",
|
||||
"America/Tortola",
|
||||
"America/Vancouver",
|
||||
"America/Virgin",
|
||||
"America/Whitehorse",
|
||||
"America/Winnipeg",
|
||||
"America/Yakutat",
|
||||
"America/Yellowknife",
|
||||
"Antarctica/Casey",
|
||||
"Antarctica/Davis",
|
||||
"Antarctica/DumontDUrville",
|
||||
"Antarctica/Macquarie",
|
||||
"Antarctica/Mawson",
|
||||
"Antarctica/McMurdo",
|
||||
"Antarctica/Palmer",
|
||||
"Antarctica/Rothera",
|
||||
"Antarctica/South_Pole",
|
||||
"Antarctica/Syowa",
|
||||
"Antarctica/Troll",
|
||||
"Antarctica/Vostok",
|
||||
"Arctic/Longyearbyen",
|
||||
"Asia/Aden",
|
||||
"Asia/Almaty",
|
||||
"Asia/Amman",
|
||||
"Asia/Anadyr",
|
||||
"Asia/Aqtau",
|
||||
"Asia/Aqtobe",
|
||||
"Asia/Ashgabat",
|
||||
"Asia/Ashkhabad",
|
||||
"Asia/Atyrau",
|
||||
"Asia/Baghdad",
|
||||
"Asia/Bahrain",
|
||||
"Asia/Baku",
|
||||
"Asia/Bangkok",
|
||||
"Asia/Barnaul",
|
||||
"Asia/Beirut",
|
||||
"Asia/Bishkek",
|
||||
"Asia/Brunei",
|
||||
"Asia/Calcutta",
|
||||
"Asia/Chita",
|
||||
"Asia/Choibalsan",
|
||||
"Asia/Chongqing",
|
||||
"Asia/Chungking",
|
||||
"Asia/Colombo",
|
||||
"Asia/Dacca",
|
||||
"Asia/Damascus",
|
||||
"Asia/Dhaka",
|
||||
"Asia/Dili",
|
||||
"Asia/Dubai",
|
||||
"Asia/Dushanbe",
|
||||
"Asia/Famagusta",
|
||||
"Asia/Gaza",
|
||||
"Asia/Harbin",
|
||||
"Asia/Hebron",
|
||||
"Asia/Ho_Chi_Minh",
|
||||
"Asia/Hong_Kong",
|
||||
"Asia/Hovd",
|
||||
"Asia/Irkutsk",
|
||||
"Asia/Istanbul",
|
||||
"Asia/Jakarta",
|
||||
"Asia/Jayapura",
|
||||
"Asia/Jerusalem",
|
||||
"Asia/Kabul",
|
||||
"Asia/Kamchatka",
|
||||
"Asia/Karachi",
|
||||
"Asia/Kashgar",
|
||||
"Asia/Kathmandu",
|
||||
"Asia/Katmandu",
|
||||
"Asia/Khandyga",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Krasnoyarsk",
|
||||
"Asia/Kuala_Lumpur",
|
||||
"Asia/Kuching",
|
||||
"Asia/Kuwait",
|
||||
"Asia/Macao",
|
||||
"Asia/Macau",
|
||||
"Asia/Magadan",
|
||||
"Asia/Makassar",
|
||||
"Asia/Manila",
|
||||
"Asia/Muscat",
|
||||
"Asia/Nicosia",
|
||||
"Asia/Novokuznetsk",
|
||||
"Asia/Novosibirsk",
|
||||
"Asia/Omsk",
|
||||
"Asia/Oral",
|
||||
"Asia/Phnom_Penh",
|
||||
"Asia/Pontianak",
|
||||
"Asia/Pyongyang",
|
||||
"Asia/Qatar",
|
||||
"Asia/Qostanay",
|
||||
"Asia/Qyzylorda",
|
||||
"Asia/Rangoon",
|
||||
"Asia/Riyadh",
|
||||
"Asia/Saigon",
|
||||
"Asia/Sakhalin",
|
||||
"Asia/Samarkand",
|
||||
"Asia/Seoul",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Singapore",
|
||||
"Asia/Srednekolymsk",
|
||||
"Asia/Taipei",
|
||||
"Asia/Tashkent",
|
||||
"Asia/Tbilisi",
|
||||
"Asia/Tehran",
|
||||
"Asia/Tel_Aviv",
|
||||
"Asia/Thimbu",
|
||||
"Asia/Thimphu",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Tomsk",
|
||||
"Asia/Ujung_Pandang",
|
||||
"Asia/Ulaanbaatar",
|
||||
"Asia/Ulan_Bator",
|
||||
"Asia/Urumqi",
|
||||
"Asia/Ust-Nera",
|
||||
"Asia/Vientiane",
|
||||
"Asia/Vladivostok",
|
||||
"Asia/Yakutsk",
|
||||
"Asia/Yangon",
|
||||
"Asia/Yekaterinburg",
|
||||
"Asia/Yerevan",
|
||||
"Atlantic/Azores",
|
||||
"Atlantic/Bermuda",
|
||||
"Atlantic/Canary",
|
||||
"Atlantic/Cape_Verde",
|
||||
"Atlantic/Faeroe",
|
||||
"Atlantic/Faroe",
|
||||
"Atlantic/Jan_Mayen",
|
||||
"Atlantic/Madeira",
|
||||
"Atlantic/Reykjavik",
|
||||
"Atlantic/South_Georgia",
|
||||
"Atlantic/St_Helena",
|
||||
"Atlantic/Stanley",
|
||||
"Australia/ACT",
|
||||
"Australia/Adelaide",
|
||||
"Australia/Brisbane",
|
||||
"Australia/Broken_Hill",
|
||||
"Australia/Canberra",
|
||||
"Australia/Currie",
|
||||
"Australia/Darwin",
|
||||
"Australia/Eucla",
|
||||
"Australia/Hobart",
|
||||
"Australia/LHI",
|
||||
"Australia/Lindeman",
|
||||
"Australia/Lord_Howe",
|
||||
"Australia/Melbourne",
|
||||
"Australia/NSW",
|
||||
"Australia/North",
|
||||
"Australia/Perth",
|
||||
"Australia/Queensland",
|
||||
"Australia/South",
|
||||
"Australia/Sydney",
|
||||
"Australia/Tasmania",
|
||||
"Australia/Victoria",
|
||||
"Australia/West",
|
||||
"Australia/Yancowinna",
|
||||
"Brazil/Acre",
|
||||
"Brazil/DeNoronha",
|
||||
"Brazil/East",
|
||||
"Brazil/West",
|
||||
"CET",
|
||||
"CST6CDT",
|
||||
"Canada/Atlantic",
|
||||
"Canada/Central",
|
||||
"Canada/Eastern",
|
||||
"Canada/Mountain",
|
||||
"Canada/Newfoundland",
|
||||
"Canada/Pacific",
|
||||
"Canada/Saskatchewan",
|
||||
"Canada/Yukon",
|
||||
"Chile/Continental",
|
||||
"Chile/EasterIsland",
|
||||
"Cuba",
|
||||
"EET",
|
||||
"EST",
|
||||
"EST5EDT",
|
||||
"Egypt",
|
||||
"Eire",
|
||||
"Etc/GMT",
|
||||
"Etc/GMT+0",
|
||||
"Etc/GMT+1",
|
||||
"Etc/GMT+10",
|
||||
"Etc/GMT+11",
|
||||
"Etc/GMT+12",
|
||||
"Etc/GMT+2",
|
||||
"Etc/GMT+3",
|
||||
"Etc/GMT+4",
|
||||
"Etc/GMT+5",
|
||||
"Etc/GMT+6",
|
||||
"Etc/GMT+7",
|
||||
"Etc/GMT+8",
|
||||
"Etc/GMT+9",
|
||||
"Etc/GMT-0",
|
||||
"Etc/GMT-1",
|
||||
"Etc/GMT-10",
|
||||
"Etc/GMT-11",
|
||||
"Etc/GMT-12",
|
||||
"Etc/GMT-13",
|
||||
"Etc/GMT-14",
|
||||
"Etc/GMT-2",
|
||||
"Etc/GMT-3",
|
||||
"Etc/GMT-4",
|
||||
"Etc/GMT-5",
|
||||
"Etc/GMT-6",
|
||||
"Etc/GMT-7",
|
||||
"Etc/GMT-8",
|
||||
"Etc/GMT-9",
|
||||
"Etc/GMT0",
|
||||
"Etc/Greenwich",
|
||||
"Etc/UCT",
|
||||
"Etc/UTC",
|
||||
"Etc/Universal",
|
||||
"Etc/Zulu",
|
||||
"Europe/Amsterdam",
|
||||
"Europe/Andorra",
|
||||
"Europe/Astrakhan",
|
||||
"Europe/Athens",
|
||||
"Europe/Belfast",
|
||||
"Europe/Belgrade",
|
||||
"Europe/Berlin",
|
||||
"Europe/Bratislava",
|
||||
"Europe/Brussels",
|
||||
"Europe/Bucharest",
|
||||
"Europe/Budapest",
|
||||
"Europe/Busingen",
|
||||
"Europe/Chisinau",
|
||||
"Europe/Copenhagen",
|
||||
"Europe/Dublin",
|
||||
"Europe/Gibraltar",
|
||||
"Europe/Guernsey",
|
||||
"Europe/Helsinki",
|
||||
"Europe/Isle_of_Man",
|
||||
"Europe/Istanbul",
|
||||
"Europe/Jersey",
|
||||
"Europe/Kaliningrad",
|
||||
"Europe/Kiev",
|
||||
"Europe/Kirov",
|
||||
"Europe/Kyiv",
|
||||
"Europe/Lisbon",
|
||||
"Europe/Ljubljana",
|
||||
"Europe/London",
|
||||
"Europe/Luxembourg",
|
||||
"Europe/Madrid",
|
||||
"Europe/Malta",
|
||||
"Europe/Mariehamn",
|
||||
"Europe/Minsk",
|
||||
"Europe/Monaco",
|
||||
"Europe/Moscow",
|
||||
"Europe/Nicosia",
|
||||
"Europe/Oslo",
|
||||
"Europe/Paris",
|
||||
"Europe/Podgorica",
|
||||
"Europe/Prague",
|
||||
"Europe/Riga",
|
||||
"Europe/Rome",
|
||||
"Europe/Samara",
|
||||
"Europe/San_Marino",
|
||||
"Europe/Sarajevo",
|
||||
"Europe/Saratov",
|
||||
"Europe/Simferopol",
|
||||
"Europe/Skopje",
|
||||
"Europe/Sofia",
|
||||
"Europe/Stockholm",
|
||||
"Europe/Tallinn",
|
||||
"Europe/Tirane",
|
||||
"Europe/Tiraspol",
|
||||
"Europe/Ulyanovsk",
|
||||
"Europe/Uzhgorod",
|
||||
"Europe/Vaduz",
|
||||
"Europe/Vatican",
|
||||
"Europe/Vienna",
|
||||
"Europe/Vilnius",
|
||||
"Europe/Volgograd",
|
||||
"Europe/Warsaw",
|
||||
"Europe/Zagreb",
|
||||
"Europe/Zaporozhye",
|
||||
"Europe/Zurich",
|
||||
"Factory",
|
||||
"GB",
|
||||
"GB-Eire",
|
||||
"GMT",
|
||||
"GMT+0",
|
||||
"GMT-0",
|
||||
"GMT0",
|
||||
"Greenwich",
|
||||
"HST",
|
||||
"Hongkong",
|
||||
"Iceland",
|
||||
"Indian/Antananarivo",
|
||||
"Indian/Chagos",
|
||||
"Indian/Christmas",
|
||||
"Indian/Cocos",
|
||||
"Indian/Comoro",
|
||||
"Indian/Kerguelen",
|
||||
"Indian/Mahe",
|
||||
"Indian/Maldives",
|
||||
"Indian/Mauritius",
|
||||
"Indian/Mayotte",
|
||||
"Indian/Reunion",
|
||||
"Iran",
|
||||
"Israel",
|
||||
"Jamaica",
|
||||
"Japan",
|
||||
"Kwajalein",
|
||||
"Libya",
|
||||
"MET",
|
||||
"MST",
|
||||
"MST7MDT",
|
||||
"Mexico/BajaNorte",
|
||||
"Mexico/BajaSur",
|
||||
"Mexico/General",
|
||||
"NZ",
|
||||
"NZ-CHAT",
|
||||
"Navajo",
|
||||
"PRC",
|
||||
"PST8PDT",
|
||||
"Pacific/Apia",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Bougainville",
|
||||
"Pacific/Chatham",
|
||||
"Pacific/Chuuk",
|
||||
"Pacific/Easter",
|
||||
"Pacific/Efate",
|
||||
"Pacific/Enderbury",
|
||||
"Pacific/Fakaofo",
|
||||
"Pacific/Fiji",
|
||||
"Pacific/Funafuti",
|
||||
"Pacific/Galapagos",
|
||||
"Pacific/Gambier",
|
||||
"Pacific/Guadalcanal",
|
||||
"Pacific/Guam",
|
||||
"Pacific/Honolulu",
|
||||
"Pacific/Johnston",
|
||||
"Pacific/Kanton",
|
||||
"Pacific/Kiritimati",
|
||||
"Pacific/Kosrae",
|
||||
"Pacific/Kwajalein",
|
||||
"Pacific/Majuro",
|
||||
"Pacific/Marquesas",
|
||||
"Pacific/Midway",
|
||||
"Pacific/Nauru",
|
||||
"Pacific/Niue",
|
||||
"Pacific/Norfolk",
|
||||
"Pacific/Noumea",
|
||||
"Pacific/Pago_Pago",
|
||||
"Pacific/Palau",
|
||||
"Pacific/Pitcairn",
|
||||
"Pacific/Pohnpei",
|
||||
"Pacific/Ponape",
|
||||
"Pacific/Port_Moresby",
|
||||
"Pacific/Rarotonga",
|
||||
"Pacific/Saipan",
|
||||
"Pacific/Samoa",
|
||||
"Pacific/Tahiti",
|
||||
"Pacific/Tarawa",
|
||||
"Pacific/Tongatapu",
|
||||
"Pacific/Truk",
|
||||
"Pacific/Wake",
|
||||
"Pacific/Wallis",
|
||||
"Pacific/Yap",
|
||||
"Poland",
|
||||
"Portugal",
|
||||
"ROC",
|
||||
"ROK",
|
||||
"Singapore",
|
||||
"Turkey",
|
||||
"UCT",
|
||||
"US/Alaska",
|
||||
"US/Aleutian",
|
||||
"US/Arizona",
|
||||
"US/Central",
|
||||
"US/East-Indiana",
|
||||
"US/Eastern",
|
||||
"US/Hawaii",
|
||||
"US/Indiana-Starke",
|
||||
"US/Michigan",
|
||||
"US/Mountain",
|
||||
"US/Pacific",
|
||||
"US/Samoa",
|
||||
"UTC",
|
||||
"Universal",
|
||||
"W-SU",
|
||||
"WET",
|
||||
"Zulu",
|
||||
}
|
147
jiggler.go
147
jiggler.go
|
@ -1,39 +1,156 @@
|
|||
package kvm
|
||||
|
||||
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))
|
||||
}
|
||||
|
|
|
@ -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"}},
|
||||
|
|
5
main.go
5
main.go
|
@ -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) {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 <></>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
|
||||
import { InputFieldWithLabel } from "./InputField";
|
||||
import { SelectMenuBasic } from "./SelectMenuBasic";
|
||||
|
||||
export interface JigglerConfig {
|
||||
inactivity_limit_seconds: number;
|
||||
jitter_percentage: number;
|
||||
schedule_cron_tab: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export function JigglerSetting({
|
||||
onSave,
|
||||
defaultJigglerState,
|
||||
}: {
|
||||
onSave: (jigglerConfig: JigglerConfig) => void;
|
||||
defaultJigglerState?: JigglerConfig;
|
||||
}) {
|
||||
const [jigglerConfigState, setJigglerConfigState] = useState<JigglerConfig>(
|
||||
defaultJigglerState || {
|
||||
inactivity_limit_seconds: 20,
|
||||
jitter_percentage: 0,
|
||||
schedule_cron_tab: "*/20 * * * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
);
|
||||
|
||||
const { send } = useJsonRpc();
|
||||
const [timezones, setTimezones] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
send("getTimezones", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) return;
|
||||
setTimezones(resp.result as string[]);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const timezoneOptions = useMemo(
|
||||
() =>
|
||||
timezones.map((timezone: string) => ({
|
||||
value: timezone,
|
||||
label: timezone,
|
||||
})),
|
||||
[timezones],
|
||||
);
|
||||
|
||||
const exampleConfigs = [
|
||||
{
|
||||
name: "Business Hours 9-17",
|
||||
config: {
|
||||
inactivity_limit_seconds: 60,
|
||||
jitter_percentage: 25,
|
||||
schedule_cron_tab: "0 * 9-17 * * 1-5",
|
||||
timezone: jigglerConfigState.timezone || "UTC",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Business Hours 8-17",
|
||||
config: {
|
||||
inactivity_limit_seconds: 60,
|
||||
jitter_percentage: 25,
|
||||
schedule_cron_tab: "0 * 8-17 * * 1-5",
|
||||
timezone: jigglerConfigState.timezone || "UTC",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Examples
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{exampleConfigs.map((example, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="XS"
|
||||
theme="light"
|
||||
text={example.name}
|
||||
onClick={() => setJigglerConfigState(example.config)}
|
||||
/>
|
||||
))}
|
||||
<LinkButton
|
||||
to="https://crontab.guru/examples.html"
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="More examples"
|
||||
LeadingIcon={LuExternalLink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 items-end gap-4 md:grid-cols-2">
|
||||
<InputFieldWithLabel
|
||||
required
|
||||
size="SM"
|
||||
label="Cron Schedule"
|
||||
description="Cron expression for scheduling"
|
||||
placeholder="*/20 * * * * *"
|
||||
value={jigglerConfigState.schedule_cron_tab}
|
||||
onChange={e =>
|
||||
setJigglerConfigState({
|
||||
...jigglerConfigState,
|
||||
schedule_cron_tab: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label="Inactivity Limit Seconds"
|
||||
description="Inactivity time before jiggle"
|
||||
value={jigglerConfigState.inactivity_limit_seconds}
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
onChange={e =>
|
||||
setJigglerConfigState({
|
||||
...jigglerConfigState,
|
||||
inactivity_limit_seconds: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<InputFieldWithLabel
|
||||
required
|
||||
size="SM"
|
||||
label="Random delay"
|
||||
description="To avoid recognizable patterns"
|
||||
placeholder="25"
|
||||
TrailingElm={<span className="px-2 text-xs text-slate-500">%</span>}
|
||||
value={jigglerConfigState.jitter_percentage}
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
onChange={e =>
|
||||
setJigglerConfigState({
|
||||
...jigglerConfigState,
|
||||
jitter_percentage: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label="Timezone"
|
||||
description="Timezone for cron schedule"
|
||||
value={jigglerConfigState.timezone || "UTC"}
|
||||
disabled={timezones.length === 0}
|
||||
onChange={e =>
|
||||
setJigglerConfigState({
|
||||
...jigglerConfigState,
|
||||
timezone: e.target.value,
|
||||
})
|
||||
}
|
||||
options={timezoneOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Save Jiggler Config"
|
||||
onClick={() => onSave(jigglerConfigState)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -10,7 +10,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
|
|||
export default function MacroBar() {
|
||||
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
||||
const { executeMacro } = useKeyboard();
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
useEffect(() => {
|
||||
setSendFn(send);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
export default function SettingsNestedSection({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="ml-2 border-l border-slate-800/30 pl-4 dark:border-slate-300/30">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback , useEffect, useState } from "react";
|
||||
|
||||
import { 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"}`,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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"}`,
|
||||
|
|
|
@ -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"}`,
|
||||
|
|
|
@ -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"}`,
|
||||
|
|
|
@ -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"}`,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -78,5 +78,5 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
|||
};
|
||||
}, [rpcDataChannel, onRequest]);
|
||||
|
||||
return [send];
|
||||
return { send };
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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"}`,
|
||||
|
|
|
@ -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"}`,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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"}`,
|
||||
|
|
|
@ -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"}`,
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
3
web.go
|
@ -457,6 +457,9 @@ func setupRouter() *gin.Engine {
|
|||
})
|
||||
})
|
||||
|
||||
// Audio memory allocation metrics endpoint
|
||||
protected.GET("/audio/memory-metrics", gin.WrapF(audio.HandleMemoryMetrics))
|
||||
|
||||
protected.GET("/microphone/process-metrics", func(c *gin.Context) {
|
||||
if currentSession == nil || currentSession.AudioInputManager == nil {
|
||||
c.JSON(200, gin.H{
|
||||
|
|
19
webrtc.go
19
webrtc.go
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue