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
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)
|
|
}
|
|
|