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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
@@ -92,6 +93,9 @@ type Status struct {
|
|||||||
Frames int64
|
Frames int64
|
||||||
Dropped int64
|
Dropped int64
|
||||||
Conns int
|
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,
|
// Err is the most recent runtime error encountered (capture, encode,
|
||||||
// packet or audio), or nil if the stream is healthy.
|
// packet or audio), or nil if the stream is healthy.
|
||||||
Err error
|
Err error
|
||||||
@@ -111,9 +115,16 @@ type Engine struct {
|
|||||||
start time.Time
|
start time.Time
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
|
|
||||||
frames atomic.Int64
|
frames atomic.Int64
|
||||||
|
|
||||||
errMu sync.RWMutex
|
errMu sync.RWMutex
|
||||||
lastErr error
|
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.
|
// setErr records the most recent runtime error. Pass nil to clear it.
|
||||||
@@ -254,15 +265,48 @@ func (e *Engine) Status() Status {
|
|||||||
e.errMu.RLock()
|
e.errMu.RLock()
|
||||||
err := e.lastErr
|
err := e.lastErr
|
||||||
e.errMu.RUnlock()
|
e.errMu.RUnlock()
|
||||||
|
|
||||||
return Status{
|
return Status{
|
||||||
Running: e.stop != nil,
|
Running: e.stop != nil,
|
||||||
Frames: e.frames.Load(),
|
Frames: e.frames.Load(),
|
||||||
Dropped: e.sender.Dropped(),
|
Dropped: e.sender.Dropped(),
|
||||||
Conns: e.sender.NumConns(),
|
Conns: e.sender.NumConns(),
|
||||||
|
Bitrate: e.measureBitrate(),
|
||||||
Err: err,
|
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
|
// 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
|
// reference clock used by the video loop so audio and video timestamps stay
|
||||||
// aligned on the receiver.
|
// aligned on the receiver.
|
||||||
@@ -375,10 +419,11 @@ func (e *Engine) statsLoop() {
|
|||||||
select {
|
select {
|
||||||
case <-tick.C:
|
case <-tick.C:
|
||||||
st := e.Status()
|
st := e.Status()
|
||||||
|
rate := formatBitrate(st.Bitrate)
|
||||||
if st.Dropped > 0 {
|
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 {
|
} 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:
|
case <-e.stop:
|
||||||
return
|
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.
|
// frameSource abstracts the frame source: real capture or the test pattern.
|
||||||
type frameSource interface {
|
type frameSource interface {
|
||||||
Next() (*capture.VideoFrame, error)
|
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 {
|
func itoa(v int) string {
|
||||||
if v == 0 {
|
if v == 0 {
|
||||||
return "0"
|
return "0"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type App struct {
|
|||||||
portEnt *widget.Entry
|
portEnt *widget.Entry
|
||||||
qualitySel *widget.Select
|
qualitySel *widget.Select
|
||||||
fpsSel *widget.Select
|
fpsSel *widget.Select
|
||||||
|
presetSel *widget.Select
|
||||||
nameEnt *widget.Entry
|
nameEnt *widget.Entry
|
||||||
audioChk *widget.Check
|
audioChk *widget.Check
|
||||||
announceChk *widget.Check
|
announceChk *widget.Check
|
||||||
@@ -147,6 +148,12 @@ func (g *App) buildUI() {
|
|||||||
g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, g.applyLiveSettings)
|
g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, g.applyLiveSettings)
|
||||||
g.fpsSel.SetSelected(itoa(g.cfg.FPS))
|
g.fpsSel.SetSelected(itoa(g.cfg.FPS))
|
||||||
|
|
||||||
|
// Preset: one-click quality/fps combos. Choosing one sets the Quality and
|
||||||
|
// Frame rate selectors and applies them (live if running). Created after
|
||||||
|
// the quality/fps selects so applyPreset's references are valid.
|
||||||
|
g.presetSel = widget.NewSelect([]string{"Low", "Medium", "High", "Ultra"}, g.applyPreset)
|
||||||
|
g.presetSel.SetSelected("High")
|
||||||
|
|
||||||
// Audio.
|
// Audio.
|
||||||
g.audioChk = widget.NewCheck("Capture system audio", nil)
|
g.audioChk = widget.NewCheck("Capture system audio", nil)
|
||||||
g.audioChk.SetChecked(g.cfg.Audio)
|
g.audioChk.SetChecked(g.cfg.Audio)
|
||||||
@@ -173,6 +180,7 @@ func (g *App) buildUI() {
|
|||||||
{Text: "Port", Widget: g.portEnt},
|
{Text: "Port", Widget: g.portEnt},
|
||||||
{Text: "Source", Widget: g.srcSel},
|
{Text: "Source", Widget: g.srcSel},
|
||||||
{Text: "Monitor", Widget: g.monSel},
|
{Text: "Monitor", Widget: g.monSel},
|
||||||
|
{Text: "Preset", Widget: g.presetSel},
|
||||||
{Text: "Quality", Widget: g.qualitySel},
|
{Text: "Quality", Widget: g.qualitySel},
|
||||||
{Text: "Frame rate", Widget: g.fpsSel},
|
{Text: "Frame rate", Widget: g.fpsSel},
|
||||||
{Text: "", Widget: g.audioChk},
|
{Text: "", Widget: g.audioChk},
|
||||||
@@ -241,6 +249,25 @@ func (g *App) selectedMonitorIndex() int {
|
|||||||
return g.cfg.StreamIndex
|
return g.cfg.StreamIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyPreset applies a named quality/fps preset to the Quality and Frame
|
||||||
|
// rate selectors, then pushes it live if the engine is running.
|
||||||
|
func (g *App) applyPreset(string) {
|
||||||
|
var q, f int
|
||||||
|
switch g.presetSel.Selected {
|
||||||
|
case "Low":
|
||||||
|
q, f = 50, 15
|
||||||
|
case "Medium":
|
||||||
|
q, f = 70, 30
|
||||||
|
case "Ultra":
|
||||||
|
q, f = 100, 60
|
||||||
|
default: // High
|
||||||
|
q, f = 85, 30
|
||||||
|
}
|
||||||
|
g.qualitySel.SetSelected(itoa(q))
|
||||||
|
g.fpsSel.SetSelected(itoa(f))
|
||||||
|
g.applyLiveSettings("")
|
||||||
|
}
|
||||||
|
|
||||||
// applyLiveSettings pushes the current form values (quality, fps, name,
|
// applyLiveSettings pushes the current form values (quality, fps, name,
|
||||||
// audio, announce) into a running engine via SetConfig so changes take
|
// audio, announce) into a running engine via SetConfig so changes take
|
||||||
// effect without restarting the stream. When the engine is not running it is
|
// effect without restarting the stream. When the engine is not running it is
|
||||||
@@ -354,6 +381,9 @@ func (g *App) watchStats() {
|
|||||||
g.statusLab.Importance = widget.SuccessImportance
|
g.statusLab.Importance = widget.SuccessImportance
|
||||||
}
|
}
|
||||||
tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped"
|
tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped"
|
||||||
|
if st.Bitrate > 0 {
|
||||||
|
tip += " · " + formatBitrate(st.Bitrate)
|
||||||
|
}
|
||||||
if st.Err != nil {
|
if st.Err != nil {
|
||||||
tip += " · error"
|
tip += " · error"
|
||||||
}
|
}
|
||||||
@@ -372,12 +402,27 @@ func formatStatus(st flinger.Status) string {
|
|||||||
if st.Dropped > 0 {
|
if st.Dropped > 0 {
|
||||||
base += " · " + itoa(int(st.Dropped)) + " dropped"
|
base += " · " + itoa(int(st.Dropped)) + " dropped"
|
||||||
}
|
}
|
||||||
|
if st.Bitrate > 0 {
|
||||||
|
base += " · " + formatBitrate(st.Bitrate)
|
||||||
|
}
|
||||||
if st.Err != nil {
|
if st.Err != nil {
|
||||||
base += "\nError: " + st.Err.Error()
|
base += "\nError: " + st.Err.Error()
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// stop halts the engine and returns the UI to the stopped state.
|
// stop halts the engine and returns the UI to the stopped state.
|
||||||
func (g *App) stop() {
|
func (g *App) stop() {
|
||||||
if g.statsDone != nil {
|
if g.statsDone != nil {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type Sender struct {
|
|||||||
port int
|
port int
|
||||||
|
|
||||||
dropped atomic.Int64
|
dropped atomic.Int64
|
||||||
|
bytes atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an unconnected Sender.
|
// New creates an unconnected Sender.
|
||||||
@@ -95,6 +96,10 @@ func (s *Sender) Send(b []byte) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
// Count the packet once as produced bandwidth (independent of how many
|
||||||
|
// receivers are attached).
|
||||||
|
s.bytes.Add(int64(len(b)))
|
||||||
|
|
||||||
for c, ch := range s.conns {
|
for c, ch := range s.conns {
|
||||||
switch {
|
switch {
|
||||||
case len(ch) > dropAt:
|
case len(ch) > dropAt:
|
||||||
@@ -125,6 +130,11 @@ func (s *Sender) Dropped() int64 {
|
|||||||
return s.dropped.Load()
|
return s.dropped.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BytesSent returns the total number of payload bytes handed to Send.
|
||||||
|
func (s *Sender) BytesSent() int64 {
|
||||||
|
return s.bytes.Load()
|
||||||
|
}
|
||||||
|
|
||||||
// Close shuts down the listener and waits for all writer goroutines to
|
// Close shuts down the listener and waits for all writer goroutines to
|
||||||
// drain. After Close returns the Sender must not be reused.
|
// drain. After Close returns the Sender must not be reused.
|
||||||
func (s *Sender) Close() {
|
func (s *Sender) Close() {
|
||||||
|
|||||||
Reference in New Issue
Block a user