163 lines
4.8 KiB
Go
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()
|
|
}
|