Files

163 lines
4.8 KiB
Go

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 <pw> (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:SS<sep>mmm (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()
}