Spaces:
Running
Running
| <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> | |