package main import ( "encoding/binary" "encoding/json" "errors" "fmt" "io" "log" "net" "net/http" "net/url" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/webauthn" ) // 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 // 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()), Method: voice.MethodPushToTalk, Params: mustMarshal(voice.PushToTalkReq{ Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, Lang: "mixed", Surface: voice.SurfacePCClient, }), } } func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { if r.Method != http.MethodPost { writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed, "POST only", nil) return } if !stepUpGate(w, r, session, requireStepUp) { return } body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPTTAudioBytes)) if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { writeProblem(w, r, http.StatusRequestEntityTooLarge, problemPayloadTooLarge, "audio exceeds the ten-minute PTT limit", err) return } writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest, "read audio", fmt.Errorf("read PTT audio: %w", err)) return } if len(body) < 4 { writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest, "audio too short", nil) 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 { writeProblem(w, r, http.StatusServiceUnavailable, problemVoiceUnavailable, "voice unavailable", fmt.Errorf("dial voice service: %w", err)) return } defer tc.Close() req := pushToTalk(body) if err := writeFrame(tc, &req); err != nil { writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport, "voice request failed", fmt.Errorf("write voice request: %w", err)) return } for { resp, push, err := readOneFrame(tc) if err != nil { writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport, "voice response failed", fmt.Errorf("read voice response: %w", err)) return } if push != nil { continue } if resp.Error != nil { writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse, "voice turn failed", fmt.Errorf("voice RPC error: %s", resp.Error.Message)) return } var pttResp voice.PushToTalkResp if err := json.Unmarshal(resp.Result, &pttResp); err != nil { writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse, "voice response failed", fmt.Errorf("decode voice response: %w", err)) 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 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 }