package server import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "git.misaka.ren/m1saka/record-asr/internal/audio" "git.misaka.ren/m1saka/record-asr/internal/transcribe" ) // handleOpenAITranscription implements POST /v1/audio/transcriptions — an // OpenAI-compatible, *synchronous* transcription endpoint. Drop-in for any // client that speaks the OpenAI audio-transcription API. // // Multipart fields: // - file (required) the audio/video file // - model (ignored for compatibility; there is one model) // - language (accepted for compatibility; recognition auto-detects per // segment unless the server was configured with a fixed language) // - response_format json (default) | text | verbose_json | srt | vtt // // Auth: the shared ASR_PASSWORD, sent as Authorization: Bearer (the form // OpenAI clients use), X-Api-Key, or ?token= — same as the task API. func (s *Server) handleOpenAITranscription(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes) if err := r.ParseMultipartForm(s.cfg.MaxUploadBytes); err != nil { renderError(w, http.StatusRequestEntityTooLarge, "upload rejected: "+err.Error()) return } file, hdr, err := r.FormFile("file") if err != nil { renderError(w, http.StatusBadRequest, "missing 'file' multipart field") return } defer file.Close() name := hdr.Filename if name == "" { name = "upload.wav" } if !audio.IsSupported(name) { renderError(w, http.StatusUnsupportedMediaType, "unsupported extension; supported: "+audio.SupportedExtensions()) return } // The pipeline reads from a path; spill the upload to a temp file keeping // the original extension so the decoder picks the right path, then clean up. tmp, err := os.CreateTemp("", "asr-*"+filepath.Ext(name)) if err != nil { renderError(w, http.StatusInternalServerError, "create temp: "+err.Error()) return } tmpPath := tmp.Name() defer os.Remove(tmpPath) if _, err := io.Copy(tmp, file); err != nil { tmp.Close() renderError(w, http.StatusBadRequest, "read upload: "+err.Error()) return } tmp.Close() // Acquire a concurrency slot before the CPU/thread-heavy transcribe. // While all ASR_MAX_CONCURRENT slots are busy this blocks — excess // requests queue rather than ballooning onnxruntime threads. If the // client disconnects while waiting, reply 503 so it retries. select { case s.concSem <- struct{}{}: defer func() { <-s.concSem }() case <-r.Context().Done(): renderError(w, http.StatusServiceUnavailable, "transcription slots busy; retry later") return } res, err := s.pipe.Transcribe(tmpPath) if err != nil { renderError(w, http.StatusUnprocessableEntity, "transcribe failed: "+err.Error()) return } format := strings.ToLower(strings.TrimSpace(r.FormValue("response_format"))) if format == "" { format = "json" } switch format { case "text": w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = io.WriteString(w, res.Text) case "srt": w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = io.WriteString(w, toSRT(res)) case "vtt": w.Header().Set("Content-Type", "text/vtt; charset=utf-8") _, _ = io.WriteString(w, toVTT(res)) case "verbose_json": writeJSON(w, http.StatusOK, verboseJSON(res)) default: // "json" and anything else w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]string{"text": res.Text}) } } // verboseJSON mirrors OpenAI's verbose_json shape (a useful subset). func verboseJSON(res *transcribe.Result) map[string]any { segs := make([]map[string]any, 0, len(res.Segments)) for i, s := range res.Segments { segs = append(segs, map[string]any{ "id": i, "seek": i, "start": s.Start, "end": s.End, "text": s.Text, "language": strings.Trim(s.Lang, "<>|"), }) } return map[string]any{ "task": "transcribe", "language": strings.Trim(res.Lang, "<>|"), "duration": res.Duration, "text": res.Text, "segments": segs, } } // fmtTS formats a timestamp as HH:MM:SSmmm (SRT uses ",", VTT uses "."). func fmtTS(t float64, sep string) string { if t < 0 { t = 0 } ms := int(t*1000 + 0.5) h := ms / 3600000 ms %= 3600000 m := ms / 60000 ms %= 60000 s := ms / 1000 ms %= 1000 return fmt.Sprintf("%02d:%02d:%02d%s%03d", h, m, s, sep, ms) } func toSRT(res *transcribe.Result) string { var b strings.Builder for i, seg := range res.Segments { fmt.Fprintf(&b, "%d\n%s --> %s\n%s\n\n", i+1, fmtTS(seg.Start, ","), fmtTS(seg.End, ","), seg.Text) } return b.String() } func toVTT(res *transcribe.Result) string { var b strings.Builder b.WriteString("WEBVTT\n\n") for _, seg := range res.Segments { fmt.Fprintf(&b, "%s --> %s\n%s\n\n", fmtTS(seg.Start, "."), fmtTS(seg.End, "."), seg.Text) } return b.String() }