first commit

This commit is contained in:
2026-06-04 18:10:52 +07:00
commit b5c083e06f
105 changed files with 8172 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
package api
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"go-whisper-api/whisper"
)
const (
cacheWaiting = "waiting"
cacheReady = "ready"
fileParams = "params.conf"
fileAudio = "audio.wav"
fileAudioJSON = "audio.json"
)
type TaskParams struct {
ID string `json:"id"`
Created string `json:"created"`
Processed string `json:"processed,omitempty"`
Status string `json:"status"`
Model string `json:"model"`
Language string `json:"language,omitempty"`
Punctuation bool `json:"punctuation,omitempty"`
Speakers bool `json:"speakers,omitempty"`
NumClusters int `json:"num_clusters,omitempty"`
Text string `json:"text,omitempty"`
Words []whisper.Word `json:"words,omitempty"`
Error string `json:"error,omitempty"`
}
type AudioJSON struct {
Waveform []float64 `json:"waveform"`
Buckets int `json:"buckets"`
}
type DiskCache struct {
root string
}
func (c *DiskCache) Root() string {
return c.root
}
func resolveCacheRoot(root string) (string, error) {
if root == "" {
root = "./cache"
}
abs, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("cache dir: %w", err)
}
return filepath.Clean(abs), nil
}
func NewDiskCache(root string) (*DiskCache, error) {
abs, err := resolveCacheRoot(root)
if err != nil {
return nil, err
}
c := &DiskCache{root: abs}
for _, sub := range []string{cacheWaiting, cacheReady} {
if err := os.MkdirAll(filepath.Join(abs, sub), 0o755); err != nil {
return nil, err
}
}
return c, nil
}
func (c *DiskCache) waitingDir(id string) string {
return filepath.Join(c.root, cacheWaiting, id)
}
func (c *DiskCache) readyDir(id string) string {
return filepath.Join(c.root, cacheReady, id)
}
func (c *DiskCache) locate(id string) (dir, phase string, ok bool) {
if id == "" {
return "", "", false
}
ready := c.readyDir(id)
if st, err := os.Stat(ready); err == nil && st.IsDir() {
return ready, cacheReady, true
}
waiting := c.waitingDir(id)
if st, err := os.Stat(waiting); err == nil && st.IsDir() {
return waiting, cacheWaiting, true
}
return "", "", false
}
func (c *DiskCache) Enqueue(id string, params TaskParams, audioWavPath string) error {
dir := c.waitingDir(id)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
params.ID = id
if err := c.writeParams(dir, params); err != nil {
_ = os.RemoveAll(dir)
return err
}
dst := filepath.Join(dir, fileAudio)
if err := os.Rename(audioWavPath, dst); err != nil {
if err2 := copyFile(audioWavPath, dst); err2 != nil {
_ = os.RemoveAll(dir)
return fmt.Errorf("move audio to %s: %w", dst, err2)
}
_ = os.Remove(audioWavPath)
}
return nil
}
func (c *DiskCache) writeParams(dir string, params TaskParams) error {
data, err := json.Marshal(params)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, fileParams), data, 0o644)
}
func (c *DiskCache) LoadParams(id string) (TaskParams, string, error) {
dir, phase, ok := c.locate(id)
if !ok {
return TaskParams{}, "", fmt.Errorf("task not found")
}
params, err := c.readParams(dir)
if err != nil {
return TaskParams{}, "", err
}
return params, phase, nil
}
func (c *DiskCache) readParams(dir string) (TaskParams, error) {
path := filepath.Join(dir, fileParams)
data, err := os.ReadFile(path)
if err != nil {
return TaskParams{}, fmt.Errorf("read %s: %w", path, err)
}
var params TaskParams
if err := json.Unmarshal(data, &params); err != nil {
return TaskParams{}, err
}
return params, nil
}
func (c *DiskCache) List() (map[string]map[string]string, error) {
out := make(map[string]map[string]string)
for _, phase := range []string{cacheWaiting, cacheReady} {
base := filepath.Join(c.root, phase)
entries, err := os.ReadDir(base)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
id := e.Name()
params, err := c.readParams(filepath.Join(base, id))
if err != nil {
continue
}
out[id] = map[string]string{
"created": params.Created,
"status": params.Status,
}
}
}
return out, nil
}
func (c *DiskCache) NextWaiting() (string, bool, error) {
base := filepath.Join(c.root, cacheWaiting)
entries, err := os.ReadDir(base)
if err != nil {
if os.IsNotExist(err) {
return "", false, nil
}
return "", false, err
}
type item struct {
id string
created time.Time
}
var pending []item
for _, e := range entries {
if !e.IsDir() {
continue
}
params, err := c.readParams(filepath.Join(base, e.Name()))
if err != nil || params.Status != string(statusWaiting) {
continue
}
t, _ := time.ParseInLocation("2006-01-02 15:04:05", params.Created, time.Local)
pending = append(pending, item{id: e.Name(), created: t})
}
if len(pending) == 0 {
return "", false, nil
}
sort.Slice(pending, func(i, j int) bool {
return pending[i].created.Before(pending[j].created)
})
return pending[0].id, true, nil
}
func (c *DiskCache) SetStatus(id string, status taskStatus, mutate func(*TaskParams)) error {
dir := c.waitingDir(id)
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("task %s not in waiting", id)
}
params, err := c.readParams(dir)
if err != nil {
return err
}
params.Status = string(status)
if mutate != nil {
mutate(&params)
}
return c.writeParams(dir, params)
}
func (c *DiskCache) FinishWaiting(id string, result whisper.TranscriptResult, errMsg string, waveform []float64) error {
dir := c.waitingDir(id)
params, err := c.readParams(dir)
if err != nil {
return err
}
params.Processed = time.Now().Format("2006-01-02 15:04:05")
if errMsg != "" {
params.Status = string(statusError)
params.Error = errMsg
} else {
params.Status = string(statusReady)
params.Text = result.Text
params.Words = result.Words
}
if err := c.writeParams(dir, params); err != nil {
return fmt.Errorf("update %s: %w", filepath.Join(dir, fileParams), err)
}
if len(waveform) > 0 {
aj := AudioJSON{Waveform: waveform, Buckets: len(waveform)}
data, err := json.Marshal(aj)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, fileAudioJSON), data, 0o644); err != nil {
return err
}
}
return nil
}
func (c *DiskCache) PromoteToReady(id string) error {
if _, phase, ok := c.locate(id); ok && phase == cacheReady {
return nil
}
src := c.waitingDir(id)
dst := c.readyDir(id)
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("task %s not in waiting", id)
}
if _, err := os.Stat(dst); err == nil {
return os.RemoveAll(src)
}
return os.Rename(src, dst)
}
func (c *DiskCache) Delete(id string) bool {
dir, _, ok := c.locate(id)
if !ok {
return false
}
_ = os.RemoveAll(dir)
return true
}
func (c *DiskCache) AudioPath(id string) (string, bool) {
dir, _, ok := c.locate(id)
if !ok {
return "", false
}
p := filepath.Join(dir, fileAudio)
if _, err := os.Stat(p); err != nil {
return "", false
}
return p, true
}
func (c *DiskCache) Waveform(id string) ([]float64, error) {
dir, _, ok := c.locate(id)
if !ok {
return nil, fmt.Errorf("task not found")
}
data, err := os.ReadFile(filepath.Join(dir, fileAudioJSON))
if err == nil {
var aj AudioJSON
if json.Unmarshal(data, &aj) == nil && len(aj.Waveform) > 0 {
return aj.Waveform, nil
}
}
return waveformFromWav(filepath.Join(dir, fileAudio), 512)
}
func (c *DiskCache) RecoverInterrupted() error {
base := filepath.Join(c.root, cacheWaiting)
entries, err := os.ReadDir(base)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
id := e.Name()
dir := filepath.Join(base, id)
params, err := c.readParams(dir)
if err != nil {
continue
}
switch params.Status {
case string(statusProcessing):
params.Status = string(statusWaiting)
_ = c.writeParams(dir, params)
case string(statusReady), string(statusError):
_ = c.PromoteToReady(id)
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func isValidTaskID(id string) bool {
return id != "" && !strings.Contains(id, "..") && !strings.ContainsAny(id, `/\`)
}
+127
View File
@@ -0,0 +1,127 @@
package api
import (
"os"
"path/filepath"
"testing"
)
func TestDiskCache_params_promote_list(t *testing.T) {
root := t.TempDir()
c, err := NewDiskCache(root)
if err != nil {
t.Fatal(err)
}
id := "319d72c7-301d-44fd-935f-3526dfb70f9f"
dir := c.waitingDir(id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
params := TaskParams{
ID: id,
Created: "2026-03-31 21:37:46",
Status: string(statusReady),
Model: "ggml-small",
Text: "test",
}
if err := c.writeParams(dir, params); err != nil {
t.Fatal(err)
}
list, err := c.List()
if err != nil {
t.Fatal(err)
}
if list[id]["status"] != string(statusReady) {
t.Fatalf("list: %v", list[id])
}
if err := c.PromoteToReady(id); err != nil {
t.Fatal(err)
}
_, phase, err := c.LoadParams(id)
if err != nil || phase != cacheReady {
t.Fatalf("phase=%s err=%v", phase, err)
}
if _, err := os.Stat(filepath.Join(c.readyDir(id), fileParams)); err != nil {
t.Fatal(err)
}
}
func TestIsValidTaskID(t *testing.T) {
if !isValidTaskID("319d72c7-301d-44fd-935f-3526dfb70f9f") {
t.Fatal("uuid should be valid")
}
if isValidTaskID("../etc") {
t.Fatal("path traversal must be rejected")
}
}
func TestResolveCacheRoot_absolute(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
abs, err := resolveCacheRoot("./cache")
if err != nil {
t.Fatal(err)
}
want := filepath.Join(cwd, "cache")
if abs != want {
t.Fatalf("got %q want %q", abs, want)
}
}
func TestDiskCache_RecoverInterrupted(t *testing.T) {
root := t.TempDir()
c, err := NewDiskCache(root)
if err != nil {
t.Fatal(err)
}
id := "task-1"
dir := c.waitingDir(id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := c.writeParams(dir, TaskParams{
ID: id, Created: "2026-01-01 00:00:00", Status: string(statusProcessing), Model: "m",
}); err != nil {
t.Fatal(err)
}
if err := c.RecoverInterrupted(); err != nil {
t.Fatal(err)
}
p, _, err := c.LoadParams(id)
if err != nil {
t.Fatal(err)
}
if p.Status != string(statusWaiting) {
t.Fatalf("got %q", p.Status)
}
}
func TestDiskCache_RecoverInterrupted_promotesReady(t *testing.T) {
root := t.TempDir()
c, err := NewDiskCache(root)
if err != nil {
t.Fatal(err)
}
id := "done-task"
dir := c.waitingDir(id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := c.writeParams(dir, TaskParams{
ID: id, Created: "2026-01-01 00:00:00", Status: string(statusReady), Model: "m", Text: "hi",
}); err != nil {
t.Fatal(err)
}
if err := c.RecoverInterrupted(); err != nil {
t.Fatal(err)
}
_, phase, err := c.LoadParams(id)
if err != nil || phase != cacheReady {
t.Fatalf("phase=%s err=%v", phase, err)
}
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"go-whisper-api/garbage"
"go-whisper-api/whisper"
)
func applyGarbage(r whisper.TranscriptResult, patterns []string) whisper.TranscriptResult {
if len(patterns) == 0 {
return r
}
r.Text = garbage.FilterText(r.Text, patterns)
if len(r.Words) == 0 {
return r
}
gw := make([]garbage.Word, len(r.Words))
for i, w := range r.Words {
gw[i] = garbage.Word{Word: w.Word, Start: w.Start, Stop: w.Stop}
}
gw = garbage.FilterWords(gw, patterns)
r.Words = make([]whisper.Word, len(gw))
for i, w := range gw {
r.Words[i] = whisper.Word{Word: w.Word, Start: w.Start, Stop: w.Stop}
}
return r
}
+181
View File
@@ -0,0 +1,181 @@
package api
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
)
// Subdirectories under models_dir that hold non-Whisper assets (VAD, punctuation, etc.).
var reservedModelSubdirs = map[string]struct{}{
"vad": {},
"punctuation": {},
}
// Top-level .bin files that are not Whisper STT models.
var excludedWhisperModelFiles = map[string]struct{}{
"vad.bin": {},
}
type Registry struct {
dir string
mu sync.RWMutex
}
func NewRegistry(dir string) *Registry {
return &Registry{dir: dir}
}
func isReservedModelSubdir(name string) bool {
_, ok := reservedModelSubdirs[strings.ToLower(name)]
return ok
}
func isWhisperModelFile(name string) bool {
if !strings.HasSuffix(strings.ToLower(name), ".bin") {
return false
}
_, excluded := excludedWhisperModelFiles[strings.ToLower(name)]
return !excluded
}
func (r *Registry) List() ([]string, error) {
r.mu.RLock()
defer r.mu.RUnlock()
entries, err := os.ReadDir(r.dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var models []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !isWhisperModelFile(name) {
continue
}
models = append(models, strings.TrimSuffix(name, filepath.Ext(name)))
}
return models, nil
}
func (r *Registry) Path(id string) (string, error) {
if id == "" {
return "", fmt.Errorf("model id is required")
}
if strings.Contains(id, "/") || strings.Contains(id, "..") {
return "", fmt.Errorf("invalid model id")
}
if isReservedModelSubdir(id) {
return "", fmt.Errorf("model %q not found", id)
}
r.mu.RLock()
defer r.mu.RUnlock()
candidates := []string{
filepath.Join(r.dir, id+".bin"),
filepath.Join(r.dir, id),
filepath.Join(r.dir, "ggml-"+id+".bin"),
}
for _, p := range candidates {
if st, err := os.Stat(p); err == nil && !st.IsDir() && isWhisperModelFile(filepath.Base(p)) {
return p, nil
}
}
return "", fmt.Errorf("model %q not found", id)
}
func (r *Registry) Delete(id string) error {
p, err := r.Path(id)
if err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
return os.Remove(p)
}
func (r *Registry) Import(id string, src io.Reader) error {
if id == "" {
return fmt.Errorf("model id is required")
}
if strings.Contains(id, "/") || strings.Contains(id, "..") {
return fmt.Errorf("invalid model id")
}
if isReservedModelSubdir(id) {
return fmt.Errorf("invalid model id")
}
r.mu.Lock()
defer r.mu.Unlock()
if err := os.MkdirAll(r.dir, 0o755); err != nil {
return err
}
dst := filepath.Join(r.dir, id+".bin")
f, err := os.Create(dst)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, src); err != nil {
os.Remove(dst)
return err
}
return nil
}
func (r *Registry) Open(id string) (*os.File, error) {
p, err := r.Path(id)
if err != nil {
return nil, err
}
return os.Open(p)
}
// Resolve maps an OpenAI-style model name to a local whisper model id.
func (r *Registry) Resolve(id, defaultModel string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
id = strings.TrimSpace(defaultModel)
}
if id == "" {
models, err := r.List()
if err != nil {
return "", err
}
if len(models) == 0 {
return "", fmt.Errorf("model is required")
}
return models[0], nil
}
if id == "whisper-1" {
if dm := strings.TrimSpace(defaultModel); dm != "" {
if _, err := r.Path(dm); err == nil {
return dm, nil
}
}
models, err := r.List()
if err != nil {
return "", err
}
if len(models) == 0 {
return "", fmt.Errorf("no whisper models installed")
}
return models[0], nil
}
if _, err := r.Path(id); err == nil {
return id, nil
}
if alt := strings.TrimPrefix(id, "whisper-"); alt != id {
if _, err := r.Path(alt); err == nil {
return alt, nil
}
}
return "", fmt.Errorf("model %q not found", id)
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"os"
"path/filepath"
"testing"
)
func TestRegistry_List_excludesAuxiliaryDirs(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"common.bin", "vad/vad.bin", "punctuation/model.onnx"} {
p := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
r := NewRegistry(root)
list, err := r.List()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0] != "common" {
t.Fatalf("list=%v want [common]", list)
}
}
func TestRegistry_List_excludesVADAtRoot(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "vad.bin"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "ggml-small.bin"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
list, err := NewRegistry(root).List()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0] != "ggml-small" {
t.Fatalf("list=%v", list)
}
}
func TestRegistry_Path_rejectsReservedIDs(t *testing.T) {
root := t.TempDir()
r := NewRegistry(root)
for _, id := range []string{"vad", "punctuation"} {
if _, err := r.Path(id); err == nil {
t.Fatalf("expected error for %q", id)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
)
func (s *Server) handleOpenAITranscriptions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeOpenAIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if err := r.ParseMultipartForm(128 << 20); err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error())
return
}
modelID, err := s.models.Resolve(r.FormValue("model"), s.cfg.DefaultModel)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error())
return
}
modelPath, err := s.models.Path(modelID)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error())
return
}
audioPath, cleanup, err := s.saveUploadedOpenAI(r)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error())
return
}
defer cleanup()
stt := s.parseOpenAISTTOptions(r)
result, err := s.transcribe(r.Context(), modelPath, audioPath, stt)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error())
return
}
switch strings.ToLower(strings.TrimSpace(r.FormValue("response_format"))) {
case "text":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(result.Text))
default:
writeJSON(w, http.StatusOK, map[string]string{"text": result.Text})
}
}
func (s *Server) handleOpenAIModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeOpenAIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ids, err := s.models.List()
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error())
return
}
now := time.Now().Unix()
data := make([]map[string]any, 0, len(ids))
for _, id := range ids {
data = append(data, map[string]any{
"id": id,
"object": "model",
"created": now,
"owned_by": "go-whisper-api",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"object": "list",
"data": data,
})
}
func (s *Server) parseOpenAISTTOptions(r *http.Request) sttOptions {
lang := strings.TrimSpace(r.FormValue("language"))
if lang == "" {
lang = s.cfg.Language
}
return sttOptions{
language: lang,
punctuate: s.punctCfg.ShouldApplyAPI(r, s.cfg.DefaultPunctuation),
}
}
func (s *Server) saveUploadedOpenAI(r *http.Request) (path string, cleanup func(), err error) {
if r.MultipartForm == nil {
if err := r.ParseMultipartForm(128 << 20); err != nil {
return "", nil, err
}
}
return saveUploadedRawFields(r, []string{"file", "audio", "wav"})
}
func writeOpenAIError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"message": msg,
"type": openAIErrorType(code),
},
})
}
func openAIErrorType(code int) string {
switch code {
case http.StatusBadRequest:
return "invalid_request_error"
case http.StatusUnauthorized:
return "authentication_error"
case http.StatusNotFound:
return "not_found_error"
default:
return "server_error"
}
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"go-whisper-api/config"
)
func TestRegistryResolve(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "ggml-small.bin"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "ggml-large-v3-turbo.bin"), []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
reg := NewRegistry(dir)
id, err := reg.Resolve("whisper-1", "ggml-large-v3-turbo")
if err != nil || id != "ggml-large-v3-turbo" {
t.Fatalf("whisper-1: id=%q err=%v", id, err)
}
id, err = reg.Resolve("whisper-large-v3-turbo", "")
if err != nil || id != "large-v3-turbo" {
t.Fatalf("whisper-large-v3-turbo: id=%q err=%v", id, err)
}
id, err = reg.Resolve("ggml-small", "")
if err != nil || id != "ggml-small" {
t.Fatalf("ggml-small: id=%q err=%v", id, err)
}
id, err = reg.Resolve("", "ggml-small")
if err != nil || id != "ggml-small" {
t.Fatalf("empty with default: id=%q err=%v", id, err)
}
}
func TestHandleOpenAITranscriptionsMissingFile(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "ggml-small.bin"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
srv := &Server{
cfg: config.API{ModelsDir: dir, Language: "ru"},
models: NewRegistry(dir),
}
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
_ = w.WriteField("model", "ggml-small")
w.Close()
req := httptest.NewRequest(http.MethodPost, "/v1/audio/transcriptions", body)
req.Header.Set("Content-Type", w.FormDataContentType())
rec := httptest.NewRecorder()
srv.handleOpenAITranscriptions(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "file") {
t.Fatalf("expected file error, got %s", rec.Body.String())
}
}
func TestHandleOpenAIModels(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "ggml-small.bin"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
srv := &Server{
cfg: config.API{ModelsDir: dir, Language: "ru"},
models: NewRegistry(dir),
}
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
rec := httptest.NewRecorder()
srv.handleOpenAIModels(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "ggml-small") {
t.Fatalf("expected model list, got %s", rec.Body.String())
}
}
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"context"
"time"
"go-whisper-api/whisper"
"github.com/rs/zerolog/log"
)
func (s *Server) StartWorker(ctx context.Context) {
go s.queueWorker(ctx)
}
func (s *Server) queueWorker(ctx context.Context) {
const idlePoll = 2 * time.Second
for {
if ctx.Err() != nil {
return
}
id, ok, err := s.cache.NextWaiting()
if err != nil {
log.Error().Err(err).Msg("cache queue scan")
if !sleepOrWake(ctx, s.queueWake, time.Second) {
return
}
continue
}
if ok {
s.processCacheTask(ctx, id)
continue
}
if !sleepOrWake(ctx, s.queueWake, idlePoll) {
return
}
}
}
func sleepOrWake(ctx context.Context, wake <-chan struct{}, d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-wake:
return true
case <-timer.C:
return true
}
}
func (s *Server) processCacheTask(ctx context.Context, id string) {
params, _, err := s.cache.LoadParams(id)
if err != nil {
return
}
modelPath, err := s.models.Path(params.Model)
if err != nil {
s.completeCacheTask(id, whisper.TranscriptResult{}, err.Error())
return
}
if err := s.cache.SetStatus(id, statusProcessing, nil); err != nil {
log.Error().Err(err).Str("task", id).Msg("set processing")
return
}
audioPath, ok := s.cache.AudioPath(id)
if !ok {
s.completeCacheTask(id, whisper.TranscriptResult{}, "audio file missing")
return
}
stt := sttOptions{
language: params.Language,
punctuate: params.Punctuation,
speakers: params.Speakers,
numClusters: params.NumClusters,
}
if stt.language == "" {
stt.language = s.cfg.Language
}
result, err := s.transcribe(ctx, modelPath, audioPath, stt)
if err != nil {
s.completeCacheTask(id, result, err.Error())
log.Error().Err(err).Str("task", id).Msg("async transcribe")
return
}
s.completeCacheTask(id, result, "")
}
func (s *Server) completeCacheTask(id string, result whisper.TranscriptResult, errMsg string) {
if err := s.cache.FinishWaiting(id, result, errMsg, nil); err != nil {
log.Error().Err(err).Str("task", id).Str("cache", s.cache.Root()).Msg("finish task")
return
}
if err := s.cache.PromoteToReady(id); err != nil {
log.Error().Err(err).Str("task", id).Str("cache", s.cache.Root()).Msg("promote to ready")
}
}
+28
View File
@@ -0,0 +1,28 @@
package api
import "go-whisper-api/whisper"
// sprResultReady builds GET /spr/result/{taskID} body for completed tasks (SPR-compatible).
func sprResultReady(params TaskParams) map[string]any {
words := params.Words
if words == nil {
words = []whisper.Word{}
}
return map[string]any{
"model": params.Model,
"text": params.Text,
"words": words,
"toxicity": map[string]float64{
"insult": 0,
"obscenity": 0,
"threat": 0,
"politeness": 0,
},
"emotion": map[string]any{},
"voice_analysis": map[string]any{},
"status": "ready",
"taskID": params.ID,
"created": params.Created,
"processed": params.Processed,
}
}
+26
View File
@@ -0,0 +1,26 @@
package api
import "testing"
func TestSprResultReady(t *testing.T) {
body := sprResultReady(TaskParams{
ID: "701b3e84-5815-4baf-97a2-933ff820f16d",
Created: "2026-06-03 10:06:35",
Processed: "2026-06-03 10:07:48",
Status: "ready",
Model: "common",
Text: "hello",
})
for _, key := range []string{"model", "text", "words", "toxicity", "emotion", "voice_analysis", "status", "taskID", "created", "processed"} {
if body[key] == nil {
t.Fatalf("missing %q", key)
}
}
if body["status"] != "ready" {
t.Fatalf("status=%v", body["status"])
}
tox, ok := body["toxicity"].(map[string]float64)
if !ok || len(tox) != 4 {
t.Fatalf("toxicity=%v", body["toxicity"])
}
}
+640
View File
@@ -0,0 +1,640 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"go-whisper-api/config"
"go-whisper-api/diarization"
"go-whisper-api/punctuation"
"go-whisper-api/transcode"
"go-whisper-api/whisper"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
type Server struct {
cfg config.API
punctCfg config.Punctuation
diarCfg config.Diarization
transcode *transcode.Engine
modelPool *whisper.ModelPool
punct punctuation.Restorer
diarizer diarization.Engine
models *Registry
cache *DiskCache
mux *http.ServeMux
queueWake chan struct{}
}
func NewServer(cfg config.API, tc config.Transcode, pc config.Punctuation, dc config.Diarization) (*Server, error) {
cfg = cfg.WithDefaults()
tc = tc.WithDefaults()
pc = pc.WithDefaults()
dc = dc.WithDefaults()
restorer, err := punctuation.New(pc)
if err != nil {
return nil, err
}
if pc.Active() && !restorer.Active() {
return nil, fmt.Errorf("punctuation is enabled but engine %q is not available", pc.Engine)
}
if cfg.ModelsDir == "" {
cfg.ModelsDir = "./models"
}
if cfg.Addr == "" {
cfg.Addr = ":8080"
}
if cfg.Threads == 0 {
cfg.Threads = uint(runtime.NumCPU())
}
cfg = cfg.WithDefaults()
if cfg.MaxContext == 0 {
cfg.MaxContext = 32
}
if cfg.BeamSize == 0 {
cfg.BeamSize = 5
}
if cfg.EntropyThold == 0 {
cfg.EntropyThold = 2.4
}
if err := os.MkdirAll(cfg.ModelsDir, 0o755); err != nil {
return nil, err
}
cacheDir := cfg.CacheDir
if cacheDir == "" {
cacheDir = "./cache"
}
cache, err := NewDiskCache(cacheDir)
if err != nil {
return nil, err
}
cfg.CacheDir = cache.Root()
if err := cache.RecoverInterrupted(); err != nil {
return nil, err
}
diar, err := diarization.New(dc)
if err != nil {
return nil, err
}
s := &Server{
cfg: cfg,
punctCfg: pc,
diarCfg: dc,
transcode: transcode.NewEngine(tc.FFmpegPath),
modelPool: whisper.NewModelPool(),
punct: restorer,
diarizer: diar,
models: NewRegistry(cfg.ModelsDir),
cache: cache,
mux: http.NewServeMux(),
queueWake: make(chan struct{}, 1),
}
s.routes()
go s.warmModels()
return s, nil
}
func (s *Server) warmModels() {
ids, err := s.models.List()
if err != nil {
return
}
for _, id := range ids {
path, err := s.models.Path(id)
if err != nil {
continue
}
if err := s.modelPool.WithModel(path, func(whisper.Model) error { return nil }); err != nil {
log.Warn().Err(err).Str("model", id).Msg("preload whisper model")
} else {
log.Info().Str("model", id).Msg("whisper model loaded")
}
}
}
func (s *Server) routes() {
s.mux.HandleFunc("/", s.handleSwaggerUI)
s.mux.HandleFunc("/swagger.json", s.handleSwaggerJSON)
s.mux.HandleFunc("/spr/models", s.handleModels)
s.mux.HandleFunc("/spr/hostname", s.handleHostname)
s.mux.HandleFunc("/spr/queue", s.handleQueue)
s.mux.HandleFunc("/spr/stt/", s.handleSTT)
s.mux.HandleFunc("/spr/result/", s.handleResult)
s.mux.HandleFunc("/spr/queue/", s.handleQueueItem)
s.mux.HandleFunc("/spr/audio/", s.handleAudio)
s.mux.HandleFunc("/spr/waveform/", s.handleWaveform)
s.mux.HandleFunc("/spr/delete/", s.handleDeleteModel)
s.mux.HandleFunc("/spr/export/", s.handleExportModel)
s.mux.HandleFunc("/spr/import/", s.handleImportModel)
s.mux.HandleFunc("/v1/audio/transcriptions", s.handleOpenAITranscriptions)
s.mux.HandleFunc("/v1/audio/transcriptions/", s.handleOpenAITranscriptions)
s.mux.HandleFunc("/v1/models", s.handleOpenAIModels)
}
func (s *Server) ListenAndServe() error {
log.Info().
Str("addr", s.cfg.Addr).
Str("models", s.cfg.ModelsDir).
Str("cache", s.cache.Root()).
Msg("starting API server")
return http.ListenAndServe(s.cfg.Addr, s.mux)
}
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
models, err := s.models.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"models": models})
}
func (s *Server) handleHostname(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
host, _ := os.Hostname()
cwd, _ := os.Getwd()
writeJSON(w, http.StatusOK, map[string]any{
"error": 0,
"message": "Success",
"hostname": host,
"version": "go-whisper-api",
"cwd": cwd,
"models": s.cfg.ModelsDir,
"cache": s.cache.Root(),
})
}
func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
list, err := s.cache.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleQueueItem(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/spr/queue/")
if id == "" || !isValidTaskID(id) {
writeError(w, http.StatusBadRequest, "task id required")
return
}
switch r.Method {
case http.MethodGet:
s.handleQueueGet(w, id)
case http.MethodDelete:
if !s.cache.Delete(id) {
writeAPIError(w, http.StatusNotFound, "TaskNotFound")
return
}
writeJSON(w, http.StatusOK, map[string]any{"error": 0, "message": "Success"})
default:
methodNotAllowed(w)
}
}
func (s *Server) handleQueueGet(w http.ResponseWriter, id string) {
params, phase, err := s.cache.LoadParams(id)
if err != nil {
writeAPIError(w, http.StatusNotFound, "task not found")
return
}
switch params.Status {
case string(statusReady):
if phase == cacheWaiting {
if err := s.cache.PromoteToReady(id); err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"error": 0, "message": "Success"})
case string(statusError):
msg := params.Error
if msg == "" {
msg = "transcription failed"
}
writeAPIError(w, http.StatusNotFound, msg)
default:
writeJSON(w, http.StatusOK, map[string]any{
"error": 0,
"message": params.Status,
"status": params.Status,
})
}
}
func (s *Server) handleSTT(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
modelID := strings.TrimPrefix(r.URL.Path, "/spr/stt/")
if modelID == "" {
writeError(w, http.StatusBadRequest, "model id required")
return
}
modelPath, err := s.models.Path(modelID)
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
audioPath, cleanup, err := s.saveUploadedWav(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
stt, err := s.parseSTTOptions(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if queryAsync(r, s.cfg.DefaultAsync) {
taskID, err := s.enqueueAsync(r, modelID, audioPath, stt)
cleanup()
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"taskID": taskID})
return
}
defer cleanup()
result, err := s.transcribe(r.Context(), modelPath, audioPath, stt)
if err != nil {
writeAPIError(w, http.StatusMethodNotAllowed, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"model": modelID,
"text": result.Text,
"words": result.Words,
})
}
func (s *Server) handleResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
id := strings.TrimPrefix(r.URL.Path, "/spr/result/")
if !isValidTaskID(id) {
writeAPIError(w, http.StatusBadRequest, "task id required")
return
}
params, _, err := s.cache.LoadParams(id)
if err != nil {
writeAPIError(w, http.StatusNotFound, "TaskNotFound")
return
}
switch params.Status {
case string(statusWaiting), string(statusProcessing):
writeJSON(w, http.StatusOK, map[string]string{"status": params.Status})
case string(statusError):
msg := params.Error
if msg == "" {
msg = "TaskNotFound"
}
writeAPIError(w, http.StatusNotFound, msg)
case string(statusReady):
writeJSON(w, http.StatusOK, sprResultReady(params))
default:
writeJSON(w, http.StatusOK, map[string]string{"status": params.Status})
}
}
func (s *Server) handleAudio(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
id := strings.TrimPrefix(r.URL.Path, "/spr/audio/")
path, ok := s.cache.AudioPath(id)
if !ok {
writeError(w, http.StatusNotFound, "task not found")
return
}
http.ServeFile(w, r, path)
}
func (s *Server) handleWaveform(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
id := strings.TrimPrefix(r.URL.Path, "/spr/waveform/")
if !isValidTaskID(id) {
writeAPIError(w, http.StatusBadRequest, "task id required")
return
}
wf, err := s.cache.Waveform(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"error": 0, "waveform": wf})
}
func (s *Server) handleDeleteModel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
methodNotAllowed(w)
return
}
id := strings.TrimPrefix(r.URL.Path, "/spr/delete/")
if err := s.models.Delete(id); err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleExportModel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
id := strings.TrimPrefix(r.URL.Path, "/spr/export/")
f, err := s.models.Open(id)
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
defer f.Close()
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q.bin", id))
w.Header().Set("Content-Type", "application/octet-stream")
io.Copy(w, f)
}
func (s *Server) handleImportModel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
id := strings.TrimPrefix(r.URL.Path, "/spr/import/")
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
file, header, err := r.FormFile("zip-model")
if err != nil {
file, header, err = r.FormFile("model")
}
if err != nil {
writeAPIError(w, http.StatusBadRequest, "model file required")
return
}
defer file.Close()
src := io.Reader(file)
if strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
writeAPIError(w, http.StatusBadRequest, "zip import is not supported; upload .bin model file as zip-model field")
return
}
if err := s.models.Import(id, src); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) enqueueAsync(r *http.Request, modelID, audioWavPath string, stt sttOptions) (string, error) {
id := uuid.New().String()
params := TaskParams{
ID: id,
Created: time.Now().Format("2006-01-02 15:04:05"),
Status: string(statusWaiting),
Model: modelID,
Language: stt.language,
Punctuation: stt.punctuate,
Speakers: stt.speakers,
NumClusters: stt.numClusters,
}
if err := s.cache.Enqueue(id, params, audioWavPath); err != nil {
return "", err
}
s.notifyQueue()
log.Info().
Str("task", id).
Str("model", modelID).
Str("cache", s.cache.Root()).
Msg("enqueued async task")
return id, nil
}
func (s *Server) transcribe(ctx context.Context, modelPath, audioPath string, stt sttOptions) (whisper.TranscriptResult, error) {
turns, err := s.runDiarization(ctx, audioPath, stt)
if err != nil {
return whisper.TranscriptResult{}, err
}
vad := s.cfg.VAD
if vad.Enabled {
vad.Model = vad.ResolveModelPath(s.cfg.ModelsDir)
}
cfg := &config.Whisper{
Model: modelPath,
AudioPath: audioPath,
Threads: s.cfg.Threads,
Language: stt.language,
Debug: s.cfg.Debug,
SpeedUp: s.cfg.SpeedUp,
Translate: s.cfg.Translate,
Prompt: s.cfg.Prompt,
MaxContext: s.cfg.MaxContext,
BeamSize: s.cfg.BeamSize,
EntropyThold: s.cfg.EntropyThold,
VAD: vad,
PrintProgress: false,
PrintSegment: false,
}
runOpts := s.whisperRunOpts(stt, turns)
if stt.punctuate && s.punct.Active() {
runOpts.PunctuateRestore = func(text string) (string, error) {
return punctuation.Apply(ctx, s.punct, true, text, stt.language)
}
}
result, err := whisper.TranscribeWithPool(s.modelPool, cfg, runOpts)
if err != nil {
return whisper.TranscriptResult{}, err
}
return applyGarbage(result, s.cfg.GarbagePatterns()), nil
}
func (s *Server) runDiarization(ctx context.Context, audioPath string, stt sttOptions) ([]whisper.Turn, error) {
if !stt.speakers {
return nil, nil
}
samples, err := whisper.LoadPCM16Mono(audioPath)
if err != nil {
return nil, fmt.Errorf("diarization audio: %w", err)
}
return s.diarizer.Process(ctx, samples, stt.numClusters)
}
func saveUploadedRaw(r *http.Request) (path string, cleanup func(), err error) {
return saveUploadedRawFields(r, []string{"audio", "wav", "file"})
}
func saveUploadedRawFields(r *http.Request, fieldNames []string) (path string, cleanup func(), err error) {
if r.MultipartForm == nil {
if err := r.ParseMultipartForm(128 << 20); err != nil {
return "", nil, err
}
}
var (
file multipart.File
header *multipart.FileHeader
found bool
)
for _, name := range fieldNames {
file, header, err = r.FormFile(name)
if err == nil {
found = true
break
}
}
if !found {
return "", nil, fmt.Errorf("audio file required (form field: %s)", strings.Join(fieldNames, ", "))
}
defer file.Close()
dir, err := config.MkdirTemp("go-whisper-api-upload-*")
if err != nil {
return "", nil, err
}
cleanup = func() { os.RemoveAll(dir) }
base := "input"
if header != nil {
if ext := filepath.Ext(header.Filename); ext != "" {
base += ext
}
}
raw := filepath.Join(dir, base)
out, err := os.Create(raw)
if err != nil {
cleanup()
return "", nil, err
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
cleanup()
return "", nil, err
}
out.Close()
return raw, cleanup, nil
}
func (s *Server) saveUploadedWav(r *http.Request) (path string, cleanup func(), err error) {
raw, cleanup, err := saveUploadedRaw(r)
if err != nil {
return "", nil, err
}
dst := filepath.Join(filepath.Dir(raw), "audio.wav")
if err := s.transcode.Transcode(r.Context(), raw, dst, transcode.WhisperOptions()); err != nil {
cleanup()
return "", nil, err
}
return dst, cleanup, nil
}
func queryBoolDefault(r *http.Request, key string, def bool) bool {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func queryAsync(r *http.Request, defaultAsync bool) bool {
v := r.URL.Query().Get("async")
if v == "" {
return defaultAsync
}
n, err := strconv.Atoi(v)
if err != nil {
return defaultAsync
}
return n == 1
}
func queryInt(r *http.Request, key string, def int) int {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
http.Error(w, msg, code)
}
func writeAPIError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]any{"error": 1, "message": msg})
}
func methodNotAllowed(w http.ResponseWriter) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
func (s *Server) notifyQueue() {
select {
case s.queueWake <- struct{}{}:
default:
}
}
func Run(ctx context.Context, cfg config.API, tc config.Transcode, pc config.Punctuation, dc config.Diarization) error {
srv, err := NewServer(cfg, tc, pc, dc)
if err != nil {
return err
}
defer srv.modelPool.Close()
srv.StartWorker(ctx)
hs := &http.Server{Addr: cfg.Addr, Handler: srv.mux}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = hs.Shutdown(shutdownCtx)
}()
if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
punctuation.Close(srv.punct)
srv.diarizer.Close()
return nil
}
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>go-whisper-api</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: '/swagger.json',
dom_id: '#swagger-ui',
presets: [SwaggerUIBundle.presets.apis],
docExpansion: 'list',
displayRequestDuration: true,
});
};
</script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
package api
import (
_ "embed"
"net/http"
)
//go:embed swagger.json
var swaggerSpec []byte
//go:embed swagger-ui.html
var swaggerUI []byte
func (s *Server) handleSwaggerJSON(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write(swaggerSpec)
}
func (s *Server) handleSwaggerUI(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(swaggerUI)
}
+457
View File
@@ -0,0 +1,457 @@
{
"swagger": "2.0",
"basePath": "/",
"paths": {
"/spr/audio/{taskID}": {
"parameters": [
{
"name": "taskID",
"in": "path",
"required": true,
"type": "string"
}
],
"get": {
"responses": {
"200": {
"description": "Success"
}
},
"operationId": "get_audio_stt",
"tags": [
"spr"
]
}
},
"/spr/delete/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
}
],
"delete": {
"responses": {
"200": {
"description": "Success"
}
},
"operationId": "delete_model_delete",
"tags": [
"spr"
]
}
},
"/spr/export/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
}
],
"get": {
"responses": {
"200": {
"description": "Success"
}
},
"operationId": "get_model_export",
"tags": [
"spr"
]
}
},
"/spr/hostname": {
"get": {
"responses": {
"200": {
"description": "Success"
}
},
"operationId": "get_hostname_class",
"tags": [
"spr"
]
}
},
"/spr/import/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
}
],
"post": {
"responses": {
"200": {
"description": "Success"
}
},
"operationId": "post_model_import",
"parameters": [
{
"name": "zip-model",
"in": "formData",
"type": "file",
"required": true,
"description": "prepared model zip file"
}
],
"consumes": [
"multipart/form-data"
],
"tags": [
"spr"
]
}
},
"/spr/models": {
"get": {
"responses": {
"200": {
"description": "Success",
"schema": {
"$ref": "#/definitions/modelList"
}
}
},
"operationId": "get_model_list",
"tags": [
"spr"
]
}
},
"/spr/queue": {
"get": {
"responses": {
"200": {
"description": "Success"
}
},
"operationId": "get_queue_stt",
"tags": [
"spr"
]
}
},
"/spr/queue/{taskID}": {
"parameters": [
{
"name": "taskID",
"in": "path",
"required": true,
"type": "string"
}
],
"delete": {
"responses": {
"200": {
"description": "Success",
"schema": {
"type": "object",
"properties": {
"error": { "type": "integer" },
"message": { "type": "string" }
}
}
},
"404": {
"description": "TaskNotFound"
}
},
"operationId": "delete_queue_del_stt",
"tags": [
"spr"
]
}
},
"/spr/result/{taskID}": {
"parameters": [
{
"name": "taskID",
"in": "path",
"required": true,
"type": "string"
}
],
"get": {
"responses": {
"404": {
"description": "Not found",
"schema": {
"$ref": "#/definitions/error"
}
},
"200": {
"description": "Success",
"schema": {
"$ref": "#/definitions/resultTTS"
}
}
},
"operationId": "get_result_stt",
"tags": [
"spr"
]
}
},
"/spr/stt/{id}": {
"post": {
"responses": {
"405": {
"description": "Error",
"schema": {
"$ref": "#/definitions/error"
}
},
"404": {
"description": "Not found",
"schema": {
"$ref": "#/definitions/error"
}
},
"200": {
"description": "Success",
"schema": {
"$ref": "#/definitions/modelTTS"
}
}
},
"operationId": "post_model_test",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string",
"description": "NN Model ID"
},
{
"name": "wav",
"in": "formData",
"type": "file",
"description": "file to recognize"
},
{
"name": "async",
"in": "query",
"type": "integer",
"description": "async mode (default 1: enqueue to cache/waiting; 0: sync text response)",
"default": 1,
"enum": [
0,
1
]
},
{
"name": "speakers",
"in": "query",
"type": "integer",
"description": "find speakers",
"default": 0,
"enum": [
0,
1
]
},
{
"name": "speaker_counter",
"in": "query",
"type": "integer",
"description": "number of speakers. 0 for autodetect. -1 disable speaker detection.",
"default": 0
},
{
"name": "normalization",
"in": "query",
"type": "integer",
"description": "normalize text",
"default": 1,
"enum": [
0,
1
]
},
{
"name": "punctuation",
"in": "query",
"type": "integer",
"description": "punctuate text",
"default": 1,
"enum": [
0,
1
]
},
{
"name": "toxicity",
"in": "query",
"type": "integer",
"description": "toxicity analyzer",
"default": 1,
"enum": [
0,
1
]
},
{
"name": "emotion",
"in": "query",
"type": "integer",
"description": "emotion analyzer",
"default": 0,
"enum": [
0,
1
]
},
{
"name": "voice_analyzer",
"in": "query",
"type": "integer",
"description": "voice analyzer",
"default": 1,
"enum": [
0,
1
]
},
{
"name": "vad",
"in": "query",
"type": "string",
"description": "VAD type",
"default": "webrtc",
"enum": [
"webrtc"
]
},
{
"name": "classifiers",
"in": "query",
"type": "string",
"description": "JSON with classification models to process each sentence"
},
{
"name": "webhook",
"in": "query",
"type": "string",
"description": "webhook url to send stt async result"
}
],
"consumes": [
"multipart/form-data"
],
"tags": [
"spr"
]
}
},
"/spr/waveform/{taskID}": {
"parameters": [
{
"name": "taskID",
"in": "path",
"required": true,
"type": "string"
}
],
"get": {
"responses": {
"200": {
"description": "Success"
}
},
"operationId": "get_audioarray_stt",
"tags": [
"spr"
]
}
}
},
"info": {
"title": "go-whisper-api",
"version": "5.008 release",
"description": "Whisper speech-to-text API (SPR-compatible)"
},
"produces": [
"application/json"
],
"consumes": [
"application/json"
],
"tags": [
{
"name": "spr",
"description": "NN Model operations"
}
],
"definitions": {
"modelList": {
"properties": {
"models": {
"type": "array",
"items": {
"type": "string",
"description": "NN Model ID"
}
}
},
"type": "object"
},
"error": {
"properties": {
"error": {
"type": "integer",
"description": "Error flag"
},
"message": {
"type": "string",
"description": "Error description"
}
},
"type": "object"
},
"modelTTS": {
"required": [
"text"
],
"properties": {
"text": {
"type": "string",
"description": "Recognized text"
}
},
"type": "object"
},
"resultTTS": {
"properties": {
"model": { "type": "string" },
"text": { "type": "string" },
"words": { "type": "array" },
"toxicity": { "type": "object" },
"emotion": { "type": "object" },
"voice_analysis": { "type": "object" },
"status": { "type": "string" },
"taskID": { "type": "string" },
"created": { "type": "string" },
"processed": { "type": "string" }
},
"type": "object"
}
},
"responses": {
"ParseError": {
"description": "When a mask can't be parsed"
},
"MaskError": {
"description": "When any error occurs on mask"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
package api
type taskStatus string
const (
statusWaiting taskStatus = "waiting"
statusProcessing taskStatus = "processing"
statusReady taskStatus = "ready"
statusError taskStatus = "error"
)
+77
View File
@@ -0,0 +1,77 @@
package api
import (
"fmt"
"net/http"
"strings"
"go-whisper-api/config"
"go-whisper-api/whisper"
)
type sttOptions struct {
language string
punctuate bool
speakers bool
numClusters int
}
func (s *Server) parseSTTOptions(r *http.Request) (sttOptions, error) {
opts := sttOptions{
language: resolveLanguage(r, s.cfg.Language),
punctuate: s.punctCfg.ShouldApplyAPI(r, s.cfg.DefaultPunctuation),
}
sp, clusters, err := querySpeakers(r, s.cfg.DefaultSpeakers, s.diarCfg, s.diarizer.Active())
if err != nil {
return opts, err
}
opts.speakers = sp
opts.numClusters = clusters
return opts, nil
}
func resolveLanguage(r *http.Request, defaultLang string) string {
if v := strings.TrimSpace(r.URL.Query().Get("language")); v != "" {
return v
}
return strings.TrimSpace(defaultLang)
}
func querySpeakers(r *http.Request, defaultOn bool, dc config.Diarization, diarizerActive bool) (enabled bool, numClusters int, err error) {
counter := queryInt(r, "speaker_counter", -999)
if counter == -1 {
return false, 0, nil
}
speakersQ := r.URL.Query().Get("speakers")
enabled = defaultOn
if speakersQ != "" {
enabled = queryInt(r, "speakers", 0) == 1
}
if !enabled {
return false, 0, nil
}
if !dc.Active() {
return false, 0, fmt.Errorf("speaker diarization is disabled in config (diarization.enabled: true)")
}
if !diarizerActive {
return false, 0, fmt.Errorf("speaker diarization requires server built with -tags sherpa (make build-sherpa) and models (make download-diarization-models)")
}
if counter > 0 {
numClusters = counter
} else if dc.NumClusters > 0 {
numClusters = dc.NumClusters
}
return true, numClusters, nil
}
func (s *Server) whisperRunOpts(stt sttOptions, turns []whisper.Turn) whisper.RunOptions {
t := s.cfg.Transcript.WithDefaults()
return whisper.RunOptions{
Turns: turns,
Format: whisper.FormatOptions{
PauseGap: t.PauseGapDuration(),
SpeakerLabel: t.SpeakerLabel,
UseSpeakers: stt.speakers && len(turns) > 0,
},
}
}
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"math"
"os"
"github.com/go-audio/wav"
)
func waveformFromWav(path string, buckets int) ([]float64, error) {
if buckets <= 0 {
buckets = 256
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
dec := wav.NewDecoder(f)
buf, err := dec.FullPCMBuffer()
if err != nil {
return nil, err
}
samples := buf.AsFloat32Buffer().Data
if len(samples) == 0 {
return make([]float64, buckets), nil
}
chunk := len(samples) / buckets
if chunk < 1 {
chunk = 1
}
out := make([]float64, 0, buckets)
for i := 0; i < len(samples) && len(out) < buckets; i += chunk {
end := i + chunk
if end > len(samples) {
end = len(samples)
}
peak := 0.0
for _, s := range samples[i:end] {
v := math.Abs(float64(s))
if v > peak {
peak = v
}
}
out = append(out, math.Round(peak*1000)/1000)
}
return out, nil
}