Google STT(Speech-to-Text) streaming socket 통신
프론트엔드에서 음성 스트림 데이터를 실시간으로 Google STT API로 보내 인식된 문자열을 프론트앤드에 뿌려주려고 한다.
이 작업을 하면서 동작을 안하는 문제가 발생했는데, 이를 정리해보려고 한다.
STT 사용 예시는 구글 독스에서 살펴볼 수 있다: https://cloud.google.com/speech-to-text/docs/transcribe-streaming-audio?hl=ko
스트리밍 입력에서 오디오를 텍스트로 변환 | Cloud Speech-to-Text Documentation | Google Cloud
Vertex AI의 최신 멀티모달 모델인 Gemini 1.5 모델을 사용해 보고 최대 2백만 개의 토큰 컨텍스트 윈도우를 사용해 무엇을 빌드할 수 있는지 확인해 보세요. Vertex AI의 최신 멀티모달 모델인 Gemini 1.5
cloud.google.com
"스트리밍" 으로 STT를 사용할 시 유의할 점
public class SpeechToTextService {
private final ClientStream<StreamingRecognizeRequest> requestObserver;
public SpeechToTextService(int sampleRate, ResponseObserver<StreamingRecognizeResponse> responseObserver) throws Exception {
SpeechClient speechClient = SpeechClient.create();
RecognitionConfig recognitionConfig = RecognitionConfig.newBuilder()
.setEncoding(RecognitionConfig.AudioEncoding.LINEAR16)
.setSampleRateHertz(sampleRate)
.setLanguageCode("ko-KR")
.build();
StreamingRecognitionConfig streamingConfig = StreamingRecognitionConfig.newBuilder()
.setConfig(recognitionConfig)
.setInterimResults(true)
.build();
requestObserver = speechClient.streamingRecognizeCallable().splitCall(responseObserver);
// 첫 요청에 StreamingConfig를 보내야 함
StreamingRecognizeRequest initialRequest = StreamingRecognizeRequest.newBuilder()
.setStreamingConfig(streamingConfig)
.build();
requestObserver.send(initialRequest);
}
1. StreamingRecognitionConfig 설정 후, 첫 요청을 보내야한다.
첫 번째 요청은 음성 데이터를 전송하기 전에 스트리밍 세션의 설정을 서버에 전달하는 요청이어야한다.
오디오 데이터가 포함되지 않은, setStreamingConfig를 통해 스트리밍 정보가 포함된 요청을 서버에 전송하여 스트리밍 세션을 시작한다.
이후 오디오 데이터를 전송해야 정상 동작한다.
2. 입력 음성 스트림의 샘플 사이즈는 16 비트, 타입은 byte[] 여야한다.
Google Cloud Speech-to-Text API는 16비트 샘플사이즈를 지원한다. 프론트엔드에서 실시간 스트리밍을 위해 사용했던 AudioWorkletProcessor의 경우 샘플사이즈 32 비트의 Float32Array 형식의 음성 스트림을 생성한다.
때문에 입력 음성 스트림 데이터를 샘플사이즈 16비트의 PCM 데이터로 바꾸고, 이를 Uint8Array로 변경 후 백엔드로(API로) 전달해야한다.
프론트 엔드
32비트 PCM -> 16비트 PCM -> Uint8Array
16비트 PCM 데이터는 2바이트씩 처리되어야 하는데, 이를 그대로 전송하면 데이터의 해석 방식이 달라질 수 있다. 때문에 8비트 바이트 배열로 변환하여 일관된 형식으로 전송하는 것이 좋다.
32비트 PCM -> 16비트 PCM
float32ToInt16(buffer) {
const len = buffer.length;
const output = new Int16Array(len);
for (let i = 0; i < len; i++) {
// 32-bit float (-1.0 to 1.0) -> 16-bit PCM (-32768 to 32767)
output[i] = Math.max(-1, Math.min(1, buffer[i])) * 32767;
}
return output;
}
16비트 PCM -> Uint8Array
#int16ToByteArray(int16Array) {
const byteArray = new Uint8Array(int16Array.length * 2); // 16비트 -> 2바이트로 변환
for (let i = 0; i < int16Array.length; i++) {
byteArray[i * 2] = int16Array[i] & 0xff; // 하위 바이트
byteArray[i * 2 + 1] = (int16Array[i] >> 8) & 0xff; // 상위 바이트
}
return byteArray;
}
백 엔드
Uint8Array -> byte[]
Google Cloud Speech-to-Text API는 byte[]형식의 음성 데이터를 받기 때문에, 프론트에서 전달받은 데이터를 byte[]형식으로 변환 후 사용한다.
3. 실시간으로 데이터 전송이 끝난 후, ClientStream을 종료해 주지않으면 Timeout Error가 발생한다.
실시간으로 데이터 전송이 끝난 후, 끝났음을 서버에 알려주지 않으면 서버는 계속 데이터를 기다리다가 타임아웃 에러를 발생시키다. 때문에 서버에게 마지막 데이터가 전송되었음을 알려주고 스트림을 종료시켜야한다.
public void sendAudioData(byte[] audioData, boolean isFinal) {
if (requestObserver != null) {
StreamingRecognizeRequest request = StreamingRecognizeRequest.newBuilder()
.setAudioContent(ByteString.copyFrom(audioData))
.build();
requestObserver.send(request);
} else {
System.err.println("Audio stream is not initialized.");
}
if (isFinal) {
try {
Thread.sleep(300);
System.out.println("Closing stream.");
requestObserver.closeSend();
} catch (Exception e) {
e.printStackTrace();
}
}
}
위는 오디오 데이터를 전송하는 함수다. 마지막 요청이라면(isFinal == True) 300ms 후 ClientStream(requestObserver)을 종료한다. 시간을 두는 이유는, ClientStream가 종료되면 ResponseObserver<StreamingRecognizeResponse> 의 onComplete()가 호출되는데, onComplete()가 호출되면 onResponse()가 호출되지 않아 프론트엔드에 결과 데이터를 전송해 줄 수 없다. 때문에 서버에게 처리 시간을 주기 위해 약간의 시간을 주는 것이 좋다.
여기서 ResponseObserver 는 ClientStream 생성 시 등록한 클래스 객체다.
requestObserver = speechClient.streamingRecognizeCallable().splitCall(responseObserver);
ResponseObserver 는 Google Cloud Speech-to-Text API에서 스트리밍 음성 인식을 사용할 때, 서버로부터 실시간으로 응답을 처리하기 위해 사용하는 인터페이스다. onResponse() 함수에서 텍스트 변환의 중간 결과와 최종결과를 처리할 수 있다.
다음은 내가 사용한 ResponseObserver 클래스 코드다.
public static class ResponseObserverSend implements ResponseObserver<StreamingRecognizeResponse> {
private Session session;
private StringBuilder finalTranscript = new StringBuilder();
public ResponseObserverSend(Session session) {
this.session = session;
}
@Override
public void onStart(StreamController controller) {
System.out.println("Streaming started.");
}
@Override
public void onResponse(StreamingRecognizeResponse response) {
try {
if (!response.getResultsList().isEmpty()) {
StreamingRecognitionResult result = response.getResultsList().get(0);
String transcript = result.getAlternativesList().get(0).getTranscript();
if (result.getIsFinal()) {
finalTranscript.append(transcript);
this.session.getBasicRemote().sendObject(new ResponseAudioText(transcript, true));
} else {
this.session.getBasicRemote().sendObject(new ResponseAudioText(transcript, false));
}
}
} catch (Exception e) {
System.err.println("Error during speech response: " + e.getMessage());
e.printStackTrace();
}
}
@Override
public void onError(Throwable t) {
String errorMessage = t.getMessage();
System.err.println("Error during speech recognition: " + errorMessage);
t.printStackTrace();
}
@Override
public void onComplete() {
System.out.println("Speech recognition completed.");
}
public String getFinalTranscript() {
return finalTranscript.toString();
}
}
결과
프론트 엔드의 콘솔에서 실시간으로 결과를 잘 가져오는 것을 볼 수 있다.
아무 에러도 발생하지 않고, onResponse()의 함수가 동작하지 않아 해결하는데 많은 시간이 소요되었다. 이 글을 읽는 분들은 음성데이터 형식을 잘 맞추어 시간을 낭비하지 않길 바란다.