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