1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
package main

// gate.go implements a SERVER-SIDE silence gate (RMS over incoming PCM16): the
// browser streams mic audio dumbly; the server decides whether to forward each
// buffer to Gemini. Silence is dropped BEFORE the billed server->Gemini hop, so
// no client-side audio processing is needed and no tokens are paid for silence.

import (
	"encoding/base64"
	"math"
	"time"
)

// gateConfig holds the hardcoded silence-gate tuning.
type gateConfig struct {
	Threshold   float64       // RMS (0..1) above which audio counts as speech
	HangoverFor time.Duration // keep gate open this long after level drops
	PrerollFor  time.Duration // include this much audio before speech onset
}

var defaultGate = gateConfig{
	Threshold:   0.012,
	HangoverFor: 1000 * time.Millisecond,
	PrerollFor:  300 * time.Millisecond,
}

// silenceGate decides, per incoming PCM16 buffer, whether it should be forwarded
// to Gemini. It keeps a small preroll of recent buffers so the first syllable
// after silence isn't clipped, and a hangover so short pauses mid-speech don't
// close the gate. Not safe for concurrent use (called from one goroutine).
type silenceGate struct {
	cfg        gateConfig
	sampleRate int
	open       bool
	openUntil  time.Time
	preroll    []string // recent below-threshold buffers (base64), flushed on open
	prerollLen time.Duration
}

func newSilenceGate(cfg gateConfig, sampleRate int) *silenceGate {
	return &silenceGate{cfg: cfg, sampleRate: sampleRate}
}

// rms computes the root-mean-square (0..1) of little-endian PCM16 bytes.
func rmsPCM16(b []byte) float64 {
	n := len(b) / 2
	if n == 0 {
		return 0
	}
	var sum float64
	for i := 0; i+1 < len(b); i += 2 {
		s := int16(uint16(b[i]) | uint16(b[i+1])<<8)
		f := float64(s) / 32768.0
		sum += f * f
	}
	return math.Sqrt(sum / float64(n))
}

// gateDecision is what the gate returns for one incoming buffer.
type gateDecision struct {
	// forward are the base64 buffers to send to Gemini this step (may include
	// flushed preroll ahead of the current buffer). Empty means: drop (silence).
	forward []string
	// stateChanged is true when the open/closed state flipped this step.
	stateChanged bool
	// open is the new gate state.
	open bool
}

// push feeds one base64 PCM16 buffer and returns what to forward. b64 is decoded
// once to measure RMS; the original base64 is forwarded to avoid re-encoding.
func (g *silenceGate) push(b64 string, now time.Time) gateDecision {
	raw, err := base64.StdEncoding.DecodeString(b64)
	if err != nil {
		return gateDecision{}
	}
	level := rmsPCM16(raw)
	bufDur := time.Duration(audioSecondsFromBytes(len(raw), g.sampleRate) * float64(time.Second))

	speech := level >= g.cfg.Threshold
	prevOpen := g.open

	if speech {
		g.openUntil = now.Add(g.cfg.HangoverFor)
		g.open = true
	} else if g.open && now.After(g.openUntil) {
		g.open = false
	}

	dec := gateDecision{open: g.open, stateChanged: g.open != prevOpen}

	if g.open {
		if dec.stateChanged {
			// flush preroll first so speech onset isn't clipped
			dec.forward = append(dec.forward, g.preroll...)
			g.preroll = nil
			g.prerollLen = 0
		}
		dec.forward = append(dec.forward, b64)
	} else {
		// keep a rolling preroll of recent silence
		g.preroll = append(g.preroll, b64)
		g.prerollLen += bufDur
		for g.prerollLen > g.cfg.PrerollFor && len(g.preroll) > 1 {
			drop := g.preroll[0]
			g.preroll = g.preroll[1:]
			if dr, e := base64.StdEncoding.DecodeString(drop); e == nil {
				g.prerollLen -= time.Duration(audioSecondsFromBytes(len(dr), g.sampleRate) * float64(time.Second))
			} else {
				g.prerollLen = 0
			}
		}
	}
	return dec
}