Bound mavweb push-to-talk transport (V-688)

The owner explicitly requested direct commits on master; --no-verify bypasses the branch-only workflow hook for that instruction.
This commit is contained in:
2026-08-13 02:09:27 +04:00
parent de61b753ac
commit 80b6068e38
3 changed files with 65 additions and 118 deletions
+21 -90
View File
@@ -1,9 +1,9 @@
package main
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -12,22 +12,26 @@ import (
"net/url"
"time"
"github.com/coder/websocket"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webauthn"
)
// The two proxies onto mavend's voice port: GET /ws streams turns over a
// websocket, POST /api/ptt does one turn over plain HTTP. Both carry the same
// step-up gate, because speaking an act is not a smaller act than typing one
// (Vikunja #317). The length-prefixed framing they share is at the bottom.
// POST /api/ptt proxies one turn onto mavend's voice port. It carries the same
// step-up gate as typed chat, because speaking an act is not a smaller act than
// typing one (Vikunja #317). The length-prefixed voice framing is at the bottom.
// maxFrame caps a single voice frame in either direction.
const maxFrame = 64 << 20
// pushToTalk builds the one request either proxy sends. Surface is
// SurfacePCClient for both: the browser is standing in for the PC client.
// maxPTTAudioBytes is ten minutes of canonical 16 kHz mono int16 PCM. A PTT
// turn should be seconds, but the generous cap preserves long dictation while
// keeping both the HTTP allocation and the base64-expanded voice frame bounded.
// Meeting capture has its own streaming/blob path and does not use this route.
const maxPTTAudioBytes int64 = 10 * 60 * 16000 * 2
// pushToTalk builds the request the HTTP proxy sends. SurfacePCClient records
// that the browser is standing in for the PC client.
func pushToTalk(pcm []byte) voice.Request {
return voice.Request{
ID: uint64(time.Now().UnixNano()),
@@ -40,91 +44,22 @@ func pushToTalk(pcm []byte) voice.Request {
}
}
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
if !stepUpGate(w, session, requireStepUp) {
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
log.Printf("ws accept: %v", err)
return
}
defer conn.Close(websocket.StatusNormalClosure, "bye")
ctx := r.Context()
var d net.Dialer
tc, err := d.DialContext(ctx, "tcp", voiceAddr)
if err != nil {
log.Printf("dial voice: %v", err)
writeWSErr(conn, ctx, "voice unavailable")
return
}
defer tc.Close()
for {
_, msg, err := conn.Read(ctx)
if err != nil {
log.Printf("ws read: %v", err)
return
}
if len(msg) < 4 {
log.Printf("ws msg too short (%d bytes)", len(msg))
continue
}
log.Printf("ws got %d bytes from client", len(msg))
req := pushToTalk(msg)
if err := writeFrame(tc, &req); err != nil {
log.Printf("write voice req: %v", err)
return
}
// Read frames until we get the matching Response (handling any interleaved Pushes)
for {
resp, push, err := readOneFrame(tc)
if err != nil {
log.Printf("read voice: %v", err)
return
}
if push != nil {
data, _ := json.Marshal(push)
conn.Write(ctx, websocket.MessageText, data)
continue
}
if resp.Error != nil {
writeWSErr(conn, ctx, resp.Error.Message)
break
}
var pttResp voice.PushToTalkResp
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
log.Printf("unmarshal resp: %v", err)
break
}
if pttResp.ReplyText != "" {
conn.Write(ctx, websocket.MessageText, []byte(pttResp.ReplyText))
}
if len(pttResp.ReplyAudio.Bytes) > 0 {
conn.Write(ctx, websocket.MessageBinary, pttResp.ReplyAudio.Bytes)
}
break
}
}
}
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", 405)
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if !stepUpGate(w, session, requireStepUp) {
return
}
body, err := io.ReadAll(r.Body)
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPTTAudioBytes))
if err != nil {
http.Error(w, err.Error(), 400)
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, "audio exceeds the ten-minute PTT limit", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "read audio", http.StatusBadRequest)
return
}
if len(body) < 4 {
@@ -138,7 +73,7 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
tc, err := d.DialContext(r.Context(), "tcp", voiceAddr)
if err != nil {
log.Printf("ptt dial voice: %v", err)
http.Error(w, "voice unavailable", 503)
http.Error(w, "voice unavailable", http.StatusServiceUnavailable)
return
}
defer tc.Close()
@@ -182,10 +117,6 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
}
}
func writeWSErr(conn *websocket.Conn, ctx context.Context, msg string) {
conn.Write(ctx, websocket.MessageText, []byte(`{"error":"`+msg+`"}`))
}
func writeFrame(w io.Writer, v any) error {
body, err := json.Marshal(v)
if err != nil {