Marek4321's picture
Update public/index.html
7ae5d83 verified
Raw
History Blame Contribute Delete
16.1 kB
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Realtime Translator — gpt-realtime-translate</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f0f13;
color: #e8e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
h1 {
font-size: 1.6rem;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 0.25rem;
}
.subtitle {
color: #888;
font-size: 0.85rem;
margin-bottom: 2rem;
}
.card {
background: #18181f;
border: 1px solid #2a2a35;
border-radius: 16px;
padding: 1.5rem;
width: 100%;
max-width: 560px;
margin-bottom: 1rem;
}
.lang-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.25rem;
}
select {
background: #0f0f13;
border: 1px solid #2a2a35;
border-radius: 8px;
color: #e8e8f0;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
width: 100%;
cursor: pointer;
}
select:focus { outline: 2px solid #5b6af0; }
.arrow { color: #555; font-size: 1.2rem; text-align: center; }
/* Przycisk mikrofonu */
.mic-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
#micBtn {
width: 80px; height: 80px;
border-radius: 50%;
border: none;
background: #5b6af0;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
box-shadow: 0 0 0 0 rgba(91,106,240,0.4);
}
#micBtn:hover { background: #6e7cf5; }
#micBtn:active { transform: scale(0.95); }
#micBtn.active {
background: #e05252;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(224,82,82,0.5); }
50% { box-shadow: 0 0 0 16px rgba(224,82,82,0); }
}
#micBtn svg { width: 32px; height: 32px; fill: #fff; }
#statusLabel {
font-size: 0.82rem;
color: #888;
}
/* Transkrypcja */
.transcript-wrap {
position: relative;
}
.transcript-label {
font-size: 0.75rem;
color: #555;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.5rem;
}
#transcript {
min-height: 120px;
max-height: 260px;
overflow-y: auto;
background: #0f0f13;
border: 1px solid #2a2a35;
border-radius: 10px;
padding: 0.85rem 1rem;
font-size: 1rem;
line-height: 1.6;
color: #d0d0e0;
white-space: pre-wrap;
}
#transcript:empty::before {
content: "Transkrypcja tłumaczenia pojawi się tutaj…";
color: #444;
}
.clear-btn {
position: absolute;
top: 0; right: 0;
background: none;
border: none;
color: #555;
font-size: 0.78rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
}
.clear-btn:hover { color: #e8e8f0; }
/* Pasek statusu połączenia */
.conn-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #555;
margin-top: 0.75rem;
}
.dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #333;
flex-shrink: 0;
}
.dot.green { background: #3ecf8e; }
.dot.red { background: #e05252; }
.dot.yellow { background: #f0a040; }
/* Klucz API */
.api-key-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 1.25rem;
}
.api-key-row input[type="password"],
.api-key-row input[type="text"] {
flex: 1;
background: #0f0f13;
border: 1px solid #2a2a35;
border-radius: 8px;
color: #e8e8f0;
padding: 0.5rem 0.75rem;
font-size: 0.88rem;
font-family: monospace;
}
.api-key-row input:focus { outline: 2px solid #5b6af0; }
.api-key-row input.valid { border-color: #3ecf8e44; }
.toggle-vis {
background: none;
border: 1px solid #2a2a35;
border-radius: 8px;
color: #888;
padding: 0.5rem 0.6rem;
cursor: pointer;
font-size: 0.85rem;
flex-shrink: 0;
}
.toggle-vis:hover { color: #e8e8f0; border-color: #444; }
.key-status {
font-size: 0.75rem;
color: #555;
margin-top: -0.75rem;
margin-bottom: 1rem;
}
.key-status.ok { color: #3ecf8e; }
/* Koszt */
.cost-note {
font-size: 0.75rem;
color: #444;
text-align: center;
margin-top: 0.5rem;
}
/* Błąd */
#errorBox {
display: none;
background: #2a1515;
border: 1px solid #5a2020;
border-radius: 10px;
padding: 0.75rem 1rem;
font-size: 0.85rem;
color: #ff8080;
width: 100%;
max-width: 560px;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<h1>Realtime Translator</h1>
<p class="subtitle">gpt-realtime-translate · WebRTC · mikrofon → tłumaczony głos + napisy</p>
<div id="errorBox"></div>
<div class="card">
<!-- Klucz API -->
<div class="transcript-label" style="margin-bottom:0.3rem">Klucz API OpenAI</div>
<div class="api-key-row">
<input type="password" id="apiKeyInput" placeholder="sk-..." autocomplete="off" spellcheck="false" />
<button class="toggle-vis" id="toggleVis" title="Pokaż/ukryj">👁</button>
</div>
<div class="key-status" id="keyStatus">Klucz nie jest zapisany</div>
<!-- Wybór języków -->
<div class="lang-row">
<div>
<div class="transcript-label" style="margin-bottom:0.3rem">Język wejściowy</div>
<select id="inputLang">
<option value="">Auto-detect</option>
<option value="pl">Polski</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="it">Italiano</option>
<option value="ja">日本語</option>
<option value="zh">中文</option>
<option value="uk">Українська</option>
<option value="ru">Русский</option>
</select>
</div>
<div class="arrow"></div>
<div>
<div class="transcript-label" style="margin-bottom:0.3rem">Język docelowy</div>
<select id="outputLang">
<option value="en">English</option>
<option value="pl">Polski</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="it">Italiano</option>
<option value="ja">日本語</option>
<option value="zh">中文</option>
<option value="uk">Українська</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
<!-- Przycisk mikrofonu -->
<div class="mic-wrap">
<button id="micBtn" aria-label="Start / Stop nagrywania">
<!-- ikona mikrofonu -->
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 1a4 4 0 0 1 4 4v7a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v7a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-7 9a7 7 0 0 0 14 0h2a9 9 0 0 1-8 8.94V23h-2v-2.06A9 9 0 0 1 3 12H5z"/>
</svg>
</button>
<span id="statusLabel">Kliknij mikrofon, aby rozpocząć</span>
</div>
<!-- Transkrypcja -->
<div class="transcript-wrap">
<div class="transcript-label">
Transkrypcja tłumaczenia
<button class="clear-btn" id="clearBtn" title="Wyczyść">✕ wyczyść</button>
</div>
<div id="transcript"></div>
</div>
<!-- Pasek połączenia -->
<div class="conn-bar">
<div class="dot" id="connDot"></div>
<span id="connLabel">Rozłączono</span>
</div>
</div>
<p class="cost-note">Koszt: ~$0.034 / minutę audio · rozliczane przez OpenAI</p>
<!-- Ukryty element audio do odtwarzania przetłumaczonego głosu -->
<audio id="remoteAudio" autoplay playsinline></audio>
<script>
const apiKeyInput = document.getElementById("apiKeyInput");
const toggleVis = document.getElementById("toggleVis");
const keyStatus = document.getElementById("keyStatus");
const micBtn = document.getElementById("micBtn");
const statusLabel = document.getElementById("statusLabel");
const transcript = document.getElementById("transcript");
const connDot = document.getElementById("connDot");
const connLabel = document.getElementById("connLabel");
const errorBox = document.getElementById("errorBox");
const clearBtn = document.getElementById("clearBtn");
const remoteAudio = document.getElementById("remoteAudio");
const inputLang = document.getElementById("inputLang");
const outputLang = document.getElementById("outputLang");
let pc = null; // RTCPeerConnection
let localStream = null;
let active = false;
// --- Klucz API: localStorage ---
const STORAGE_KEY = "oai_api_key";
function loadSavedKey() {
const saved = localStorage.getItem(STORAGE_KEY) ?? "";
if (saved) {
apiKeyInput.value = saved;
updateKeyStatus(saved);
}
}
function updateKeyStatus(val) {
if (val.startsWith("sk-") && val.length > 20) {
keyStatus.textContent = "Klucz zapisany lokalnie (localStorage)";
keyStatus.className = "key-status ok";
apiKeyInput.classList.add("valid");
} else if (val.length > 0) {
keyStatus.textContent = "Nieprawidłowy format klucza (powinien zaczynać się od sk-)";
keyStatus.className = "key-status";
apiKeyInput.classList.remove("valid");
} else {
keyStatus.textContent = "Klucz nie jest zapisany";
keyStatus.className = "key-status";
apiKeyInput.classList.remove("valid");
}
}
apiKeyInput.addEventListener("input", () => {
const val = apiKeyInput.value.trim();
localStorage.setItem(STORAGE_KEY, val);
updateKeyStatus(val);
});
toggleVis.addEventListener("click", () => {
apiKeyInput.type = apiKeyInput.type === "password" ? "text" : "password";
});
loadSavedKey();
clearBtn.addEventListener("click", () => { transcript.textContent = ""; });
function setConn(state) {
connDot.className = "dot";
if (state === "connected") { connDot.classList.add("green"); connLabel.textContent = "Połączono z OpenAI"; }
else if (state === "error") { connDot.classList.add("red"); connLabel.textContent = "Błąd połączenia"; }
else if (state === "loading") { connDot.classList.add("yellow"); connLabel.textContent = "Łączenie…"; }
else { connLabel.textContent = "Rozłączono"; }
}
function showError(msg) {
errorBox.style.display = "block";
errorBox.textContent = msg;
}
function clearError() {
errorBox.style.display = "none";
errorBox.textContent = "";
}
async function startSession() {
clearError();
const apiKey = apiKeyInput.value.trim();
if (!apiKey.startsWith("sk-")) {
showError("Wpisz prawidłowy klucz API OpenAI (zaczyna się od sk-).");
micBtn.classList.remove("active");
active = false;
return;
}
setConn("loading");
statusLabel.textContent = "Pobieranie tokenu sesji…";
// 1. Pobierz client secret z własnego backendu
let secretData;
try {
const res = await fetch("/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetLanguage: outputLang.value,
inputLanguage: inputLang.value || undefined,
apiKey,
}),
});
secretData = await res.json();
if (!res.ok) throw new Error(secretData.error ?? `HTTP ${res.status}`);
} catch (err) {
showError("Błąd sesji: " + err.message);
setConn("error");
statusLabel.textContent = "Kliknij mikrofon, aby spróbować ponownie";
micBtn.classList.remove("active");
active = false;
return;
}
const clientSecret = secretData?.value ?? secretData?.client_secret?.value;
if (!clientSecret) {
showError("Brak client_secret w odpowiedzi serwera. Sprawdź logi backendu.");
setConn("error");
micBtn.classList.remove("active");
active = false;
return;
}
// 2. Pobierz mikrofon
statusLabel.textContent = "Prośba o dostęp do mikrofonu…";
try {
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
showError("Brak dostępu do mikrofonu: " + err.message);
setConn("error");
micBtn.classList.remove("active");
active = false;
return;
}
// 3. Utwórz RTCPeerConnection
pc = new RTCPeerConnection();
// Dodaj ścieżkę audio z mikrofonu
pc.addTrack(localStream.getAudioTracks()[0], localStream);
// Odtwórz przetłumaczony głos z serwera OpenAI
pc.ontrack = ({ streams }) => {
remoteAudio.srcObject = streams[0];
};
// Kanał zdarzeń — transkrypcja
const events = pc.createDataChannel("oai-events");
events.onmessage = ({ data }) => {
try {
const event = JSON.parse(data);
// Tłumaczona transkrypcja (output)
if (event.type === "session.output_transcript.delta" && event.delta) {
transcript.textContent += event.delta;
transcript.scrollTop = transcript.scrollHeight;
}
// Opcjonalnie: loguj inne zdarzenia
if (event.type === "error") {
console.error("OpenAI event error:", event);
showError("Błąd modelu: " + (event.error?.message ?? JSON.stringify(event.error)));
}
} catch { /* ignoruj nieparsowalny payload */ }
};
pc.onconnectionstatechange = () => {
const s = pc.connectionState;
if (s === "connected") { setConn("connected"); statusLabel.textContent = "Tłumaczę… mów do mikrofonu"; }
else if (s === "failed" || s === "disconnected") {
setConn("error");
statusLabel.textContent = "Połączenie utracone";
cleanup();
}
};
// 4. SDP offer → OpenAI → SDP answer
statusLabel.textContent = "Nawiązywanie połączenia WebRTC…";
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
let sdpAnswer;
try {
const sdpRes = await fetch(
"https://api.openai.com/v1/realtime/translations/calls",
{
method: "POST",
headers: {
Authorization: `Bearer ${clientSecret}`,
"Content-Type": "application/sdp",
},
body: offer.sdp,
}
);
if (!sdpRes.ok) {
const txt = await sdpRes.text();
throw new Error(`OpenAI SDP ${sdpRes.status}: ${txt}`);
}
sdpAnswer = await sdpRes.text();
} catch (err) {
showError("Błąd WebRTC SDP: " + err.message);
setConn("error");
cleanup();
return;
}
await pc.setRemoteDescription({ type: "answer", sdp: sdpAnswer });
// connectionstatechange obsłuży resztę
}
function cleanup() {
if (localStream) { localStream.getTracks().forEach(t => t.stop()); localStream = null; }
if (pc) { pc.close(); pc = null; }
remoteAudio.srcObject = null;
micBtn.classList.remove("active");
active = false;
setConn("disconnected");
statusLabel.textContent = "Kliknij mikrofon, aby rozpocząć";
}
micBtn.addEventListener("click", () => {
if (!active) {
active = true;
micBtn.classList.add("active");
statusLabel.textContent = "Uruchamianie…";
startSession();
} else {
cleanup();
}
});
</script>
</body>
</html>