feat(openai-go) : Add openai feature for transcribe and later chats

This commit is contained in:
Symb0x76
2025-10-15 16:07:14 +08:00
parent 3f55832215
commit 0e091aede2
7 changed files with 394 additions and 1043 deletions
+5
View File
@@ -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
+12
View File
@@ -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=
+13 -11
View File
@@ -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.
+2 -31
View File
@@ -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()
+11 -12
View File
@@ -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
View File
@@ -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))
}
-757
View File
@@ -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