feat: add bitrate measurement and quality presets
- sender tracks payload bytes; engine measures stream bitrate over a sliding window and exposes it in Status - GUI status label and tray tooltip show the live bitrate (e.g. 36 Mbps) - add a Preset dropdown (Low/Medium/High/Ultra) that sets quality+fps together and applies live via SetConfig Verified: 36 Mbps shown for Medium (quality 70 @ 30fps) matches the wire measurement.
This commit is contained in:
@@ -10,6 +10,7 @@ package flinger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
@@ -92,6 +93,9 @@ type Status struct {
|
||||
Frames int64
|
||||
Dropped int64
|
||||
Conns int
|
||||
// Bitrate is the measured stream bandwidth in bits per second, averaged
|
||||
// over the previous measurement window.
|
||||
Bitrate int64
|
||||
// Err is the most recent runtime error encountered (capture, encode,
|
||||
// packet or audio), or nil if the stream is healthy.
|
||||
Err error
|
||||
@@ -111,9 +115,16 @@ type Engine struct {
|
||||
start time.Time
|
||||
stop chan struct{}
|
||||
|
||||
frames atomic.Int64
|
||||
frames atomic.Int64
|
||||
|
||||
errMu sync.RWMutex
|
||||
lastErr error
|
||||
|
||||
// bitrate tracking
|
||||
bitMu sync.Mutex
|
||||
bitLast time.Time
|
||||
bitBytes int64
|
||||
bitrate int64
|
||||
}
|
||||
|
||||
// setErr records the most recent runtime error. Pass nil to clear it.
|
||||
@@ -254,15 +265,48 @@ func (e *Engine) Status() Status {
|
||||
e.errMu.RLock()
|
||||
err := e.lastErr
|
||||
e.errMu.RUnlock()
|
||||
|
||||
return Status{
|
||||
Running: e.stop != nil,
|
||||
Frames: e.frames.Load(),
|
||||
Dropped: e.sender.Dropped(),
|
||||
Conns: e.sender.NumConns(),
|
||||
Bitrate: e.measureBitrate(),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// bitrateWindow is the sliding window over which bitrate is averaged.
|
||||
const bitrateWindow = 2 * time.Second
|
||||
|
||||
// measureBitrate computes the current stream bitrate (bits/sec) over a
|
||||
// sliding window. It is called from Status.
|
||||
func (e *Engine) measureBitrate() int64 {
|
||||
e.bitMu.Lock()
|
||||
defer e.bitMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
bytes := e.sender.BytesSent()
|
||||
|
||||
if e.bitLast.IsZero() {
|
||||
e.bitLast = now
|
||||
e.bitBytes = bytes
|
||||
return 0
|
||||
}
|
||||
|
||||
elapsed := now.Sub(e.bitLast)
|
||||
if elapsed < bitrateWindow {
|
||||
return e.bitrate
|
||||
}
|
||||
|
||||
// Bytes accumulated since the previous sample.
|
||||
delta := bytes - e.bitBytes
|
||||
e.bitrate = int64(float64(delta*8) / elapsed.Seconds())
|
||||
e.bitLast = now
|
||||
e.bitBytes = bytes
|
||||
return e.bitrate
|
||||
}
|
||||
|
||||
// audioLoop reads raw PCM and emits WAVE packets. start is the shared
|
||||
// reference clock used by the video loop so audio and video timestamps stay
|
||||
// aligned on the receiver.
|
||||
@@ -375,10 +419,11 @@ func (e *Engine) statsLoop() {
|
||||
select {
|
||||
case <-tick.C:
|
||||
st := e.Status()
|
||||
rate := formatBitrate(st.Bitrate)
|
||||
if st.Dropped > 0 {
|
||||
log.Printf("flinger: %d frames, %d dropped, %d conns", st.Frames, st.Dropped, st.Conns)
|
||||
log.Printf("flinger: %d frames, %d dropped, %d conns, %s", st.Frames, st.Dropped, st.Conns, rate)
|
||||
} else {
|
||||
log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns)
|
||||
log.Printf("flinger: %d frames, %d conns, %s", st.Frames, st.Conns, rate)
|
||||
}
|
||||
case <-e.stop:
|
||||
return
|
||||
@@ -386,6 +431,18 @@ func (e *Engine) statsLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// formatBitrate renders a bits/sec value in a human-readable form.
|
||||
func formatBitrate(bps int64) string {
|
||||
switch {
|
||||
case bps >= 1_000_000:
|
||||
return fmt.Sprintf("%.1f Mbps", float64(bps)/1_000_000)
|
||||
case bps >= 1_000:
|
||||
return fmt.Sprintf("%.0f kbps", float64(bps)/1_000)
|
||||
default:
|
||||
return fmt.Sprintf("%d bps", bps)
|
||||
}
|
||||
}
|
||||
|
||||
// frameSource abstracts the frame source: real capture or the test pattern.
|
||||
type frameSource interface {
|
||||
Next() (*capture.VideoFrame, error)
|
||||
|
||||
@@ -129,6 +129,63 @@ func TestValidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBitrateMeasurement verifies the engine reports a non-zero bitrate once
|
||||
// it has been streaming for a bit.
|
||||
func TestBitrateMeasurement(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Source = "pattern"
|
||||
cfg.Port = 19759
|
||||
|
||||
eng, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
eng.Start()
|
||||
defer eng.Stop()
|
||||
|
||||
// Connect a receiver so packets actually flow, and wait for the bitrate
|
||||
// window to produce a measurement.
|
||||
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
go func() {
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
if _, err := conn.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if eng.Status().Bitrate > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Error("bitrate stayed 0 after 4s of streaming")
|
||||
}
|
||||
|
||||
// TestFormatBitrate checks the human-readable bitrate formatting.
|
||||
func TestFormatBitrate(t *testing.T) {
|
||||
cases := []struct {
|
||||
bps int64
|
||||
want string
|
||||
}{
|
||||
{500, "500 bps"},
|
||||
{5_000, "5 kbps"},
|
||||
{5_000_000, "5.0 Mbps"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatBitrate(c.bps); got != c.want {
|
||||
t.Errorf("formatBitrate(%d) = %q, want %q", c.bps, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
|
||||
Reference in New Issue
Block a user