package yandex import ( "context" "encoding/binary" "errors" "fmt" "io" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" "google.golang.org/protobuf/proto" stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3" "github.com/yandex-cloud/go-genproto/yandex/cloud/operation" "git.vakhrushev.me/av/transcriber/internal/entity" ) const ( SpeechKitEndpoint = "stt.api.cloud.yandex.net:443" OperationEndpoint = "operation.api.cloud.yandex.net:443" RecognitionModel = "deferred-general" ) type speechKitConfig struct { ApiKey string FolderID string } type speechKitService struct { sttConn *grpc.ClientConn opConn *grpc.ClientConn sttClient stt.AsyncRecognizerClient opClient operation.OperationServiceClient apiKey string folderID string } func newSpeechKitService(cfg speechKitConfig) (*speechKitService, error) { apiKey := cfg.ApiKey folderID := cfg.FolderID if apiKey == "" || folderID == "" { return nil, fmt.Errorf("missing required Yandex Cloud environment variables") } // Создаем защищенное соединение для SpeechKit creds := credentials.NewTLS(nil) sttConn, err := grpc.NewClient(SpeechKitEndpoint, grpc.WithTransportCredentials(creds)) if err != nil { return nil, fmt.Errorf("failed to connect to SpeechKit: %w", err) } // Создаем защищенное соединение для Operations API opConn, err := grpc.NewClient(OperationEndpoint, grpc.WithTransportCredentials(creds)) if err != nil { // Отказы независимы, и второй не теряется. На сегодняшнем клиенте он // почти наверняка не наступит: grpc.NewClient ленив, соединение к этому // моменту не открыто, и Close вернёт отказ только при повторном // закрытии — то есть сообщит о нашей ошибке, а не о Yandex. Сборка // оставлена как защита от смены реализации клиента; nil от закрытия // errors.Join отбрасывает, и форма ошибки в обычном случае не меняется. return nil, errors.Join( fmt.Errorf("failed to connect to Operations API: %w", err), sttConn.Close(), ) } sttClient := stt.NewAsyncRecognizerClient(sttConn) opClient := operation.NewOperationServiceClient(opConn) return &speechKitService{ sttConn: sttConn, opConn: opConn, sttClient: sttClient, opClient: opClient, apiKey: apiKey, folderID: folderID, }, nil } func (s *speechKitService) Close() error { var err1, err2 error if s.sttConn != nil { err1 = s.sttConn.Close() } if s.opConn != nil { err2 = s.opConn.Close() } // Отказы двух соединений независимы, и вернуть только первый — значит // потерять половину причины: журнал пишется при остановке процесса, и // восстановить утраченное будет уже негде. return errors.Join(err1, err2) } // recognizeFileFromS3 запускает асинхронное распознавание файла из S3 func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) { // Добавляем авторизацию и folder_id в контекст ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) // Создаем запрос на распознавание req := &stt.RecognizeFileRequest{ AudioSource: &stt.RecognizeFileRequest_Uri{ Uri: s3URI, }, RecognitionModel: &stt.RecognitionModelOptions{ Model: RecognitionModel, AudioFormat: &stt.AudioFormatOptions{ AudioFormat: &stt.AudioFormatOptions_ContainerAudio{ ContainerAudio: &stt.ContainerAudio{ ContainerAudioType: stt.ContainerAudio_OGG_OPUS, }, }, }, TextNormalization: &stt.TextNormalizationOptions{ TextNormalization: stt.TextNormalizationOptions_TEXT_NORMALIZATION_ENABLED, ProfanityFilter: false, LiteratureText: true, }, AudioProcessingType: stt.RecognitionModelOptions_FULL_DATA, }, SpeakerLabeling: &stt.SpeakerLabelingOptions{ SpeakerLabeling: stt.SpeakerLabelingOptions_SPEAKER_LABELING_ENABLED, }, } // Отправляем запрос op, err := s.sttClient.RecognizeFile(ctx, req) if err != nil { return "", fmt.Errorf("failed to start recognition: %w", err) } return op.Id, nil } // fetchRecognition забирает результат операции целиком и отдаёт его доменным, // вместе с сырым ответом на хранение. // // Ответ сохраняется потому, что **результат операции у провайдера не // переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и // когда научится, архив пересчитается из сохранённого без единого рубля. func (s *speechKitService) fetchRecognition(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) { ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) req := &stt.GetRecognitionRequest{ OperationId: operationID, } stream, err := s.sttClient.GetRecognition(ctx, req) if err != nil { return nil, fmt.Errorf("failed to get recognition stream: %w", err) } var responses []*stt.StreamingResponse for { resp, err := stream.Recv() if err != nil { // Конец потока библиотека отдаёт ровно `io.EOF`. Прежде он узнавался // сравнением текста сообщения: так же выглядел бы и настоящий отказ // с текстом «EOF», и распознавание молча вернуло бы половину текста. if errors.Is(err, io.EOF) { break } return nil, fmt.Errorf("failed to receive recognition response: %w", err) } responses = append(responses, resp) } raw, err := encodeResponses(responses) if err != nil { return nil, err } outcome := outcomeFromResponses(responses) outcome.Raw = raw return outcome, nil } // checkOperationStatus проверяет статус операции распознавания func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) { ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) op, err := s.opClient.Get(ctx, &operation.GetOperationRequest{ OperationId: operationID, }) if err != nil { return nil, fmt.Errorf("failed to get operation status: %w", err) } return op, nil } // encodeResponses укладывает ответ провайдера целиком, в том виде, в каком он // пришёл: сообщения потока подряд, каждое со своей длиной впереди. // // Форма **двоичная**, а не текстовая, и это несущее решение. Текстовое // представление собирается по нашей скомпилированной схеме и молча выбрасывает // поля, которых в ней нет, — а провайдер добавляет их без предупреждения. // Двоичная форма неизвестные поля переносит: они переживают запись и чтение и // станут читаемыми, когда мы обновим схему. Ради этого архив и заводился — // результат операции у провайдера не переспрашивается, и повторное // распознавание стоит денег. // // Цена названа прямо: сохранённое не читается глазами и не разбирается ничем, // кроме нашего же кода. func encodeResponses(responses []*stt.StreamingResponse) ([]byte, error) { var raw []byte for _, resp := range responses { encoded, err := proto.Marshal(resp) if err != nil { // Текст расшифровки наружу не выходит даже отказом: сообщение // провайдера несёт её целиком. return nil, errors.New("failed to encode provider response") } raw = binary.AppendUvarint(raw, uint64(len(encoded))) raw = append(raw, encoded...) } return raw, nil } // decodeResponses читает сохранённый ответ провайдера обратно. func decodeResponses(raw []byte) ([]*stt.StreamingResponse, error) { var responses []*stt.StreamingResponse for len(raw) > 0 { size, read := binary.Uvarint(raw) if read <= 0 || uint64(len(raw)-read) < size { return nil, errors.New("stored provider payload is truncated") } raw = raw[read:] var resp stt.StreamingResponse if err := proto.Unmarshal(raw[:size], &resp); err != nil { return nil, errors.New("failed to decode provider response") } responses = append(responses, &resp) raw = raw[size:] } return responses, nil } // outcomeFromResponses строит доменный результат: реплики со временем и плоский // текст. Формата провайдера отсюда наружу не выходит ничего. // // Говорящие не размечаются: связь реплики с разбором говорящего у провайдера не // выяснена. Структура при этом строится из сохранённого ответа, поэтому разметка // станет возможной без повторной оплаты. func outcomeFromResponses(responses []*stt.StreamingResponse) *entity.RecognitionOutcome { outcome := &entity.RecognitionOutcome{} var plain []byte for _, resp := range responses { refinement := resp.GetFinalRefinement() if refinement == nil { continue } text := refinement.GetNormalizedText() if text == nil { continue } for _, alt := range text.GetAlternatives() { if alt.GetText() == "" { continue } outcome.Replicas = append(outcome.Replicas, entity.Replica{ StartMs: alt.GetStartTimeMs(), EndMs: alt.GetEndTimeMs(), Text: alt.GetText(), }) if len(plain) > 0 { plain = append(plain, ' ') } plain = append(plain, alt.GetText()...) } } outcome.PlainText = string(plain) return outcome }