package main import ( "context" "encoding/binary" "encoding/json" "fmt" "io" "log" "net" "net/http" "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. // 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. func pushToTalk(pcm []byte) voice.Request { return voice.Request{ ID: uint64(time.Now().UnixNano()), Method: voice.MethodPushToTalk, Params: mustMarshal(voice.PushToTalkReq{ Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, Lang: "mixed", Surface: voice.SurfacePCClient, }), } } 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) return } if !stepUpGate(w, session, requireStepUp) { return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), 400) return } if len(body) < 4 { http.Error(w, "too short", 400) return } log.Printf("ptt got %d bytes from client", len(body)) var d net.Dialer tc, err := d.DialContext(r.Context(), "tcp", voiceAddr) if err != nil { log.Printf("ptt dial voice: %v", err) http.Error(w, "voice unavailable", 503) return } defer tc.Close() req := pushToTalk(body) if err := writeFrame(tc, &req); err != nil { log.Printf("ptt write: %v", err) http.Error(w, err.Error(), 500) return } for { resp, push, err := readOneFrame(tc) if err != nil { log.Printf("ptt read: %v", err) http.Error(w, err.Error(), 500) return } if push != nil { continue } if resp.Error != nil { http.Error(w, resp.Error.Message, 500) return } var pttResp voice.PushToTalkResp if err := json.Unmarshal(resp.Result, &pttResp); err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1") // PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space // as "+", which is form encoding, and the client decodes this header // with decodeURIComponent, which only knows "%20" — so every space in a // spoken reply reached the on-page log as a plus sign. PathEscape is the // flavour decodeURIComponent actually reverses, which keeps the encoding // a property of the header rather than something the client has to know. w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText)) w.Write(pttResp.ReplyAudio.Bytes) return } } 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 { return fmt.Errorf("marshal: %w", err) } if len(body) > maxFrame { return fmt.Errorf("frame too large: %d", len(body)) } var hdr [4]byte binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) if _, err := w.Write(hdr[:]); err != nil { return err } _, err = w.Write(body) return err } func readFrame(r io.Reader, v any) error { var hdr [4]byte if _, err := io.ReadFull(r, hdr[:]); err != nil { return err } n := binary.BigEndian.Uint32(hdr[:]) if n > maxFrame { return fmt.Errorf("frame too large: %d", n) } buf := make([]byte, n) if _, err := io.ReadFull(r, buf); err != nil { return err } return json.Unmarshal(buf, v) } func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) { var raw struct { ID uint64 `json:"id"` Result json.RawMessage `json:"r,omitempty"` Error *voice.RpcError `json:"e,omitempty"` Kind voice.PushKind `json:"kind,omitempty"` Params json.RawMessage `json:"p,omitempty"` } if err := readFrame(r, &raw); err != nil { return nil, nil, err } if raw.Kind != "" && raw.ID == 0 { return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil } return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil } func mustMarshal(v any) json.RawMessage { b, err := json.Marshal(v) if err != nil { panic(err) } return b }