스트리밍 STT - WebSocket
본 문서는 스트리밍 STT 중에서 WebSocket로 구현하는 방식에 대한 가이드를 제공합니다.
연동예제
본 문서의 예제는 로컬 오디오 파일로부터 스트리밍 음성인식을 수행하는 방법을 설명해줍니다. 마이크와 같은 스트리밍 입력 장치로 API를 이용하고 싶은 경우, 파일로 읽어오는 코드 부분을 장치 인식을 수행하는 코드로 변경함으로써 사용하실 수 있습니다.
인증 토큰 발급
스트리밍 STT API를 사용하기 위해서는 인증 토큰 발급 가이드를 통해 토큰을 발급받아야 합니다.
Parameter
Name | Type | Description | Required |
---|---|---|---|
sample_rate | integer | 범위: 8000 ~ 48000, 단위: Hz | O |
encoding | string | 인코딩 타입 (참고. 지원 인코딩) | O |
model_name | string | 사용할 언어 모델 (default: sommers_ko, 참고: 한국어 - sommers_ko, 일본어 - sommers_ja) | X |
use_itn | boolean | 영어/숫자/단위 변환 사용 여부 (default: true, 참고: 영어/숫자/단위 변환) | X |
use_disfluency_filter | boolean | 간투어 필터기능 사용 여부 (default: false, 참고: 간투어 필터) | X |
use_profanity_filter | boolean | 비속어 필터기능 사용 여부 (default: false, 참고: 비속어 필터) | X |
keywords | string | 아래 키워드 부스팅 참고 | X |
키워드 부스팅 (keywords)
키워드 부스팅에 사용할 단어와 그에 해당하는 스코어를 지정합니다.
개별 키워드는 다음과 같은 형식의 문자열(string)으로 제공되어야 합니다: "단어"
또는 "단어:스코어"
2개 이상의 키워드를 지정할 경우, 개별 키워드는 쉼표(,
)로 구분되어야 합니다: "단어1,단어2:스코어2,단어3:스코어3"
- 단어: 인식률을 높이고 싶은 단어를 지정해야 하고, 반드시 한글발음과 띄어쓰기로만 이루어져야 합니다.
- 스코어:
- 스코어를 지정하지 않는 경우 기본값인 2.0으로 설정됩니다.
- 스코어를 지정할 경우 각 단어와 스코어는 반드시 콜론(
:
)으로 구분되어야 합니다. - 스코어는 -5.0부터 5.0까지의 범위 내에 있는 실수 값을 지정해야 합니다. 0보다 큰 경우 해당 단어를 잘 인식하고, 0보다 작은 경우 덜 인식하도록 작동합니다.
- 0을 설정하는 경우 해당 단어에 대해 키워드 부스팅을 사용하지 않는 것과 동일하게 작동합니다.
- keywords 예시:
"부스팅" // 스코어를 지정하지 않은 경우 2.0을 사용합니다.
"부스팅:3.5"
"음성인식,리턴제로:3.5,에스티티:-1"
caution
- 스코어 값을 지정할 경우 반드시 -5.0 이상 5.0 이하여야 합니다.
- 단어는 한글 발음대로 적어야 합니다. <예시: STT (X), 에스티티 (O)>
- 단어는 한글과 공백으로만 구성되어야 하며 음절이어야 합니다. <예시: 에스TT (X), 에스ㅌㅌ (X), 에스티티2 (X), 에스티티 (O)>
- 각 단어의 길이는 20자 이하로 제한되며, keywords는 최대 100개까지 지원합니다.
- 유효하지 않은 형식의 단어나 스코어가 포함된 경우, 요청은 거부될 수 있습니다.
Response
{
// 문장의 발화 id
seq: integer
// 스트리밍 시작 기준 문장의 발화 시점 (단위: msec)
start_at: integer
// final이 true 일 경우 문자의 발화 시간, final 이 false일 경우 0 (단위: msec)
duration: integer
// 문장의 종료 여부
final: boolean
// 대체 텍스트, 첫번째 값이 정확도가 가장 높은 결과
alternatives: [
{
// 문장의 텍스트
text: string
// 문장의 정확도 (beta)
confidence: float
// 단어(토큰)의 정보, final 이 true 일 경우만 제공
words?: [
{
// 단어(토큰)의 텍스트, `|` 로 띄어쓰기 구분
text: string
// 문장의 시작 기준 단어(토큰)의 발화 시점 (단위: msec)
start_at: integer
// 단어(토큰)의 발화 시간 (단위: msec)
duration: integer
// 단어(토큰)의 정확도 (미지원)
confidence: float
}
]
}
]
}
샘플 코드
- Golang
- Python
- Java
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/xfrr/goffmpeg/transcoder"
)
const ServerHost = "openapi.vito.ai"
const ClientId = "{YOUR_CLIENT_ID}"
const ClientSecret = "{YOUR_CLIENT_SECRET}"
const SAMPLE_RATE int = 8000
const BYTES_PER_SAMPLE int = 2
/*
본 예제에서는 스트리밍 입력을 음성파일을 읽어서 시뮬레이션 합니다.
실제사용시에는 마이크 입력 등의 실시간 음성 스트림이 들어와야합니다.
*/
type FileStreamer struct {
file *os.File
}
func (fs *FileStreamer) Read(p []byte) (int, error) {
byteSize := len(p)
maxSize := 1024 * 1024
if byteSize > maxSize {
byteSize = maxSize
}
defer time.Sleep(time.Duration(byteSize/((SAMPLE_RATE*BYTES_PER_SAMPLE)/1000)) * time.Millisecond)
return fs.file.Read(p[:byteSize])
}
func (fs *FileStreamer) Close() error {
defer os.Remove(fs.file.Name())
return fs.file.Close()
}
func OpenAudioFile(audioFile string) (io.ReadCloser, error) {
fileName := filepath.Base(audioFile)
i := strings.LastIndex(fileName, ".")
audioFileName8K := filepath.Join(os.TempDir(), fileName[:i]) + fmt.Sprintf("_%d.%s", SAMPLE_RATE, "wav")
trans := new(transcoder.Transcoder)
if err := trans.Initialize(audioFile, audioFileName8K); err != nil {
log.Fatal(err)
}
trans.MediaFile().SetAudioRate(SAMPLE_RATE)
trans.MediaFile().SetAudioChannels(1)
trans.MediaFile().SetSkipVideo(true)
trans.MediaFile().SetAudioFilter("aresample=resampler=soxr")
err := <-trans.Run(false)
if err != nil {
return nil, fmt.Errorf("transcode audio file failed: %w", err)
}
file, err := os.Open(audioFileName8K)
if err != nil {
return nil, fmt.Errorf("open audio file failed: %w", err)
}
return &FileStreamer{file: file}, nil
}
func main() {
flag.Parse()
log.SetFlags(0)
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
u := url.URL{Scheme: "wss", Host: ServerHost, Path: "/v1/transcribe:streaming"}
query := u.Query()
query.Set("sample_rate", strconv.Itoa(SAMPLE_RATE))
query.Set("encoding", "LINEAR16")
query.Set("use_itn", "true")
query.Set("use_disfluency_filter", "true")
query.Set("use_profanity_filter", "false")
u.RawQuery = query.Encode()
log.Printf("connecting to %s", u.String())
audioFile := flag.Arg(0)
streamingFile, err := OpenAudioFile(audioFile)
if err != nil {
log.Fatal(err)
}
defer streamingFile.Close()
data := map[string][]string{
"client_id": []string{ClientId},
"client_secret": []string{ClientSecret},
}
resp, _ := http.PostForm("https://openapi.vito.ai/v1/authenticate", data)
if resp.StatusCode != 200 {
panic("Failed to authenticate")
}
bytes, _ := io.ReadAll(resp.Body)
var result struct {
Token string `json:"access_token"`
}
json.Unmarshal(bytes, &result)
requestHeader := http.Header{
"Authorization": []string{fmt.Sprintf("Bearer %s", result.Token)},
}
c, res, err := websocket.DefaultDialer.Dial(u.String(), requestHeader)
if err != nil {
log.Printf("status: %s, body: %v", res.Status, res.Body)
log.Fatal("dial:", err)
}
defer c.Close()
start := time.Now()
go func() {
buf := make([]byte, 1024)
for {
n, err := streamingFile.Read(buf)
if err == io.EOF {
// Nothing else to pipe, close the stream.
log.Println("send EOS")
if err := c.WriteMessage(websocket.TextMessage, []byte("EOS")); err != nil {
log.Fatalf("Could not close stream: %v", err)
}
return
}
if err != nil {
log.Printf("Could not read from %s: %v", audioFile, err)
continue
}
err = c.WriteMessage(websocket.BinaryMessage, buf[:n])
if err != nil {
log.Println("write:", err)
}
select {
case <-interrupt:
log.Println("interrupt")
if err := c.WriteMessage(websocket.TextMessage, []byte("EOS")); err != nil {
log.Fatalf("Could not close: %v", err)
}
return
default:
}
}
}()
start2 := time.Now()
for {
_, message, err := c.ReadMessage()
if err != nil {
log.Printf("elapsed[%v]\n", time.Since(start))
log.Println("read:", err)
return
}
log.Printf("[%v]recv: %s", time.Since(start2), message)
start2 = time.Now()
}
}
import asyncio
import json
import logging
import os
import tempfile
import time
from io import DEFAULT_BUFFER_SIZE
import websockets
from pydub import AudioSegment
from requests import Session
API_BASE = "https://openapi.vito.ai"
SAMPLE_RATE = 8000
BYTES_PER_SAMPLE = 2
# 본 예제에서는 스트리밍 입력을 음성파일을 읽어서 시뮬레이션 합니다.
# 실제사용시에는 마이크 입력 등의 실시간 음성 스트림이 들어와야합니다.
class FileStreamer:
def __init__(self, file):
file_name = os.path.basename(file)
i = file_name.rindex(".")
audio_file_8k_path = (
os.path.join(tempfile.gettempdir(), file_name[:i])
+ "_"
+ str(SAMPLE_RATE)
+ ".wav"
)
self.filepath = audio_file_8k_path
audio = AudioSegment.from_file(file=file, format=file[i + 1 :])
audio = audio.set_frame_rate(SAMPLE_RATE)
audio = audio.set_channels(1)
audio.export(audio_file_8k_path, format="wav")
self.file = open(audio_file_8k_path, "rb")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
os.remove(self.filepath)
async def read(self, size):
if size > 1024 * 1024:
size = 1024 * 1024
await asyncio.sleep(size / (SAMPLE_RATE * BYTES_PER_SAMPLE))
content = self.file.read(size)
return content
class RTZROpenAPIClient:
def __init__(self, client_id, client_secret):
super().__init__()
self._logger = logging.getLogger(__name__)
self.client_id = client_id
self.client_secret = client_secret
self._sess = Session()
self._token = None
@property
def token(self):
if self._token is None or self._token["expire_at"] < time.time():
resp = self._sess.post(
API_BASE + "/v1/authenticate",
data={"client_id": self.client_id, "client_secret": self.client_secret},
)
resp.raise_for_status()
self._token = resp.json()
return self._token["access_token"]
async def streaming_transcribe(self, filename, config=None):
if config is None:
config = dict(
sample_rate=str(SAMPLE_RATE),
encoding="LINEAR16",
use_itn="true",
use_disfluency_filter="false",
use_profanity_filter="false",
)
STREAMING_ENDPOINT = "wss://{}/v1/transcribe:streaming?{}".format(
API_BASE.split("://")[1], "&".join(map("=".join, config.items()))
)
conn_kwargs = dict(extra_headers={"Authorization": "bearer " + self.token})
async def streamer(websocket):
with FileStreamer(filename) as f:
while True:
buff = await f.read(DEFAULT_BUFFER_SIZE)
if buff is None or len(buff) == 0:
break
await websocket.send(buff)
await websocket.send("EOS")
async def transcriber(websocket):
async for msg in websocket:
msg = json.loads(msg)
print(msg)
if msg["final"]:
print("final ended with " + msg["alternatives"][0]["text"])
async with websockets.connect(STREAMING_ENDPOINT, **conn_kwargs) as websocket:
await asyncio.gather(
streamer(websocket),
transcriber(websocket),
)
if __name__ == "__main__":
CLIENT_ID = "{YOUR_CLIENT_ID}"
CLIENT_SECRET = "{YOUR_CLIENT_SECRET}"
client = RTZROpenAPIClient(CLIENT_ID, CLIENT_SECRET)
fname = "sample.wav"
asyncio.run(client.streaming_transcribe(fname))
// full version
// https://github.com/vito-ai/java-sample
/* 해당 예제는 아래 파일 포맷을 지원합니다
WAV, AU, AIFF
https://docs.oracle.com/javase/8/docs/technotes/guides/sound/index.html
*/
package ai.vito.openapi.stream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.HashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.concurrent.CountDownLatch;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.UnsupportedAudioFileException;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.FormBody;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okio.ByteString;
/*
본 예제에서는 스트리밍 입력을 음성파일을 읽어서 시뮬레이션 합니다.
실제사용시에는 마이크 입력 등의 실시간 음성 스트림이 들어와야합니다.
*/
final class FileStreamer {
private AudioInputStream audio8KStream;
private int SAMPLE_RATE = 8000;
private int BITS_PER_SAMPLE = 16;
public FileStreamer(String filePath) throws IOException, UnsupportedAudioFileException {
File file = new File(filePath);
try {
AudioInputStream originalAudioStream = AudioSystem.getAudioInputStream(file);
AudioFormat originalFormat = originalAudioStream.getFormat();
AudioFormat newFormat = new AudioFormat(
AudioFormat.Encoding.PCM_SIGNED,
SAMPLE_RATE,
BITS_PER_SAMPLE,
1,
1 * (BITS_PER_SAMPLE / 8),
SAMPLE_RATE,
originalFormat.isBigEndian());
this.audio8KStream = AudioSystem.getAudioInputStream(newFormat, originalAudioStream);
} catch (IOException | UnsupportedAudioFileException e) {
throw e;
}
}
public int read(byte[] b) throws IOException, InterruptedException {
int maxSize = 1024 * 1024;
int byteSize = Math.min(b.length, maxSize);
try {
Thread.sleep(byteSize / ((SAMPLE_RATE * (BITS_PER_SAMPLE / 8)) / 1000));
} catch (InterruptedException e) {
throw e;
}
return this.audio8KStream.read(b, 0, byteSize);
}
public void close() throws IOException {
this.audio8KStream.close();
}
}
public class RTZRSttWebSocketClient {
public static String getAccessToken() throws IOException {
OkHttpClient client = new OkHttpClient();
RequestBody formBody = new FormBody.Builder()
.add("client_id", "{YOUR_CLIENT_ID}")
.add("client_secret", "{YOUR_CLIENT_SECRET}")
.build();
Request request = new Request.Builder()
.url("https://openapi.vito.ai/v1/authenticate")
.post(formBody)
.build();
Response response = client.newCall(request).execute();
ObjectMapper objectMapper = new ObjectMapper();
HashMap<String, String> map = objectMapper.readValue(response.body().string(), HashMap.class);
return map.get("access_token");
}
public static void main(String[] args) throws Exception {
Logger logger = Logger.getLogger(VitoSttWebSocketClient.class.getName());
OkHttpClient client = new OkHttpClient();
String token = Auth.getAccessToken();
HttpUrl.Builder httpBuilder = HttpUrl.get("https://openapi.vito.ai/v1/transcribe:streaming").newBuilder();
httpBuilder.addQueryParameter("sample_rate", "8000");
httpBuilder.addQueryParameter("encoding", "LINEAR16");
httpBuilder.addQueryParameter("use_itn", "true");
httpBuilder.addQueryParameter("use_disfluency_filter", "true");
httpBuilder.addQueryParameter("use_profanity_filter", "false");
String url = httpBuilder.toString().replace("https://", "wss://");
Request request = new Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer " + token)
.build();
RTZRWebSocketListener webSocketListener = new RTZRWebSocketListener();
WebSocket rtzrWebSocket = client.newWebSocket(request, webSocketListener);
FileStreamer fileStreamer = new FileStreamer("sample.wav");
byte[] buffer = new byte[1024];
int readBytes;
while ((readBytes = fileStreamer.read(buffer)) != -1) {
boolean sent = vitoWebSocket.send(ByteString.of(buffer, 0, readBytes));
if (!sent) {
logger.log(Level.WARNING, "Send buffer is full. Cannot complete request. Increase sleep interval.");
System.exit(1);
}
}
fileStreamer.close();
vitoWebSocket.send("EOS");
webSocketListener.waitClose();
client.dispatcher().executorService().shutdown();
}
}
class RTZRWebSocketListener extends WebSocketListener {
private static final Logger logger = Logger.getLogger(RTZRWebSocketListener.class.getName());
private static final int NORMAL_CLOSURE_STATUS = 1000;
private CountDownLatch latch = null;
private static void log(Level level, String msg, Object... args) {
logger.log(level, msg, args);
}
@Override
public void onOpen(WebSocket webSocket, Response response) {
log(Level.INFO, "Open " + response.message());
latch = new CountDownLatch(1);
}
@Override
public void onMessage(WebSocket webSocket, String text) {
System.out.println(text);
}
@Override
public void onMessage(WebSocket webSocket, ByteString bytes) {
System.out.println(bytes.hex());
}
@Override
public void onClosing(WebSocket webSocket, int code, String reason) {
webSocket.close(NORMAL_CLOSURE_STATUS, null);
log(Level.INFO, "Closing {0} {1}", code, reason);
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
webSocket.close(NORMAL_CLOSURE_STATUS, null);
log(Level.INFO, "Closed {0} {1}", code, reason);
latch.countDown();
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
t.printStackTrace();
latch.countDown();
}
public void waitClose() throws InterruptedException {
log(Level.INFO, "Wait for finish");
latch.await();
}
}
오류 코드
스트리밍 STT - WebSocket 의 오류 처리는 Dial시 응답값의 StatusCode로 오류를 처리 합니다.
HttpStatus | Code | Notes |
---|---|---|
400 | H0001 | 잘못된 파라미터 요청 |
401 | H0002 | 인증실패 |
429 | A0001 | 사용량 초과 |
500 | E500 | 서버 오류 |
참고사항
오디오 파일을 텍스트로 변환할 경우, 스트리밍 STT API를 이용하여 처리할 수도 있지만 일반 STT 가이드 문서에서 기술된 것처럼 일반 STT API로 변환 작업을 수행하는 것이 더 편리합니다.