リアルタイム翻訳では、元の音声を翻訳専用のセッションにストリーミングすると、話者が話している間に翻訳音声と書き起こしの差分を受け取れます。同時通訳、多言語での通話、放送、会議、授業、ビデオ通話ルームなどに利用できます。
人の発言を翻訳するアプリケーションには gpt-realtime-translate を使用してください。質問に答え、ツールを呼び出しながら会話を進めるアシスタントが必要な場合は、代わりに gpt-realtime-2.1 を標準の Realtime セッションで使用してください。
リアルタイム翻訳セッションと音声エージェントセッションでは、アーキテクチャが異なります。
| 音声エージェントセッション |
翻訳セッション |
/v1/realtime に接続します。 |
/v1/realtime/translations に接続します。 |
| モデルがアシスタントとして動作します。 |
モデルが通訳者として動作します。 |
| 会話と応答のライフサイクルに沿って動作します。 |
入力音声に応じて翻訳結果を継続的にストリーミングします。 |
| ツールを呼び出したり、アシスタントの発話を生成したりすることがあります。 |
翻訳音声と書き起こしの差分を生成します。 |
response.create を呼び出せます。 |
response.create は呼び出しません。 |
音声ストリームが届くと翻訳が始まります。フレーズの間の無音も含めて音声を追加し続け、出力イベントは届くたびに処理してください。
ブラウザで音声を取得または再生する場合は WebRTC を使用します。WebRTC では、元の音声をメディアトラックとして送信し、翻訳音声をリモートの音声トラックとして受信します。アプリ側で音声をリサンプリングしたり、PCM チャンクを再生したりする必要はありません。
Twilio Media Streams、SIP メディア、放送用の入力ストリーム、メディアワーカーなどを通じて、サーバーがすでに生の音声データを受信している場合は WebSocket を使用します。WebSocket では、24 kHz の PCM16 音声を Base64 でエンコードして送信し、返された音声の差分をアプリ側で再生します。
ブラウザアプリでは、有効期間の短いクライアントシークレットをサーバー上で作成します。通常の API キーをブラウザに公開しないでください。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25app.post("/session", async (req, res) => {
const language = req.body.targetLanguage ?? "es";
const response = await fetch(
"https://api.openai.com/v1/realtime/translations/client_secrets",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
"OpenAI-Safety-Identifier": "hashed-user-id",
},
body: JSON.stringify({
session: {
model: "gpt-realtime-translate",
audio: {
output: { language },
},
},
}),
}
);
res.status(response.status).json(await response.json());
});
ブラウザで音声を取得し、ピア接続を作成して、翻訳通話用のエンドポイントに SDP オファーを POST します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50const { value: clientSecret } = await fetch("/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetLanguage: "es" }),
}).then((response) => response.json());
const sourceStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
const pc = new RTCPeerConnection();
pc.addTrack(sourceStream.getAudioTracks()[0], sourceStream);
const translatedAudio = new Audio();
translatedAudio.autoplay = true;
pc.ontrack = ({ streams }) => {
translatedAudio.srcObject = streams[0];
};
const events = pc.createDataChannel("oai-events");
events.onmessage = ({ data }) => {
const event = JSON.parse(data);
if (event.type === "session.output_transcript.delta") {
subtitles.textContent += event.delta;
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sdpResponse = await fetch(
"https://api.openai.com/v1/realtime/translations/calls",
{
method: "POST",
headers: {
Authorization: `Bearer ${clientSecret}`,
"Content-Type": "application/sdp",
},
body: offer.sdp,
}
);
if (!sdpResponse.ok) {
throw new Error(await sdpResponse.text());
}
await pc.setRemoteDescription({
type: "answer",
sdp: await sdpResponse.text(),
});
翻訳専用のエンドポイントに接続し、URL でモデルを指定します。
この例を実行する前に、Node.js では ws、Python では websocket-client と sounddevice、Ruby では async-websocket(gem install async-websocket)をインストールしてください。Java では、Maven の依存関係に com.openai:openai-java:4.75.1 を追加してください。
Java の例では、接続の確立、言語の設定、音声の入力を個別に示します。Java SDK はイベントの受信とセッションの終了にも対応していますが、このガイドにはその手順の Java コード例がないため、Java の例だけでは翻訳の流れ全体を確認できません。以下の手順を最初から最後まで試すには、Node.js または Python の例を使用してください。
1
2
3
4
5
6
7
8
9
10
11import WebSocket from "ws";
const ws = new WebSocket(
"wss://api.openai.com/v1/realtime/translations?model=gpt-realtime-translate",
{
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"OpenAI-Safety-Identifier": "hashed-user-id",
},
}
);
1
2
3
4
5
6
7
8
9
10
11import os
import websocket
ws = websocket.WebSocket()
ws.connect(
"wss://api.openai.com/v1/realtime/translations?model=gpt-realtime-translate",
header=[
f"Authorization: Bearer {os.environ['OPENAI_API_KEY']}",
"OpenAI-Safety-Identifier: hashed-user-id",
],
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
client := openai.NewClient()
session, err := client.Realtime.ConnectTranslation(ctx, "gpt-realtime-translate", realtime.TranslationConnectionOptions{}, option.WithHeader("OpenAI-Safety-Identifier", "hashed-user-id"))
if err != nil {
return err
}
defer session.Abort()
created, err := session.Recv(ctx)
if err != nil {
return err
}
fmt.Println(created.Type)
if created.Type != "session.created" {
return fmt.Errorf("expected session.created, got %s", created.Type)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32import com.openai.client.okhttp.OkHttpClient;
import com.openai.core.ClientOptions;
import com.openai.helpers.TranslationConnection;
import com.openai.helpers.TranslationWebSocketOptions;
import com.openai.models.realtime.*;
var http = OkHttpClient.builder().build();
var options =
ClientOptions.builder()
.fromEnv()
.httpClient(http)
.putHeader("OpenAI-Safety-Identifier", "hashed-user-id")
.build();
try (http;
var connection =
TranslationConnection.connect(
options,
TranslationWebSocketOptions.builder()
.model("gpt-realtime-translate")
.sendTimeout(java.time.Duration.ofSeconds(30))
.build())) {
while (true) {
var event = connection.receive();
if (event.error().isPresent())
throw new IllegalStateException(event.error().orElseThrow().toString());
if (event.sessionCreated().isPresent()) {
System.out.println("Connected to translation session.");
System.out.println(event);
break;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21require "async"
require "async/http/endpoint"
require "async/websocket/client"
require "json"
endpoint = Async::HTTP::Endpoint.parse("wss://api.openai.com/v1/realtime/translations?model=gpt-realtime-translate", timeout: 10, alpn_protocols: ["http/1.1"])
headers = {
"Authorization" => "Bearer #{ENV.fetch("OPENAI_API_KEY")}",
"OpenAI-Safety-Identifier" => "hashed-user-id"
}
Sync do |task|
task.with_timeout(120) do
Async::WebSocket::Client.connect(endpoint, headers: headers) do |connection|
message = connection.read or raise "Connection closed before session creation"
event = JSON.parse(message.to_str)
raise "Expected session.created: #{event}" unless event["type"] == "session.created"
puts(event.fetch("type"))
end
end
end
Ruby では、以下の設定と音声追加のコードを Async::WebSocket::Client.connect ブロック内に挿入します。挿入位置は、セッションの作成を確認した後、ブロックが終わる前です。音声の送信中と翻訳イベントの受信中は接続を開いたままにしてください。
Java の接続例では、try ブロックを抜けるとソケットが閉じます。Java の設定例と音声入力例には、開いた状態の TranslationConnection が必要です。音声の送信中と出力の受信中は接続を開いたままにしてください。ブロックを抜ける前にセッションの終了処理を開始し、session.closed を受信するまで残りのイベントをすべて読み取ってください。手順は「WebSocket セッションの終了」を参照してください。
ソケットが開いたら、翻訳先の言語を設定します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14ws.on("open", () => {
ws.send(
JSON.stringify({
type: "session.update",
session: {
audio: {
output: {
language: "es",
},
},
},
})
);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import json
ws.send(
json.dumps(
{
"type": "session.update",
"session": {
"audio": {
"output": {
"language": "es",
},
},
},
}
)
)
1
2
3
4
5
6
7
8
9if err := session.Send(ctx, realtime.RealtimeTranslationClientEventParamOfSessionUpdate(realtime.RealtimeTranslationSessionUpdateRequestParam{
Audio: realtime.RealtimeTranslationSessionUpdateRequestAudioParam{
Output: realtime.RealtimeTranslationSessionUpdateRequestAudioOutputParam{
Language: openai.String("es"),
},
},
})); err != nil {
return err
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20import com.openai.client.okhttp.OkHttpClient;
import com.openai.core.ClientOptions;
import com.openai.helpers.TranslationConnection;
import com.openai.helpers.TranslationWebSocketOptions;
import com.openai.models.realtime.*;
connection.send(
RealtimeTranslationClientEvent.ofSessionUpdate(
RealtimeTranslationSessionUpdateEvent.builder()
.session(
RealtimeTranslationSessionUpdateRequest.builder()
.audio(
RealtimeTranslationSessionUpdateRequest.Audio.builder()
.output(
RealtimeTranslationSessionUpdateRequest.Audio.Output.builder()
.language("es")
.build())
.build())
.build())
.build()));
1
2connection.write(JSON.generate(type: "session.update", session: { audio: { output: { language: "es" } } }))
connection.flush
次に、音声を継続的に追加します。
1
2
3
4
5
6ws.send(
JSON.stringify({
type: "session.input_audio_buffer.append",
audio: base64Pcm16,
})
);
1
2
3
4
5
6
7
8ws.send(
json.dumps(
{
"type": "session.input_audio_buffer.append",
"audio": base64_pcm16,
}
)
)
1
2
3
4
5
6
7// audio contains mono PCM16 samples at 24 kHz.
for start := 0; start < len(audio); start += 4800 {
end := min(start+4800, len(audio))
if err := session.Send(ctx, realtime.RealtimeTranslationClientEventParamOfSessionInputAudioBufferAppend(base64.StdEncoding.EncodeToString(audio[start:end]))); err != nil {
return err
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14import com.openai.client.okhttp.OkHttpClient;
import com.openai.core.ClientOptions;
import com.openai.helpers.TranslationConnection;
import com.openai.helpers.TranslationWebSocketOptions;
import com.openai.models.realtime.*;
import java.util.Base64;
// Call for each PCM16, 24 kHz, mono chunk supplied by your audio source.
static void appendAudio(TranslationConnection connection, byte[] pcmAudio) {
String base64Pcm16 = Base64.getEncoder().encodeToString(pcmAudio);
connection.send(
RealtimeTranslationClientEvent.ofSessionInputAudioBufferAppend(
RealtimeTranslationInputAudioBufferAppendEvent.builder().audio(base64Pcm16).build()));
}
1
2
3
4
5
6
7
8require "base64"
File.open("speech.pcm", "rb") do |audio|
while (chunk = audio.read(4_800))
connection.write(JSON.generate(type: "session.input_audio_buffer.append", audio: Base64.strict_encode64(chunk)))
connection.flush
end
end
翻訳音声と書き起こしのイベントを受信します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15ws.on("message", (data) => {
const event = JSON.parse(data.toString());
if (event.type === "session.output_audio.delta") {
playPcm16(event.delta);
}
if (event.type === "session.output_transcript.delta") {
process.stdout.write(event.delta);
}
if (event.type === "session.input_transcript.delta") {
updateSourceTranscript(event.delta);
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50import base64
import json
from concurrent.futures import ThreadPoolExecutor
from contextlib import suppress
from queue import SimpleQueue
import sounddevice as sd
audio_queue = SimpleQueue()
def play_audio():
try:
# Closing the stream waits for PortAudio's pending buffers to finish.
with sd.RawOutputStream(samplerate=24000, channels=1, dtype="int16") as stream:
while (pcm := audio_queue.get()) is not None:
stream.write(pcm)
except Exception:
# The receiver may have already closed the socket.
with suppress(OSError):
ws.abort() # Wake recv() so the receiver can report the playback failure.
raise
with ThreadPoolExecutor(max_workers=1) as executor:
playback = executor.submit(play_audio)
try:
while True:
event = json.loads(ws.recv())
if playback.done():
playback.result() # Report a playback failure on the receiver.
if event["type"] == "session.output_audio.delta":
audio_queue.put(base64.b64decode(event["delta"]))
if event["type"] in (
"session.output_transcript.delta",
"session.input_transcript.delta",
):
print(event["delta"], end="", flush=True)
if event["type"] == "error":
raise RuntimeError(f"Translation error: {event['error']['code']}")
if event["type"] == "session.closed":
break
finally:
ws.close()
audio_queue.put(None)
playback.result()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// Run this listener while another goroutine sends audio into the session.
func listen(ctx context.Context, session *realtime.TranslationSession) error {
for {
event, err := session.Recv(ctx)
if err != nil {
return err
}
switch event.Type {
case "error":
return fmt.Errorf("translation error: %s", event.OfRealtimeErrorEvent.Error.Message)
case "session.output_transcript.delta":
fmt.Print(event.OfRealtimeTranslationOutputTranscriptDeltaEvent.Delta)
case "session.output_audio.delta":
pcm, err := base64.StdEncoding.DecodeString(event.OfRealtimeTranslationOutputAudioDeltaEvent.Delta)
if err != nil {
return err
}
// Feed pcm into your application's 24 kHz audio player.
fmt.Fprintln(log.Writer(), "Received audio bytes:", len(pcm))
case "session.closed":
return session.Close()
}
}
}
Go では、音声の送信と並行して listen を実行します。この関数は session.closed を受信すると接続を閉じます。
元の音声ストリームが終了したら、WebSocket を閉じる前に session.close イベントを送信してください。このイベントを受け取ると、サービスは未処理の入力音声を処理し、残りの翻訳音声と書き起こしをすべて出力してから、session.closed イベントを送信します。session.close イベントは翻訳セッションでのみ使用できます。
session.close を送信した後は音声の追加を止め、通常の受信ループで session.closed を受信するまでイベントを読み取り続けてください。すぐにソケットを閉じると、セッションからの送信が完了していない翻訳結果が失われることがあります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59let translationSessionClosing = false;
let gotTranslationClosed = false;
let drainDeadline;
ws.on("close", () => {
clearTimeout(drainDeadline);
if (!gotTranslationClosed) {
console.error("Translation disconnected before final session.closed");
process.exitCode = 1;
}
});
function closeTranslationSession() {
if (translationSessionClosing) {
return;
}
translationSessionClosing = true;
// Adjust this deadline for your application's finalization requirements.
drainDeadline = setTimeout(() => {
console.error("Translation finalization timed out");
process.exitCode = 1;
ws.close();
}, 15_000);
ws.send(JSON.stringify({ type: "session.close" }));
}
// Use this in place of the earlier output handler, so audio plays only once.
ws.on("message", (data) => {
const event = JSON.parse(data.toString());
if (event.type === "session.output_audio.delta") {
playPcm16(event.delta);
}
if (event.type === "session.output_transcript.delta") {
process.stdout.write(event.delta);
}
if (event.type === "session.input_transcript.delta") {
updateSourceTranscript(event.delta);
}
if (event.type === "error") {
clearTimeout(drainDeadline);
console.error("Translation error", event.error?.code);
process.exitCode = 1;
ws.close();
}
if (event.type === "session.closed") {
clearTimeout(drainDeadline);
gotTranslationClosed = true;
ws.close();
}
});
// Call this when the source stream ends.
closeTranslationSession();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76import base64
import json
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import suppress
from queue import SimpleQueue
import sounddevice as sd
audio_queue = SimpleQueue()
def play_audio():
try:
# Closing the stream waits for PortAudio's pending buffers to finish.
with sd.RawOutputStream(samplerate=24000, channels=1, dtype="int16") as stream:
while (pcm := audio_queue.get()) is not None:
stream.write(pcm)
except Exception:
# The receiver may have already closed the socket.
with suppress(OSError):
ws.abort() # Wake recv() so the receiver can report the playback failure.
raise
translation_session_closing = False
def close_translation_session():
global translation_session_closing
if translation_session_closing:
return
translation_session_closing = True
ws.send(json.dumps({"type": "session.close"}))
with ThreadPoolExecutor(max_workers=1) as executor:
playback = executor.submit(play_audio)
try:
# Call this when the source stream ends.
close_translation_session()
# Adjust this deadline for your application's finalization requirements.
drain_deadline = time.monotonic() + 15.0
while True:
remaining = drain_deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Translation finalization timed out")
ws.settimeout(remaining)
data = ws.recv()
if not data:
raise RuntimeError("Translation disconnected before session.closed")
event = json.loads(data)
if playback.done():
playback.result() # Report a playback failure on the receiver.
if event["type"] == "error":
raise RuntimeError(f"Translation error: {event['error']['code']}")
if event["type"] == "session.output_audio.delta":
audio_queue.put(base64.b64decode(event["delta"]))
if event["type"] in (
"session.output_transcript.delta",
"session.input_transcript.delta",
):
print(event["delta"], end="", flush=True)
if event["type"] == "session.closed":
break
finally:
# The server deadline covers receiving events, not local playback.
ws.close()
audio_queue.put(None)
playback.result()
1
2
3
4
5
6// Stop sending audio first. The listener closes the transport after session.closed.
if err := session.Send(ctx, realtime.RealtimeTranslationClientEventUnionParam{
OfSessionClose: &realtime.RealtimeTranslationSessionCloseEventParam{},
}); err != nil {
return err
}
1 人の話者の発言や 1 つのストリームの音声を翻訳して聴衆に届ける場合は、聴衆向けのリアルタイム翻訳を使用します。ライブ配信、カンファレンスでの講演、ウェビナー、決算説明会、講義、動画などがその例です。
一般的な構成は次のとおりです。
source audio -> translation session -> translated audio + subtitles
翻訳先の言語ごとに翻訳セッションを 1 つ作成します。同じ英語の音声をスペイン語とフランス語で出力するには、英語からスペイン語へのセッションと、英語からフランス語へのセッションを 1 つずつ作成します。
聴衆向けのブラウザアプリでは、getDisplayMedia() でタブの音声を取得し、WebRTC で送信して、リモートの翻訳音声トラックを再生します。本番の放送では、サーバー上のメディアワーカーで翻訳を実行し、翻訳音声トラックや字幕を視聴者に配信します。
2 人以上の参加者が異なる言語で会話する場合は、会話翻訳を使用します。サポートや営業の通話、個別指導、ビデオ通話ルームなどがその例です。
参加者の音声トラックは分けてください。複数の話者の音声を 1 つのストリームに混ぜると、話者の識別、話者別の字幕表示、発話の重なりへの対応が難しくなります。
2 人での通話では、翻訳の方向ごとにセッションを 1 つ作成します。
Caller A audio -> translate into Caller B language -> play to Caller B
Caller B audio -> translate into Caller A language -> play to Caller A
グループ通話ルームで必要なセッション数は、発話中の話者の数と翻訳先の言語数によって決まります。
translation sessions ~= active source speaker tracks x distinct target languages
小規模なルームでは、各参加者が自分のブラウザ上で、翻訳を聞きたい相手ごとに翻訳用のサイドカーを作成できます。大規模なルームでは、サーバー側の参加者またはメディアワーカーが各話者の音声トラックを 1 回ずつ購読し、翻訳先の言語ごとに翻訳セッションを 1 つ作成して、翻訳済みのトラックを再配信する構成にします。
実際の音声を使って翻訳をテストし、元の言語と翻訳先の言語の両方に通じた人に結果を確認してもらってください。自動評価の指標は役立ちますが、利用者が気づく誤りをすべて検出できるわけではありません。
次の項目をテストしてください。
- 言語ペアごとの翻訳品質
- 名前、数値、日付、通貨、電話番号の正確さ
- 分野固有の用語
- 発話中の言語の切り替えや複数の言語が混在する会話
- アクセントの違い、早口、発話の重なり
- 最初の翻訳音声が出力されるまでの遅延
- 発話が終わってから翻訳の出力が完了するまでの遅延
- 字幕を表示するタイミング
- 声の一貫性
- 再接続時の動作
名前や専門用語の正確さが求められる場合は、リリース前に正解例を含む評価用データセットを作成し、誤りがあった事例を人手で確認してください。
- ブラウザでメディアを扱う場合は WebRTC、サーバーで扱う場合は WebSocket を選びます。
- 翻訳専用の
/v1/realtime/translations エンドポイントを使用します。
- フレーズの間の無音も含めて、音声を継続的にストリーミングします。
- WebSocket セッションを閉じる前に
session.close を送信し、session.closed を受信するまで待ちます。
- 会話翻訳では、話者ごとに音声トラックを分けます。
- 出力言語ごとにセッションを 1 つ使用します。
- 必要に応じて、元の言語と翻訳先の言語の両方で書き起こしを表示します。
- 元の音声、翻訳音声、字幕、ミュート、音量を利用者が操作できるようにします。
- 再接続中、遅延中、利用不可の各状態を表示します。
- 遅延は翻訳品質とは別に計測します。
音声エージェント、翻訳、書き起こしの各セッションを比較します。
ブラウザのメディアを Realtime セッションに接続します。
サーバー側のメディアパイプラインを通じて、生の音声データをストリーミングします。
ライブ音声の書き起こしの差分をストリーミングで受け取ります。