You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

337 lines
9.5 KiB

package speech
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// Google Cloud Speech-to-Text v1 LongRunningRecognize
// Docs: https://cloud.google.com/speech-to-text/docs/async-recognize
// 注意:长音频要求音频必须放在 GCS(gs://...),公网 http URL 不被该 API 接受
const (
scope = "https://www.googleapis.com/auth/cloud-platform"
submitURL = "https://speech.googleapis.com/v1/speech:longrunningrecognize"
operationURL = "https://speech.googleapis.com/v1/operations/"
)
type Speech struct {
options Options
client *http.Client
tokenSrc oauth2.TokenSource
projectId string
}
func newSys(options Options) (sys *Speech, err error) {
data := options.JsonContent
if len(data) == 0 {
if options.JsonPath == "" {
return nil, fmt.Errorf("google speech: JsonPath or JsonContent required")
}
data, err = os.ReadFile(options.JsonPath)
if err != nil {
return nil, fmt.Errorf("read service account: %w", err)
}
}
projectId := options.ProjectId
if projectId == "" {
var sa struct {
ProjectId string `json:"project_id"`
}
if err = json.Unmarshal(data, &sa); err != nil {
return nil, fmt.Errorf("parse service account: %w", err)
}
projectId = sa.ProjectId
}
cfg, err := google.JWTConfigFromJSON(data, scope)
if err != nil {
return nil, fmt.Errorf("jwt config: %w", err)
}
sys = &Speech{
options: options,
client: &http.Client{Timeout: time.Duration(options.Timeout) * time.Second},
tokenSrc: cfg.TokenSource(context.Background()),
projectId: projectId,
}
return
}
// ==================== 创建任务 ====================
type recognitionConfig struct {
Encoding string `json:"encoding,omitempty"`
SampleRateHertz int `json:"sampleRateHertz,omitempty"`
LanguageCode string `json:"languageCode"`
EnableAutomaticPunctuation bool `json:"enableAutomaticPunctuation"`
EnableWordTimeOffsets bool `json:"enableWordTimeOffsets"`
DiarizationConfig *speakerDiarization `json:"diarizationConfig,omitempty"`
}
type speakerDiarization struct {
EnableSpeakerDiarization bool `json:"enableSpeakerDiarization"`
MinSpeakerCount int `json:"minSpeakerCount,omitempty"`
MaxSpeakerCount int `json:"maxSpeakerCount,omitempty"`
}
type recognitionAudio struct {
Uri string `json:"uri"`
}
type longRunningRecognizeRequest struct {
Config recognitionConfig `json:"config"`
Audio recognitionAudio `json:"audio"`
}
type operationResponse struct {
Name string `json:"name"`
Done bool `json:"done"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
Response *json.RawMessage `json:"response,omitempty"`
}
func (this *Speech) CreateTask(audioURL, language string, enableSpeaker bool, callbackURL string) (taskID string, err error) {
if audioURL == "" {
return "", fmt.Errorf("audioURL is empty")
}
if !strings.HasPrefix(audioURL, "gs://") {
return "", fmt.Errorf("google speech requires gs:// URI, got: %s", audioURL)
}
if language == "" {
language = "en-US"
}
cfg := recognitionConfig{
Encoding: this.options.Encoding,
SampleRateHertz: this.options.SampleRateHertz,
LanguageCode: language,
EnableAutomaticPunctuation: true,
EnableWordTimeOffsets: true,
}
if enableSpeaker {
cfg.DiarizationConfig = &speakerDiarization{
EnableSpeakerDiarization: true,
MinSpeakerCount: this.options.DiarizationMin,
MaxSpeakerCount: this.options.DiarizationMax,
}
}
reqBody := longRunningRecognizeRequest{
Config: cfg,
Audio: recognitionAudio{Uri: audioURL},
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
token, err := this.tokenSrc.Token()
if err != nil {
return "", fmt.Errorf("get access token: %w", err)
}
req, err := http.NewRequest(http.MethodPost, submitURL, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err := this.client.Do(req)
if err != nil {
return "", fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("google speech create task failed: status=%d body=%s", resp.StatusCode, string(data))
}
var out operationResponse
if err = json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("unmarshal response: %w body=%s", err, string(data))
}
if out.Name == "" {
return "", fmt.Errorf("missing operation name: body=%s", string(data))
}
return out.Name, nil
}
// ==================== 查询任务 ====================
// longRunningRecognizeResponse 异步识别结果
type longRunningRecognizeResponse struct {
Type string `json:"@type,omitempty"`
Results []longRunningResultItem `json:"results"`
}
type longRunningResultItem struct {
Alternatives []alternative `json:"alternatives"`
ResultEndTime string `json:"resultEndTime,omitempty"`
}
type alternative struct {
Transcript string `json:"transcript"`
Confidence float64 `json:"confidence"`
Words []wordInfo `json:"words"`
}
type wordInfo struct {
StartTime string `json:"startTime"` // 形如 "1.300s"
EndTime string `json:"endTime"`
Word string `json:"word"`
SpeakerTag int `json:"speakerTag,omitempty"`
}
func (this *Speech) QueryTask(taskID string) (status string, contexts []ContextStruct, err error) {
if taskID == "" {
return StatusFailed, nil, fmt.Errorf("taskID is empty")
}
token, err := this.tokenSrc.Token()
if err != nil {
return StatusFailed, nil, fmt.Errorf("get access token: %w", err)
}
// taskID 形如 projects/.../operations/{id} 或仅 {id}
url := operationURL + strings.TrimPrefix(taskID, "operations/")
if strings.HasPrefix(taskID, "projects/") || strings.HasPrefix(taskID, "operations/") {
// Google 接受短名("operations/{id}")或仅 {id};如返回了全路径用 v1/{name} 直接拼
if strings.HasPrefix(taskID, "operations/") {
url = "https://speech.googleapis.com/v1/" + taskID
} else {
// 长路径用 v1/{name} 形式
url = "https://speech.googleapis.com/v1/" + taskID
}
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return StatusFailed, nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err := this.client.Do(req)
if err != nil {
return StatusFailed, nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return StatusFailed, nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode/100 != 2 {
return StatusFailed, nil, fmt.Errorf("google speech query failed: status=%d body=%s", resp.StatusCode, string(data))
}
var op operationResponse
if err = json.Unmarshal(data, &op); err != nil {
return StatusFailed, nil, fmt.Errorf("unmarshal operation: %w body=%s", err, string(data))
}
if op.Error != nil {
return StatusFailed, nil, fmt.Errorf("operation error: code=%d msg=%s", op.Error.Code, op.Error.Message)
}
if !op.Done {
return StatusRunning, nil, nil
}
if op.Response == nil {
return StatusFailed, nil, fmt.Errorf("operation done but response missing: body=%s", string(data))
}
var lr longRunningRecognizeResponse
if err = json.Unmarshal(*op.Response, &lr); err != nil {
return StatusFailed, nil, fmt.Errorf("unmarshal response: %w body=%s", err, string(*op.Response))
}
contexts = extractContexts(lr)
return StatusSuccess, contexts, nil
}
// extractContexts 把按 result/alternative 排布的词级时间戳合并成「整句」片段
// 策略:每个 result 取首选 alternative,按 speakerTag 切片;
// 若禁用说话人分离则整个 alternative 作为一句。
func extractContexts(lr longRunningRecognizeResponse) []ContextStruct {
contexts := make([]ContextStruct, 0, len(lr.Results))
for _, r := range lr.Results {
if len(r.Alternatives) == 0 {
continue
}
alt := r.Alternatives[0]
if len(alt.Words) == 0 {
// 无词级信息,作为单句
if alt.Transcript != "" {
contexts = append(contexts, ContextStruct{Content: alt.Transcript})
}
continue
}
// 按 speakerTag 切片
var cur ContextStruct
curSpeaker := -1
var sb strings.Builder
flush := func() {
if sb.Len() > 0 {
cur.Content = strings.TrimSpace(sb.String())
contexts = append(contexts, cur)
sb.Reset()
}
}
for _, w := range alt.Words {
startMs := parseDurationMs(w.StartTime)
endMs := parseDurationMs(w.EndTime)
if w.SpeakerTag != curSpeaker && sb.Len() > 0 {
flush()
}
if sb.Len() == 0 {
cur = ContextStruct{
StartTime: startMs,
Speaker: speakerStr(w.SpeakerTag),
}
curSpeaker = w.SpeakerTag
}
if sb.Len() > 0 {
sb.WriteByte(' ')
}
sb.WriteString(w.Word)
cur.EndTime = endMs
}
flush()
}
return contexts
}
// parseDurationMs 解析 "1.300s" 形式为毫秒
func parseDurationMs(s string) int64 {
if s == "" {
return 0
}
s = strings.TrimSuffix(s, "s")
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return int64(v * 1000)
}
func speakerStr(tag int) string {
if tag <= 0 {
return ""
}
return strconv.Itoa(tag)
}