diff --git a/openai/openai.go b/openai/openai.go index de2c1bdd..766ef790 100644 --- a/openai/openai.go +++ b/openai/openai.go @@ -253,6 +253,43 @@ func (openai OpenAI) AudioTranscriptions(dataBase64 string, option map[string]in return openai.postFile(openai.baseURL+"/audio/transcriptions", map[string][]byte{"file": data}, option) } +// AudioTranscriptionsFile Transcribes audio from an OS file path. +// Unlike AudioTranscriptions which accepts base64-encoded data, this method +// reads the file directly via streaming upload, avoiding 4× memory copies. +// https://platform.openai.com/docs/api-reference/audio/create +func (openai OpenAI) AudioTranscriptionsFile(filePath string, option map[string]interface{}) (interface{}, *exception.Exception) { + + if option == nil { + option = map[string]interface{}{} + } + + url := fmt.Sprintf("%s%s", openai.host, openai.baseURL+"/audio/transcriptions") + if _, ok := option["model"].(string); !ok { + option["model"] = openai.model + } + + req := http.New(url) + if openai.azure { + req.WithHeader(map[string][]string{ + "Content-Type": {"multipart/form-data"}, + "api-key": {openai.key}, + }) + } else { + req.WithHeader(map[string][]string{ + "Content-Type": {"multipart/form-data"}, + "Authorization": {fmt.Sprintf("Bearer %s", openai.key)}, + }) + } + + req.AddFile("file", filePath) + + res := req.Send("POST", option) + if err := openai.isError(res); err != nil { + return nil, err + } + return res.Data, nil +} + // ImagesGenerations Creates an image given a prompt. // https://platform.openai.com/docs/api-reference/images func (openai OpenAI) ImagesGenerations(prompt string, option map[string]interface{}) (interface{}, *exception.Exception) { @@ -437,7 +474,12 @@ func (openai OpenAI) postFile(path string, files map[string][]byte, option map[s } for name, data := range files { - req.AddFileBytes(name, fmt.Sprintf("%s.mp3", name), data) + filename := fmt.Sprintf("%s.mp3", name) + if fn, ok := option["filename"].(string); ok && fn != "" { + filename = fn + delete(option, "filename") // don't send as form field to API + } + req.AddFileBytes(name, filename, data) } res := req.Send("POST", option) @@ -461,7 +503,12 @@ func (openai OpenAI) postFileWithoutModel(path string, files map[string][]byte, } for name, data := range files { - req.AddFileBytes(name, fmt.Sprintf("%s.mp3", name), data) + filename := fmt.Sprintf("%s.mp3", name) + if fn, ok := option["filename"].(string); ok && fn != "" { + filename = fn + delete(option, "filename") // don't send as form field to API + } + req.AddFileBytes(name, filename, data) } res := req.Send("POST", option) diff --git a/openai/openai_test.go b/openai/openai_test.go index 09f9f8f4..516e2364 100644 --- a/openai/openai_test.go +++ b/openai/openai_test.go @@ -3,6 +3,7 @@ package openai import ( "context" "encoding/base64" + "path/filepath" "testing" "time" @@ -201,6 +202,47 @@ func TestAudioTranscriptions(t *testing.T) { assert.Equal(t, "今晚打老虎", data.(map[string]interface{})["text"]) } +func TestAudioTranscriptionsFile(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + openai := prepare(t, "whisper-1") + filePath := audioFilePath(t) + + data, err := openai.AudioTranscriptionsFile(filePath, nil) + if err != nil { + t.Fatal(err.Message) + } + assert.Equal(t, "今晚打老虎", data.(map[string]interface{})["text"]) +} + +func TestAudioTranscriptionsFile_WithLanguage(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + openai := prepare(t, "whisper-1") + filePath := audioFilePath(t) + + data, err := openai.AudioTranscriptionsFile(filePath, map[string]interface{}{"language": "zh"}) + if err != nil { + t.Fatal(err.Message) + } + text, ok := data.(map[string]interface{})["text"].(string) + assert.True(t, ok) + assert.NotEmpty(t, text) + t.Logf("Transcription with language=zh: %s", text) +} + +func TestAudioTranscriptionsFile_FileNotFound(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + openai := prepare(t, "whisper-1") + _, err := openai.AudioTranscriptionsFile("/non/existent/audio.mp3", nil) + assert.NotNil(t, err, "Expected error for non-existent file") + t.Logf("Error for non-existent file: %s", err.Message) +} + func TestImagesGenerations(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() @@ -302,3 +344,10 @@ func audio(t *testing.T) string { } return base64.StdEncoding.EncodeToString(data) } + +func audioFilePath(t *testing.T) string { + stor := fs.MustGet("system") + root := stor.Root() + absPath := filepath.Join(root, "assets", "audio_transcriptions.mp3") + return absPath +} diff --git a/openai/process.go b/openai/process.go index ce0e266f..49a4c6bd 100644 --- a/openai/process.go +++ b/openai/process.go @@ -12,10 +12,11 @@ import ( func init() { process.RegisterGroup("openai", map[string]process.Handler{ - "tiktoken": ProcessTiktoken, - "embeddings": ProcessEmbeddings, - "chat.completions": ProcessChatCompletions, - "audio.transcriptions": ProcessAudioTranscriptions, + "tiktoken": ProcessTiktoken, + "embeddings": ProcessEmbeddings, + "chat.completions": ProcessChatCompletions, + "audio.transcriptions": ProcessAudioTranscriptions, + "audio.transcriptionsfile": ProcessAudioTranscriptionsFile, }) } @@ -77,6 +78,45 @@ func ProcessAudioTranscriptions(process *process.Process) interface{} { return res } +// ProcessAudioTranscriptionsFile openai.audio.TranscriptionsFile +// Transcribe audio from an OS file path (streaming upload, no base64 overhead). +// This is the recommended way to call Whisper from TS scripts, consistent with +// office.Parse / ffmpeg.* handler style. +// +// Args: +// - connector string - AI connector name (e.g. "openai.whisper-1") +// - filePath string - OS absolute path to the audio file +// - options map - Optional: { language, model, ... } +// +// Returns: map[string]interface{} - Transcription result (e.g. {"text": "..."}) +// +// Usage: +// +// var result = Process("openai.audio.transcriptionsfile", "openai.whisper-1", "/abs/path/to/audio.mp3", {"language": "en"}) +func ProcessAudioTranscriptionsFile(process *process.Process) interface{} { + process.ValidateArgNums(2) + connector := process.ArgsString(0) + filePath := process.ArgsString(1) + + options := map[string]interface{}{} + if process.NumOfArgs() > 2 { + if opts, ok := process.Args[2].(map[string]interface{}); ok { + options = opts + } + } + + ai, err := New(connector) + if err != nil { + exception.New("AudioTranscriptionsFile error: %s", 400, err).Throw() + } + + res, ex := ai.AudioTranscriptionsFile(filePath, options) + if ex != nil { + ex.Throw() + } + return res +} + // ProcessChatCompletions openai.chat.Completions func ProcessChatCompletions(process *process.Process) interface{} { diff --git a/openai/process_test.go b/openai/process_test.go index 262ffd31..e58ada8b 100644 --- a/openai/process_test.go +++ b/openai/process_test.go @@ -42,6 +42,45 @@ func TestProcessAudioTranscriptions(t *testing.T) { assert.Equal(t, "今晚打老虎", data.(map[string]interface{})["text"]) } +func TestProcessAudioTranscriptionsFile(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + filePath := audioFilePath(t) + args := []interface{}{"whisper-1", filePath} + data := process.New("openai.audio.transcriptionsfile", args...).Run() + assert.Equal(t, "今晚打老虎", data.(map[string]interface{})["text"]) +} + +func TestProcessAudioTranscriptionsFile_WithOptions(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + filePath := audioFilePath(t) + args := []interface{}{"whisper-1", filePath, map[string]interface{}{"language": "zh"}} + data := process.New("openai.audio.transcriptionsfile", args...).Run() + text, ok := data.(map[string]interface{})["text"].(string) + assert.True(t, ok) + assert.NotEmpty(t, text) + t.Logf("ProcessAudioTranscriptionsFile with language=zh: %s", text) +} + +func TestProcessAudioTranscriptionsFile_InvalidConnector(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + defer func() { + r := recover() + if r == nil { + t.Error("Expected panic for invalid connector, but got none") + } + t.Logf("Correctly panicked with: %v", r) + }() + + args := []interface{}{"non-existent-connector", "/some/path.mp3"} + process.New("openai.audio.transcriptionsfile", args...).Run() +} + func TestProcessChatCompletions(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean()