first commit
This commit is contained in:
+362
@@ -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, ¶ms); 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(¶ms)
|
||||
}
|
||||
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, `/\`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package api
|
||||
|
||||
type taskStatus string
|
||||
|
||||
const (
|
||||
statusWaiting taskStatus = "waiting"
|
||||
statusProcessing taskStatus = "processing"
|
||||
statusReady taskStatus = "ready"
|
||||
statusError taskStatus = "error"
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user