0.29.1原版
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
// Package openai implements stt.Transcriber against the OpenAI
|
||||
// /audio/transcriptions endpoint (and any compatible third-party endpoint
|
||||
// such as Groq Whisper, faster-whisper self-hosted, or Azure Whisper).
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
openaisdk "github.com/openai/openai-go/v3"
|
||||
openaioption "github.com/openai/openai-go/v3/option"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/usememos/memos/internal/ai"
|
||||
"github.com/usememos/memos/internal/ai/stt"
|
||||
)
|
||||
|
||||
const defaultEndpoint = "https://api.openai.com/v1"
|
||||
|
||||
// Transcriber implements stt.Transcriber for OpenAI-compatible STT endpoints.
|
||||
type Transcriber struct {
|
||||
client openaisdk.Client
|
||||
}
|
||||
|
||||
// New constructs a Transcriber from a provider config.
|
||||
func New(cfg ai.ProviderConfig, options stt.Options) (*Transcriber, error) {
|
||||
endpoint, err := normalizeEndpoint(cfg.Endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, errors.New("OpenAI API key is required")
|
||||
}
|
||||
return &Transcriber{
|
||||
client: openaisdk.NewClient(
|
||||
openaioption.WithAPIKey(cfg.APIKey),
|
||||
openaioption.WithBaseURL(endpoint),
|
||||
openaioption.WithHTTPClient(options.HTTPClient),
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Transcribe sends the audio to /audio/transcriptions.
|
||||
func (t *Transcriber) Transcribe(ctx context.Context, req stt.Request) (*stt.Response, error) {
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if req.Audio == nil {
|
||||
return nil, errors.New("audio is required")
|
||||
}
|
||||
|
||||
filename, contentType, err := normalizeAudioMetadata(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := openaisdk.AudioTranscriptionNewParams{
|
||||
File: openaisdk.File(req.Audio, filename, contentType),
|
||||
Model: openaisdk.AudioModel(req.Model),
|
||||
ResponseFormat: openaisdk.AudioResponseFormatJSON,
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
params.Prompt = openaisdk.String(req.Prompt)
|
||||
}
|
||||
if req.Language != "" {
|
||||
params.Language = openaisdk.String(req.Language)
|
||||
}
|
||||
|
||||
resp, err := t.client.Audio.Transcriptions.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to send OpenAI transcription request")
|
||||
}
|
||||
return &stt.Response{
|
||||
Text: resp.Text,
|
||||
Language: resp.Language,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeEndpoint(endpoint string) (string, error) {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint == "" {
|
||||
endpoint = defaultEndpoint
|
||||
}
|
||||
if _, err := url.ParseRequestURI(endpoint); err != nil {
|
||||
return "", errors.Wrap(err, "invalid OpenAI endpoint")
|
||||
}
|
||||
return strings.TrimRight(endpoint, "/"), nil
|
||||
}
|
||||
|
||||
func normalizeAudioMetadata(req stt.Request) (string, string, error) {
|
||||
filename := strings.TrimSpace(req.Filename)
|
||||
if filename == "" {
|
||||
filename = "audio"
|
||||
}
|
||||
contentType := strings.TrimSpace(req.ContentType)
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
} else {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return "", "", errors.Wrap(err, "invalid audio content type")
|
||||
}
|
||||
contentType = mediaType
|
||||
}
|
||||
return sanitizeFilename(filename), contentType, nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(filename string) string {
|
||||
filename = strings.NewReplacer("\r", "_", "\n", "_").Replace(filename)
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
return "audio"
|
||||
}
|
||||
return filename
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package openai_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/internal/ai"
|
||||
"github.com/usememos/memos/internal/ai/stt"
|
||||
sttopenai "github.com/usememos/memos/internal/ai/stt/openai"
|
||||
)
|
||||
|
||||
func TestTranscribe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, "/audio/transcriptions", r.URL.Path)
|
||||
require.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
|
||||
require.NoError(t, r.ParseMultipartForm(10<<20))
|
||||
require.Equal(t, "gpt-4o-transcribe", r.FormValue("model"))
|
||||
require.Equal(t, "json", r.FormValue("response_format"))
|
||||
require.Equal(t, "domain words", r.FormValue("prompt"))
|
||||
require.Equal(t, "en", r.FormValue("language"))
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
require.Equal(t, "voice.wav", header.Filename)
|
||||
require.Equal(t, "audio/wav", header.Header.Get("Content-Type"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
|
||||
"text": "hello world",
|
||||
"language": "en",
|
||||
"duration": 1.5,
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transcriber, err := sttopenai.New(ai.ProviderConfig{
|
||||
Type: ai.ProviderOpenAI,
|
||||
Endpoint: server.URL,
|
||||
APIKey: "test-key",
|
||||
}, stt.ApplyOptions(nil))
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
response, err := transcriber.Transcribe(ctx, stt.Request{
|
||||
Model: "gpt-4o-transcribe",
|
||||
Filename: "voice.wav",
|
||||
ContentType: "audio/wav",
|
||||
Audio: strings.NewReader("RIFF"),
|
||||
Prompt: "domain words",
|
||||
Language: "en",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello world", response.Text)
|
||||
require.Equal(t, "en", response.Language)
|
||||
// Note: Duration intentionally omitted from stt.Response — not exposed in the new contract.
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package stt
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultHTTPTimeout = 2 * time.Minute
|
||||
|
||||
// Options is the resolved option set passed to provider implementations.
|
||||
type Options struct {
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// TranscriberOption customizes a Transcriber.
|
||||
type TranscriberOption func(*Options)
|
||||
|
||||
// WithHTTPClient overrides the HTTP client used by the transcriber.
|
||||
func WithHTTPClient(client *http.Client) TranscriberOption {
|
||||
return func(o *Options) {
|
||||
if client != nil {
|
||||
o.HTTPClient = client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyOptions resolves a TranscriberOption slice into Options with defaults.
|
||||
func ApplyOptions(opts []TranscriberOption) Options {
|
||||
resolved := Options{HTTPClient: &http.Client{Timeout: defaultHTTPTimeout}}
|
||||
for _, apply := range opts {
|
||||
apply(&resolved)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package stt defines the speech-to-text capability for AI providers.
|
||||
// Implementations call dedicated STT endpoints (e.g. OpenAI /audio/transcriptions)
|
||||
// and return deterministic transcription output. For multimodal LLMs that
|
||||
// happen to accept audio input, see internal/ai/audiollm.
|
||||
package stt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Transcriber transcribes audio to text using a provider's dedicated STT endpoint.
|
||||
type Transcriber interface {
|
||||
Transcribe(ctx context.Context, req Request) (*Response, error)
|
||||
}
|
||||
|
||||
// Request is the input to a transcription call.
|
||||
type Request struct {
|
||||
Audio io.Reader
|
||||
Size int64
|
||||
Filename string
|
||||
ContentType string // IANA media type, e.g. "audio/wav"
|
||||
Model string // provider-specific model id (e.g. "whisper-1", "gpt-4o-transcribe")
|
||||
Prompt string // soft spelling/vocabulary hint (Whisper "prompt" parameter)
|
||||
Language string // ISO 639-1, optional
|
||||
}
|
||||
|
||||
// Response is the output of a transcription call.
|
||||
type Response struct {
|
||||
Text string
|
||||
Language string // empty if provider did not return it
|
||||
Segments []Segment // empty unless provider returned timestamps
|
||||
}
|
||||
|
||||
// Segment is a timestamped portion of the transcript.
|
||||
type Segment struct {
|
||||
Text string
|
||||
Start float64
|
||||
End float64
|
||||
Speaker string // empty unless using a diarization-capable model
|
||||
}
|
||||
Reference in New Issue
Block a user