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:
+21
-90
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user