feat(openai-go) : Add openai feature for transcribe and later chats
This commit is contained in:
@@ -27,6 +27,7 @@ require (
|
||||
golang.org/x/sys v0.35.0
|
||||
google.golang.org/protobuf v1.36.7
|
||||
howett.net/plist v1.0.1
|
||||
github.com/openai/openai-go/v3 v3.3.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -90,6 +91,10 @@ require (
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
|
||||
@@ -152,6 +152,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/openai/openai-go/v3 v3.3.0 h1:3Xu4+3pJW5HG5hzMflakVbp3IB0uSq70nbLVZZd8AwY=
|
||||
github.com/openai/openai-go/v3 v3.3.0/go.mod h1:UOpNxkqC9OdNXNUfpNByKOtB4jAL0EssQXq5p8gO0Xs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
@@ -210,6 +212,16 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
|
||||
@@ -4,17 +4,19 @@ import "github.com/sjzar/chatlog/internal/whisper"
|
||||
|
||||
// SpeechConfig controls optional speech-to-text features.
|
||||
type SpeechConfig struct {
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled"`
|
||||
Model string `mapstructure:"model" json:"model"`
|
||||
Threads int `mapstructure:"threads" json:"threads"`
|
||||
Language string `mapstructure:"language" json:"language"`
|
||||
Translate *bool `mapstructure:"translate" json:"translate"`
|
||||
InitialPrompt string `mapstructure:"initial_prompt" json:"initial_prompt"`
|
||||
Temperature *float64 `mapstructure:"temperature" json:"temperature"`
|
||||
TemperatureFallback *float64 `mapstructure:"temperature_fallback" json:"temperature_fallback"`
|
||||
PythonExec string `mapstructure:"python_exec" json:"python_exec"`
|
||||
ScriptDir string `mapstructure:"script_dir" json:"script_dir"`
|
||||
Env map[string]string `mapstructure:"env" json:"env"`
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled"`
|
||||
Model string `mapstructure:"model" json:"model"`
|
||||
TranslateModel string `mapstructure:"translate_model" json:"translate_model"`
|
||||
Threads int `mapstructure:"threads" json:"threads"`
|
||||
Language string `mapstructure:"language" json:"language"`
|
||||
Translate *bool `mapstructure:"translate" json:"translate"`
|
||||
InitialPrompt string `mapstructure:"initial_prompt" json:"initial_prompt"`
|
||||
Temperature *float64 `mapstructure:"temperature" json:"temperature"`
|
||||
TemperatureFallback *float64 `mapstructure:"temperature_fallback" json:"temperature_fallback"`
|
||||
APIKey string `mapstructure:"api_key" json:"api_key"`
|
||||
BaseURL string `mapstructure:"base_url" json:"base_url"`
|
||||
Organization string `mapstructure:"organization" json:"organization"`
|
||||
RequestTimeoutSeconds int `mapstructure:"request_timeout_seconds" json:"request_timeout_seconds"`
|
||||
}
|
||||
|
||||
// ToOptions converts the speech config into runtime options for a transcription backend.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package ctx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -11,7 +9,6 @@ import (
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sjzar/chatlog/internal/chatlog/conf"
|
||||
"github.com/sjzar/chatlog/internal/speech/whisperdl"
|
||||
"github.com/sjzar/chatlog/internal/wechat"
|
||||
"github.com/sjzar/chatlog/pkg/config"
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
@@ -94,7 +91,7 @@ func (c *Context) loadConfig() {
|
||||
c.speech = &sc
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
defaultSpeech := conf.SpeechConfig{Enabled: true}
|
||||
defaultSpeech := conf.SpeechConfig{Enabled: true, Model: "whisper-1"}
|
||||
payload, marshalErr := json.MarshalIndent(defaultSpeech, "", " ")
|
||||
if marshalErr != nil {
|
||||
log.Error().Err(marshalErr).Msg("failed to marshal default speech config")
|
||||
@@ -107,39 +104,13 @@ func (c *Context) loadConfig() {
|
||||
}
|
||||
if c.speech != nil {
|
||||
if c.speech.Model == "" {
|
||||
if err := c.ensureDefaultModel(speechPath); err != nil {
|
||||
log.Error().Err(err).Msg("ensure default speech model failed")
|
||||
}
|
||||
c.speech.Model = "whisper-1"
|
||||
}
|
||||
c.speech.Enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) ensureDefaultModel(configPath string) error {
|
||||
if c.cm == nil {
|
||||
return errors.New("config manager unavailable")
|
||||
}
|
||||
modelsDir := filepath.Join(c.cm.Path, "speech-models")
|
||||
if err := os.MkdirAll(modelsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
downloader := whisperdl.NewDownloader(modelsDir)
|
||||
rsl, err := downloader.EnsureModel(context.Background(), whisperdl.DefaultModel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.speech.Model = rsl.Path
|
||||
configBytes, err := json.MarshalIndent(c.speech, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(configPath, configBytes, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Context) SwitchHistory(account string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -3,7 +3,6 @@ package http
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -73,24 +72,24 @@ func (s *Service) initSpeech(cfg Config) {
|
||||
}
|
||||
|
||||
opts := speechCfg.ToOptions()
|
||||
scriptDir := speechCfg.ScriptDir
|
||||
if scriptDir == "" {
|
||||
scriptDir = filepath.Join(cfg.GetDataDir(), "whisper")
|
||||
}
|
||||
pTranscriber, err := whisper.NewPythonTranscriber(whisper.PythonConfig{
|
||||
ScriptDir: scriptDir,
|
||||
PythonPath: speechCfg.PythonExec,
|
||||
timeout := time.Duration(speechCfg.RequestTimeoutSeconds) * time.Second
|
||||
transcriber, err := whisper.NewOpenAITranscriber(whisper.OpenAIConfig{
|
||||
Model: speechCfg.Model,
|
||||
TranslateModel: speechCfg.TranslateModel,
|
||||
APIKey: speechCfg.APIKey,
|
||||
BaseURL: speechCfg.BaseURL,
|
||||
Organization: speechCfg.Organization,
|
||||
RequestTimeout: timeout,
|
||||
DefaultOptions: opts,
|
||||
Env: speechCfg.Env,
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("initialise python whisper transcriber failed")
|
||||
log.Err(err).Msg("initialise openai whisper transcriber failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.speechTranscriber = pTranscriber
|
||||
s.speechTranscriber = transcriber
|
||||
s.speechOptions = opts
|
||||
log.Info().Str("script", pTranscriber.ScriptPath()).Msg("speech transcription backend initialised via python whisper")
|
||||
log.Info().Str("model", transcriber.ModelName()).Msg("speech transcription backend initialised via openai whisper")
|
||||
}
|
||||
|
||||
func (s *Service) Start() error {
|
||||
|
||||
+351
-232
@@ -1,278 +1,397 @@
|
||||
package whisper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
openai "github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/option"
|
||||
openaiparam "github.com/openai/openai-go/v3/packages/param"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/sjzar/chatlog/pkg/util/silk"
|
||||
"github.com/sjzar/chatlog/pkg/util/silk"
|
||||
)
|
||||
|
||||
//go:embed whisper.py
|
||||
var embeddedWhisperScript []byte
|
||||
|
||||
// PythonConfig describes how to initialise a Python based whisper backend.
|
||||
type PythonConfig struct {
|
||||
ScriptDir string
|
||||
PythonPath string
|
||||
DefaultOptions Options
|
||||
Env map[string]string
|
||||
// OpenAIConfig describes how to initialise an OpenAI-backed transcriber.
|
||||
type OpenAIConfig struct {
|
||||
Model string
|
||||
TranslateModel string
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Organization string
|
||||
RequestTimeout time.Duration
|
||||
DefaultOptions Options
|
||||
}
|
||||
|
||||
// PythonTranscriber bridges to the bundled Python whisper implementation.
|
||||
type PythonTranscriber struct {
|
||||
cfg PythonConfig
|
||||
scriptPath string
|
||||
// OpenAITranscriber uses OpenAI's REST API to perform speech-to-text tasks.
|
||||
type OpenAITranscriber struct {
|
||||
client *openai.Client
|
||||
model openai.AudioModel
|
||||
translateModel openai.AudioModel
|
||||
defaultOptions Options
|
||||
}
|
||||
|
||||
// NewPythonTranscriber ensures the Python helper script is available and ready.
|
||||
func NewPythonTranscriber(cfg PythonConfig) (*PythonTranscriber, error) {
|
||||
if cfg.ScriptDir == "" {
|
||||
return nil, errors.New("script directory is required")
|
||||
}
|
||||
if cfg.Env == nil {
|
||||
cfg.Env = make(map[string]string)
|
||||
}
|
||||
// NewOpenAITranscriber builds a new instance of the OpenAI transcription backend.
|
||||
func NewOpenAITranscriber(cfg OpenAIConfig) (*OpenAITranscriber, error) {
|
||||
model := normalizeAudioModel(cfg.Model)
|
||||
translateModel := model
|
||||
if cfg.TranslateModel != "" {
|
||||
translateModel = normalizeAudioModel(cfg.TranslateModel)
|
||||
}
|
||||
|
||||
pythonPath := cfg.PythonPath
|
||||
if pythonPath == "" {
|
||||
if envPath := os.Getenv("CHATLOG_WHISPER_PYTHON"); envPath != "" {
|
||||
pythonPath = envPath
|
||||
}
|
||||
}
|
||||
if pythonPath == "" {
|
||||
if runtime.GOOS == "windows" {
|
||||
pythonPath = "python.exe"
|
||||
} else {
|
||||
pythonPath = "python3"
|
||||
}
|
||||
}
|
||||
var opts []option.RequestOption
|
||||
if cfg.APIKey != "" {
|
||||
opts = append(opts, option.WithAPIKey(cfg.APIKey))
|
||||
}
|
||||
if cfg.Organization != "" {
|
||||
opts = append(opts, option.WithOrganization(cfg.Organization))
|
||||
}
|
||||
if cfg.BaseURL != "" {
|
||||
opts = append(opts, option.WithBaseURL(cfg.BaseURL))
|
||||
}
|
||||
if cfg.RequestTimeout > 0 {
|
||||
opts = append(opts, option.WithRequestTimeout(cfg.RequestTimeout))
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.ScriptDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("ensure script directory: %w", err)
|
||||
}
|
||||
clientVal := openai.NewClient(opts...)
|
||||
client := &clientVal
|
||||
|
||||
scriptPath := filepath.Join(cfg.ScriptDir, "whisper.py")
|
||||
if err := ensurePythonScript(scriptPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg.PythonPath = pythonPath
|
||||
|
||||
return &PythonTranscriber{
|
||||
cfg: cfg,
|
||||
scriptPath: scriptPath,
|
||||
}, nil
|
||||
return &OpenAITranscriber{
|
||||
client: client,
|
||||
model: model,
|
||||
translateModel: translateModel,
|
||||
defaultOptions: cfg.DefaultOptions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ScriptPath returns the path to the extracted Python helper script.
|
||||
func (p *PythonTranscriber) ScriptPath() string {
|
||||
return p.scriptPath
|
||||
// Close releases resources held by the transcriber. No-op for the OpenAI backend.
|
||||
func (t *OpenAITranscriber) Close() {}
|
||||
|
||||
// ModelName returns the audio model identifier currently in use.
|
||||
func (t *OpenAITranscriber) ModelName() string {
|
||||
return string(t.model)
|
||||
}
|
||||
|
||||
// Close implements the Transcriber interface. No-op for the Python backend.
|
||||
func (p *PythonTranscriber) Close() {}
|
||||
// TranscribePCM converts PCM float32 samples into text via OpenAI's API.
|
||||
func (t *OpenAITranscriber) TranscribePCM(ctx context.Context, samples []float32, sampleRate int, opts Options) (*Result, error) {
|
||||
merged := t.mergeOptions(opts)
|
||||
|
||||
// TranscribePCM transcribes raw PCM samples by first writing a WAV file.
|
||||
func (p *PythonTranscriber) TranscribePCM(ctx context.Context, samples []float32, sampleRate int, opts Options) (*Result, error) {
|
||||
pcm := float32ToPCM16(samples)
|
||||
return p.transcribePCM16(ctx, pcm, sampleRate, opts)
|
||||
if len(samples) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if sampleRate <= 0 {
|
||||
sampleRate = 24000
|
||||
}
|
||||
|
||||
pcm := float32ToPCM16(samples)
|
||||
wav, err := encodePCM16AsWAV(pcm, sampleRate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t.transcribeWAV(ctx, wav, sampleRate, len(pcm), merged)
|
||||
}
|
||||
|
||||
// TranscribeSilk decodes Silk data and transcribes it via Python whisper.
|
||||
func (p *PythonTranscriber) TranscribeSilk(ctx context.Context, silkData []byte, opts Options) (*Result, error) {
|
||||
pcm, rate, err := silk.Silk2PCM16(silkData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.transcribePCM16(ctx, pcm, rate, opts)
|
||||
// TranscribeSilk converts Silk-encoded payloads into text via OpenAI's API.
|
||||
func (t *OpenAITranscriber) TranscribeSilk(ctx context.Context, silkData []byte, opts Options) (*Result, error) {
|
||||
merged := t.mergeOptions(opts)
|
||||
|
||||
if len(silkData) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
samples, sampleRate, err := silk.Silk2PCM16(silkData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wav, err := encodePCM16AsWAV(samples, sampleRate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t.transcribeWAV(ctx, wav, sampleRate, len(samples), merged)
|
||||
}
|
||||
|
||||
func (p *PythonTranscriber) transcribePCM16(ctx context.Context, samples []int16, sampleRate int, opts Options) (*Result, error) {
|
||||
if len(samples) == 0 {
|
||||
return nil, errors.New("empty audio data")
|
||||
}
|
||||
tmpFile, err := os.CreateTemp("", "chatlog-whisper-*.wav")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp wav: %w", err)
|
||||
}
|
||||
wavPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
defer os.Remove(wavPath)
|
||||
func (t *OpenAITranscriber) transcribeWAV(ctx context.Context, wav []byte, sampleRate, sampleCount int, opts Options) (*Result, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if err := writePCM16AsWAV(wavPath, samples, sampleRate); err != nil {
|
||||
return nil, fmt.Errorf("write wav: %w", err)
|
||||
}
|
||||
duration := pcmDuration(sampleCount, sampleRate)
|
||||
|
||||
args := []string{p.scriptPath, "--wav", wavPath, "--log-level", "WARNING"}
|
||||
if opts.LanguageSet && strings.TrimSpace(opts.Language) != "" {
|
||||
args = append(args, "--language", strings.TrimSpace(opts.Language))
|
||||
}
|
||||
if opts.TranslateSet && opts.Translate {
|
||||
args = append(args, "--translate")
|
||||
}
|
||||
if opts.TranslateSet && opts.Translate {
|
||||
return t.sendTranslation(ctx, wav, opts, duration)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, p.cfg.PythonPath, args...)
|
||||
env := append([]string{}, os.Environ()...)
|
||||
env = append(env, "PYTHONIOENCODING=utf-8")
|
||||
for key, value := range p.cfg.Env {
|
||||
env = append(env, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
cmd.Env = env
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("python whisper: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
var resp pythonResult
|
||||
if err := json.Unmarshal(bytes.TrimSpace(output), &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode python whisper response: %w", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return nil, errors.New(resp.Error)
|
||||
}
|
||||
|
||||
result := &Result{
|
||||
Text: resp.Text,
|
||||
Language: resp.Language,
|
||||
Duration: pcmDuration(len(samples), sampleRate),
|
||||
}
|
||||
if result.Language == "" && opts.LanguageSet {
|
||||
result.Language = opts.Language
|
||||
}
|
||||
|
||||
if len(resp.Segments) > 0 {
|
||||
segments := make([]Segment, 0, len(resp.Segments))
|
||||
for _, seg := range resp.Segments {
|
||||
segments = append(segments, Segment{
|
||||
ID: seg.ID,
|
||||
Start: secondsToDuration(seg.Start),
|
||||
End: secondsToDuration(seg.End),
|
||||
Text: seg.Text,
|
||||
})
|
||||
}
|
||||
result.Segments = segments
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return t.sendTranscription(ctx, wav, opts, duration)
|
||||
}
|
||||
|
||||
func ensurePythonScript(path string) error {
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
current, readErr := os.ReadFile(path)
|
||||
if readErr == nil && bytes.Equal(current, embeddedWhisperScript) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, embeddedWhisperScript, 0o644); err != nil {
|
||||
return fmt.Errorf("write whisper helper: %w", err)
|
||||
}
|
||||
return nil
|
||||
func (t *OpenAITranscriber) sendTranscription(ctx context.Context, wav []byte, opts Options, fallbackDuration time.Duration) (*Result, error) {
|
||||
params := openai.AudioTranscriptionNewParams{
|
||||
File: openai.File(bytes.NewReader(wav), "audio.wav", "audio/wav"),
|
||||
Model: t.model,
|
||||
ResponseFormat: openai.AudioResponseFormatVerboseJSON,
|
||||
TimestampGranularities: []string{"segment"},
|
||||
}
|
||||
|
||||
if opts.LanguageSet {
|
||||
trimmed := strings.TrimSpace(opts.Language)
|
||||
if trimmed != "" && !strings.EqualFold(trimmed, "auto") {
|
||||
params.Language = openaiparam.NewOpt(trimmed)
|
||||
}
|
||||
}
|
||||
if opts.InitialPromptSet {
|
||||
prompt := strings.TrimSpace(opts.InitialPrompt)
|
||||
if prompt != "" {
|
||||
params.Prompt = openaiparam.NewOpt(prompt)
|
||||
}
|
||||
}
|
||||
if opts.TemperatureSet {
|
||||
params.Temperature = openaiparam.NewOpt(float64(opts.Temperature))
|
||||
}
|
||||
|
||||
transcription, err := t.client.Audio.Transcriptions.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildResultFromTranscription(transcription, opts, fallbackDuration)
|
||||
}
|
||||
|
||||
func (t *OpenAITranscriber) sendTranslation(ctx context.Context, wav []byte, opts Options, fallbackDuration time.Duration) (*Result, error) {
|
||||
params := openai.AudioTranslationNewParams{
|
||||
File: openai.File(bytes.NewReader(wav), "audio.wav", "audio/wav"),
|
||||
Model: t.translateModel,
|
||||
}
|
||||
|
||||
if opts.InitialPromptSet {
|
||||
prompt := strings.TrimSpace(opts.InitialPrompt)
|
||||
if prompt != "" {
|
||||
params.Prompt = openaiparam.NewOpt(prompt)
|
||||
}
|
||||
}
|
||||
if opts.TemperatureSet {
|
||||
params.Temperature = openaiparam.NewOpt(float64(opts.Temperature))
|
||||
}
|
||||
|
||||
translation, err := t.client.Audio.Translations.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Text: strings.TrimSpace(translation.Text),
|
||||
Language: fallbackLanguage(opts, true),
|
||||
Duration: fallbackDuration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *OpenAITranscriber) mergeOptions(overrides Options) Options {
|
||||
merged := t.defaultOptions
|
||||
|
||||
if overrides.LanguageSet {
|
||||
merged.Language = overrides.Language
|
||||
merged.LanguageSet = true
|
||||
}
|
||||
if overrides.TranslateSet {
|
||||
merged.Translate = overrides.Translate
|
||||
merged.TranslateSet = true
|
||||
}
|
||||
if overrides.ThreadsSet {
|
||||
merged.Threads = overrides.Threads
|
||||
merged.ThreadsSet = true
|
||||
}
|
||||
if overrides.InitialPromptSet {
|
||||
merged.InitialPrompt = overrides.InitialPrompt
|
||||
merged.InitialPromptSet = true
|
||||
}
|
||||
if overrides.TemperatureSet {
|
||||
merged.Temperature = overrides.Temperature
|
||||
merged.TemperatureSet = true
|
||||
}
|
||||
if overrides.TemperatureFloorSet {
|
||||
merged.TemperatureFloor = overrides.TemperatureFloor
|
||||
merged.TemperatureFloorSet = true
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func buildResultFromTranscription(tr *openai.Transcription, opts Options, fallbackDuration time.Duration) (*Result, error) {
|
||||
if tr == nil {
|
||||
return nil, errors.New("openai transcription response is empty")
|
||||
}
|
||||
|
||||
res := &Result{
|
||||
Text: strings.TrimSpace(tr.Text),
|
||||
Duration: fallbackDuration,
|
||||
}
|
||||
|
||||
raw := tr.RawJSON()
|
||||
if raw != "" {
|
||||
var payload verboseTranscription
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
log.Debug().Err(err).Msg("failed to parse verbose transcription payload")
|
||||
} else {
|
||||
if payload.Duration > 0 {
|
||||
res.Duration = secondsToDuration(payload.Duration)
|
||||
}
|
||||
if text := strings.TrimSpace(payload.Text); text != "" {
|
||||
res.Text = text
|
||||
}
|
||||
if lang := strings.TrimSpace(payload.Language); lang != "" {
|
||||
res.Language = lang
|
||||
}
|
||||
if len(payload.Segments) > 0 {
|
||||
segments := make([]Segment, 0, len(payload.Segments))
|
||||
for _, seg := range payload.Segments {
|
||||
segments = append(segments, Segment{
|
||||
ID: seg.ID,
|
||||
Start: secondsToDuration(seg.Start),
|
||||
End: secondsToDuration(seg.End),
|
||||
Text: strings.TrimSpace(seg.Text),
|
||||
})
|
||||
}
|
||||
res.Segments = segments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if res.Duration == 0 {
|
||||
res.Duration = fallbackDuration
|
||||
}
|
||||
if res.Language == "" {
|
||||
res.Language = fallbackLanguage(opts, false)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func normalizeAudioModel(name string) openai.AudioModel {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return openai.AudioModelWhisper1
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasSuffix(lower, ".bin") || strings.ContainsAny(trimmed, "\\/") {
|
||||
log.Warn().Str("model", trimmed).Msg("ignoring local whisper model path; using OpenAI whisper-1")
|
||||
return openai.AudioModelWhisper1
|
||||
}
|
||||
|
||||
return openai.AudioModel(trimmed)
|
||||
}
|
||||
|
||||
func fallbackLanguage(opts Options, translated bool) string {
|
||||
if translated {
|
||||
return "en"
|
||||
}
|
||||
if opts.LanguageSet {
|
||||
trimmed := strings.TrimSpace(opts.Language)
|
||||
if trimmed != "" && !strings.EqualFold(trimmed, "auto") {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type verboseTranscription struct {
|
||||
Text string `json:"text"`
|
||||
Language string `json:"language"`
|
||||
Duration float64 `json:"duration"`
|
||||
Segments []verboseTranscriptionSegment `json:"segments"`
|
||||
}
|
||||
|
||||
type verboseTranscriptionSegment struct {
|
||||
ID int `json:"id"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func float32ToPCM16(src []float32) []int16 {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]int16, len(src))
|
||||
for i, sample := range src {
|
||||
v := float64(sample)
|
||||
if v > 1 {
|
||||
v = 1
|
||||
} else if v < -1 {
|
||||
v = -1
|
||||
}
|
||||
dst[i] = int16(math.Round(v * 32767))
|
||||
}
|
||||
return dst
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]int16, len(src))
|
||||
for i, sample := range src {
|
||||
v := float64(sample)
|
||||
if v > 1 {
|
||||
v = 1
|
||||
} else if v < -1 {
|
||||
v = -1
|
||||
}
|
||||
dst[i] = int16(math.Round(v * 32767))
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func writePCM16AsWAV(path string, samples []int16, sampleRate int) error {
|
||||
if sampleRate <= 0 {
|
||||
sampleRate = 24000
|
||||
}
|
||||
func writePCM16AsWAVToWriter(w io.Writer, samples []int16, sampleRate int) error {
|
||||
if sampleRate <= 0 {
|
||||
sampleRate = 24000
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
dataSize := len(samples) * 2
|
||||
riffSize := 36 + dataSize
|
||||
byteRate := sampleRate * 2
|
||||
blockAlign := 2
|
||||
|
||||
dataSize := len(samples) * 2
|
||||
riffSize := 36 + dataSize
|
||||
byteRate := sampleRate * 2
|
||||
blockAlign := 2
|
||||
header := make([]byte, 44)
|
||||
copy(header[0:], []byte("RIFF"))
|
||||
binary.LittleEndian.PutUint32(header[4:], uint32(riffSize))
|
||||
copy(header[8:], []byte("WAVEfmt "))
|
||||
binary.LittleEndian.PutUint32(header[16:], 16)
|
||||
binary.LittleEndian.PutUint16(header[20:], 1)
|
||||
binary.LittleEndian.PutUint16(header[22:], 1)
|
||||
binary.LittleEndian.PutUint32(header[24:], uint32(sampleRate))
|
||||
binary.LittleEndian.PutUint32(header[28:], uint32(byteRate))
|
||||
binary.LittleEndian.PutUint16(header[32:], uint16(blockAlign))
|
||||
binary.LittleEndian.PutUint16(header[34:], 16)
|
||||
copy(header[36:], []byte("data"))
|
||||
binary.LittleEndian.PutUint32(header[40:], uint32(dataSize))
|
||||
|
||||
header := make([]byte, 44)
|
||||
copy(header[0:], []byte("RIFF"))
|
||||
binary.LittleEndian.PutUint32(header[4:], uint32(riffSize))
|
||||
copy(header[8:], []byte("WAVEfmt "))
|
||||
binary.LittleEndian.PutUint32(header[16:], 16)
|
||||
binary.LittleEndian.PutUint16(header[20:], 1)
|
||||
binary.LittleEndian.PutUint16(header[22:], 1)
|
||||
binary.LittleEndian.PutUint32(header[24:], uint32(sampleRate))
|
||||
binary.LittleEndian.PutUint32(header[28:], uint32(byteRate))
|
||||
binary.LittleEndian.PutUint16(header[32:], uint16(blockAlign))
|
||||
binary.LittleEndian.PutUint16(header[34:], 16)
|
||||
copy(header[36:], []byte("data"))
|
||||
binary.LittleEndian.PutUint32(header[40:], uint32(dataSize))
|
||||
if _, err := w.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := file.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := make([]byte, len(samples)*2)
|
||||
for i, sample := range samples {
|
||||
binary.LittleEndian.PutUint16(payload[i*2:], uint16(sample))
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := make([]byte, len(samples)*2)
|
||||
for i, sample := range samples {
|
||||
binary.LittleEndian.PutUint16(payload[i*2:], uint16(sample))
|
||||
}
|
||||
if _, err := file.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
func encodePCM16AsWAV(samples []int16, sampleRate int) ([]byte, error) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err := writePCM16AsWAVToWriter(buf, samples, sampleRate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func pcmDuration(sampleCount int, sampleRate int) time.Duration {
|
||||
if sampleRate <= 0 || sampleCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
seconds := float64(sampleCount) / float64(sampleRate)
|
||||
return secondsToDuration(seconds)
|
||||
if sampleRate <= 0 || sampleCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
seconds := float64(sampleCount) / float64(sampleRate)
|
||||
return secondsToDuration(seconds)
|
||||
}
|
||||
|
||||
func secondsToDuration(seconds float64) time.Duration {
|
||||
if math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(seconds * float64(time.Second))
|
||||
}
|
||||
|
||||
type pythonResult struct {
|
||||
Text string `json:"text"`
|
||||
Language string `json:"language"`
|
||||
Segments []pythonSegment `json:"segments"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type pythonSegment struct {
|
||||
ID int `json:"id"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Text string `json:"text"`
|
||||
if math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(seconds * float64(time.Second))
|
||||
}
|
||||
|
||||
@@ -1,757 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from typing import Optional, Dict, Any, Union
|
||||
import warnings
|
||||
import wave
|
||||
from contextlib import closing
|
||||
|
||||
_DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
|
||||
|
||||
def _str_to_bool(value: Optional[Union[str, bool]]) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
try:
|
||||
# TODO: 重写读取{Users}/.chatlog/whisper.json
|
||||
pass
|
||||
except Exception:
|
||||
LOCAL_WHISPER = _str_to_bool(
|
||||
os.getenv("CHATLOG_LOCAL_WHISPER", os.getenv("LOCAL_WHISPER"))
|
||||
)
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
||||
OPENAI_BASE_URL = os.getenv(
|
||||
"OPENAI_BASE_URL",
|
||||
os.getenv("CHATLOG_OPENAI_BASE_URL", _DEFAULT_OPENAI_BASE_URL),
|
||||
)
|
||||
else:
|
||||
"""
|
||||
LOCAL_WHISPER = _str_to_bool(getattr(_wechatlog_config, "LOCAL_WHISPER", False))
|
||||
OPENAI_API_KEY = getattr(_wechatlog_config, "OPENAI_API_KEY", "")
|
||||
OPENAI_BASE_URL = (
|
||||
getattr(_wechatlog_config, "OPENAI_BASE_URL", _DEFAULT_OPENAI_BASE_URL)
|
||||
or _DEFAULT_OPENAI_BASE_URL
|
||||
)
|
||||
""" #! TODO
|
||||
|
||||
# --- Simplified chunking configuration: only split every 10 seconds ---
|
||||
WHISPER_CHUNK_SECONDS = 10
|
||||
|
||||
# 条件导入
|
||||
if LOCAL_WHISPER:
|
||||
try:
|
||||
import whisper
|
||||
|
||||
LOCAL_WHISPER_AVAILABLE = True
|
||||
except ImportError:
|
||||
LOCAL_WHISPER_AVAILABLE = False
|
||||
warnings.warn("whisper package not available, falling back to OpenAI API")
|
||||
else:
|
||||
LOCAL_WHISPER_AVAILABLE = False
|
||||
|
||||
if not LOCAL_WHISPER or not LOCAL_WHISPER_AVAILABLE:
|
||||
try:
|
||||
from openai import OpenAI
|
||||
|
||||
OPENAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
OPENAI_AVAILABLE = False
|
||||
warnings.warn("openai package not available")
|
||||
|
||||
# Import silk2audio from PyWxDump
|
||||
try:
|
||||
import sys
|
||||
|
||||
sys.path.append("./PyWxDump")
|
||||
# TODO: _pywxdump_utils = importlib.import_module("pywxdump.db.utils.common_utils")
|
||||
# TODO: silk2audio = getattr(_pywxdump_utils, "silk2audio")
|
||||
|
||||
SILK_AVAILABLE = True
|
||||
except Exception:
|
||||
SILK_AVAILABLE = False
|
||||
warnings.warn(
|
||||
"silk2audio from PyWxDump not available, cannot decode silk audio from database"
|
||||
)
|
||||
|
||||
|
||||
class TranscriptionService:
|
||||
"""Service for transcribing audio messages to text"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
self._openai_client = None
|
||||
self._whisper_model = None
|
||||
|
||||
# Initialize appropriate client/model
|
||||
if LOCAL_WHISPER and LOCAL_WHISPER_AVAILABLE:
|
||||
self._init_local_whisper()
|
||||
elif OPENAI_AVAILABLE:
|
||||
self._init_openai_client()
|
||||
else:
|
||||
raise RuntimeError("No transcription backend available")
|
||||
|
||||
def _get_silk_audio_from_db(self, msg_svr_id: str) -> Optional[bytes]:
|
||||
"""
|
||||
Get silk audio data from database using MSG and Media tables
|
||||
|
||||
Args:
|
||||
msg_svr_id: Message server ID
|
||||
|
||||
Returns:
|
||||
Silk audio data bytes or None if not found
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# First, verify this is a voice message in MSG table (Type = 34)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT MsgSvrID FROM MSG WHERE MsgSvrID = ? AND Type = 34
|
||||
""",
|
||||
(msg_svr_id,),
|
||||
)
|
||||
|
||||
msg_result = cursor.fetchone()
|
||||
if not msg_result:
|
||||
logging.warning(
|
||||
f"Message {msg_svr_id} not found in MSG table or not a voice message (Type != 34)"
|
||||
)
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# Then get the silk audio data from Media table using Reserved0 field
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT Buf FROM Media WHERE Reserved0 = ?
|
||||
""",
|
||||
(msg_svr_id,),
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if result and result[0]:
|
||||
logging.info(
|
||||
f"Found silk audio data for message {msg_svr_id}: {len(result[0])} bytes"
|
||||
)
|
||||
return result[0]
|
||||
else:
|
||||
logging.warning(
|
||||
f"No silk audio data found in Media.Reserved0 for message {msg_svr_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting silk audio from database: {e}")
|
||||
return None
|
||||
|
||||
def _convert_silk_to_wav(self, silk_data: bytes) -> Optional[str]:
|
||||
"""Convert silk audio data to wav file and return temp file path"""
|
||||
if not SILK_AVAILABLE:
|
||||
logging.error("silk2audio from PyWxDump not available for audio conversion")
|
||||
return None
|
||||
|
||||
try:
|
||||
import tempfile
|
||||
|
||||
# Create temp wav file
|
||||
wav_temp_path = tempfile.mktemp(suffix=".wav")
|
||||
|
||||
# Use PyWxDump's silk2audio function to convert silk to wav
|
||||
# TODO: wav_data = silk2audio(silk_data, is_wave=True, save_path=wav_temp_path, rate=24000)
|
||||
|
||||
if os.path.exists(wav_temp_path) and os.path.getsize(wav_temp_path) > 0:
|
||||
logging.info(f"Successfully converted silk to wav: {wav_temp_path}")
|
||||
return wav_temp_path
|
||||
else:
|
||||
logging.error(
|
||||
"Failed to convert silk to wav - output file empty or missing"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error converting silk to wav using PyWxDump's silk2audio: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _init_local_whisper(self):
|
||||
"""Initialize local Whisper model with CUDA support and large model"""
|
||||
if not LOCAL_WHISPER:
|
||||
logging.info("Local Whisper disabled via configuration")
|
||||
return
|
||||
|
||||
if not LOCAL_WHISPER_AVAILABLE:
|
||||
logging.error("Local Whisper requested but whisper package unavailable")
|
||||
self._handle_whisper_init_failure()
|
||||
return
|
||||
|
||||
try:
|
||||
import torch # type: ignore
|
||||
except ImportError:
|
||||
torch = None
|
||||
|
||||
device = "cuda" if torch and torch.cuda.is_available() else "cpu"
|
||||
model_name = os.getenv(
|
||||
"CHATLOG_WHISPER_MODEL",
|
||||
os.getenv("WHISPER_MODEL", "large-v2" if device == "cuda" else "base"),
|
||||
)
|
||||
|
||||
try:
|
||||
self._whisper_model = whisper.load_model(model_name, device=device)
|
||||
self._whisper_device = device
|
||||
logging.info(
|
||||
f"Local Whisper model '{model_name}' loaded successfully on {device}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(f"Failed to load Whisper model '{model_name}': {exc}")
|
||||
self._handle_whisper_init_failure()
|
||||
|
||||
def _handle_whisper_init_failure(self):
|
||||
"""Handle Whisper initialization failure"""
|
||||
# Fall back to OpenAI if available
|
||||
if OPENAI_AVAILABLE:
|
||||
logging.info("Falling back to OpenAI Whisper API")
|
||||
self._init_openai_client()
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Failed to initialize both local Whisper and OpenAI Whisper API"
|
||||
)
|
||||
|
||||
def _init_openai_client(self):
|
||||
"""Initialize OpenAI client"""
|
||||
try:
|
||||
self._openai_client = OpenAI(
|
||||
api_key=OPENAI_API_KEY, base_url=OPENAI_BASE_URL
|
||||
)
|
||||
logging.info("OpenAI client initialized successfully")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to initialize OpenAI client: {e}")
|
||||
raise
|
||||
|
||||
def get_existing_transcription(self, msg_svr_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get existing transcription from database using MSG table
|
||||
|
||||
Args:
|
||||
msg_svr_id: Message server ID
|
||||
|
||||
Returns:
|
||||
Existing transcription text or None if not found
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# First check MSG table to verify this is a voice message (Type = 34)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT MsgSvrID, StrContent FROM MSG
|
||||
WHERE MsgSvrID = ? AND Type = 34
|
||||
""",
|
||||
(msg_svr_id,),
|
||||
)
|
||||
|
||||
msg_result = cursor.fetchone()
|
||||
if msg_result:
|
||||
# Try to extract any existing transcription from StrContent
|
||||
str_content = msg_result[1]
|
||||
if str_content:
|
||||
# MSG table for voice messages typically contains XML like:
|
||||
# <msg><voicemsg voiceformat="4" length="2732" endflag="1" cancelflag="0" voicelength="1839" fromusername="xxx" isPlayed="0" /></msg>
|
||||
# Check if there's any transcription data in the XML
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(str_content)
|
||||
voicemsg = root.find("voicemsg")
|
||||
if voicemsg is not None:
|
||||
# Look for any transcription attributes
|
||||
transcription = voicemsg.get("transcription")
|
||||
if transcription:
|
||||
conn.close()
|
||||
return transcription
|
||||
except (ET.ParseError, AttributeError):
|
||||
pass
|
||||
|
||||
# Check if we have stored transcription in our separate table
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS WL_TRANSCRIPTIONS (
|
||||
MsgSvrID TEXT PRIMARY KEY,
|
||||
transcription TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT transcription FROM WL_TRANSCRIPTIONS WHERE MsgSvrID = ?
|
||||
""",
|
||||
(msg_svr_id,),
|
||||
)
|
||||
|
||||
transcription_result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if transcription_result:
|
||||
return transcription_result[0]
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting existing transcription: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def store_transcription(self, msg_svr_id: str, transcription: str):
|
||||
"""
|
||||
Store transcription in database
|
||||
|
||||
Args:
|
||||
msg_svr_id: Message server ID
|
||||
transcription: Transcription text
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Store in WL_TRANSCRIPTIONS table
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS WL_TRANSCRIPTIONS (
|
||||
MsgSvrID TEXT PRIMARY KEY,
|
||||
transcription TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Verify table was created
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT name FROM sqlite_master WHERE type='table' AND name='WL_TRANSCRIPTIONS'
|
||||
"""
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
raise RuntimeError("Failed to create WL_TRANSCRIPTIONS table")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO WL_TRANSCRIPTIONS (MsgSvrID, transcription)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(msg_svr_id, transcription),
|
||||
)
|
||||
|
||||
# Verify insertion
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT transcription FROM WL_TRANSCRIPTIONS WHERE MsgSvrID = ?
|
||||
""",
|
||||
(msg_svr_id,),
|
||||
)
|
||||
inserted_record = cursor.fetchone()
|
||||
if not inserted_record:
|
||||
raise RuntimeError("Failed to insert transcription record")
|
||||
|
||||
# Also update WL_MSG table if record exists
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT name FROM sqlite_master WHERE type='table' AND name='WL_MSG'
|
||||
"""
|
||||
)
|
||||
if cursor.fetchone():
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE WL_MSG SET textualized_content = ?
|
||||
WHERE MsgSvrID = ? AND type_name = '语音' AND textualized_content IS NULL
|
||||
""",
|
||||
(transcription, msg_svr_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logging.info(f"Stored transcription for message {msg_svr_id}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error storing transcription: {e}")
|
||||
if "conn" in locals():
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
raise
|
||||
|
||||
def transcribe_audio_file(self, audio_path: str) -> Optional[str]:
|
||||
"""
|
||||
Transcribe audio file using available backend
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
|
||||
Returns:
|
||||
Transcription text or None if failed
|
||||
"""
|
||||
if not os.path.exists(audio_path):
|
||||
logging.error(f"Audio file not found: {audio_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
if LOCAL_WHISPER and self._whisper_model:
|
||||
return self._transcribe_with_local_whisper(audio_path)
|
||||
elif self._openai_client:
|
||||
return self._transcribe_with_openai(audio_path)
|
||||
else:
|
||||
logging.error("No transcription backend available")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error transcribing audio: {e}")
|
||||
return None
|
||||
|
||||
def _transcribe_with_local_whisper(self, audio_path: str) -> Optional[str]:
|
||||
"""Transcribe using local Whisper model with optimized settings"""
|
||||
try:
|
||||
if self._whisper_model is not None:
|
||||
# Use Chinese language hint for better accuracy
|
||||
result = self._whisper_model.transcribe(audio_path, language="zh")
|
||||
# Handle type safely
|
||||
if isinstance(result, dict) and "text" in result:
|
||||
return str(result["text"]).strip()
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"Local Whisper transcription failed: {e}")
|
||||
return None
|
||||
|
||||
def _transcribe_with_openai(self, audio_path: str) -> Optional[str]:
|
||||
"""Transcribe using OpenAI Whisper API (chunk every 10s if duration > 10s)."""
|
||||
try:
|
||||
if self._openai_client is None:
|
||||
return None
|
||||
is_wav = audio_path.lower().endswith(".wav")
|
||||
if is_wav:
|
||||
try:
|
||||
with closing(wave.open(audio_path, "rb")) as wf:
|
||||
framerate = wf.getframerate()
|
||||
nframes = wf.getnframes()
|
||||
duration_sec = nframes / float(framerate) if framerate else 0
|
||||
if duration_sec > WHISPER_CHUNK_SECONDS:
|
||||
logging.info(
|
||||
f"Duration {duration_sec:.2f}s > {WHISPER_CHUNK_SECONDS}s: chunking"
|
||||
)
|
||||
return self._chunk_wav_and_transcribe(
|
||||
audio_path, max_chunk_seconds=WHISPER_CHUNK_SECONDS
|
||||
)
|
||||
except Exception as inspect_err:
|
||||
logging.debug(
|
||||
f"Duration inspect failed, falling back to single-shot: {inspect_err}"
|
||||
)
|
||||
with open(audio_path, "rb") as audio_file:
|
||||
transcription = self._openai_client.audio.transcriptions.create(
|
||||
model="whisper-1", file=audio_file, language="zh"
|
||||
)
|
||||
return (
|
||||
transcription.text.strip()
|
||||
if getattr(transcription, "text", None)
|
||||
else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"OpenAI Whisper transcription failed: {e}")
|
||||
# Fallback: attempt chunking anyway if wav
|
||||
try:
|
||||
if audio_path.lower().endswith(".wav"):
|
||||
logging.info("Retry after failure: forcing 10s chunk transcription")
|
||||
return self._chunk_wav_and_transcribe(
|
||||
audio_path, max_chunk_seconds=WHISPER_CHUNK_SECONDS
|
||||
)
|
||||
except Exception as chunk_err:
|
||||
logging.error(f"Forced chunk transcription failed: {chunk_err}")
|
||||
return None
|
||||
|
||||
def _chunk_wav_and_transcribe(
|
||||
self,
|
||||
audio_path: str,
|
||||
max_chunk_seconds: int = WHISPER_CHUNK_SECONDS,
|
||||
target_max_bytes: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
"""Chunk a WAV file every fixed number of seconds (default 10s) and transcribe sequentially."""
|
||||
if self._openai_client is None:
|
||||
return None
|
||||
if not audio_path.lower().endswith(".wav"):
|
||||
# As a safeguard just do single-shot
|
||||
try:
|
||||
with open(audio_path, "rb") as f:
|
||||
resp = self._openai_client.audio.transcriptions.create(
|
||||
model="whisper-1", file=f, language="zh"
|
||||
)
|
||||
return resp.text.strip() if getattr(resp, "text", None) else None
|
||||
except Exception as e:
|
||||
logging.error(f"Single-shot (non-wav) transcription failed: {e}")
|
||||
return None
|
||||
|
||||
try:
|
||||
with closing(wave.open(audio_path, "rb")) as wf:
|
||||
params = {
|
||||
"nchannels": wf.getnchannels(),
|
||||
"sampwidth": wf.getsampwidth(),
|
||||
"framerate": wf.getframerate(),
|
||||
"nframes": wf.getnframes(),
|
||||
}
|
||||
bytes_per_frame = params["sampwidth"] * params["nchannels"]
|
||||
if bytes_per_frame == 0:
|
||||
logging.error("Invalid WAV parameters (bytes_per_frame=0)")
|
||||
return None
|
||||
frames_per_chunk = (
|
||||
int(max_chunk_seconds * params["framerate"])
|
||||
if params["framerate"]
|
||||
else 0
|
||||
)
|
||||
if frames_per_chunk <= 0:
|
||||
logging.error("Computed frames_per_chunk <= 0")
|
||||
return None
|
||||
|
||||
chunks_text = []
|
||||
total_frames = params["nframes"]
|
||||
processed = 0
|
||||
idx = 0
|
||||
while processed < total_frames:
|
||||
frames_to_read = min(frames_per_chunk, total_frames - processed)
|
||||
audio_bytes = wf.readframes(frames_to_read)
|
||||
processed += frames_to_read
|
||||
idx += 1
|
||||
|
||||
# Write temp wav chunk
|
||||
tmp_path = tempfile.mktemp(suffix=f"_chunk{idx}.wav")
|
||||
try:
|
||||
with closing(wave.open(tmp_path, "wb")) as out_wav:
|
||||
out_wav.setnchannels(params["nchannels"])
|
||||
out_wav.setsampwidth(params["sampwidth"])
|
||||
out_wav.setframerate(params["framerate"])
|
||||
out_wav.writeframes(audio_bytes)
|
||||
|
||||
with open(tmp_path, "rb") as chunk_f:
|
||||
resp = self._openai_client.audio.transcriptions.create(
|
||||
model="whisper-1", file=chunk_f, language="zh"
|
||||
)
|
||||
chunk_text = (
|
||||
resp.text.strip() if getattr(resp, "text", None) else ""
|
||||
)
|
||||
logging.info(
|
||||
f"Transcribed chunk {idx}: frames={frames_to_read}, text_len={len(chunk_text)}"
|
||||
)
|
||||
if chunk_text:
|
||||
chunks_text.append(chunk_text)
|
||||
except Exception as ce:
|
||||
logging.error(f"Failed to transcribe chunk {idx}: {ce}")
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
combined = "\n".join(chunks_text).strip()
|
||||
return combined if combined else None
|
||||
except Exception as e:
|
||||
logging.error(f"Error during chunked wav transcription: {e}")
|
||||
return None
|
||||
|
||||
def transcribe_message(
|
||||
self, msg_svr_id: str, audio_path: Optional[str] = None, force: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Transcribe a voice message
|
||||
|
||||
Args:
|
||||
msg_svr_id: Message server ID
|
||||
audio_path: Optional audio file path
|
||||
|
||||
Returns:
|
||||
Dictionary with success status, transcription text, and source info
|
||||
"""
|
||||
# First, check for existing transcription unless force regenerate
|
||||
if not force:
|
||||
existing_transcription = self.get_existing_transcription(msg_svr_id)
|
||||
if existing_transcription:
|
||||
return {
|
||||
"success": True,
|
||||
"transcription": existing_transcription,
|
||||
"source": "cached",
|
||||
"backend": None,
|
||||
"force": False,
|
||||
"message": "Using existing transcription",
|
||||
}
|
||||
|
||||
# If audio_path not provided, try to get it from database first (legacy files)
|
||||
if not audio_path:
|
||||
audio_path = self._get_audio_path_from_db(msg_svr_id)
|
||||
|
||||
# If no file path found or file doesn't exist, try to get silk data from database
|
||||
if not audio_path or not os.path.exists(audio_path):
|
||||
logging.info(
|
||||
f"Audio file not found, attempting to get silk data from database for message {msg_svr_id}"
|
||||
)
|
||||
|
||||
silk_data = self._get_silk_audio_from_db(msg_svr_id)
|
||||
if silk_data:
|
||||
# Convert silk to wav
|
||||
audio_path = self._convert_silk_to_wav(silk_data)
|
||||
if not audio_path:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to convert silk audio data to wav format",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No audio data found in database (neither file path nor silk data)",
|
||||
}
|
||||
|
||||
# Now we should have a valid audio path
|
||||
if not audio_path or not os.path.exists(audio_path):
|
||||
return {"success": False, "message": "Audio file processing failed"}
|
||||
|
||||
# Transcribe the audio
|
||||
transcription = self.transcribe_audio_file(audio_path)
|
||||
|
||||
# Clean up temporary file if we created one from silk data
|
||||
if audio_path and audio_path.startswith(tempfile.gettempdir()):
|
||||
try:
|
||||
os.unlink(audio_path)
|
||||
logging.info(f"Cleaned up temporary audio file: {audio_path}")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to clean up temporary file {audio_path}: {e}")
|
||||
if not transcription:
|
||||
backend = (
|
||||
"local_whisper"
|
||||
if (LOCAL_WHISPER and LOCAL_WHISPER_AVAILABLE)
|
||||
else "openai_whisper"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"backend": backend,
|
||||
"force": force,
|
||||
"message": f"Transcription failed using {backend}",
|
||||
}
|
||||
|
||||
# Store the transcription
|
||||
self.store_transcription(msg_svr_id, transcription)
|
||||
|
||||
backend = (
|
||||
"local_whisper"
|
||||
if (LOCAL_WHISPER and self._whisper_model)
|
||||
else "openai_whisper"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"transcription": transcription,
|
||||
"source": "generated",
|
||||
"backend": backend,
|
||||
"force": force,
|
||||
"message": f"Transcription completed using {backend}{' (regenerated)' if force else ''}",
|
||||
}
|
||||
|
||||
def _delete_existing_transcription(self, msg_svr_id: str) -> bool:
|
||||
"""Delete existing transcription so it can be regenerated."""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS WL_TRANSCRIPTIONS (
|
||||
MsgSvrID TEXT PRIMARY KEY,
|
||||
transcription TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"DELETE FROM WL_TRANSCRIPTIONS WHERE MsgSvrID = ?", (msg_svr_id,)
|
||||
)
|
||||
# Clear WL_MSG textualized_content if table exists
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='WL_MSG'"
|
||||
)
|
||||
if cursor.fetchone():
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE WL_MSG SET textualized_content = NULL
|
||||
WHERE MsgSvrID = ? AND type_name = '语音'
|
||||
""",
|
||||
(msg_svr_id,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error deleting existing transcription for {msg_svr_id}: {e}"
|
||||
)
|
||||
try:
|
||||
if "conn" in locals():
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def regenerate_transcription(
|
||||
self, msg_svr_id: str, audio_path: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Force regeneration: delete existing then transcribe with force=True."""
|
||||
deleted = self._delete_existing_transcription(msg_svr_id)
|
||||
result = self.transcribe_message(msg_svr_id, audio_path=audio_path, force=True)
|
||||
result["regenerated"] = True
|
||||
result["deleted_previous"] = deleted
|
||||
return result
|
||||
|
||||
def _get_audio_path_from_db(self, msg_svr_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get audio file path from MSG table (legacy support)
|
||||
|
||||
Note: This method is mainly for legacy compatibility.
|
||||
The new approach uses silk data directly from Media table.
|
||||
|
||||
Args:
|
||||
msg_svr_id: Message server ID
|
||||
|
||||
Returns:
|
||||
Audio file path or None if not found
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check MSG table for voice messages (Type = 34)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT MsgSvrID, StrContent FROM MSG
|
||||
WHERE MsgSvrID = ? AND Type = 34
|
||||
""",
|
||||
(msg_svr_id,),
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if result:
|
||||
# MSG table typically doesn't contain file paths for voice messages
|
||||
# Voice messages are stored as silk data in Media table
|
||||
# This method mainly exists for legacy compatibility
|
||||
logging.info(
|
||||
f"Voice message {msg_svr_id} found in MSG table, but audio path not available from this table"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logging.warning(f"Voice message {msg_svr_id} not found in MSG table")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting audio path from database: {e}")
|
||||
return None
|
||||
Reference in New Issue
Block a user