157 lines
4.7 KiB
Go
157 lines
4.7 KiB
Go
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 本地 whisper.cpp HTTP 服务客户端。配置项 CANVAS_WHISPER_BASE_URL
|
||
// 指向自建 whisper.cpp server(/inference,多部件 file 上传)。
|
||
// 语音数据不出本机,未配置该地址时任务在进入转写前明确失败。
|
||
|
||
type timelineTranscriptionSegment struct {
|
||
StartMs int64 `json:"startMs"`
|
||
EndMs int64 `json:"endMs"`
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
type timelineTranscriptionResult struct {
|
||
Segments []timelineTranscriptionSegment `json:"segments"`
|
||
SRT string `json:"srt"`
|
||
Language string `json:"language"`
|
||
}
|
||
|
||
type whisperSegment struct {
|
||
Start float64 `json:"start"`
|
||
End float64 `json:"end"`
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
type whisperVerboseJSON struct {
|
||
Segments []whisperSegment `json:"segments"`
|
||
Language string `json:"language"`
|
||
}
|
||
|
||
type whisperClient struct {
|
||
baseURL string
|
||
http *http.Client
|
||
}
|
||
|
||
func newWhisperClient(baseURL string) *whisperClient {
|
||
return &whisperClient{
|
||
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||
http: &http.Client{Timeout: 20 * time.Minute},
|
||
}
|
||
}
|
||
|
||
// transcribe 将本地 wav 送至 whisper.cpp /inference,返回段落与语言。
|
||
func (c *whisperClient) transcribe(ctx context.Context, wavPath string, language string) ([]timelineTranscriptionSegment, string, error) {
|
||
if c.baseURL == "" {
|
||
return nil, "", fmt.Errorf("本地转写服务未配置:请设置 CANVAS_WHISPER_BASE_URL")
|
||
}
|
||
var body bytes.Buffer
|
||
writer := multipart.NewWriter(&body)
|
||
audio, err := os.Open(wavPath)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("读取待转写音频失败: %w", err)
|
||
}
|
||
defer audio.Close()
|
||
part, err := writer.CreateFormFile("file", filepath.Base(wavPath))
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if _, err := io.Copy(part, audio); err != nil {
|
||
return nil, "", fmt.Errorf("上传音频失败: %w", err)
|
||
}
|
||
if err := writer.WriteField("response_format", "verbose_json"); err != nil {
|
||
return nil, "", err
|
||
}
|
||
if strings.TrimSpace(language) != "" {
|
||
if err := writer.WriteField("language", strings.TrimSpace(language)); err != nil {
|
||
return nil, "", err
|
||
}
|
||
}
|
||
if err := writer.Close(); err != nil {
|
||
return nil, "", err
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/inference", &body)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||
resp, err := c.http.Do(req)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("无法连接本地转写服务(whisper.cpp): %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||
return nil, "", fmt.Errorf("本地转写服务返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||
}
|
||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("读取转写结果失败: %w", err)
|
||
}
|
||
segments, languageOut, err := decodeWhisperVerboseJSON(payload)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if len(segments) == 0 {
|
||
return nil, "", fmt.Errorf("本地转写服务未识别出语音内容")
|
||
}
|
||
return segments, languageOut, nil
|
||
}
|
||
|
||
// decodeWhisperVerboseJSON 解析 whisper.cpp verbose_json 响应为内部段落,
|
||
// 过滤空文本,秒精度取整为毫秒。
|
||
func decodeWhisperVerboseJSON(payload []byte) ([]timelineTranscriptionSegment, string, error) {
|
||
var parsed whisperVerboseJSON
|
||
if err := json.Unmarshal(payload, &parsed); err != nil {
|
||
return nil, "", fmt.Errorf("转写结果解析失败: %w", err)
|
||
}
|
||
segments := make([]timelineTranscriptionSegment, 0, len(parsed.Segments))
|
||
for _, seg := range parsed.Segments {
|
||
if strings.TrimSpace(seg.Text) == "" {
|
||
continue
|
||
}
|
||
segments = append(segments, timelineTranscriptionSegment{
|
||
StartMs: int64(math.Round(seg.Start * 1000)),
|
||
EndMs: int64(math.Round(seg.End * 1000)),
|
||
Text: strings.TrimSpace(seg.Text),
|
||
})
|
||
}
|
||
return segments, parsed.Language, nil
|
||
}
|
||
|
||
// formatSRTTimestamp 将毫秒时间格式化为 SRT 时间戳 hh:mm:ss,mmm。
|
||
func formatSRTTimestamp(ms int64) string {
|
||
if ms < 0 {
|
||
ms = 0
|
||
}
|
||
hours := ms / 3600000
|
||
minutes := (ms % 3600000) / 60000
|
||
seconds := (ms % 60000) / 1000
|
||
millis := ms % 1000
|
||
return fmt.Sprintf("%02d:%02d:%02d,%03d", hours, minutes, seconds, millis)
|
||
}
|
||
|
||
// buildTimelineSRT 由段落生成 SRT 字幕文本,供前端直接消费或导出。
|
||
func buildTimelineSRT(segments []timelineTranscriptionSegment) string {
|
||
var out strings.Builder
|
||
for i, seg := range segments {
|
||
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n", i+1, formatSRTTimestamp(seg.StartMs), formatSRTTimestamp(seg.EndMs), seg.Text)
|
||
}
|
||
return out.String()
|
||
}
|