From ec23fdf211e7d903602f8b754380e538d0a42396 Mon Sep 17 00:00:00 2001 From: Alice Chen <78815181+weiyu1029@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:48:04 -0500 Subject: [PATCH 1/3] feat: add two-stage interview voice transcription --- platform/web/.dev.vars.example | 8 +- platform/web/README.md | 21 +- platform/web/app/api/transcribe/route.ts | 132 ++++++ platform/web/app/faq-copy.ts | 164 +++++++ platform/web/app/interview-stt-copy.ts | 386 ++++++++++++++++ platform/web/app/interview-stt.ts | 184 ++++++++ platform/web/app/page.tsx | 412 ++++++++++++++--- platform/web/app/site-information.ts | 9 +- platform/web/tests/interview-stt.test.mjs | 517 ++++++++++++++++++++++ platform/web/tests/rendered-html.test.mjs | 7 +- 10 files changed, 1778 insertions(+), 62 deletions(-) create mode 100644 platform/web/app/api/transcribe/route.ts create mode 100644 platform/web/app/interview-stt-copy.ts create mode 100644 platform/web/app/interview-stt.ts create mode 100644 platform/web/tests/interview-stt.test.mjs diff --git a/platform/web/.dev.vars.example b/platform/web/.dev.vars.example index ada0168..fcabcd4 100644 --- a/platform/web/.dev.vars.example +++ b/platform/web/.dev.vars.example @@ -12,10 +12,14 @@ GITHUB_CLIENT_SECRET= LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= -# Optional server-side neural read-aloud. Keep the subscription key private. -# Without both values, interview questions fall back to the browser/device voice. +# Optional server-side Microsoft Azure Speech. Keep the subscription key private. +# Neural read-aloud needs the key and region. Signed-in final transcript +# correction needs the key and resource endpoint. Without the required values, +# voice answers keep the browser/device transcript and read-aloud uses the +# browser/device voice. AZURE_SPEECH_KEY= AZURE_SPEECH_REGION= +AZURE_SPEECH_ENDPOINT=https://replace-with-resource.cognitiveservices.azure.com # Transactional website email. Keep the API key secret and verify the sender # domain with the provider before using the production address. diff --git a/platform/web/README.md b/platform/web/README.md index c7d3a5c..0b9c725 100644 --- a/platform/web/README.md +++ b/platform/web/README.md @@ -72,8 +72,25 @@ unavailable, the client falls back to the browser or device voice. Read-aloud does not send the resume, job description, interview answer, transcript, or raw voice recording to Azure Speech. Voice recognition remains a -separate browser capability. Keep the public privacy policy and FAQ aligned if -this data flow changes. +separate capability. Keep the public privacy policy and FAQ aligned if this +data flow changes. + +## Two-stage interview voice answers + +Voice answers show provisional captions from the browser or device while the +candidate speaks. For signed-in users, when `AZURE_SPEECH_KEY` and +`AZURE_SPEECH_ENDPOINT` are configured, the completed recording is sent to the +Microsoft Azure Speech Fast Transcription endpoint for a final correction pass. +The request contains the recorded answer audio, one of the 40 supported locale +codes, and at most 80 short vocabulary hints derived from the selected role, +resume, and job description. It never sends the full resume or job description +as transcription context. + +The transcription route is same-origin, sign-in protected, size and media-type +limited, and returns private no-store JSON. InterviewThread does not persist or +log the raw recording. The user can edit the final text before submitting it. +Guest mode remains browser-only, and any unavailable or failed cloud correction +keeps the existing browser/device transcript instead of clearing the answer. Resume text, job descriptions, tracker items, and Story Signal alert settings remain on the device until an authenticated persistence feature explicitly diff --git a/platform/web/app/api/transcribe/route.ts b/platform/web/app/api/transcribe/route.ts new file mode 100644 index 0000000..f4bbba4 --- /dev/null +++ b/platform/web/app/api/transcribe/route.ts @@ -0,0 +1,132 @@ +import { + buildAzureTranscriptionRequest, + isSttLocale, + isSupportedInterviewAudioType, + normalizeSttTranscript, + sanitizeSpeechVocabulary, + STT_MAX_AUDIO_BYTES, + transcriptFromAzureResponse, +} from "../../interview-stt.ts"; +import { getAppUser } from "../../auth"; + +const TRANSCRIPTION_WINDOW_MS = 10 * 60 * 1_000; +const TRANSCRIPTION_WINDOW_LIMIT = 12; +const MAX_MULTIPART_BYTES = STT_MAX_AUDIO_BYTES + 256 * 1024; + +const transcriptionWindows = new Map< + string, + { count: number; resetAt: number } +>(); + +const PRIVATE_HEADERS = { + "Cache-Control": "private, no-store, max-age=0", + Pragma: "no-cache", + "X-Content-Type-Options": "nosniff", +}; + +function json(payload: Record, status = 200) { + return Response.json(payload, { status, headers: PRIVATE_HEADERS }); +} + +function sameOrigin(request: Request) { + const origin = request.headers.get("origin"); + return origin === new URL(request.url).origin; +} + +function rateLimitExceeded(userId: string) { + const now = Date.now(); + const current = transcriptionWindows.get(userId); + if (!current || current.resetAt <= now) { + transcriptionWindows.set(userId, { + count: 1, + resetAt: now + TRANSCRIPTION_WINDOW_MS, + }); + return false; + } + current.count += 1; + return current.count > TRANSCRIPTION_WINDOW_LIMIT; +} + +export async function POST(request: Request) { + if (!sameOrigin(request)) return json({ error: "invalid_origin" }, 403); + if (!request.headers.get("content-type")?.includes("multipart/form-data")) + return json({ error: "invalid_request" }, 415); + const declaredLength = Number(request.headers.get("content-length") || "0"); + if ( + !Number.isFinite(declaredLength) || + declaredLength < 0 || + declaredLength > MAX_MULTIPART_BYTES + ) + return json({ error: "payload_too_large" }, 413); + + const user = await getAppUser(); + if (!user) return json({ error: "sign_in_required" }, 401); + if (rateLimitExceeded(user.userId)) + return json({ error: "transcription_rate_limited" }, 429); + + let form: FormData; + try { + form = await request.formData(); + } catch { + return json({ error: "invalid_request" }, 400); + } + + const audio = form.get("audio"); + const locale = form.get("locale"); + const vocabularyValue = form.get("vocabulary"); + if ( + !(audio instanceof Blob) || + !audio.size || + audio.size > STT_MAX_AUDIO_BYTES || + !isSupportedInterviewAudioType(audio.type) || + !isSttLocale(locale) + ) + return json({ error: "invalid_request" }, 400); + + let vocabulary: string[] = []; + if (typeof vocabularyValue === "string" && vocabularyValue) { + try { + vocabulary = sanitizeSpeechVocabulary(JSON.parse(vocabularyValue)); + } catch { + return json({ error: "invalid_request" }, 400); + } + } + + const apiKey = process.env.AZURE_SPEECH_KEY?.trim(); + const endpoint = process.env.AZURE_SPEECH_ENDPOINT?.trim(); + if (!apiKey || !endpoint) return json({ error: "premium_unavailable" }, 503); + + try { + const providerRequest = buildAzureTranscriptionRequest({ + audio, + locale, + vocabulary, + apiKey, + endpoint, + }); + const providerResponse = await fetch(providerRequest.url, { + ...providerRequest.init, + signal: AbortSignal.timeout(30_000), + }); + if (!providerResponse.ok) + return json( + { + error: + providerResponse.status === 429 + ? "transcription_rate_limited" + : "transcription_unavailable", + }, + providerResponse.status === 429 ? 429 : 502, + ); + + const providerPayload = (await providerResponse.json()) as unknown; + const transcript = normalizeSttTranscript( + transcriptFromAzureResponse(providerPayload), + vocabulary, + ); + if (!transcript) return json({ error: "no_speech" }, 422); + return json({ transcript, locale }, 200); + } catch { + return json({ error: "transcription_unavailable" }, 503); + } +} diff --git a/platform/web/app/faq-copy.ts b/platform/web/app/faq-copy.ts index d7cd1f6..76bbf79 100644 --- a/platform/web/app/faq-copy.ts +++ b/platform/web/app/faq-copy.ts @@ -224,6 +224,169 @@ const speechPrivacyFaqCopy = { }, } satisfies Record; +const voiceAnswerPrivacyFaqCopy = { + en: { + question: "What is sent when I answer by voice?", + answer: "Live draft captions use your browser or device. For signed-in users, when cloud correction is available, the recorded answer audio, selected language, and up to 80 short vocabulary hints from the role, resume, and job description are sent temporarily to Microsoft Azure Speech for a final transcript. InterviewThread does not store or log the raw audio, returns the result with private no-store instructions, and lets you edit the text before submitting. Guest mode stays browser-only. If cloud correction fails, your device transcript remains in the answer box.", + }, + ja: { + question: "音声で回答すると、何が送信されますか?", + answer: "入力中の下書き字幕にはブラウザまたは端末の音声認識を使います。ログイン中でクラウド補正が利用できる場合は、回答音声、選択言語、職種・履歴書・求人票から抽出した最大80個の短い語彙ヒントを、一時的に Microsoft Azure Speech へ送って最終文字起こしを作成します。InterviewThread は元の音声を保存・記録せず、結果には非公開・保存禁止の指示を付け、送信前に文字を編集できます。ゲストモードはブラウザ内だけで動作し、クラウド補正に失敗しても端末の文字起こしは回答欄に残ります。", + }, + ko: { + question: "음성으로 답변하면 어떤 정보가 전송되나요?", + answer: "말하는 동안의 초안 자막은 브라우저 또는 기기 음성 인식을 사용합니다. 로그인한 상태에서 클라우드 보정이 가능하면 답변 녹음, 선택한 언어, 직무·이력서·채용 공고에서 가져온 짧은 어휘 힌트 최대 80개를 최종 전사를 위해 Microsoft Azure Speech에 일시적으로 보냅니다. InterviewThread는 원본 오디오를 저장하거나 로그에 남기지 않으며 결과를 비공개·저장 금지로 반환하고 제출 전에 텍스트를 수정할 수 있게 합니다. 게스트 모드는 브라우저에서만 작동하며, 클라우드 보정에 실패해도 기기 전사는 답변 상자에 남습니다.", + }, + "zh-CN": { + question: "使用语音作答时会发送哪些资料?", + answer: "说话时的即时草稿字幕由浏览器或设备识别。已登录且云端校正可用时,回答录音、所选语言,以及从职位、简历和职位描述中提取的最多 80 个简短词汇提示,会临时发送给 Microsoft Azure Speech 生成最终逐字稿。InterviewThread 不保存或记录原始音频,响应采用私密、禁止缓存设置,你可在提交前编辑文字。访客模式只使用浏览器;若云端校正失败,设备逐字稿仍会保留在回答框中。", + }, + "zh-TW": { + question: "使用語音作答時會傳送哪些資料?", + answer: "說話時的即時草稿字幕由瀏覽器或裝置辨識。已登入且雲端校正可用時,回答錄音、所選語言,以及從職務、履歷與職缺描述擷取的最多 80 個簡短詞彙提示,會暫時傳送至 Microsoft Azure Speech 產生最終逐字稿。InterviewThread 不保存或記錄原始音訊,回應採私密且不得快取的設定,你可在送出前編輯文字。訪客模式只使用瀏覽器;若雲端校正失敗,裝置逐字稿仍會保留在回答欄。", + }, + es: { + question: "¿Qué se envía cuando respondo por voz?", + answer: "Los subtítulos provisionales en directo usan el navegador o el dispositivo. Si has iniciado sesión y la corrección en la nube está disponible, el audio de tu respuesta, el idioma elegido y hasta 80 pistas breves de vocabulario del puesto, el CV y la descripción se envían temporalmente a Microsoft Azure Speech para obtener la transcripción final. InterviewThread no guarda ni registra el audio original; devuelve el resultado como privado y sin almacenamiento, y puedes editarlo antes de enviarlo. El modo invitado funciona solo en el navegador. Si falla la corrección, se conserva la transcripción del dispositivo.", + }, + fr: { + question: "Qu’est-ce qui est envoyé lorsque je réponds à l’oral ?", + answer: "Les sous-titres provisoires en direct utilisent le navigateur ou l’appareil. Si vous êtes connecté et que la correction cloud est disponible, l’audio de votre réponse, la langue choisie et jusqu’à 80 courts indices de vocabulaire issus du poste, du CV et de l’offre sont envoyés temporairement à Microsoft Azure Speech pour produire la transcription finale. InterviewThread ne conserve ni ne journalise l’audio brut, renvoie le résultat en mode privé sans stockage et vous laisse corriger le texte avant envoi. Le mode invité reste uniquement dans le navigateur. En cas d’échec, la transcription de l’appareil est conservée.", + }, + de: { + question: "Welche Daten werden bei einer Sprachantwort gesendet?", + answer: "Die vorläufigen Live-Untertitel verwenden den Browser oder das Gerät. Wenn Sie angemeldet sind und die Cloud-Korrektur verfügbar ist, werden die Antwortaufnahme, die gewählte Sprache und bis zu 80 kurze Vokabelhinweise aus Rolle, Lebenslauf und Stellenbeschreibung vorübergehend an Microsoft Azure Speech gesendet, um das endgültige Transkript zu erstellen. InterviewThread speichert oder protokolliert das Roh-Audio nicht, liefert das Ergebnis privat und ohne Speicherung zurück und lässt Sie den Text vor dem Senden bearbeiten. Im Gastmodus bleibt alles im Browser. Bei einem Cloud-Fehler bleibt das Geräte-Transkript erhalten.", + }, + "pt-BR": { + question: "O que é enviado quando respondo por voz?", + answer: "As legendas provisórias ao vivo usam o navegador ou o dispositivo. Para usuários conectados, quando a correção na nuvem está disponível, o áudio da resposta, o idioma escolhido e até 80 dicas curtas de vocabulário da função, do currículo e da vaga são enviados temporariamente ao Microsoft Azure Speech para gerar a transcrição final. O InterviewThread não salva nem registra o áudio bruto, retorna o resultado de forma privada e sem armazenamento e permite editar o texto antes do envio. O modo visitante usa apenas o navegador. Se a correção falhar, a transcrição do dispositivo permanece.", + }, + it: { + question: "Cosa viene inviato quando rispondo a voce?", + answer: "I sottotitoli provvisori in tempo reale usano il browser o il dispositivo. Se hai effettuato l’accesso e la correzione cloud è disponibile, l’audio della risposta, la lingua scelta e fino a 80 brevi suggerimenti di vocabolario ricavati da ruolo, CV e annuncio vengono inviati temporaneamente a Microsoft Azure Speech per creare la trascrizione finale. InterviewThread non salva né registra l’audio originale, restituisce il risultato in modalità privata senza memorizzazione e consente di modificare il testo prima dell’invio. La modalità ospite resta nel browser. Se la correzione fallisce, la trascrizione del dispositivo rimane.", + }, + nl: { + question: "Wat wordt verzonden wanneer ik met mijn stem antwoord?", + answer: "Voorlopige live-ondertiteling gebruikt je browser of apparaat. Als je bent ingelogd en cloudcorrectie beschikbaar is, worden de opname van je antwoord, de gekozen taal en maximaal 80 korte woordenschat-hints uit de rol, het cv en de vacature tijdelijk naar Microsoft Azure Speech gestuurd voor het definitieve transcript. InterviewThread bewaart of logt de ruwe audio niet, retourneert het resultaat privé en zonder opslag en laat je de tekst voor verzending bewerken. Gastmodus blijft in de browser. Als cloudcorrectie mislukt, blijft het apparaattranscript staan.", + }, + pl: { + question: "Co jest wysyłane, gdy odpowiadam głosowo?", + answer: "Wstępne napisy na żywo korzystają z przeglądarki lub urządzenia. Gdy użytkownik jest zalogowany i korekta w chmurze jest dostępna, nagranie odpowiedzi, wybrany język i maksymalnie 80 krótkich podpowiedzi słownikowych z roli, CV i ogłoszenia są tymczasowo wysyłane do Microsoft Azure Speech w celu utworzenia końcowej transkrypcji. InterviewThread nie zapisuje ani nie rejestruje surowego dźwięku, zwraca wynik prywatnie bez zapisu i pozwala edytować tekst przed wysłaniem. Tryb gościa działa tylko w przeglądarce. W razie błędu transkrypcja urządzenia pozostaje.", + }, + tr: { + question: "Sesli yanıt verdiğimde hangi bilgiler gönderilir?", + answer: "Canlı taslak altyazılar tarayıcıyı veya cihazı kullanır. Oturum açtıysanız ve bulut düzeltmesi kullanılabiliyorsa yanıt kaydı, seçilen dil ve rol, özgeçmiş ile iş ilanından alınan en fazla 80 kısa terim ipucu son döküm için geçici olarak Microsoft Azure Speech’e gönderilir. InterviewThread ham sesi saklamaz veya günlüğe kaydetmez; sonucu özel ve kaydedilemez olarak döndürür ve göndermeden önce metni düzenlemenize izin verir. Misafir modu yalnızca tarayıcıda çalışır. Bulut düzeltmesi başarısız olursa cihaz dökümü korunur.", + }, + ru: { + question: "Какие данные отправляются при голосовом ответе?", + answer: "Черновые субтитры в реальном времени создаются браузером или устройством. Для вошедших пользователей, когда доступна облачная коррекция, запись ответа, выбранный язык и до 80 коротких словарных подсказок из роли, резюме и вакансии временно отправляются в Microsoft Azure Speech для итоговой расшифровки. InterviewThread не хранит и не журналирует исходное аудио, возвращает результат конфиденциально без сохранения и позволяет исправить текст до отправки. Гостевой режим работает только в браузере. При сбое облака расшифровка устройства сохраняется.", + }, + uk: { + question: "Які дані надсилаються, коли я відповідаю голосом?", + answer: "Чернеткові субтитри наживо створює браузер або пристрій. Для користувачів, які ввійшли, коли доступне хмарне виправлення, запис відповіді, вибрана мова й до 80 коротких словникових підказок із ролі, резюме та вакансії тимчасово надсилаються до Microsoft Azure Speech для остаточної транскрипції. InterviewThread не зберігає й не журналює сире аудіо, повертає результат приватно без збереження та дає відредагувати текст. Гостьовий режим працює лише в браузері. У разі збою транскрипція пристрою лишається.", + }, + ar: { + question: "ما البيانات التي تُرسل عندما أجيب بالصوت؟", + answer: "تستخدم المسودة الفورية للنص المتصفح أو الجهاز. للمستخدم المسجّل، وعند توفر التصحيح السحابي، يُرسل تسجيل الإجابة واللغة المختارة وما يصل إلى 80 تلميحًا قصيرًا للمصطلحات من الدور والسيرة والوصف الوظيفي مؤقتًا إلى Microsoft Azure Speech لإنتاج النص النهائي. لا يخزّن InterviewThread الصوت الخام ولا يسجّله في السجلات، ويعيد النتيجة بصورة خاصة ومن دون تخزين، ويمكنك تعديل النص قبل إرساله. وضع الضيف يعمل في المتصفح فقط، وإذا فشل التصحيح يبقى نص الجهاز محفوظًا.", + }, + he: { + question: "אילו נתונים נשלחים כשאני עונה בקול?", + answer: "כתוביות הטיוטה בזמן אמת משתמשות בדפדפן או במכשיר. למשתמשים מחוברים, כאשר תיקון בענן זמין, הקלטת התשובה, השפה שנבחרה ועד 80 רמזי מונחים קצרים מהתפקיד, מקורות החיים ומתיאור המשרה נשלחים זמנית ל‑Microsoft Azure Speech ליצירת התמלול הסופי. InterviewThread אינו שומר או רושם את האודיו הגולמי, מחזיר את התוצאה באופן פרטי וללא אחסון ומאפשר לערוך את הטקסט לפני השליחה. מצב אורח נשאר בדפדפן בלבד. אם התיקון נכשל, תמלול המכשיר נשמר.", + }, + hi: { + question: "आवाज़ से उत्तर देने पर क्या भेजा जाता है?", + answer: "बोलते समय शुरुआती कैप्शन आपके ब्राउज़र या डिवाइस से बनते हैं। साइन इन होने और क्लाउड सुधार उपलब्ध होने पर उत्तर का ऑडियो, चुनी हुई भाषा और भूमिका, रिज़्यूमे व नौकरी विवरण से अधिकतम 80 छोटे शब्द-संकेत अंतिम ट्रांसक्रिप्ट के लिए अस्थायी रूप से Microsoft Azure Speech को भेजे जाते हैं। InterviewThread मूल ऑडियो को सहेजता या लॉग नहीं करता, परिणाम को निजी और नो-स्टोर रूप में लौटाता है और भेजने से पहले टेक्स्ट बदलने देता है। गेस्ट मोड केवल ब्राउज़र पर रहता है। क्लाउड विफल होने पर डिवाइस ट्रांसक्रिप्ट सुरक्षित रहता है।", + }, + bn: { + question: "ভয়েসে উত্তর দিলে কী পাঠানো হয়?", + answer: "কথা বলার সময় প্রাথমিক ক্যাপশন ব্রাউজার বা ডিভাইস তৈরি করে। সাইন ইন করা থাকলে এবং ক্লাউড সংশোধন পাওয়া গেলে, উত্তরটির অডিও, নির্বাচিত ভাষা এবং ভূমিকা, জীবনবৃত্তান্ত ও চাকরির বিবরণ থেকে সর্বোচ্চ ৮০টি ছোট শব্দ-ইঙ্গিত চূড়ান্ত ট্রান্সক্রিপ্টের জন্য সাময়িকভাবে Microsoft Azure Speech-এ পাঠানো হয়। InterviewThread কাঁচা অডিও সংরক্ষণ বা লগ করে না, ফল ব্যক্তিগত ও নো-স্টোর হিসেবে ফেরত দেয় এবং পাঠানোর আগে লেখা সম্পাদনা করা যায়। অতিথি মোড শুধু ব্রাউজারে কাজ করে। ক্লাউড ব্যর্থ হলেও ডিভাইসের লেখা থেকে যায়।", + }, + ur: { + question: "آواز سے جواب دینے پر کیا معلومات بھیجی جاتی ہیں؟", + answer: "بولتے وقت ابتدائی کیپشن براؤزر یا ڈیوائس بناتا ہے۔ سائن ان ہونے اور کلاؤڈ درستگی دستیاب ہونے پر جواب کی آڈیو، منتخب زبان اور کردار، ریزیومے اور ملازمت کی تفصیل سے زیادہ سے زیادہ 80 مختصر اصطلاحی اشارے حتمی ٹرانسکرپٹ کے لیے عارضی طور پر Microsoft Azure Speech کو بھیجے جاتے ہیں۔ InterviewThread خام آڈیو محفوظ یا لاگ نہیں کرتا، نتیجہ نجی اور نو اسٹور طور پر واپس کرتا ہے اور بھیجنے سے پہلے متن میں ترمیم کی جا سکتی ہے۔ گیسٹ موڈ صرف براؤزر میں رہتا ہے۔ کلاؤڈ ناکام ہو تو ڈیوائس کا ٹرانسکرپٹ برقرار رہتا ہے۔", + }, + id: { + question: "Data apa yang dikirim saat saya menjawab dengan suara?", + answer: "Teks sementara secara langsung menggunakan browser atau perangkat. Untuk pengguna yang masuk, saat koreksi cloud tersedia, audio jawaban, bahasa pilihan, dan hingga 80 petunjuk kosakata singkat dari peran, resume, dan lowongan dikirim sementara ke Microsoft Azure Speech untuk transkrip akhir. InterviewThread tidak menyimpan atau mencatat audio mentah, mengembalikan hasil secara privat tanpa penyimpanan, dan memungkinkan Anda mengedit teks sebelum dikirim. Mode tamu hanya menggunakan browser. Jika koreksi cloud gagal, transkrip perangkat tetap tersedia.", + }, + ms: { + question: "Apakah data yang dihantar apabila saya menjawab dengan suara?", + answer: "Sari kata draf secara langsung menggunakan pelayar atau peranti. Bagi pengguna yang telah log masuk, apabila pembetulan awan tersedia, audio jawapan, bahasa pilihan dan sehingga 80 petunjuk istilah ringkas daripada peranan, resume dan iklan kerja dihantar sementara kepada Microsoft Azure Speech untuk transkrip akhir. InterviewThread tidak menyimpan atau merekod audio mentah, mengembalikan hasil secara peribadi tanpa storan dan membolehkan anda menyunting teks sebelum dihantar. Mod tetamu hanya menggunakan pelayar. Jika pembetulan gagal, transkrip peranti dikekalkan.", + }, + th: { + question: "เมื่อฉันตอบด้วยเสียง ระบบจะส่งข้อมูลอะไรบ้าง?", + answer: "คำบรรยายฉบับร่างแบบสดใช้การรู้จำของเบราว์เซอร์หรืออุปกรณ์ สำหรับผู้ใช้ที่ลงชื่อเข้าใช้ เมื่อใช้การแก้ไขผ่านคลาวด์ได้ ระบบจะส่งเสียงคำตอบ ภาษาที่เลือก และคำใบ้ศัพท์สั้น ๆ สูงสุด 80 รายการจากบทบาท เรซูเม่ และรายละเอียดงานไปยัง Microsoft Azure Speech ชั่วคราวเพื่อสร้างข้อความถอดเสียงฉบับสุดท้าย InterviewThread ไม่บันทึกหรือเก็บล็อกเสียงดิบ ส่งผลลัพธ์กลับแบบส่วนตัวและห้ามแคช และให้คุณแก้ไขข้อความก่อนส่ง โหมดผู้เยี่ยมชมใช้เฉพาะเบราว์เซอร์ หากคลาวด์ล้มเหลว ข้อความจากอุปกรณ์จะยังอยู่", + }, + vi: { + question: "Dữ liệu nào được gửi khi tôi trả lời bằng giọng nói?", + answer: "Phụ đề nháp trực tiếp dùng trình duyệt hoặc thiết bị. Với người dùng đã đăng nhập, khi hiệu chỉnh trên đám mây khả dụng, âm thanh câu trả lời, ngôn ngữ đã chọn và tối đa 80 gợi ý thuật ngữ ngắn từ vai trò, CV và tin tuyển dụng được gửi tạm thời tới Microsoft Azure Speech để tạo bản chép lời cuối. InterviewThread không lưu hay ghi nhật ký âm thanh gốc, trả kết quả ở chế độ riêng tư không lưu trữ và cho phép sửa văn bản trước khi gửi. Chế độ khách chỉ dùng trình duyệt. Nếu hiệu chỉnh thất bại, bản chép lời của thiết bị vẫn được giữ lại.", + }, + fil: { + question: "Anong data ang ipinapadala kapag sumasagot ako gamit ang boses?", + answer: "Ginagamit ng live draft captions ang browser o device. Para sa naka-sign in na user, kapag available ang cloud correction, pansamantalang ipinapadala sa Microsoft Azure Speech ang audio ng sagot, napiling wika, at hanggang 80 maiikling vocabulary hint mula sa role, résumé, at job post para sa final transcript. Hindi sine-save o nilo-log ng InterviewThread ang raw audio, pribado at no-store ang tugon, at maaari mong i-edit ang text bago isumite. Browser-only ang guest mode. Kapag pumalya ang cloud correction, mananatili ang transcript ng device.", + }, + sv: { + question: "Vad skickas när jag svarar med rösten?", + answer: "Preliminära livetexter använder webbläsaren eller enheten. För inloggade användare skickas, när molnkorrigering finns, svarsljudet, valt språk och upp till 80 korta ordledtrådar från rollen, CV:t och jobbannonsen tillfälligt till Microsoft Azure Speech för den slutliga transkriberingen. InterviewThread sparar eller loggar inte råljudet, returnerar resultatet privat utan lagring och låter dig redigera texten före inlämning. Gästläget stannar i webbläsaren. Om korrigeringen misslyckas behålls enhetens transkript.", + }, + no: { + question: "Hva sendes når jeg svarer med stemmen?", + answer: "Foreløpige direktetekster bruker nettleseren eller enheten. For innloggede brukere sendes, når skykorrigering er tilgjengelig, lydopptaket av svaret, valgt språk og opptil 80 korte ordtips fra rollen, CV-en og stillingsannonsen midlertidig til Microsoft Azure Speech for den endelige transkripsjonen. InterviewThread lagrer eller logger ikke rålyden, returnerer resultatet privat uten lagring og lar deg redigere teksten før innsending. Gjeste­modus bruker bare nettleseren. Hvis korrigeringen mislykkes, beholdes enhetens transkripsjon.", + }, + da: { + question: "Hvad sendes, når jeg svarer med stemmen?", + answer: "Foreløbige live-undertekster bruger browseren eller enheden. For brugere, der er logget ind, sendes svarlyden, det valgte sprog og op til 80 korte ordtips fra rollen, CV’et og jobopslaget midlertidigt til Microsoft Azure Speech for den endelige transskription, når cloudkorrektion er tilgængelig. InterviewThread gemmer eller logger ikke rå lyd, returnerer resultatet privat uden lagring og lader dig redigere teksten før indsendelse. Gæstetilstand bruger kun browseren. Hvis korrektionen fejler, bevares enhedens transskription.", + }, + fi: { + question: "Mitä tietoja lähetetään, kun vastaan puheella?", + answer: "Alustavat live-tekstitykset käyttävät selainta tai laitetta. Kun kirjautuneelle käyttäjälle on saatavilla pilvikorjaus, vastauksen ääni, valittu kieli ja enintään 80 lyhyttä sanastovihjettä roolista, ansioluettelosta ja työpaikkailmoituksesta lähetetään tilapäisesti Microsoft Azure Speechille lopullista litterointia varten. InterviewThread ei tallenna tai lokita raakaa ääntä, palauttaa tuloksen yksityisesti ilman tallennusta ja antaa muokata tekstiä ennen lähettämistä. Vierastila toimii vain selaimessa. Jos korjaus epäonnistuu, laitteen litterointi säilyy.", + }, + cs: { + question: "Co se odesílá, když odpovídám hlasem?", + answer: "Průběžný pracovní přepis používá prohlížeč nebo zařízení. U přihlášených uživatelů se při dostupné cloudové opravě do Microsoft Azure Speech dočasně odešle zvuk odpovědi, zvolený jazyk a až 80 krátkých slovníkových nápověd z role, životopisu a nabídky práce pro vytvoření konečného přepisu. InterviewThread surový zvuk neukládá ani nezapisuje do protokolů, výsledek vrací soukromě bez ukládání a umožní text upravit. Režim hosta zůstává v prohlížeči. Při selhání zůstane přepis zařízení zachován.", + }, + sk: { + question: "Čo sa odosiela, keď odpovedám hlasom?", + answer: "Priebežný pracovný prepis používa prehliadač alebo zariadenie. Prihláseným používateľom sa pri dostupnej cloudovej oprave do Microsoft Azure Speech dočasne odošle zvuk odpovede, zvolený jazyk a najviac 80 krátkych slovníkových pomôcok z roly, životopisu a pracovnej ponuky na vytvorenie konečného prepisu. InterviewThread surový zvuk neukladá ani nezapisuje do protokolov, výsledok vracia súkromne bez uloženia a umožní text upraviť. Režim hosťa zostáva v prehliadači. Pri zlyhaní zostane prepis zariadenia zachovaný.", + }, + hu: { + question: "Milyen adat kerül elküldésre, amikor hanggal válaszolok?", + answer: "Az élő piszkozatfeliratok a böngészőt vagy az eszközt használják. Bejelentkezett felhasználóknál, ha elérhető a felhős javítás, a válasz hangja, a kiválasztott nyelv, valamint a szerepkörből, önéletrajzból és álláshirdetésből származó legfeljebb 80 rövid szókincssegéd ideiglenesen a Microsoft Azure Speechhez kerül a végleges átirathoz. Az InterviewThread nem tárolja és nem naplózza a nyers hangot, az eredményt privát, nem tárolható válaszként adja vissza, és beküldés előtt szerkeszthető. A vendég mód csak a böngészőt használja. Hiba esetén az eszköz átirata megmarad.", + }, + ro: { + question: "Ce date sunt trimise când răspund vocal?", + answer: "Subtitrările provizorii în direct folosesc browserul sau dispozitivul. Pentru utilizatorii autentificați, când corectarea în cloud este disponibilă, sunetul răspunsului, limba aleasă și până la 80 de indicii scurte de vocabular din rol, CV și anunț sunt trimise temporar către Microsoft Azure Speech pentru transcrierea finală. InterviewThread nu stochează și nu înregistrează în jurnale sunetul brut, returnează rezultatul privat fără stocare și permite editarea textului înainte de trimitere. Modul vizitator rămâne în browser. Dacă corectarea eșuează, transcrierea dispozitivului se păstrează.", + }, + el: { + question: "Ποια δεδομένα αποστέλλονται όταν απαντώ με φωνή;", + answer: "Οι προσωρινοί ζωντανοί υπότιτλοι χρησιμοποιούν το πρόγραμμα περιήγησης ή τη συσκευή. Για συνδεδεμένους χρήστες, όταν είναι διαθέσιμη η διόρθωση cloud, ο ήχος της απάντησης, η επιλεγμένη γλώσσα και έως 80 σύντομες υποδείξεις όρων από τον ρόλο, το βιογραφικό και την αγγελία αποστέλλονται προσωρινά στο Microsoft Azure Speech για την τελική απομαγνητοφώνηση. Το InterviewThread δεν αποθηκεύει ούτε καταγράφει τον αρχικό ήχο, επιστρέφει το αποτέλεσμα ιδιωτικά χωρίς αποθήκευση και επιτρέπει επεξεργασία πριν την υποβολή. Η λειτουργία επισκέπτη μένει στον browser. Σε αποτυχία διατηρείται η απομαγνητοφώνηση της συσκευής.", + }, + bg: { + question: "Какви данни се изпращат, когато отговарям с глас?", + answer: "Предварителните субтитри на живо използват браузъра или устройството. За влезли потребители, когато е налична облачна корекция, звукът на отговора, избраният език и до 80 кратки терминологични подсказки от ролята, автобиографията и обявата временно се изпращат към Microsoft Azure Speech за окончателния препис. InterviewThread не съхранява и не записва в дневници необработения звук, връща резултата частно без съхранение и позволява редакция преди изпращане. Гост режимът остава в браузъра. При неуспех преписът на устройството се запазва.", + }, + hr: { + question: "Koji se podaci šalju kada odgovaram glasom?", + answer: "Privremeni titlovi uživo koriste preglednik ili uređaj. Za prijavljene korisnike, kada je dostupno ispravljanje u oblaku, zvuk odgovora, odabrani jezik i do 80 kratkih pojmovnih smjernica iz uloge, životopisa i oglasa privremeno se šalju u Microsoft Azure Speech radi konačnog prijepisa. InterviewThread ne sprema ni ne bilježi izvorni zvuk, rezultat vraća privatno bez pohrane i omogućuje uređivanje prije slanja. Gostujući način ostaje u pregledniku. Ako ispravak ne uspije, prijepis uređaja ostaje.", + }, + sr: { + question: "Који подаци се шаљу када одговарам гласом?", + answer: "Привремени титлови уживо користе прегледач или уређај. За пријављене кориснике, када је доступна исправка у облаку, звук одговора, изабрани језик и до 80 кратких појмовних смерница из улоге, биографије и огласа привремено се шаљу у Microsoft Azure Speech ради коначног преписа. InterviewThread не чува нити бележи изворни звук, резултат враћа приватно без складиштења и омогућава уређивање пре слања. Гост режим остаје у прегледачу. Ако исправка не успе, препис уређаја остаје.", + }, + sl: { + question: "Kateri podatki se pošljejo, ko odgovorim z glasom?", + answer: "Začasni podnapisi v živo uporabljajo brskalnik ali napravo. Pri prijavljenih uporabnikih se, ko je na voljo popravek v oblaku, zvok odgovora, izbrani jezik in do 80 kratkih izraznih namigov iz vloge, življenjepisa in oglasa začasno pošljejo v Microsoft Azure Speech za končni prepis. InterviewThread surovega zvoka ne shrani ali beleži, rezultat vrne zasebno brez shranjevanja in omogoča urejanje pred oddajo. Gostujoči način ostane v brskalniku. Če popravek ne uspe, prepis naprave ostane.", + }, + sw: { + question: "Ni data gani hutumwa ninapojibu kwa sauti?", + answer: "Manukuu ya rasimu ya moja kwa moja hutumia kivinjari au kifaa. Kwa mtumiaji aliyeingia, urekebishaji wa wingu ukiwepo, sauti ya jibu, lugha iliyochaguliwa na hadi vidokezo 80 vifupi vya msamiati kutoka nafasi, wasifu na tangazo la kazi hutumwa kwa muda kwa Microsoft Azure Speech ili kutengeneza nakala ya mwisho. InterviewThread haihifadhi wala kuweka kumbukumbu ya sauti ghafi, hurudisha matokeo kwa faragha bila kuhifadhi na hukuruhusu kuhariri kabla ya kutuma. Hali ya mgeni hutumia kivinjari pekee. Urekebishaji ukishindwa, nakala ya kifaa hubaki.", + }, + fa: { + question: "هنگام پاسخ صوتی چه داده‌ای ارسال می‌شود؟", + answer: "زیرنویس پیش‌نویس زنده از مرورگر یا دستگاه استفاده می‌کند. برای کاربر واردشده، اگر اصلاح ابری در دسترس باشد، صدای پاسخ، زبان انتخابی و حداکثر ۸۰ راهنمای کوتاه واژگان از نقش، رزومه و آگهی شغلی به‌طور موقت برای متن نهایی به Microsoft Azure Speech فرستاده می‌شود. InterviewThread صدای خام را ذخیره یا ثبت نمی‌کند، نتیجه را خصوصی و بدون ذخیره برمی‌گرداند و امکان ویرایش متن پیش از ارسال را می‌دهد. حالت مهمان فقط در مرورگر است. اگر اصلاح ابری ناموفق باشد، متن دستگاه باقی می‌ماند.", + }, +} satisfies Record; + // The fourth FAQ answer is injected by faqCopyFor from the single localized // account-access policy, so account requirements cannot drift across surfaces. const faqCopy = { @@ -805,6 +968,7 @@ export function faqCopyFor(locale: LocaleCode): FaqCopy { : item, ), speechPrivacyFaqCopy[locale], + voiceAnswerPrivacyFaqCopy[locale], ], }; } diff --git a/platform/web/app/interview-stt-copy.ts b/platform/web/app/interview-stt-copy.ts new file mode 100644 index 0000000..376e5c0 --- /dev/null +++ b/platform/web/app/interview-stt-copy.ts @@ -0,0 +1,386 @@ +import type { LocaleCode } from "./i18n"; + +export type InterviewSttCopy = { + listening: string; + refining: string; + deviceFallback: string; + permissionDenied: string; + unavailable: string; + privacy: string; +}; + +const INTERVIEW_STT_COPY = { + en: { + listening: "Listening — live transcript", + refining: "Improving your transcript…", + deviceFallback: "Cloud refinement is unavailable. Your current answer was kept.", + permissionDenied: "Allow microphone access, or answer by text.", + unavailable: "Voice input is not supported here. Answer by text.", + privacy: + "Audio is processed only to transcribe this answer and is not stored by InterviewThread.", + }, + ja: { + listening: "音声を認識中 — リアルタイム文字起こし", + refining: "文字起こしを仕上げています…", + deviceFallback: "クラウド補正を利用できないため、現在の回答を残しました。", + permissionDenied: "マイクへのアクセスを許可するか、テキストで回答してください。", + unavailable: "この環境では音声入力を利用できません。テキストで回答してください。", + privacy: + "音声はこの回答の文字起こしにのみ使用され、InterviewThread には保存されません。", + }, + ko: { + listening: "음성을 듣는 중 — 실시간 받아쓰기", + refining: "받아쓰기를 다듬는 중…", + deviceFallback: "클라우드 보정을 사용할 수 없어 현재 답변을 유지했습니다.", + permissionDenied: "마이크 접근을 허용하거나 텍스트로 답변해 주세요.", + unavailable: "이 환경에서는 음성 입력을 지원하지 않습니다. 텍스트로 답변해 주세요.", + privacy: + "음성은 이 답변을 받아쓰는 데만 처리되며 InterviewThread에 저장되지 않습니다.", + }, + "zh-CN": { + listening: "正在聆听 — 实时显示文字", + refining: "正在优化转写结果…", + deviceFallback: "云端优化暂时不可用,已保留你当前的回答。", + permissionDenied: "请允许使用麦克风,或改用文字作答。", + unavailable: "当前环境不支持语音输入,请改用文字作答。", + privacy: "音频仅用于转写本次回答,InterviewThread 不会保存音频。", + }, + "zh-TW": { + listening: "正在聆聽 — 即時顯示文字", + refining: "正在優化轉寫結果…", + deviceFallback: "雲端優化暫時無法使用,已保留你目前的回答。", + permissionDenied: "請允許使用麥克風,或改用文字作答。", + unavailable: "目前環境不支援語音輸入,請改用文字作答。", + privacy: "音訊僅用於轉寫本次回答,InterviewThread 不會儲存音訊。", + }, + es: { + listening: "Escuchando — transcripción en directo", + refining: "Mejorando la transcripción…", + deviceFallback: "La mejora en la nube no está disponible. Conservamos tu respuesta actual.", + permissionDenied: "Permite el acceso al micrófono o responde por escrito.", + unavailable: "La entrada de voz no está disponible aquí. Responde por escrito.", + privacy: + "El audio solo se procesa para transcribir esta respuesta y InterviewThread no lo guarda.", + }, + fr: { + listening: "Écoute en cours — transcription en direct", + refining: "Amélioration de la transcription…", + deviceFallback: + "L’amélioration dans le cloud est indisponible. Votre réponse actuelle a été conservée.", + permissionDenied: "Autorisez l’accès au microphone ou répondez par écrit.", + unavailable: "La saisie vocale n’est pas disponible ici. Répondez par écrit.", + privacy: + "L’audio sert uniquement à transcrire cette réponse et n’est pas conservé par InterviewThread.", + }, + de: { + listening: "Hört zu — Live-Transkript", + refining: "Transkript wird verbessert…", + deviceFallback: + "Die Cloud-Optimierung ist nicht verfügbar. Ihre aktuelle Antwort wurde beibehalten.", + permissionDenied: "Erlauben Sie den Mikrofonzugriff oder antworten Sie schriftlich.", + unavailable: "Spracheingabe wird hier nicht unterstützt. Antworten Sie schriftlich.", + privacy: + "Das Audio wird nur zur Transkription dieser Antwort verarbeitet und nicht von InterviewThread gespeichert.", + }, + "pt-BR": { + listening: "Ouvindo — transcrição ao vivo", + refining: "Aprimorando a transcrição…", + deviceFallback: + "O aprimoramento na nuvem está indisponível. Sua resposta atual foi mantida.", + permissionDenied: "Permita o acesso ao microfone ou responda por texto.", + unavailable: "A entrada por voz não está disponível aqui. Responda por texto.", + privacy: + "O áudio é processado apenas para transcrever esta resposta e não é armazenado pelo InterviewThread.", + }, + it: { + listening: "In ascolto — trascrizione in tempo reale", + refining: "Miglioramento della trascrizione…", + deviceFallback: + "Il miglioramento nel cloud non è disponibile. La risposta attuale è stata conservata.", + permissionDenied: "Consenti l’accesso al microfono oppure rispondi per iscritto.", + unavailable: "L’input vocale non è disponibile qui. Rispondi per iscritto.", + privacy: + "L’audio viene elaborato solo per trascrivere questa risposta e non viene archiviato da InterviewThread.", + }, + nl: { + listening: "Luisteren — live transcriptie", + refining: "Transcriptie wordt verbeterd…", + deviceFallback: + "Cloudverbetering is niet beschikbaar. Uw huidige antwoord is behouden.", + permissionDenied: "Sta microfoontoegang toe of antwoord met tekst.", + unavailable: "Spraakinvoer wordt hier niet ondersteund. Antwoord met tekst.", + privacy: + "Audio wordt alleen verwerkt om dit antwoord te transcriberen en niet door InterviewThread opgeslagen.", + }, + pl: { + listening: "Słuchanie — transkrypcja na żywo", + refining: "Ulepszanie transkrypcji…", + deviceFallback: + "Ulepszanie w chmurze jest niedostępne. Zachowaliśmy Twoją obecną odpowiedź.", + permissionDenied: "Zezwól na dostęp do mikrofonu lub odpowiedz tekstowo.", + unavailable: "Wprowadzanie głosowe nie jest tutaj obsługiwane. Odpowiedz tekstowo.", + privacy: + "Dźwięk jest przetwarzany wyłącznie w celu transkrypcji tej odpowiedzi i nie jest przechowywany przez InterviewThread.", + }, + tr: { + listening: "Dinleniyor — canlı döküm", + refining: "Döküm iyileştiriliyor…", + deviceFallback: "Bulut iyileştirmesi kullanılamıyor. Mevcut yanıtınız korundu.", + permissionDenied: "Mikrofon erişimine izin verin veya yazılı yanıtlayın.", + unavailable: "Sesli giriş burada desteklenmiyor. Yazılı yanıtlayın.", + privacy: + "Ses yalnızca bu yanıtı yazıya dökmek için işlenir ve InterviewThread tarafından saklanmaz.", + }, + ru: { + listening: "Идёт распознавание — текст в реальном времени", + refining: "Улучшаем расшифровку…", + deviceFallback: "Облачное улучшение недоступно. Текущий ответ сохранён.", + permissionDenied: "Разрешите доступ к микрофону или ответьте текстом.", + unavailable: "Голосовой ввод здесь не поддерживается. Ответьте текстом.", + privacy: + "Аудио обрабатывается только для расшифровки этого ответа и не сохраняется InterviewThread.", + }, + uk: { + listening: "Триває розпізнавання — текст у реальному часі", + refining: "Удосконалюємо розшифрування…", + deviceFallback: "Хмарне вдосконалення недоступне. Поточну відповідь збережено.", + permissionDenied: "Дозвольте доступ до мікрофона або дайте текстову відповідь.", + unavailable: "Голосове введення тут не підтримується. Дайте текстову відповідь.", + privacy: + "Аудіо обробляється лише для розшифрування цієї відповіді й не зберігається InterviewThread.", + }, + ar: { + listening: "جارٍ الاستماع — نسخ مباشر", + refining: "جارٍ تحسين النص…", + deviceFallback: "التحسين السحابي غير متاح. تم الاحتفاظ بإجابتك الحالية.", + permissionDenied: "اسمح بالوصول إلى الميكروفون أو أجب كتابةً.", + unavailable: "الإدخال الصوتي غير مدعوم هنا. أجب كتابةً.", + privacy: + "تتم معالجة الصوت فقط لنسخ هذه الإجابة ولا يحتفظ به InterviewThread.", + }, + he: { + listening: "מקשיב — תמלול בזמן אמת", + refining: "משפר את התמלול…", + deviceFallback: "השיפור בענן אינו זמין. התשובה הנוכחית נשמרה.", + permissionDenied: "יש לאפשר גישה למיקרופון או לענות בכתב.", + unavailable: "קלט קולי אינו נתמך כאן. יש לענות בכתב.", + privacy: + "השמע מעובד רק לצורך תמלול התשובה ואינו נשמר על ידי InterviewThread.", + }, + hi: { + listening: "सुन रहा है — लाइव ट्रांसक्रिप्ट", + refining: "ट्रांसक्रिप्ट बेहतर की जा रही है…", + deviceFallback: "क्लाउड सुधार उपलब्ध नहीं है। आपका मौजूदा उत्तर सुरक्षित रखा गया है।", + permissionDenied: "माइक्रोफ़ोन की अनुमति दें या लिखकर उत्तर दें।", + unavailable: "यहाँ वॉइस इनपुट समर्थित नहीं है। लिखकर उत्तर दें।", + privacy: + "ऑडियो का उपयोग केवल इस उत्तर को लिखने के लिए होता है और InterviewThread इसे संग्रहीत नहीं करता।", + }, + bn: { + listening: "শোনা হচ্ছে — সরাসরি প্রতিলিপি", + refining: "প্রতিলিপি আরও নির্ভুল করা হচ্ছে…", + deviceFallback: "ক্লাউড সংশোধন পাওয়া যাচ্ছে না। আপনার বর্তমান উত্তর রাখা হয়েছে।", + permissionDenied: "মাইক্রোফোন ব্যবহারের অনুমতি দিন অথবা লিখে উত্তর দিন।", + unavailable: "এখানে ভয়েস ইনপুট সমর্থিত নয়। লিখে উত্তর দিন।", + privacy: + "শুধু এই উত্তরটি প্রতিলিপি করতে অডিও প্রক্রিয়া করা হয় এবং InterviewThread এটি সংরক্ষণ করে না।", + }, + ur: { + listening: "سنا جا رہا ہے — براہِ راست نقل", + refining: "نقل کو بہتر کیا جا رہا ہے…", + deviceFallback: "کلاؤڈ بہتری دستیاب نہیں۔ آپ کا موجودہ جواب محفوظ رکھا گیا ہے۔", + permissionDenied: "مائیکروفون کی اجازت دیں یا تحریری جواب دیں۔", + unavailable: "یہاں صوتی ان پٹ دستیاب نہیں۔ تحریری جواب دیں۔", + privacy: + "آڈیو صرف اس جواب کو تحریر میں بدلنے کے لیے استعمال ہوتی ہے اور InterviewThread اسے محفوظ نہیں کرتا۔", + }, + id: { + listening: "Mendengarkan — transkripsi langsung", + refining: "Menyempurnakan transkripsi…", + deviceFallback: + "Penyempurnaan cloud tidak tersedia. Jawaban Anda saat ini tetap disimpan.", + permissionDenied: "Izinkan akses mikrofon atau jawab dengan teks.", + unavailable: "Input suara tidak didukung di sini. Jawab dengan teks.", + privacy: + "Audio hanya diproses untuk mentranskripsikan jawaban ini dan tidak disimpan oleh InterviewThread.", + }, + ms: { + listening: "Sedang mendengar — transkripsi langsung", + refining: "Memperhalus transkripsi…", + deviceFallback: + "Pemurnian awan tidak tersedia. Jawapan semasa anda telah dikekalkan.", + permissionDenied: "Benarkan akses mikrofon atau jawab dengan teks.", + unavailable: "Input suara tidak disokong di sini. Jawab dengan teks.", + privacy: + "Audio hanya diproses untuk mentranskripsi jawapan ini dan tidak disimpan oleh InterviewThread.", + }, + th: { + listening: "กำลังฟัง — ถอดเสียงแบบเรียลไทม์", + refining: "กำลังปรับข้อความถอดเสียงให้แม่นยำขึ้น…", + deviceFallback: "ไม่สามารถปรับแก้ผ่านคลาวด์ได้ ระบบเก็บคำตอบปัจจุบันของคุณไว้แล้ว", + permissionDenied: "โปรดอนุญาตให้ใช้ไมโครโฟน หรือตอบด้วยข้อความ", + unavailable: "อุปกรณ์นี้ไม่รองรับการป้อนข้อมูลด้วยเสียง โปรดตอบด้วยข้อความ", + privacy: + "เสียงจะถูกประมวลผลเพื่อถอดคำตอบนี้เท่านั้น และ InterviewThread จะไม่จัดเก็บเสียง", + }, + vi: { + listening: "Đang nghe — bản chép lời trực tiếp", + refining: "Đang hoàn thiện bản chép lời…", + deviceFallback: + "Không thể tinh chỉnh trên đám mây. Câu trả lời hiện tại của bạn vẫn được giữ nguyên.", + permissionDenied: "Hãy cho phép truy cập micrô hoặc trả lời bằng văn bản.", + unavailable: "Thiết bị này không hỗ trợ nhập liệu bằng giọng nói. Hãy trả lời bằng văn bản.", + privacy: + "Âm thanh chỉ được xử lý để chép lại câu trả lời này và không được InterviewThread lưu trữ.", + }, + fil: { + listening: "Nakikinig — live na transkripsyon", + refining: "Pinapahusay ang transkripsyon…", + deviceFallback: + "Hindi available ang cloud refinement. Napanatili ang kasalukuyan mong sagot.", + permissionDenied: "Payagan ang access sa mikropono o sumagot gamit ang text.", + unavailable: "Hindi suportado rito ang voice input. Sumagot gamit ang text.", + privacy: + "Pinoproseso lang ang audio para i-transcribe ang sagot na ito at hindi ito sine-save ng InterviewThread.", + }, + sv: { + listening: "Lyssnar — direkttranskribering", + refining: "Förbättrar transkriberingen…", + deviceFallback: "Molnförbättring är inte tillgänglig. Ditt nuvarande svar behölls.", + permissionDenied: "Tillåt mikrofonåtkomst eller svara med text.", + unavailable: "Röstinmatning stöds inte här. Svara med text.", + privacy: + "Ljudet behandlas endast för att transkribera detta svar och sparas inte av InterviewThread.", + }, + no: { + listening: "Lytter — direktetranskripsjon", + refining: "Forbedrer transkripsjonen…", + deviceFallback: "Skyforbedring er ikke tilgjengelig. Det nåværende svaret ditt ble beholdt.", + permissionDenied: "Tillat mikrofontilgang eller svar med tekst.", + unavailable: "Taleinndata støttes ikke her. Svar med tekst.", + privacy: + "Lyden behandles bare for å transkribere dette svaret og lagres ikke av InterviewThread.", + }, + da: { + listening: "Lytter — direkte transskription", + refining: "Forbedrer transskriptionen…", + deviceFallback: "Cloudforbedring er ikke tilgængelig. Dit nuværende svar blev bevaret.", + permissionDenied: "Tillad mikrofonadgang, eller svar med tekst.", + unavailable: "Stemmeinput understøttes ikke her. Svar med tekst.", + privacy: + "Lyden behandles kun for at transskribere dette svar og gemmes ikke af InterviewThread.", + }, + fi: { + listening: "Kuunnellaan — reaaliaikainen litterointi", + refining: "Parannetaan litterointia…", + deviceFallback: "Pilviparannus ei ole käytettävissä. Nykyinen vastauksesi säilytettiin.", + permissionDenied: "Salli mikrofonin käyttö tai vastaa kirjoittamalla.", + unavailable: "Puheensyöttöä ei tueta tässä ympäristössä. Vastaa kirjoittamalla.", + privacy: + "Ääntä käsitellään vain tämän vastauksen litterointiin, eikä InterviewThread tallenna sitä.", + }, + cs: { + listening: "Poslouchám — živý přepis", + refining: "Vylepšuji přepis…", + deviceFallback: "Vylepšení v cloudu není dostupné. Vaše aktuální odpověď byla zachována.", + permissionDenied: "Povolte přístup k mikrofonu nebo odpovězte textem.", + unavailable: "Hlasový vstup zde není podporován. Odpovězte textem.", + privacy: + "Zvuk se zpracovává pouze pro přepis této odpovědi a InterviewThread jej neukládá.", + }, + sk: { + listening: "Počúvam — živý prepis", + refining: "Vylepšujem prepis…", + deviceFallback: "Vylepšenie v cloude nie je dostupné. Vaša aktuálna odpoveď bola zachovaná.", + permissionDenied: "Povoľte prístup k mikrofónu alebo odpovedzte textom.", + unavailable: "Hlasový vstup tu nie je podporovaný. Odpovedzte textom.", + privacy: + "Zvuk sa spracúva iba na prepis tejto odpovede a InterviewThread ho neukladá.", + }, + hu: { + listening: "Figyelés — élő átirat", + refining: "Az átirat pontosítása…", + deviceFallback: "A felhőalapú pontosítás nem érhető el. A jelenlegi válasza megmaradt.", + permissionDenied: "Engedélyezze a mikrofon használatát, vagy válaszoljon írásban.", + unavailable: "A hangbevitel itt nem támogatott. Válaszoljon írásban.", + privacy: + "A hang feldolgozása kizárólag e válasz átírásához történik, az InterviewThread nem tárolja.", + }, + ro: { + listening: "Ascultare — transcriere în direct", + refining: "Îmbunătățim transcrierea…", + deviceFallback: + "Îmbunătățirea în cloud nu este disponibilă. Răspunsul actual a fost păstrat.", + permissionDenied: "Permiteți accesul la microfon sau răspundeți în scris.", + unavailable: "Introducerea vocală nu este acceptată aici. Răspundeți în scris.", + privacy: + "Sunetul este procesat doar pentru transcrierea acestui răspuns și nu este stocat de InterviewThread.", + }, + el: { + listening: "Ακρόαση — ζωντανή μεταγραφή", + refining: "Βελτιώνουμε τη μεταγραφή…", + deviceFallback: "Η βελτίωση στο cloud δεν είναι διαθέσιμη. Η τρέχουσα απάντησή σας διατηρήθηκε.", + permissionDenied: "Επιτρέψτε την πρόσβαση στο μικρόφωνο ή απαντήστε γραπτώς.", + unavailable: "Η φωνητική εισαγωγή δεν υποστηρίζεται εδώ. Απαντήστε γραπτώς.", + privacy: + "Ο ήχος υποβάλλεται σε επεξεργασία μόνο για τη μεταγραφή αυτής της απάντησης και δεν αποθηκεύεται από το InterviewThread.", + }, + bg: { + listening: "Слушане — транскрипция на живо", + refining: "Подобряваме транскрипцията…", + deviceFallback: "Облачното подобрение не е достъпно. Текущият ви отговор беше запазен.", + permissionDenied: "Разрешете достъп до микрофона или отговорете писмено.", + unavailable: "Гласовото въвеждане не се поддържа тук. Отговорете писмено.", + privacy: + "Аудиото се обработва само за транскрипция на този отговор и не се съхранява от InterviewThread.", + }, + hr: { + listening: "Slušanje — prijepis uživo", + refining: "Poboljšavamo prijepis…", + deviceFallback: "Poboljšanje u oblaku nije dostupno. Vaš trenutačni odgovor je sačuvan.", + permissionDenied: "Dopustite pristup mikrofonu ili odgovorite tekstom.", + unavailable: "Glasovni unos ovdje nije podržan. Odgovorite tekstom.", + privacy: + "Zvuk se obrađuje samo radi prijepisa ovog odgovora i InterviewThread ga ne pohranjuje.", + }, + sr: { + listening: "Слушање — транскрипт уживо", + refining: "Побољшавамо транскрипт…", + deviceFallback: "Побољшање у облаку није доступно. Ваш тренутни одговор је сачуван.", + permissionDenied: "Дозволите приступ микрофону или одговорите текстом.", + unavailable: "Гласовни унос овде није подржан. Одговорите текстом.", + privacy: + "Звук се обрађује само ради транскрипције овог одговора и InterviewThread га не чува.", + }, + sl: { + listening: "Poslušanje — prepis v živo", + refining: "Izboljšujemo prepis…", + deviceFallback: "Izboljšava v oblaku ni na voljo. Vaš trenutni odgovor je bil ohranjen.", + permissionDenied: "Dovolite dostop do mikrofona ali odgovorite z besedilom.", + unavailable: "Glasovni vnos tukaj ni podprt. Odgovorite z besedilom.", + privacy: + "Zvok se obdela samo za prepis tega odgovora in ga InterviewThread ne shranjuje.", + }, + sw: { + listening: "Inasikiliza — nakala ya moja kwa moja", + refining: "Inaboresha nakala…", + deviceFallback: "Uboreshaji wa wingu haupatikani. Jibu lako la sasa limehifadhiwa.", + permissionDenied: "Ruhusu matumizi ya maikrofoni au jibu kwa maandishi.", + unavailable: "Uingizaji wa sauti hautumiki hapa. Jibu kwa maandishi.", + privacy: + "Sauti huchakatwa tu ili kunakili jibu hili na haihifadhiwi na InterviewThread.", + }, + fa: { + listening: "در حال شنیدن — رونوشت زنده", + refining: "در حال بهبود رونوشت…", + deviceFallback: "بهبود ابری در دسترس نیست. پاسخ فعلی شما حفظ شد.", + permissionDenied: "اجازهٔ دسترسی به میکروفون را بدهید یا نوشتاری پاسخ دهید.", + unavailable: "ورودی صوتی در اینجا پشتیبانی نمی‌شود. نوشتاری پاسخ دهید.", + privacy: + "صدا فقط برای رونویسی این پاسخ پردازش می‌شود و InterviewThread آن را ذخیره نمی‌کند.", + }, +} satisfies Record; + +export function sttCopyFor(locale: LocaleCode): InterviewSttCopy { + return INTERVIEW_STT_COPY[locale]; +} diff --git a/platform/web/app/interview-stt.ts b/platform/web/app/interview-stt.ts new file mode 100644 index 0000000..a1c6b0d --- /dev/null +++ b/platform/web/app/interview-stt.ts @@ -0,0 +1,184 @@ +import type { LocaleCode } from "./i18n"; +import { speechLocaleFor } from "./interview-speech.ts"; + +export const STT_API_VERSION = "2025-10-15"; +export const STT_MAX_AUDIO_BYTES = 12 * 1024 * 1024; +export const STT_MAX_VOCABULARY_TERMS = 80; +export const STT_MAX_VOCABULARY_TERM_CHARACTERS = 32; + +const STT_LOCALES = new Set([ + "en", "ja", "ko", "zh-CN", "zh-TW", "es", "fr", "de", "pt-BR", "it", + "nl", "pl", "tr", "ru", "uk", "ar", "he", "hi", "bn", "ur", "id", "ms", + "th", "vi", "fil", "sv", "no", "da", "fi", "cs", "sk", "hu", "ro", "el", + "bg", "hr", "sr", "sl", "sw", "fa", +]); + +const ALLOWED_AUDIO_TYPES = [ + "audio/webm", + "audio/ogg", + "audio/mp4", + "audio/mpeg", + "audio/wav", + "audio/x-wav", +]; + +export function isSttLocale(value: unknown): value is LocaleCode { + return typeof value === "string" && STT_LOCALES.has(value as LocaleCode); +} + +export function isSupportedInterviewAudioType(value: string) { + const base = value.toLowerCase().split(";")[0].trim(); + return ALLOWED_AUDIO_TYPES.includes(base); +} + +export function sanitizeSpeechVocabulary(value: unknown) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value + .filter((term): term is string => typeof term === "string") + .map((term) => + Array.from(term) + .filter((character) => { + const code = character.charCodeAt(0); + return code >= 32 && code !== 127; + }) + .join("") + .replace(/\s+/g, " ") + .trim(), + ) + .filter( + (term) => + term.length >= 2 && + term.length <= STT_MAX_VOCABULARY_TERM_CHARACTERS, + ) + .filter((term) => { + const key = term.toLocaleLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, STT_MAX_VOCABULARY_TERMS); +} + +export function normalizeSttTranscript(value: string, vocabulary: string[] = []) { + let transcript = Array.from(value) + .filter((character) => { + const code = character.charCodeAt(0); + return code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127); + }) + .join("") + .replace(/\s+/g, " ") + .trim(); + const canonical = new Map( + sanitizeSpeechVocabulary(vocabulary).map((term) => [ + term.toLocaleLowerCase(), + term, + ]), + ); + const replacements: Array<[RegExp, string]> = [ + [/\bsequel\b/giu, canonical.get("sql") || "SQL"], + [/\bpower\s+bee\b/giu, canonical.get("power bi") || "Power BI"], + [/\btableu\b/giu, canonical.get("tableau") || "Tableau"], + [/\btype\s*script\b/giu, canonical.get("typescript") || "TypeScript"], + [/\bjava\s*script\b/giu, canonical.get("javascript") || "JavaScript"], + [/\bpost\s*gres(?:\s*q\s*l)?\b/giu, canonical.get("postgresql") || "PostgreSQL"], + ]; + for (const [pattern, replacement] of replacements) + transcript = transcript.replace(pattern, replacement); + for (const [lower, display] of canonical) { + if (!/[A-Z+#.]/.test(display)) continue; + transcript = transcript.replace( + new RegExp(`\\b${lower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "giu"), + display, + ); + } + return transcript; +} + +function validatedEndpoint(endpoint: string) { + const url = new URL(endpoint.trim()); + if ( + url.protocol !== "https:" || + !url.hostname.toLowerCase().endsWith(".cognitiveservices.azure.com") || + url.username || + url.password + ) + throw new Error("Speech configuration is unavailable."); + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +} + +export function buildAzureTranscriptionRequest({ + audio, + locale, + vocabulary, + apiKey, + endpoint, +}: { + audio: Blob; + locale: LocaleCode; + vocabulary: string[]; + apiKey: string; + endpoint: string; +}) { + if (!apiKey || !isSttLocale(locale)) + throw new Error("Speech configuration is unavailable."); + if ( + !audio.size || + audio.size > STT_MAX_AUDIO_BYTES || + !isSupportedInterviewAudioType(audio.type) + ) + throw new Error("Interview audio is invalid."); + + const terms = sanitizeSpeechVocabulary(vocabulary); + const definition: Record = { + locales: [speechLocaleFor(locale)], + profanityFilterMode: "None", + }; + if (terms.length) definition.phraseList = { phrases: terms }; + + const form = new FormData(); + form.append("audio", audio, `interview-answer.${audioExtensionFor(audio.type)}`); + form.append("definition", JSON.stringify(definition)); + + return { + url: `${validatedEndpoint(endpoint)}/speechtotext/transcriptions:transcribe?api-version=${STT_API_VERSION}`, + init: { + method: "POST", + headers: { + Accept: "application/json", + "Ocp-Apim-Subscription-Key": apiKey, + }, + body: form, + } satisfies RequestInit, + }; +} + +function audioExtensionFor(contentType: string) { + const base = contentType.toLowerCase().split(";")[0].trim(); + if (base === "audio/ogg") return "ogg"; + if (base === "audio/mp4") return "m4a"; + if (base === "audio/mpeg") return "mp3"; + if (base === "audio/wav" || base === "audio/x-wav") return "wav"; + return "webm"; +} + +export function transcriptFromAzureResponse(value: unknown) { + if (!value || typeof value !== "object") return ""; + const payload = value as { + combinedPhrases?: Array<{ text?: unknown }>; + phrases?: Array<{ text?: unknown }>; + }; + const combined = payload.combinedPhrases + ?.map((phrase) => (typeof phrase.text === "string" ? phrase.text : "")) + .filter(Boolean) + .join(" ") + .trim(); + if (combined) return combined; + return ( + payload.phrases + ?.map((phrase) => (typeof phrase.text === "string" ? phrase.text : "")) + .filter(Boolean) + .join(" ") + .trim() || "" + ); +} diff --git a/platform/web/app/page.tsx b/platform/web/app/page.tsx index 5472f14..7599c80 100644 --- a/platform/web/app/page.tsx +++ b/platform/web/app/page.tsx @@ -76,6 +76,11 @@ import { speechRateFor, } from "./interview-speech"; import { normalizeTtsText } from "./interview-tts"; +import { + normalizeSttTranscript, + STT_MAX_AUDIO_BYTES, +} from "./interview-stt"; +import { sttCopyFor } from "./interview-stt-copy"; import { INTERVIEW_QUESTION_LENSES, INTERVIEW_QUESTION_TRACKS, @@ -1906,6 +1911,31 @@ function appendTranscript(current: string, next: string, locale: LocaleCode) { return `${current.trim()}${separator}${next.trim()}`; } +const INTERVIEW_RECORDING_MAX_MILLISECONDS = 3 * 60 * 1_000; + +function preferredInterviewAudioMimeType() { + if (typeof MediaRecorder === "undefined") return ""; + const candidates = [ + "audio/webm;codecs=opus", + "audio/ogg;codecs=opus", + "audio/mp4", + "audio/webm", + ]; + return ( + candidates.find((type) => { + try { + return MediaRecorder.isTypeSupported(type); + } catch { + return false; + } + }) || "" + ); +} + +function stopInterviewMediaStream(stream: MediaStream | null) { + stream?.getTracks().forEach((track) => track.stop()); +} + function speechVocabularyFor( jd: string, resume: string, @@ -1950,25 +1980,7 @@ function speechVocabularyFor( } function normalizeSpeechTranscript(transcript: string, vocabulary: string[]) { - let normalized = transcript.replace(/\s+/g, " ").trim(); - const canonical = new Map(vocabulary.map((term) => [term.toLowerCase(), term])); - const replacements: Array<[RegExp, string]> = [ - [/\bsequel\b/giu, canonical.get("sql") || "SQL"], - [/\bpower\s+bee\b/giu, canonical.get("power bi") || "Power BI"], - [/\btableu\b/giu, canonical.get("tableau") || "Tableau"], - [/\btype\s*script\b/giu, canonical.get("typescript") || "TypeScript"], - [/\bjava\s*script\b/giu, canonical.get("javascript") || "JavaScript"], - ]; - for (const [pattern, replacement] of replacements) - normalized = normalized.replace(pattern, replacement); - for (const [lower, display] of canonical) { - if (!/[A-Z+#.]/.test(display)) continue; - normalized = normalized.replace( - new RegExp(`\\b${lower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "giu"), - display, - ); - } - return normalized; + return normalizeSttTranscript(transcript, vocabulary); } function recognitionAlternativeScore( @@ -2770,18 +2782,18 @@ export default function Home({ const [realisticReviewOpen, setRealisticReviewOpen] = useState(false); const [interviewThinking, setInterviewThinking] = useState(false); const [isListening, setIsListening] = useState(false); + const [isRefiningVoice, setIsRefiningVoice] = useState(false); const [isSpeaking, setIsSpeaking] = useState(false); const [voiceMessage, setVoiceMessage] = useState(""); const [voiceInterim, setVoiceInterim] = useState(""); - const [recognitionConfidence, setRecognitionConfidence] = useState< - number | null - >(null); + const [, setRecognitionConfidence] = useState(null); const [feedbackSent, setFeedbackSent] = useState(false); const [feedbackSubmitting, setFeedbackSubmitting] = useState(false); const [feedbackError, setFeedbackError] = useState(""); const [suggestedLocale, setSuggestedLocale] = useState(null); const copy = copyFor(locale); + const sttUi = sttCopyFor(locale); const jobSearchUi = jobSearchCopyFor(locale); const homepage = homepageCopyFor(locale); const detail = detailFor(locale); @@ -2821,6 +2833,17 @@ export default function Home({ start: () => void; stop: () => void; } | null>(null); + const mediaRecorderRef = useRef(null); + const voiceMediaStreamRef = useRef(null); + const voiceAudioChunksRef = useRef([]); + const voiceRecordingMimeRef = useRef("audio/webm"); + const voiceRecordingBaseRef = useRef(""); + const voiceBrowserTranscriptRef = useRef(""); + const interviewAnswerRef = useRef(""); + const voiceTranscriptionAbortRef = useRef(null); + const voiceTranscriptionRequestIdRef = useRef(0); + const voiceRecordingTimerRef = useRef(null); + const speechRestartTimerRef = useRef(null); const keepListeningRef = useRef(false); const interviewLocaleRef = useRef(locale); const lastFinalSpeechRef = useRef({ text: "", at: 0 }); @@ -3043,6 +3066,29 @@ export default function Home({ document.documentElement.dir = RTL_LOCALES.has(locale) ? "rtl" : "ltr"; keepListeningRef.current = false; speechRecognitionRef.current?.stop(); + speechRecognitionRef.current = null; + if (speechRestartTimerRef.current !== null) { + window.clearTimeout(speechRestartTimerRef.current); + speechRestartTimerRef.current = null; + } + if (voiceRecordingTimerRef.current !== null) { + window.clearTimeout(voiceRecordingTimerRef.current); + voiceRecordingTimerRef.current = null; + } + voiceTranscriptionRequestIdRef.current += 1; + voiceTranscriptionAbortRef.current?.abort(); + voiceTranscriptionAbortRef.current = null; + const recorder = mediaRecorderRef.current; + if (recorder) { + recorder.ondataavailable = null; + recorder.onstop = null; + recorder.onerror = null; + if (recorder.state !== "inactive") recorder.stop(); + } + mediaRecorderRef.current = null; + stopInterviewMediaStream(voiceMediaStreamRef.current); + voiceMediaStreamRef.current = null; + voiceAudioChunksRef.current = []; interviewSpeechRequestIdRef.current += 1; interviewSpeechAbortRef.current?.abort(); interviewSpeechAbortRef.current = null; @@ -3065,6 +3111,7 @@ export default function Home({ setVoiceInterim(""); setRecognitionConfidence(null); setIsListening(false); + setIsRefiningVoice(false); setIsSpeaking(false); setVoiceMessage(interviewFlowCopyFor(locale).languageLocked); } @@ -3073,6 +3120,10 @@ export default function Home({ window.localStorage.setItem("aptograph-locale", locale); }, [locale]); + useEffect(() => { + interviewAnswerRef.current = interviewAnswer; + }, [interviewAnswer]); + useEffect(() => { if (!preferencesLoaded.current || guestMode) return; window.localStorage.setItem( @@ -3141,6 +3192,22 @@ export default function Home({ () => () => { keepListeningRef.current = false; speechRecognitionRef.current?.stop(); + speechRecognitionRef.current = null; + if (speechRestartTimerRef.current !== null) + window.clearTimeout(speechRestartTimerRef.current); + if (voiceRecordingTimerRef.current !== null) + window.clearTimeout(voiceRecordingTimerRef.current); + voiceTranscriptionRequestIdRef.current += 1; + voiceTranscriptionAbortRef.current?.abort(); + const recorder = mediaRecorderRef.current; + if (recorder) { + recorder.ondataavailable = null; + recorder.onstop = null; + recorder.onerror = null; + if (recorder.state !== "inactive") recorder.stop(); + } + stopInterviewMediaStream(voiceMediaStreamRef.current); + voiceAudioChunksRef.current = []; interviewSpeechRequestIdRef.current += 1; interviewSpeechAbortRef.current?.abort(); interviewAudioRef.current?.pause(); @@ -4062,7 +4129,14 @@ export default function Home({ async function submitInterviewAnswer(event: FormEvent) { event.preventDefault(); const answer = interviewAnswer.trim(); - if (!answer || !interviewMessages.length || interviewThinking) return; + if ( + !answer || + !interviewMessages.length || + interviewThinking || + isListening || + isRefiningVoice + ) + return; keepListeningRef.current = false; speechRecognitionRef.current?.stop(); const scores = scoreInterviewAnswer(answer, matches); @@ -4084,6 +4158,7 @@ export default function Home({ setInterviewScoreHistory((current) => [...current, scores].slice(-20)); setInterviewTurn(next.turn); setInterviewTopicIndex(next.topicIndex); + interviewAnswerRef.current = ""; setInterviewAnswer(""); setVoiceMessage(""); setVoiceInterim(""); @@ -4275,7 +4350,106 @@ export default function Home({ if (latest) await speakInterviewQuestion(questionOnly(latest.content)); } - function toggleInterviewListening() { + async function refineRecordedInterviewAnswer({ + audio, + baseAnswer, + browserTranscript, + requestId, + }: { + audio: Blob; + baseAnswer: string; + browserTranscript: string; + requestId: number; + }) { + const browserDraft = appendTranscript(baseAnswer, browserTranscript, locale); + if (!authenticated || !audio.size || audio.size > STT_MAX_AUDIO_BYTES) { + if (requestId === voiceTranscriptionRequestIdRef.current) { + setIsRefiningVoice(false); + setVoiceMessage(sttUi.deviceFallback); + } + return; + } + + const controller = new AbortController(); + voiceTranscriptionAbortRef.current?.abort(); + voiceTranscriptionAbortRef.current = controller; + setIsRefiningVoice(true); + setVoiceMessage(sttUi.refining); + try { + const form = new FormData(); + form.append("audio", audio, "interview-answer"); + form.append("locale", locale); + form.append("vocabulary", JSON.stringify(speechVocabulary.slice(0, 80))); + const response = await fetch("/api/transcribe", { + method: "POST", + body: form, + signal: controller.signal, + }); + if (!response.ok) throw new Error("premium transcription unavailable"); + const payload = (await response.json()) as { transcript?: unknown }; + const refined = + typeof payload.transcript === "string" + ? normalizeSpeechTranscript(payload.transcript, speechVocabulary) + : ""; + if ( + !refined || + controller.signal.aborted || + requestId !== voiceTranscriptionRequestIdRef.current + ) + return; + + const currentAnswer = interviewAnswerRef.current.trim(); + const safeToReplace = + currentAnswer === browserDraft.trim() || + currentAnswer === baseAnswer.trim(); + if (safeToReplace) { + const nextAnswer = appendTranscript(baseAnswer, refined, locale); + interviewAnswerRef.current = nextAnswer; + setInterviewAnswer(nextAnswer); + setVoiceMessage(sttUi.privacy); + } else { + setVoiceMessage(sttUi.deviceFallback); + } + } catch (error) { + if (!(error instanceof DOMException && error.name === "AbortError")) { + setVoiceMessage( + browserTranscript ? sttUi.deviceFallback : sttUi.unavailable, + ); + } + } finally { + if (requestId === voiceTranscriptionRequestIdRef.current) + setIsRefiningVoice(false); + if (voiceTranscriptionAbortRef.current === controller) + voiceTranscriptionAbortRef.current = null; + } + } + + function stopInterviewListening() { + keepListeningRef.current = false; + speechRecognitionRef.current?.stop(); + speechRecognitionRef.current = null; + if (speechRestartTimerRef.current !== null) { + window.clearTimeout(speechRestartTimerRef.current); + speechRestartTimerRef.current = null; + } + if (voiceRecordingTimerRef.current !== null) { + window.clearTimeout(voiceRecordingTimerRef.current); + voiceRecordingTimerRef.current = null; + } + const recorder = mediaRecorderRef.current; + if (recorder && recorder.state !== "inactive") { + setIsRefiningVoice(authenticated); + recorder.stop(); + } else { + stopInterviewMediaStream(voiceMediaStreamRef.current); + voiceMediaStreamRef.current = null; + setIsRefiningVoice(false); + } + setIsListening(false); + setVoiceInterim(""); + } + + async function toggleInterviewListening() { type RecognitionAlternative = { transcript: string; confidence?: number; @@ -4312,15 +4486,101 @@ export default function Home({ }; const Recognition = voiceWindow.SpeechRecognition || voiceWindow.webkitSpeechRecognition; - if (!Recognition) { - setVoiceMessage(interview.unavailable); + const canRecord = + authenticated && + typeof MediaRecorder !== "undefined" && + Boolean(navigator.mediaDevices?.getUserMedia); + if (!Recognition && !canRecord) { + setVoiceMessage(sttUi.unavailable); return; } if (isListening) { - keepListeningRef.current = false; - speechRecognitionRef.current?.stop(); - setIsListening(false); - setVoiceInterim(""); + stopInterviewListening(); + return; + } + + voiceTranscriptionRequestIdRef.current += 1; + const requestId = voiceTranscriptionRequestIdRef.current; + voiceTranscriptionAbortRef.current?.abort(); + voiceTranscriptionAbortRef.current = null; + voiceRecordingBaseRef.current = interviewAnswerRef.current.trim(); + voiceBrowserTranscriptRef.current = ""; + voiceAudioChunksRef.current = []; + lastFinalSpeechRef.current = { text: "", at: 0 }; + speechRestartCountRef.current = 0; + setVoiceInterim(""); + setRecognitionConfidence(null); + + if (canRecord) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + autoGainControl: true, + echoCancellation: true, + noiseSuppression: true, + channelCount: 1, + }, + }); + if (requestId !== voiceTranscriptionRequestIdRef.current) { + stopInterviewMediaStream(stream); + return; + } + const preferredType = preferredInterviewAudioMimeType(); + const recorder = preferredType + ? new MediaRecorder(stream, { mimeType: preferredType }) + : new MediaRecorder(stream); + voiceMediaStreamRef.current = stream; + mediaRecorderRef.current = recorder; + voiceRecordingMimeRef.current = + recorder.mimeType || preferredType || "audio/webm"; + recorder.ondataavailable = (event) => { + if (event.data.size) voiceAudioChunksRef.current.push(event.data); + }; + recorder.onstop = () => { + stopInterviewMediaStream(voiceMediaStreamRef.current); + voiceMediaStreamRef.current = null; + mediaRecorderRef.current = null; + const audio = new Blob(voiceAudioChunksRef.current, { + type: voiceRecordingMimeRef.current, + }); + voiceAudioChunksRef.current = []; + void refineRecordedInterviewAnswer({ + audio, + baseAnswer: voiceRecordingBaseRef.current, + browserTranscript: voiceBrowserTranscriptRef.current, + requestId, + }); + }; + recorder.onerror = () => { + stopInterviewMediaStream(voiceMediaStreamRef.current); + voiceMediaStreamRef.current = null; + mediaRecorderRef.current = null; + setIsRefiningVoice(false); + }; + recorder.start(1_000); + } catch { + stopInterviewMediaStream(voiceMediaStreamRef.current); + voiceMediaStreamRef.current = null; + mediaRecorderRef.current = null; + if (!Recognition) { + setVoiceMessage(sttUi.permissionDenied); + return; + } + } + } + + if (!Recognition && mediaRecorderRef.current) { + keepListeningRef.current = true; + setIsListening(true); + setVoiceMessage(sttUi.listening); + voiceRecordingTimerRef.current = window.setTimeout( + stopInterviewListening, + INTERVIEW_RECORDING_MAX_MILLISECONDS, + ); + return; + } + if (!Recognition) { + setVoiceMessage(sttUi.unavailable); return; } const recognition = new Recognition(); @@ -4371,9 +4631,18 @@ export default function Home({ fingerprint !== lastFinalSpeechRef.current.text || now - lastFinalSpeechRef.current.at > 4_000 ) { - setInterviewAnswer((current) => - appendTranscript(current, finalText, locale), + voiceBrowserTranscriptRef.current = appendTranscript( + voiceBrowserTranscriptRef.current, + finalText, + locale, + ); + const nextAnswer = appendTranscript( + voiceRecordingBaseRef.current, + voiceBrowserTranscriptRef.current, + locale, ); + interviewAnswerRef.current = nextAnswer; + setInterviewAnswer(nextAnswer); lastFinalSpeechRef.current = { text: fingerprint, at: now }; } speechRestartCountRef.current = 0; @@ -4393,22 +4662,31 @@ export default function Home({ ); }; recognition.onend = () => { - if (keepListeningRef.current) { + if ( + keepListeningRef.current && + speechRecognitionRef.current === recognition + ) { const delay = Math.min( 1_500, 200 + speechRestartCountRef.current * 180, ); speechRestartCountRef.current += 1; - window.setTimeout(() => { - if (!keepListeningRef.current) return; + speechRestartTimerRef.current = window.setTimeout(() => { + speechRestartTimerRef.current = null; + if ( + !keepListeningRef.current || + speechRecognitionRef.current !== recognition + ) + return; try { recognition.start(); } catch { keepListeningRef.current = false; - setIsListening(false); + if (mediaRecorderRef.current?.state !== "recording") + setIsListening(false); } }, delay); - } else { + } else if (mediaRecorderRef.current?.state !== "recording") { setIsListening(false); } }; @@ -4416,13 +4694,25 @@ export default function Home({ const error = event.error || ""; if (["not-allowed", "service-not-allowed"].includes(error)) { keepListeningRef.current = false; - setVoiceMessage(interview.permissionDenied); + if (mediaRecorderRef.current?.state === "recording") { + setVoiceMessage(sttUi.listening); + } else { + setVoiceMessage(sttUi.permissionDenied); + } } else if (error === "no-speech") { setVoiceMessage(interview.noSpeech); } else if (error !== "aborted") { - setVoiceMessage(interview.unavailable); + setVoiceMessage( + mediaRecorderRef.current?.state === "recording" + ? sttUi.listening + : sttUi.unavailable, + ); } - if (error !== "no-speech") setIsListening(false); + if ( + error !== "no-speech" && + mediaRecorderRef.current?.state !== "recording" + ) + setIsListening(false); }; speechRecognitionRef.current = recognition; keepListeningRef.current = true; @@ -4433,11 +4723,24 @@ export default function Home({ setIsListening(true); setVoiceInterim(""); setRecognitionConfidence(null); - setVoiceMessage(`${interview.speechLanguage}: ${speechLocale}`); + setVoiceMessage(sttUi.listening); + voiceRecordingTimerRef.current = window.setTimeout( + stopInterviewListening, + INTERVIEW_RECORDING_MAX_MILLISECONDS, + ); } catch { keepListeningRef.current = false; - setIsListening(false); - setVoiceMessage(interview.unavailable); + if (mediaRecorderRef.current?.state === "recording") { + setIsListening(true); + setVoiceMessage(sttUi.listening); + voiceRecordingTimerRef.current = window.setTimeout( + stopInterviewListening, + INTERVIEW_RECORDING_MAX_MILLISECONDS, + ); + } else { + setIsListening(false); + setVoiceMessage(sttUi.unavailable); + } } } async function sendFeedback(event: FormEvent) { @@ -6601,25 +6904,27 @@ export default function Home({