Add audio transcription file handling and related tests

- Introduce `AudioTranscriptionsFile` method in the OpenAI package to transcribe audio directly from a file path, improving memory efficiency.
- Implement new test cases for `AudioTranscriptionsFile`, including scenarios for language options and error handling for non-existent files.
- Update the process package to support the new transcription method, ensuring compatibility with existing functionality.
- Enhance test coverage for audio transcription processes, validating expected outputs and error conditions.
This commit is contained in:
Max 2026-02-10 19:05:29 +08:00
parent bdcc3585e2
commit ad311cad2a
4 changed files with 181 additions and 6 deletions

View file

@ -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) 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. // ImagesGenerations Creates an image given a prompt.
// https://platform.openai.com/docs/api-reference/images // https://platform.openai.com/docs/api-reference/images
func (openai OpenAI) ImagesGenerations(prompt string, option map[string]interface{}) (interface{}, *exception.Exception) { 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 { 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) res := req.Send("POST", option)
@ -461,7 +503,12 @@ func (openai OpenAI) postFileWithoutModel(path string, files map[string][]byte,
} }
for name, data := range files { 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) res := req.Send("POST", option)

View file

@ -3,6 +3,7 @@ package openai
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"path/filepath"
"testing" "testing"
"time" "time"
@ -201,6 +202,47 @@ func TestAudioTranscriptions(t *testing.T) {
assert.Equal(t, "今晚打老虎", data.(map[string]interface{})["text"]) 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) { func TestImagesGenerations(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
defer test.Clean() defer test.Clean()
@ -302,3 +344,10 @@ func audio(t *testing.T) string {
} }
return base64.StdEncoding.EncodeToString(data) 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
}

View file

@ -12,10 +12,11 @@ import (
func init() { func init() {
process.RegisterGroup("openai", map[string]process.Handler{ process.RegisterGroup("openai", map[string]process.Handler{
"tiktoken": ProcessTiktoken, "tiktoken": ProcessTiktoken,
"embeddings": ProcessEmbeddings, "embeddings": ProcessEmbeddings,
"chat.completions": ProcessChatCompletions, "chat.completions": ProcessChatCompletions,
"audio.transcriptions": ProcessAudioTranscriptions, "audio.transcriptions": ProcessAudioTranscriptions,
"audio.transcriptionsfile": ProcessAudioTranscriptionsFile,
}) })
} }
@ -77,6 +78,45 @@ func ProcessAudioTranscriptions(process *process.Process) interface{} {
return res 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 // ProcessChatCompletions openai.chat.Completions
func ProcessChatCompletions(process *process.Process) interface{} { func ProcessChatCompletions(process *process.Process) interface{} {

View file

@ -42,6 +42,45 @@ func TestProcessAudioTranscriptions(t *testing.T) {
assert.Equal(t, "今晚打老虎", data.(map[string]interface{})["text"]) 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) { func TestProcessChatCompletions(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
defer test.Clean() defer test.Clean()