From 0e091aede2ed047d9f498597471650f540abaa24 Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Wed, 15 Oct 2025 16:07:14 +0800 Subject: [PATCH] feat(openai-go) : Add openai feature for transcribe and later chats --- go.mod | 5 + go.sum | 12 + internal/chatlog/conf/speech.go | 24 +- internal/chatlog/ctx/context.go | 33 +- internal/chatlog/http/service.go | 23 +- internal/whisper/transcriber.go | 583 ++++++++++++++---------- internal/whisper/whisper.py | 757 ------------------------------- 7 files changed, 394 insertions(+), 1043 deletions(-) delete mode 100644 internal/whisper/whisper.py diff --git a/go.mod b/go.mod index 105f540..bf0abdc 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 63e157e..470174b 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/chatlog/conf/speech.go b/internal/chatlog/conf/speech.go index b88a900..8d25cdd 100644 --- a/internal/chatlog/conf/speech.go +++ b/internal/chatlog/conf/speech.go @@ -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. diff --git a/internal/chatlog/ctx/context.go b/internal/chatlog/ctx/context.go index 117441d..997f6f3 100644 --- a/internal/chatlog/ctx/context.go +++ b/internal/chatlog/ctx/context.go @@ -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() diff --git a/internal/chatlog/http/service.go b/internal/chatlog/http/service.go index 5523c55..becdd81 100644 --- a/internal/chatlog/http/service.go +++ b/internal/chatlog/http/service.go @@ -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 { diff --git a/internal/whisper/transcriber.go b/internal/whisper/transcriber.go index 0ec9ba7..86c1ca3 100644 --- a/internal/whisper/transcriber.go +++ b/internal/whisper/transcriber.go @@ -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)) } diff --git a/internal/whisper/whisper.py b/internal/whisper/whisper.py deleted file mode 100644 index 6b9155c..0000000 --- a/internal/whisper/whisper.py +++ /dev/null @@ -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: - # - # 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