Integrate ASR/Omni fixes, API Route, Android IME and confirmation-based vocabulary learning. Complete settings transitions and macOS single-item credential storage. Synchronize all application versions to 2.0.0-Beta.4+build.20260930. Integrated PRs: #1116, #1120, #1121, #1123, #1124, #1067 and #1128. Linux #1117 remains independent and is excluded from automatic release. Validated: full cross-platform CI, 18 UI motion cases, 112 frontend test files, 1245 Core tests (1 ignored) and 572 macOS native tests. Co-authored-by: HKLHaoBin <we3q@qq.com>
2218 lines
100 KiB
JavaScript
2218 lines
100 KiB
JavaScript
/* ============================================================
|
||
* OpenLess Remote Input — phone-side recording page
|
||
* Pure static, no external dependencies. Streams 16kHz/mono/16-bit LE PCM
|
||
* to the PC-side Rust service over WSS in real time.
|
||
*
|
||
* Display language follows the PC UI language: Rust injects window.__OL_LANG__
|
||
* with the PC locale when serving the page (synced via the set_remote_locale command).
|
||
* ========================================================== */
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ============================================================
|
||
// i18n — string dictionary (8 languages, aligned with PC-side src/i18n)
|
||
// ============================================================
|
||
var I18N = {
|
||
'zh-CN': {
|
||
wakeLockLabel: '录音时保持亮屏',
|
||
wakeLockHint: '息屏会结束本段录音,电脑继续处理已收到的部分。',
|
||
wakeLockActive: '屏幕保持亮起,录音结束后允许自动息屏。',
|
||
wakeLockUnavailable: '浏览器或系统未允许保持亮屏;息屏后电脑会处理已收到的录音。',
|
||
interrupted: '录音已中断,电脑继续识别已收到的部分…',
|
||
offlineRecording: '连接已断开,电脑会继续处理已收到的录音。重连后可查看结果。',
|
||
recovering: '电脑正在处理上次录音…',
|
||
recovered: '已找回上次识别结果',
|
||
recoveryRetry: '识别未完成,录音已保存在电脑历史记录中,可重新转录。',
|
||
recoveryUnavailable: '暂未找到结果,请到电脑的历史记录中查看。',
|
||
title: 'OpenLess 远程输入',
|
||
brandTitle: 'OpenLess 远程输入',
|
||
brandSub: '在手机上录音,实时输入到电脑',
|
||
pinFieldLabel: '配对码(电脑上显示的 6 位数字)',
|
||
btnConnect: '连接',
|
||
btnConnecting: '连接中…',
|
||
modeToggle: '点按',
|
||
insertLabel: '电脑落字',
|
||
modeHold: '按住',
|
||
offlineTitle: '连接已断开',
|
||
offlineSub: '与电脑的连接已中断。',
|
||
btnReconnect: '重新连接',
|
||
certTip:
|
||
'信任前,请将手机系统证书详情中的完整 SHA-256 与电脑 OpenLess 设置核对;仅核对 IP 或名称不够。',
|
||
tipToggle: '点击大按钮开始录音,再次点击结束并识别。',
|
||
tipHold: '按住大按钮说话,松开结束并识别。',
|
||
labelToggleIdle: '点击开始',
|
||
labelToggleRec: '点击结束',
|
||
labelHoldIdle: '按住说话',
|
||
labelHoldRec: '松开结束',
|
||
ready: '准备就绪',
|
||
preparingMic: '正在准备麦克风…',
|
||
preparingBackend: '后端准备中…',
|
||
statusRecording: '🎤 录音中',
|
||
statusTranscribing: '🔄 识别中',
|
||
statusPolishing: '✨ 润色中',
|
||
statusDone: '✅ 已输入 {n} 字',
|
||
cancelled: '已取消',
|
||
connLost: '连接已断开',
|
||
errPinFormat: '请输入 6 位数字配对码。',
|
||
errPinWrong: '配对码错误,请重试。',
|
||
errPinLocked: '配对已锁定,请在电脑上重新生成配对码。',
|
||
errConnFail: '连接失败。多半是手机未信任电脑证书,请先信任证书后重试。',
|
||
errConnCreate: '无法建立连接,请检查网络。',
|
||
errConnTimeout: '连接超时。多半是手机未信任电脑证书,请按下方说明信任后重试。',
|
||
busy: '电脑忙:{reason}',
|
||
busyDefault: '请稍候',
|
||
micDenied: '❌ 麦克风权限被拒绝,请在浏览器设置中允许。',
|
||
micNotFound: '❌ 未找到可用麦克风。',
|
||
micBusy: '❌ 麦克风被其他应用占用。',
|
||
micTimeout: '❌ 麦克风准备超时,请重试。',
|
||
pcmQueueOverflow: '❌ 音频缓存已满,请重试。',
|
||
micUnknown: '❌ 无法启动录音{name}。',
|
||
errGeneric: '发生错误',
|
||
helpTitle: '首次设置:信任此电脑',
|
||
helpAndroid:
|
||
'安卓:下载 CA 证书,在系统证书预览中核对完整 SHA-256 后再安装。若系统无法在信任前显示指纹,请勿从此页面安装,改用已有的可信文件传输渠道。菜单名称因设备而异。',
|
||
helpVerify:
|
||
'先打开电脑上的 OpenLess 远程输入设置,保留“本机根证书 SHA-256”。在手机系统证书详情中核对全部 64 个字符;不要使用本网页、描述文件名称或标识里的值作为证明。不一致或无法查看时,请停止并移除描述文件。描述文件必须只含一张根证书,不得有其他证书、VPN 或管理配置。',
|
||
helpIos:
|
||
'iPhone / iPad:下载描述文件,在“设置 → 通用 → VPN 与设备管理”中打开它,选择“更多详细信息”中的证书并核对指纹。确认一致且没有额外配置后再安装,并到“通用 → 关于本机 → 证书信任设置”开启完全信任。若只能在安装后查看详情,先保持完全信任关闭,核对后再开启。完成后返回 Safari 刷新。',
|
||
helpDownloadCert: '↓ iPhone:下载描述文件',
|
||
helpDownloadAndroid: '↓ 安卓:下载 CA 证书',
|
||
helpTrustWarning:
|
||
'首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。',
|
||
helpCopyLink: '⧉ 复制链接',
|
||
helpCopied: '已复制 ✓',
|
||
copy: '复制',
|
||
copied: '已复制 ✓',
|
||
},
|
||
'zh-TW': {
|
||
wakeLockLabel: '錄音時保持螢幕開啟',
|
||
wakeLockHint: '螢幕關閉會結束本段錄音,電腦繼續處理已收到的部分。',
|
||
wakeLockActive: '螢幕保持開啟,錄音結束後允許自動關閉螢幕。',
|
||
wakeLockUnavailable: '瀏覽器或系統未允許保持螢幕開啟;電腦會處理已收到的錄音。',
|
||
interrupted: '錄音已中斷,電腦繼續辨識已收到的部分…',
|
||
offlineRecording: '連線已中斷,電腦會繼續處理已收到的錄音。重新連線後可查看結果。',
|
||
recovering: '電腦正在處理上次錄音…',
|
||
recovered: '已找回上次辨識結果',
|
||
recoveryRetry: '辨識未完成,錄音已保存在電腦歷史記錄中,可重新轉錄。',
|
||
recoveryUnavailable: '暫未找到結果,請到電腦的歷史記錄中查看。',
|
||
title: 'OpenLess 遠端輸入',
|
||
brandTitle: 'OpenLess 遠端輸入',
|
||
brandSub: '在手機上錄音,即時輸入到電腦',
|
||
pinFieldLabel: '配對碼(電腦上顯示的 6 位數字)',
|
||
btnConnect: '連線',
|
||
btnConnecting: '連線中…',
|
||
modeToggle: '點按',
|
||
insertLabel: '電腦落字',
|
||
modeHold: '按住',
|
||
offlineTitle: '連線已中斷',
|
||
offlineSub: '與電腦的連線已中斷。',
|
||
btnReconnect: '重新連線',
|
||
certTip:
|
||
'信任前,請將手機系統憑證詳細資訊中的完整 SHA-256 與電腦 OpenLess 設定核對;僅核對 IP 或名稱不足以驗證。',
|
||
tipToggle: '點擊大按鈕開始錄音,再次點擊結束並辨識。',
|
||
tipHold: '按住大按鈕說話,放開結束並辨識。',
|
||
labelToggleIdle: '點擊開始',
|
||
labelToggleRec: '點擊結束',
|
||
labelHoldIdle: '按住說話',
|
||
labelHoldRec: '放開結束',
|
||
ready: '準備就緒',
|
||
preparingMic: '正在準備麥克風…',
|
||
preparingBackend: '後端準備中…',
|
||
statusRecording: '🎤 錄音中',
|
||
statusTranscribing: '🔄 辨識中',
|
||
statusPolishing: '✨ 潤飾中',
|
||
statusDone: '✅ 已輸入 {n} 字',
|
||
cancelled: '已取消',
|
||
connLost: '連線已中斷',
|
||
errPinFormat: '請輸入 6 位數字配對碼。',
|
||
errPinWrong: '配對碼錯誤,請重試。',
|
||
errPinLocked: '配對已鎖定,請在電腦上重新產生配對碼。',
|
||
errConnFail: '連線失敗。多半是手機未信任電腦憑證,請先信任憑證後重試。',
|
||
errConnCreate: '無法建立連線,請檢查網路。',
|
||
errConnTimeout: '連線逾時。多半是手機未信任電腦憑證,請依下方說明信任後重試。',
|
||
busy: '電腦忙碌:{reason}',
|
||
busyDefault: '請稍候',
|
||
micDenied: '❌ 麥克風權限遭拒,請在瀏覽器設定中允許。',
|
||
micNotFound: '❌ 找不到可用的麥克風。',
|
||
micBusy: '❌ 麥克風被其他應用程式佔用。',
|
||
micTimeout: '❌ 麥克風準備逾時,請重試。',
|
||
pcmQueueOverflow: '❌ 音訊暫存已滿,請重試。',
|
||
micUnknown: '❌ 無法啟動錄音{name}。',
|
||
errGeneric: '發生錯誤',
|
||
helpTitle: '首次設定:信任這台電腦',
|
||
helpAndroid:
|
||
'Android:下載 CA 憑證,在系統憑證預覽中核對完整 SHA-256 後再安裝。若系統無法在信任前顯示指紋,請勿從此頁安裝,改用既有的可信任檔案傳輸管道。選單名稱依裝置而異。',
|
||
helpVerify:
|
||
'先開啟電腦的 OpenLess 遠端輸入設定,保留「本機根憑證 SHA-256」。在手機系統憑證詳細資訊中核對全部 64 個字元;不要使用本網頁、描述檔名稱或識別碼中的值作為證明。不一致或無法查看時,請停止並移除描述檔。描述檔必須只含一張根憑證,不得有其他憑證、VPN 或管理設定。',
|
||
helpIos:
|
||
'iPhone / iPad:下載描述檔,在「設定 → 一般 → VPN 與裝置管理」中開啟,選擇「更多詳細資訊」中的憑證並核對指紋。確認一致且沒有額外設定後再安裝,並到「一般 → 關於本機 → 憑證信任設定」開啟完全信任。若只能在安裝後查看詳細資訊,請先保持完全信任關閉,核對後再開啟。完成後返回 Safari 重新整理。',
|
||
helpDownloadCert: '↓ iPhone:下載描述檔',
|
||
helpDownloadAndroid: '↓ Android:下載 CA 憑證',
|
||
helpTrustWarning:
|
||
'首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。',
|
||
helpCopyLink: '⧉ 複製連結',
|
||
helpCopied: '已複製 ✓',
|
||
copy: '複製',
|
||
copied: '已複製 ✓',
|
||
},
|
||
en: {
|
||
wakeLockLabel: 'Keep screen awake while recording',
|
||
wakeLockHint:
|
||
'Screen lock ends this recording. The computer processes the audio already received.',
|
||
wakeLockActive: 'Screen stays awake until recording ends.',
|
||
wakeLockUnavailable:
|
||
'The browser or system did not allow screen wake lock. Received audio will still be processed.',
|
||
interrupted: 'Recording interrupted. The computer is processing the audio received…',
|
||
offlineRecording:
|
||
'Disconnected. The computer continues processing received audio. Reconnect to see the result.',
|
||
recovering: 'The computer is processing your last recording…',
|
||
recovered: 'Last transcription recovered',
|
||
recoveryRetry:
|
||
'Transcription failed. The recording is saved in computer history and can be retried.',
|
||
recoveryUnavailable: 'Result unavailable. Please check history on the computer.',
|
||
title: 'OpenLess Remote Input',
|
||
brandTitle: 'OpenLess Remote Input',
|
||
brandSub: 'Record on your phone, type to your computer in real time',
|
||
pinFieldLabel: 'Pairing code (6 digits shown on your computer)',
|
||
btnConnect: 'Connect',
|
||
btnConnecting: 'Connecting…',
|
||
modeToggle: 'Tap',
|
||
insertLabel: 'Type on PC',
|
||
modeHold: 'Hold',
|
||
offlineTitle: 'Disconnected',
|
||
offlineSub: 'The connection to your computer was lost.',
|
||
btnReconnect: 'Reconnect',
|
||
certTip:
|
||
"Before trusting, compare the full SHA-256 in the phone's system certificate details with OpenLess settings on the computer. An IP address or name alone is not enough.",
|
||
tipToggle: 'Tap the big button to start recording, tap again to finish and transcribe.',
|
||
tipHold: 'Hold the big button to talk, release to finish and transcribe.',
|
||
labelToggleIdle: 'Tap to start',
|
||
labelToggleRec: 'Tap to stop',
|
||
labelHoldIdle: 'Hold to talk',
|
||
labelHoldRec: 'Release to stop',
|
||
ready: 'Ready',
|
||
preparingMic: 'Preparing microphone…',
|
||
preparingBackend: 'Preparing backend…',
|
||
statusRecording: '🎤 Recording',
|
||
statusTranscribing: '🔄 Transcribing',
|
||
statusPolishing: '✨ Polishing',
|
||
statusDone: '✅ Inserted {n} chars',
|
||
cancelled: 'Cancelled',
|
||
connLost: 'Connection lost',
|
||
errPinFormat: 'Please enter the 6-digit pairing code.',
|
||
errPinWrong: 'Wrong pairing code, please try again.',
|
||
errPinLocked: 'Pairing locked. Please regenerate the code on your computer.',
|
||
errConnFail:
|
||
'Connection failed — usually the phone does not trust the computer certificate. Trust it, then retry.',
|
||
errConnCreate: 'Could not connect. Please check your network.',
|
||
errConnTimeout:
|
||
'Connection timed out — the phone likely does not trust the certificate. Follow the steps below to trust it, then retry.',
|
||
busy: 'Computer busy: {reason}',
|
||
busyDefault: 'please wait',
|
||
micDenied: '❌ Microphone permission denied. Please allow it in browser settings.',
|
||
micNotFound: '❌ No microphone available.',
|
||
micBusy: '❌ Microphone is in use by another app.',
|
||
micTimeout: '❌ Microphone setup timed out. Please try again.',
|
||
pcmQueueOverflow: '❌ Audio buffer is full. Please try again.',
|
||
micUnknown: '❌ Could not start recording{name}.',
|
||
errGeneric: 'An error occurred',
|
||
helpTitle: 'First-time setup: trust this computer',
|
||
helpAndroid:
|
||
'Android: download the CA and verify its full SHA-256 in the system certificate preview before installing it. If your device cannot show the fingerprint before trust, do not install from this page; use an existing authenticated file-transfer channel instead. Menu names vary by device.',
|
||
helpVerify:
|
||
"Open Remote Input settings in OpenLess on the computer and keep its root CA SHA-256 visible. Compare all 64 characters in the phone's system certificate details; do not use a value from this page, a profile name or identifier as proof. If it differs or cannot be viewed, stop and remove the profile. Expect only one root certificate, with no additional certificates, VPN or management settings.",
|
||
helpIos:
|
||
'iPhone / iPad: download the profile, open it in Settings → General → VPN & Device Management, then open More Details → certificate and verify its fingerprint. Only after it matches and no extra settings are present, install and enable full trust in General → About → Certificate Trust Settings. If details are available only after installation, leave full trust off until verified. Reload Safari afterwards.',
|
||
helpDownloadCert: '↓ iPhone: download profile',
|
||
helpDownloadAndroid: '↓ Android: download CA',
|
||
helpTrustWarning:
|
||
"The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.",
|
||
helpCopyLink: '⧉ Copy link',
|
||
helpCopied: 'Copied ✓',
|
||
copy: 'Copy',
|
||
copied: 'Copied ✓',
|
||
},
|
||
es: {
|
||
title: 'Entrada remota de OpenLess',
|
||
brandTitle: 'Entrada remota de OpenLess',
|
||
brandSub: 'Graba desde el móvil y escribe en el ordenador en tiempo real',
|
||
pinFieldLabel: 'Código de emparejamiento (6 dígitos mostrados en el ordenador)',
|
||
btnConnect: 'Conectar',
|
||
btnConnecting: 'Conectando…',
|
||
modeToggle: 'Tocar',
|
||
insertLabel: 'Escribir en el ordenador',
|
||
modeHold: 'Mantener',
|
||
offlineTitle: 'Desconectado',
|
||
offlineSub: 'Se ha perdido la conexión con el ordenador.',
|
||
btnReconnect: 'Volver a conectar',
|
||
certTip:
|
||
'Si aparece un aviso de certificado, comprueba que la dirección coincide con tu ordenador y sigue «Configuración inicial: confiar en este ordenador» para instalarlo y confiar plenamente en él.',
|
||
tipToggle: 'Toca el botón grande para grabar y vuelve a tocarlo para terminar y transcribir.',
|
||
tipHold: 'Mantén pulsado el botón para hablar y suéltalo para terminar y transcribir.',
|
||
labelToggleIdle: 'Toca para empezar',
|
||
labelToggleRec: 'Toca para terminar',
|
||
labelHoldIdle: 'Mantén para hablar',
|
||
labelHoldRec: 'Suelta para terminar',
|
||
ready: 'Listo',
|
||
preparingMic: 'Preparando el micrófono…',
|
||
preparingBackend: 'Preparando el servicio…',
|
||
statusRecording: '🎤 Grabando',
|
||
statusTranscribing: '🔄 Transcribiendo',
|
||
statusPolishing: '✨ Puliendo el texto',
|
||
statusDone: '✅ Se han insertado {n} caracteres',
|
||
cancelled: 'Cancelado',
|
||
connLost: 'Conexión perdida',
|
||
errPinFormat: 'Introduce el código de emparejamiento de 6 dígitos.',
|
||
errPinWrong: 'El código no es correcto. Inténtalo de nuevo.',
|
||
errPinLocked: 'El emparejamiento está bloqueado. Genera otro código en el ordenador.',
|
||
errConnFail:
|
||
'No se pudo conectar. Comprueba que el móvil confía en el certificado del ordenador e inténtalo de nuevo.',
|
||
errConnCreate: 'No se pudo establecer la conexión. Comprueba la red.',
|
||
errConnTimeout:
|
||
'Se agotó el tiempo de conexión. Sigue los pasos de abajo para confiar en el certificado e inténtalo de nuevo.',
|
||
busy: 'Ordenador ocupado: {reason}',
|
||
busyDefault: 'espera un momento',
|
||
micDenied: '❌ Permiso de micrófono denegado. Actívalo en los ajustes del navegador.',
|
||
micNotFound: '❌ No hay ningún micrófono disponible.',
|
||
micBusy: '❌ Otra aplicación está usando el micrófono.',
|
||
micTimeout: '❌ Se agotó el tiempo de preparación del micrófono. Inténtalo de nuevo.',
|
||
pcmQueueOverflow: '❌ El búfer de audio está lleno. Inténtalo de nuevo.',
|
||
micUnknown: '❌ No se pudo iniciar la grabación{name}.',
|
||
errGeneric: 'Se ha producido un error',
|
||
wakeLockLabel: 'Mantener la pantalla encendida al grabar',
|
||
wakeLockHint:
|
||
'El bloqueo de pantalla termina esta grabación; el ordenador procesa el audio ya recibido.',
|
||
wakeLockActive: 'La pantalla permanece encendida hasta que termine la grabación.',
|
||
wakeLockUnavailable:
|
||
'El navegador o el sistema no permitió mantener la pantalla encendida; el audio recibido se procesará igualmente.',
|
||
recovering: 'El ordenador está procesando tu última grabación…',
|
||
interrupted: 'Grabación interrumpida. El ordenador procesa el audio ya recibido…',
|
||
offlineRecording: 'Conexión perdida; el ordenador seguirá procesando la grabación recibida. Reconecta para ver el resultado.',
|
||
recovered: 'Última transcripción recuperada',
|
||
recoveryRetry:
|
||
'La transcripción falló. La grabación está guardada en el historial del ordenador y puede reintentarse.',
|
||
recoveryUnavailable: 'Resultado no disponible. Consulta el historial en el ordenador.',
|
||
helpTitle: 'Configuración inicial: confiar en este ordenador',
|
||
helpVerify:
|
||
'Abre los ajustes de entrada remota en OpenLess del ordenador y mantén visible el SHA-256 de la CA raíz. Compara los 64 caracteres en los detalles del certificado del sistema del teléfono; no uses como prueba un valor de esta página, un nombre de perfil o un identificador. Si difiere o no se puede ver, detente y elimina el perfil. Debe haber exactamente un certificado raíz, sin certificados adicionales, VPN ni ajustes de gestión.',
|
||
helpAndroid:
|
||
'Android: descarga el certificado CA y verifica su SHA-256 completo en la vista previa de certificados del sistema antes de instalarlo. Si tu dispositivo no puede mostrar la huella antes de confiar, no lo instales desde esta página; usa un canal de transferencia de archivos ya autenticado. Los nombres de los menús varían según el dispositivo.',
|
||
helpIos:
|
||
'iPhone / iPad: descarga el perfil, ábrelo en Ajustes → General → VPN y gestión de dispositivos y revisa su huella en Más detalles → certificado. Solo cuando coincida y no haya ajustes extra, instálalo y activa la confianza completa en General → Información → Ajustes de confianza de certificados. Si los detalles solo se ven tras instalar, deja la confianza completa desactivada hasta verificarlo. Después recarga Safari.',
|
||
helpDownloadCert: '↓ iPhone: descargar perfil',
|
||
helpDownloadAndroid: '↓ Android: descargar certificado CA',
|
||
helpTrustWarning:
|
||
'La descarga inicial del certificado no puede verificar la identidad del ordenador: un dispositivo malicioso en la LAN podría sustituir el certificado raíz en un ataque de intermediario. Instálalo solo en una red doméstica o privada de confianza, nunca en redes públicas o compartidas. La CA raíz puede emitir certificados y su clave privada permanece en este ordenador; elimínalo del teléfono cuando dejes de usarlo.',
|
||
helpCopyLink: '⧉ Copiar enlace',
|
||
helpCopied: 'Copiado ✓',
|
||
copy: 'Copiar',
|
||
copied: 'Copiado ✓',
|
||
},
|
||
fr: {
|
||
title: 'Saisie à distance OpenLess',
|
||
brandTitle: 'Saisie à distance OpenLess',
|
||
brandSub: 'Enregistrez sur votre téléphone et écrivez sur votre ordinateur en temps réel',
|
||
pinFieldLabel: 'Code de jumelage (6 chiffres affichés sur votre ordinateur)',
|
||
btnConnect: 'Connecter',
|
||
btnConnecting: 'Connexion…',
|
||
modeToggle: 'Toucher',
|
||
insertLabel: 'Saisir sur le PC',
|
||
modeHold: 'Maintenir',
|
||
offlineTitle: 'Déconnecté',
|
||
offlineSub: 'La connexion à votre ordinateur a été perdue.',
|
||
btnReconnect: 'Reconnecter',
|
||
certTip:
|
||
'Si un avertissement de certificat apparaît, vérifiez que l’adresse correspond à votre ordinateur, puis suivez « Réglage initial : faire confiance à cet ordinateur » pour installer le certificat et l’approuver entièrement.',
|
||
tipToggle:
|
||
'Touchez le grand bouton pour enregistrer, puis à nouveau pour terminer et transcrire.',
|
||
tipHold:
|
||
'Maintenez le grand bouton pour parler, puis relâchez-le pour terminer et transcrire.',
|
||
labelToggleIdle: 'Toucher pour démarrer',
|
||
labelToggleRec: 'Toucher pour arrêter',
|
||
labelHoldIdle: 'Maintenir pour parler',
|
||
labelHoldRec: 'Relâcher pour arrêter',
|
||
ready: 'Prêt',
|
||
preparingMic: 'Préparation du microphone…',
|
||
preparingBackend: 'Préparation du service…',
|
||
statusRecording: '🎤 Enregistrement',
|
||
statusTranscribing: '🔄 Transcription',
|
||
statusPolishing: '✨ Retouche du texte',
|
||
statusDone: '✅ {n} caractères insérés',
|
||
cancelled: 'Annulé',
|
||
connLost: 'Connexion perdue',
|
||
errPinFormat: 'Saisissez le code de jumelage à 6 chiffres.',
|
||
errPinWrong: 'Code incorrect. Réessayez.',
|
||
errPinLocked: 'Jumelage verrouillé. Générez un nouveau code sur votre ordinateur.',
|
||
errConnFail:
|
||
'Connexion impossible. Vérifiez que le téléphone accepte le certificat de votre ordinateur, puis réessayez.',
|
||
errConnCreate: 'Impossible de se connecter. Vérifiez votre réseau.',
|
||
errConnTimeout:
|
||
'Délai de connexion dépassé. Suivez les instructions ci-dessous pour accepter le certificat, puis réessayez.',
|
||
busy: 'Ordinateur occupé : {reason}',
|
||
busyDefault: 'veuillez patienter',
|
||
micDenied: '❌ Accès au microphone refusé. Autorisez-le dans les réglages du navigateur.',
|
||
micNotFound: '❌ Aucun microphone disponible.',
|
||
micBusy: '❌ Le microphone est utilisé par une autre application.',
|
||
micTimeout: '❌ La préparation du microphone a expiré. Réessayez.',
|
||
pcmQueueOverflow: '❌ Le tampon audio est plein. Réessayez.',
|
||
micUnknown: '❌ Impossible de démarrer l’enregistrement{name}.',
|
||
errGeneric: 'Une erreur est survenue',
|
||
helpTitle: 'Réglage initial : faire confiance à cet ordinateur',
|
||
wakeLockLabel: 'Garder l’écran allumé pendant l’enregistrement',
|
||
wakeLockHint:
|
||
'Le verrouillage de l’écran met fin à cet enregistrement ; l’ordinateur traite l’audio déjà reçu.',
|
||
wakeLockActive: 'L’écran reste allumé jusqu’à la fin de l’enregistrement.',
|
||
wakeLockUnavailable:
|
||
'Le navigateur ou le système n’a pas permis de garder l’écran allumé ; l’audio déjà reçu sera quand même traité.',
|
||
recovering: 'L’ordinateur traite votre dernier enregistrement…',
|
||
interrupted: 'Enregistrement interrompu. L’ordinateur traite l’audio déjà reçu…',
|
||
offlineRecording: 'Connexion perdue ; l’ordinateur continuera de traiter l’enregistrement reçu. Reconnectez-vous pour voir le résultat.',
|
||
recovered: 'Dernière transcription récupérée',
|
||
recoveryRetry:
|
||
'La transcription a échoué. L’enregistrement est conservé dans l’historique de l’ordinateur et peut être relancé.',
|
||
recoveryUnavailable: 'Résultat indisponible. Consultez l’historique sur l’ordinateur.',
|
||
helpVerify:
|
||
'Ouvrez les réglages de saisie à distance dans OpenLess sur l’ordinateur et gardez visible le SHA-256 de la CA racine. Comparez les 64 caractères dans les détails du certificat du système du téléphone ; n’utilisez jamais comme preuve une valeur de cette page, un nom de profil ou un identifiant. En cas de différence ou si l’affichage est impossible, arrêtez et supprimez le profil. Il ne doit y avoir exactement qu’un certificat racine, sans certificats, VPN ou réglages de gestion supplémentaires.',
|
||
helpAndroid:
|
||
'Android : téléchargez le certificat CA et vérifiez son SHA-256 complet dans l’aperçu des certificats du système avant de l’installer. Si votre appareil ne peut pas afficher l’empreinte avant l’approbation, ne l’installez pas depuis cette page ; utilisez un canal de transfert de fichiers déjà authentifié. Les noms des menus varient selon l’appareil.',
|
||
helpIos:
|
||
'iPhone / iPad : téléchargez le profil, ouvrez-le dans Réglages → Général → VPN et gestion des appareils et vérifiez son empreinte dans Plus de détails → certificat. Installez-le et activez la confiance complète dans Général → Informations → Réglages de confiance des certificats uniquement si l’empreinte correspond et sans réglages supplémentaires. Si les détails ne sont visibles qu’après l’installation, laissez la confiance complète désactivée jusqu’à la vérification. Rechargez ensuite Safari.',
|
||
helpDownloadCert: '↓ iPhone : télécharger le profil',
|
||
helpDownloadAndroid: '↓ Android : télécharger le certificat CA',
|
||
helpTrustWarning:
|
||
'Le téléchargement initial du certificat ne permet pas de vérifier l’identité de l’ordinateur : un appareil malveillant sur le LAN pourrait remplacer le certificat racine par une attaque de l’homme du milieu. N’installez le certificat que sur un réseau domestique ou privé de confiance, jamais sur un réseau public ou partagé. La CA racine peut émettre des certificats et sa clé privée reste sur cet ordinateur ; supprimez-la de votre téléphone lorsque vous ne l’utilisez plus.',
|
||
helpCopyLink: '⧉ Copier le lien',
|
||
helpCopied: 'Copié ✓',
|
||
copy: 'Copier',
|
||
copied: 'Copié ✓',
|
||
},
|
||
de: {
|
||
title: 'OpenLess Ferneingabe',
|
||
brandTitle: 'OpenLess Ferneingabe',
|
||
brandSub: 'Auf dem Smartphone aufnehmen und in Echtzeit am Computer schreiben',
|
||
pinFieldLabel: 'Kopplungscode (6 Ziffern auf dem Computer)',
|
||
btnConnect: 'Verbinden',
|
||
btnConnecting: 'Verbindung wird hergestellt…',
|
||
modeToggle: 'Tippen',
|
||
insertLabel: 'Am Computer einfügen',
|
||
modeHold: 'Gedrückt halten',
|
||
offlineTitle: 'Getrennt',
|
||
offlineSub: 'Die Verbindung zum Computer wurde getrennt.',
|
||
btnReconnect: 'Erneut verbinden',
|
||
certTip:
|
||
'Falls eine Zertifikatswarnung erscheint, prüfe, dass die Adresse mit der auf dem Computer angezeigten übereinstimmt, und folge dann „Erstmalige Einrichtung: diesem Computer vertrauen“, um das Zertifikat zu installieren und vollständig zu vertrauen.',
|
||
tipToggle:
|
||
'Tippe auf die große Schaltfläche, um aufzunehmen. Tippe erneut, um die Aufnahme zu beenden und zu transkribieren.',
|
||
tipHold:
|
||
'Halte die große Schaltfläche zum Sprechen gedrückt. Lass sie los, um die Aufnahme zu beenden und zu transkribieren.',
|
||
labelToggleIdle: 'Zum Starten tippen',
|
||
labelToggleRec: 'Zum Beenden tippen',
|
||
labelHoldIdle: 'Zum Sprechen halten',
|
||
labelHoldRec: 'Zum Beenden loslassen',
|
||
ready: 'Bereit',
|
||
preparingMic: 'Mikrofon wird vorbereitet…',
|
||
preparingBackend: 'Dienst wird vorbereitet…',
|
||
statusRecording: '🎤 Aufnahme läuft',
|
||
statusTranscribing: '🔄 Transkription läuft',
|
||
statusPolishing: '✨ Text wird überarbeitet',
|
||
statusDone: '✅ {n} Zeichen eingefügt',
|
||
cancelled: 'Abgebrochen',
|
||
connLost: 'Verbindung getrennt',
|
||
errPinFormat: 'Gib den 6-stelligen Kopplungscode ein.',
|
||
errPinWrong: 'Der Code ist falsch. Versuche es erneut.',
|
||
errPinLocked: 'Die Kopplung ist gesperrt. Erstelle am Computer einen neuen Code.',
|
||
errConnFail:
|
||
'Die Verbindung ist fehlgeschlagen. Prüfe, ob das Smartphone dem Zertifikat des Computers vertraut, und versuche es erneut.',
|
||
errConnCreate: 'Die Verbindung konnte nicht hergestellt werden. Prüfe dein Netzwerk.',
|
||
errConnTimeout:
|
||
'Zeitüberschreitung bei der Verbindung. Befolge die Schritte unten, um dem Zertifikat zu vertrauen, und versuche es erneut.',
|
||
busy: 'Computer beschäftigt: {reason}',
|
||
busyDefault: 'bitte warten',
|
||
micDenied: '❌ Mikrofonzugriff verweigert. Erlaube ihn in den Browsereinstellungen.',
|
||
micNotFound: '❌ Kein Mikrofon verfügbar.',
|
||
micBusy: '❌ Eine andere App verwendet das Mikrofon.',
|
||
micTimeout: '❌ Zeitüberschreitung beim Vorbereiten des Mikrofons. Versuche es erneut.',
|
||
pcmQueueOverflow: '❌ Der Audiopuffer ist voll. Versuche es erneut.',
|
||
micUnknown: '❌ Die Aufnahme konnte nicht gestartet werden{name}.',
|
||
errGeneric: 'Ein Fehler ist aufgetreten',
|
||
wakeLockLabel: 'Bildschirm bei Aufnahme eingeschaltet lassen',
|
||
wakeLockHint:
|
||
'Der Sperrbildschirm beendet diese Aufnahme; der Computer verarbeitet die bereits empfangenen Daten.',
|
||
wakeLockActive: 'Der Bildschirm bleibt eingeschaltet, bis die Aufnahme endet.',
|
||
wakeLockUnavailable:
|
||
'Browser oder System erlaubten das Wachhalten nicht; die empfangenen Audiodaten werden dennoch verarbeitet.',
|
||
recovering: 'Der Computer verarbeitet deine letzte Aufnahme…',
|
||
interrupted: 'Aufnahme unterbrochen. Der Computer verarbeitet die bereits empfangenen Daten…',
|
||
offlineRecording: 'Verbindung getrennt; der Computer verarbeitet die empfangene Aufnahme weiter. Zum Ansehen des Ergebnisses neu verbinden.',
|
||
recovered: 'Letzte Transkription wiederhergestellt',
|
||
recoveryRetry:
|
||
'Die Transkription ist fehlgeschlagen. Die Aufnahme liegt im Verlauf des Computers und kann erneut versucht werden.',
|
||
recoveryUnavailable: 'Ergebnis nicht verfügbar. Prüfe den Verlauf am Computer.',
|
||
helpTitle: 'Erstmalige Einrichtung: diesem Computer vertrauen',
|
||
helpVerify:
|
||
'Öffne die Ferneingabe-Einstellungen in OpenLess am Computer und halte den SHA-256 der Root-CA sichtbar. Vergleiche alle 64 Zeichen in den Zertifikatdetails des Telefons; verwende niemals einen Wert dieser Seite, einen Profilnamen oder Bezeichner als Nachweis. Bei Abweichung oder wenn nichts angezeigt werden kann, brich ab und entferne das Profil. Es darf genau ein Root-Zertifikat enthalten sein, ohne zusätzliche Zertifikate, VPN- oder Verwaltungsprofile.',
|
||
helpAndroid:
|
||
'Android: Lade das CA-Zertifikat herunter und prüfe vor der Installation den vollständigen SHA-256 in der System-Zertifikatsvorschau. Kann dein Gerät den Fingerabdruck vor dem Vertrauen nicht anzeigen, installiere nichts von dieser Seite; nutze stattdessen einen bereits authentifizierten Übertragungsweg. Die Menünamen unterscheiden sich je nach Gerät.',
|
||
helpIos:
|
||
'iPhone / iPad: Lade das Konfigurationsprofil herunter, öffne es unter Einstellungen → Allgemein → VPN & Geräteverwaltung und prüfe seinen Fingerabdruck unter „Mehr Details“ → Zertifikat. Installiere es und aktiviere die volle Vertrauensstellung unter Allgemein → Info → Zertifikatsvertrauenseinstellungen erst, wenn er übereinstimmt und keine zusätzlichen Einstellungen vorhanden sind. Sind Details erst nach der Installation sichtbar, lasse die volle Vertrauensstellung bis zur Prüfung aus. Lade Safari danach neu.',
|
||
helpDownloadCert: '↓ iPhone: Profil herunterladen',
|
||
helpDownloadAndroid: '↓ Android: CA-Zertifikat herunterladen',
|
||
helpTrustWarning:
|
||
'Beim ersten Zertifikatsdownload kann die Identität des Computers nicht geprüft werden: Ein bösartiges Gerät im LAN könnte das Root-Zertifikat in einem Man-in-the-Middle-Angriff ersetzen. Installiere es nur in einem vertrauenswürdigen Heim- oder Privatnetzwerk, niemals in öffentlichen oder geteilten Netzwerken. Die Root-CA kann Zertifikate ausstellen, ihr privater Schlüssel bleibt auf diesem Computer; entferne sie vom Smartphone, wenn du sie nicht mehr brauchst.',
|
||
helpCopyLink: '⧉ Link kopieren',
|
||
helpCopied: 'Kopiert ✓',
|
||
copy: 'Kopieren',
|
||
copied: 'Kopiert ✓',
|
||
},
|
||
ja: {
|
||
wakeLockLabel: '録音中は画面をオンにする',
|
||
wakeLockHint: '画面をロックすると録音を終了し、受信済みの音声をパソコンで処理します。',
|
||
wakeLockActive: '録音が終わるまで画面をオンに保ちます。',
|
||
wakeLockUnavailable:
|
||
'ブラウザーまたはシステムが画面の維持を許可しませんでした。受信済みの音声は処理されます。',
|
||
interrupted: '録音が中断されました。受信済みの音声をパソコンで処理しています…',
|
||
offlineRecording:
|
||
'接続が切れました。受信済みの音声の処理は続きます。再接続すると結果を確認できます。',
|
||
recovering: '前回の録音をパソコンで処理しています…',
|
||
recovered: '前回の文字起こし結果を復元しました',
|
||
recoveryRetry:
|
||
'文字起こしが完了しませんでした。録音はパソコンの履歴に保存され、再試行できます。',
|
||
recoveryUnavailable: '結果が見つかりません。パソコンの履歴を確認してください。',
|
||
title: 'OpenLess リモート入力',
|
||
brandTitle: 'OpenLess リモート入力',
|
||
brandSub: 'スマホで録音し、リアルタイムでパソコンに入力',
|
||
pinFieldLabel: 'ペアリングコード(パソコンに表示される6桁の数字)',
|
||
btnConnect: '接続',
|
||
btnConnecting: '接続中…',
|
||
modeToggle: 'タップ',
|
||
insertLabel: 'PCに入力',
|
||
modeHold: '長押し',
|
||
offlineTitle: '接続が切断されました',
|
||
offlineSub: 'パソコンとの接続が切断されました。',
|
||
btnReconnect: '再接続',
|
||
certTip:
|
||
'信頼する前に、スマートフォンのシステム証明書詳細にある SHA-256 全体をコンピューターの OpenLess 設定と照合してください。IP や名前だけでは確認できません。',
|
||
tipToggle: '大きいボタンをタップして録音開始、もう一度タップで終了して認識します。',
|
||
tipHold: '大きいボタンを長押しして話し、離すと終了して認識します。',
|
||
labelToggleIdle: 'タップで開始',
|
||
labelToggleRec: 'タップで終了',
|
||
labelHoldIdle: '長押しで話す',
|
||
labelHoldRec: '離して終了',
|
||
ready: '準備完了',
|
||
preparingMic: 'マイクを準備中…',
|
||
preparingBackend: 'バックエンドを準備しています…',
|
||
statusRecording: '🎤 録音中',
|
||
statusTranscribing: '🔄 認識中',
|
||
statusPolishing: '✨ 整文中',
|
||
statusDone: '✅ {n}文字を入力しました',
|
||
cancelled: 'キャンセルしました',
|
||
connLost: '接続が切断されました',
|
||
errPinFormat: '6桁の数字のペアリングコードを入力してください。',
|
||
errPinWrong: 'ペアリングコードが違います。もう一度お試しください。',
|
||
errPinLocked: 'ペアリングがロックされました。パソコンでコードを再生成してください。',
|
||
errConnFail:
|
||
'接続に失敗しました。多くはスマホがパソコンの証明書を信頼していないためです。証明書を信頼してから再試行してください。',
|
||
errConnCreate: '接続できません。ネットワークを確認してください。',
|
||
errConnTimeout:
|
||
'接続がタイムアウトしました。多くはスマホが証明書を信頼していないためです。下の手順で信頼してから再試行してください。',
|
||
busy: 'パソコンがビジー状態です:{reason}',
|
||
busyDefault: 'お待ちください',
|
||
micDenied: '❌ マイクの許可が拒否されました。ブラウザの設定で許可してください。',
|
||
micNotFound: '❌ 利用可能なマイクが見つかりません。',
|
||
micBusy: '❌ マイクが他のアプリで使用されています。',
|
||
micTimeout: '❌ マイクの準備がタイムアウトしました。もう一度お試しください。',
|
||
pcmQueueOverflow: '❌ 音声バッファがいっぱいです。もう一度お試しください。',
|
||
micUnknown: '❌ 録音を開始できませんでした{name}。',
|
||
errGeneric: 'エラーが発生しました',
|
||
helpTitle: '初回設定:このコンピュータを信頼',
|
||
helpAndroid:
|
||
'Android:CA をダウンロードし、システムの証明書プレビューで SHA-256 全体を確認してからインストールします。信頼する前に指紋を表示できない端末では、このページからインストールせず、既存の認証済みファイル転送手段を使用してください。項目名は端末によって異なります。',
|
||
helpVerify:
|
||
'コンピューターの OpenLess でリモート入力設定を開き、ルート CA の SHA-256 を表示したままにします。スマートフォンのシステム証明書詳細で全 64 文字を照合してください。このページ、プロファイル名や識別子の値は証明に使えません。一致しない場合や表示できない場合は中止し、プロファイルを削除してください。含まれるのはルート証明書 1 枚のみで、追加の証明書、VPN、管理設定がないことも確認してください。',
|
||
helpIos:
|
||
'iPhone / iPad:プロファイルをダウンロードし、「設定 → 一般 → VPN とデバイス管理」で開き、「詳細情報」の証明書で指紋を照合します。一致し、余分な設定がないことを確認してからインストールし、「一般 → 情報 → 証明書信頼設定」で完全に信頼してください。インストール後にしか詳細を表示できない場合は、確認が終わるまで完全な信頼をオフにしてください。その後 Safari を再読み込みします。',
|
||
helpDownloadCert: '↓ iPhone:プロファイルをダウンロード',
|
||
helpDownloadAndroid: '↓ Android:CA をダウンロード',
|
||
helpTrustWarning:
|
||
'初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。',
|
||
helpCopyLink: '⧉ リンクをコピー',
|
||
helpCopied: 'コピーしました ✓',
|
||
copy: 'コピー',
|
||
copied: 'コピー済み ✓',
|
||
},
|
||
ko: {
|
||
wakeLockLabel: '녹음 중 화면 켜짐 유지',
|
||
wakeLockHint: '화면을 잠그면 녹음이 끝나고 컴퓨터가 이미 받은 오디오를 처리합니다.',
|
||
wakeLockActive: '녹음이 끝날 때까지 화면을 켜진 상태로 유지합니다.',
|
||
wakeLockUnavailable:
|
||
'브라우저 또는 시스템이 화면 켜짐 유지를 허용하지 않았습니다. 수신한 오디오는 계속 처리됩니다.',
|
||
interrupted: '녹음이 중단되었습니다. 컴퓨터가 받은 오디오를 처리하고 있습니다…',
|
||
offlineRecording:
|
||
'연결이 끊겼습니다. 받은 오디오는 계속 처리됩니다. 다시 연결하면 결과를 볼 수 있습니다.',
|
||
recovering: '컴퓨터가 마지막 녹음을 처리하고 있습니다…',
|
||
recovered: '마지막 음성 인식 결과를 복구했습니다',
|
||
recoveryRetry:
|
||
'음성 인식을 완료하지 못했습니다. 녹음은 컴퓨터 기록에 저장되며 다시 시도할 수 있습니다.',
|
||
recoveryUnavailable: '결과를 찾을 수 없습니다. 컴퓨터의 기록을 확인해 주세요.',
|
||
title: 'OpenLess 원격 입력',
|
||
brandTitle: 'OpenLess 원격 입력',
|
||
brandSub: '휴대폰으로 녹음하여 실시간으로 컴퓨터에 입력',
|
||
pinFieldLabel: '페어링 코드 (컴퓨터에 표시된 6자리 숫자)',
|
||
btnConnect: '연결',
|
||
btnConnecting: '연결 중…',
|
||
modeToggle: '탭',
|
||
insertLabel: 'PC에 입력',
|
||
modeHold: '길게 누르기',
|
||
offlineTitle: '연결이 끊겼습니다',
|
||
offlineSub: '컴퓨터와의 연결이 끊겼습니다.',
|
||
btnReconnect: '다시 연결',
|
||
certTip:
|
||
'신뢰하기 전에 휴대폰 시스템의 인증서 상세 정보에 있는 전체 SHA-256을 컴퓨터의 OpenLess 설정과 비교하세요. IP나 이름만 확인해서는 충분하지 않습니다.',
|
||
tipToggle: '큰 버튼을 탭하여 녹음을 시작하고, 다시 탭하면 종료 후 인식합니다.',
|
||
tipHold: '큰 버튼을 길게 눌러 말하고, 떼면 종료 후 인식합니다.',
|
||
labelToggleIdle: '탭하여 시작',
|
||
labelToggleRec: '탭하여 종료',
|
||
labelHoldIdle: '눌러서 말하기',
|
||
labelHoldRec: '떼면 종료',
|
||
ready: '준비 완료',
|
||
preparingMic: '마이크 준비 중…',
|
||
preparingBackend: '백엔드 준비 중…',
|
||
statusRecording: '🎤 녹음 중',
|
||
statusTranscribing: '🔄 인식 중',
|
||
statusPolishing: '✨ 다듬는 중',
|
||
statusDone: '✅ {n}자 입력함',
|
||
cancelled: '취소됨',
|
||
connLost: '연결이 끊겼습니다',
|
||
errPinFormat: '6자리 숫자 페어링 코드를 입력하세요.',
|
||
errPinWrong: '페어링 코드가 잘못되었습니다. 다시 시도하세요.',
|
||
errPinLocked: '페어링이 잠겼습니다. 컴퓨터에서 코드를 다시 생성하세요.',
|
||
errConnFail:
|
||
'연결에 실패했습니다. 대개 휴대폰이 컴퓨터 인증서를 신뢰하지 않기 때문입니다. 인증서를 신뢰한 후 다시 시도하세요.',
|
||
errConnCreate: '연결할 수 없습니다. 네트워크를 확인하세요.',
|
||
errConnTimeout:
|
||
'연결 시간이 초과되었습니다. 대개 인증서를 신뢰하지 않기 때문입니다. 아래 안내대로 신뢰 후 다시 시도하세요.',
|
||
busy: '컴퓨터가 사용 중입니다: {reason}',
|
||
busyDefault: '잠시 기다려 주세요',
|
||
micDenied: '❌ 마이크 권한이 거부되었습니다. 브라우저 설정에서 허용하세요.',
|
||
micNotFound: '❌ 사용 가능한 마이크가 없습니다.',
|
||
micBusy: '❌ 마이크가 다른 앱에서 사용 중입니다.',
|
||
micTimeout: '❌ 마이크 준비 시간이 초과되었습니다. 다시 시도하세요.',
|
||
pcmQueueOverflow: '❌ 오디오 버퍼가 가득 찼습니다. 다시 시도하세요.',
|
||
micUnknown: '❌ 녹음을 시작할 수 없습니다{name}.',
|
||
errGeneric: '오류가 발생했습니다',
|
||
helpTitle: '최초 설정: 이 컴퓨터 신뢰',
|
||
helpAndroid:
|
||
'Android: CA를 다운로드하고 시스템 인증서 미리보기에서 전체 SHA-256을 확인한 뒤 설치하세요. 신뢰하기 전에 지문을 볼 수 없는 기기에서는 이 페이지에서 설치하지 말고 기존의 인증된 파일 전송 수단을 사용하세요. 메뉴 이름은 기기마다 다릅니다.',
|
||
helpVerify:
|
||
'컴퓨터의 OpenLess 원격 입력 설정에서 루트 CA SHA-256을 표시해 두세요. 휴대폰 시스템의 인증서 상세 정보에서 64자 전체를 비교하세요. 이 웹 페이지, 프로파일 이름이나 식별자의 값은 증명으로 사용할 수 없습니다. 일치하지 않거나 볼 수 없으면 중단하고 프로파일을 제거하세요. 루트 인증서 한 개만 있고 추가 인증서, VPN 또는 관리 설정이 없는지도 확인하세요.',
|
||
helpIos:
|
||
'iPhone / iPad: 프로파일을 다운로드하고 설정 → 일반 → VPN 및 기기 관리에서 여세요. 추가 세부사항의 인증서에서 지문을 확인하세요. 일치하고 추가 설정이 없는 경우에만 설치한 뒤 일반 → 정보 → 인증서 신뢰 설정에서 완전한 신뢰를 켜세요. 설치 후에만 상세 정보를 볼 수 있다면 확인이 끝날 때까지 완전한 신뢰를 꺼 두세요. 이후 Safari를 새로고침하세요.',
|
||
helpDownloadCert: '↓ iPhone: 프로파일 다운로드',
|
||
helpDownloadAndroid: '↓ Android: CA 다운로드',
|
||
helpTrustWarning:
|
||
'최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.',
|
||
helpCopyLink: '⧉ 링크 복사',
|
||
helpCopied: '복사됨 ✓',
|
||
copy: '복사',
|
||
copied: '복사됨 ✓',
|
||
},
|
||
};
|
||
|
||
// Resolve display language: prefer PC-injected window.__OL_LANG__, fall back to the phone system language.
|
||
var LANG = (function () {
|
||
var injected = (window.__OL_LANG__ || '').trim();
|
||
if (Object.prototype.hasOwnProperty.call(I18N, injected)) return injected;
|
||
var nav = (navigator.language || '').toLowerCase();
|
||
if (nav.indexOf('zh') === 0) {
|
||
if (
|
||
nav.indexOf('hant') >= 0 ||
|
||
nav.indexOf('tw') >= 0 ||
|
||
nav.indexOf('hk') >= 0 ||
|
||
nav.indexOf('mo') >= 0
|
||
)
|
||
return 'zh-TW';
|
||
return 'zh-CN';
|
||
}
|
||
var base = nav.split('-')[0];
|
||
if (Object.prototype.hasOwnProperty.call(I18N, base)) return base;
|
||
return 'zh-CN';
|
||
})();
|
||
var L = I18N[LANG] || I18N['zh-CN'];
|
||
|
||
// Minimal interpolation: replace "{n}" / "{reason}" / "{name}" with the matching values.
|
||
function fmt(tpl, vars) {
|
||
return String(tpl).replace(/\{(\w+)\}/g, function (_, k) {
|
||
return vars && vars[k] != null ? vars[k] : '';
|
||
});
|
||
}
|
||
|
||
// Render static strings in index.html marked with data-i18n in the current language.
|
||
function applyStaticI18n() {
|
||
try {
|
||
document.title = L.title;
|
||
} catch (e) {}
|
||
var nodes = document.querySelectorAll('[data-i18n]');
|
||
for (var i = 0; i < nodes.length; i++) {
|
||
var key = nodes[i].getAttribute('data-i18n');
|
||
if (L[key] != null) nodes[i].textContent = L[key];
|
||
}
|
||
}
|
||
|
||
// ---------- Constants ----------
|
||
var TARGET_SR = 16000; // target sample rate; must match the PC side
|
||
var MODE_KEY = 'ol_remote_mode'; // localStorage key: recording mode
|
||
var PIN_KEY = 'ol_remote_pin'; // localStorage key: last successful pairing code
|
||
var INSERT_KEY = 'ol_remote_insert'; // localStorage key: insert-on-PC toggle (default on)
|
||
var WAKE_LOCK_KEY = 'ol_remote_wake_lock';
|
||
var RECOVERY_KEY = 'ol_remote_recovery_session';
|
||
var MIC_PREP_TIMEOUT_MS = 10000; // mic preparation timeout: past it, fail and let the user retry instead of sticking on "preparing"
|
||
var PCM_QUEUE_MAX_BYTES = 128 * 1024;
|
||
|
||
// ---------- DOM ----------
|
||
var $ = function (id) {
|
||
return document.getElementById(id);
|
||
};
|
||
var screenPin = $('screen-pin');
|
||
var screenRec = $('screen-rec');
|
||
var screenOffline = $('screen-offline');
|
||
|
||
var pinInput = $('pin-input');
|
||
var pinError = $('pin-error');
|
||
var btnConnect = $('btn-connect');
|
||
|
||
var recordBtn = $('btn-record');
|
||
var recordLabel = $('record-label');
|
||
var statusBar = $('status-bar');
|
||
var statusText = $('status-text');
|
||
var statusIcon = $('status-icon');
|
||
var statusDots = $('status-dots');
|
||
var resultWrap = $('result-wrap');
|
||
var resultText = $('result-text');
|
||
var resultCopy = $('result-copy');
|
||
var levelBar = $('level-bar');
|
||
var recTip = $('rec-tip');
|
||
var modeSwitch = $('mode-switch');
|
||
var insertSwitch = $('insert-switch');
|
||
var wakeLockSwitch = $('wake-lock-switch');
|
||
var wakeLockHint = $('wake-lock-hint');
|
||
|
||
var btnReconnect = $('btn-reconnect');
|
||
var offlineReason = $('offline-reason');
|
||
var copyCertBtn = $('copy-cert-link');
|
||
|
||
// ---------- State ----------
|
||
var ws = null;
|
||
var authed = false;
|
||
var recording = false; // whether a recording is active (decides whether to send audio)
|
||
var startSent = false; // whether {type:'start'} for this recording has actually been sent (only after ensureAudio resolves)
|
||
var busy = false; // PC is busy; disable this session
|
||
var mode = readMode(); // 'toggle' | 'hold'
|
||
var lastPin = '';
|
||
var remoteSessionId = '';
|
||
var remoteSequence = 0;
|
||
var finishAfterStarted = ''; // released/cancelled before ACK: 'stop' | 'cancel' | ''
|
||
var pendingPcm = [];
|
||
var pendingPcmBytes = 0;
|
||
var awaitingResult = false;
|
||
var savedRecovery = readRecoverySession();
|
||
var recoverySessionId = savedRecovery.sessionId;
|
||
var recoveryKey = savedRecovery.key;
|
||
var recoveryTimer = null;
|
||
var wakeLock = null;
|
||
var wakeLockGeneration = 0;
|
||
var wakeLockPending = null;
|
||
|
||
// Audio state
|
||
var audioCtx = null;
|
||
var mediaStream = null;
|
||
var sourceNode = null;
|
||
var workletNode = null;
|
||
var scriptNode = null;
|
||
var workletUrl = null;
|
||
var usingWorklet = false;
|
||
// Audio generation counter: incremented on every reset/release. getUserMedia may resolve
|
||
// late, after the withTimeout timeout; without a generation check the late stream would leak
|
||
// live mic tracks or clobber the new stream from a successful retry.
|
||
var audioGen = 0;
|
||
// Resample state for the ScriptProcessor fallback (kept across chunks)
|
||
var resampleState = { phase: 0, last: 0, hasLast: false };
|
||
|
||
// ============================================================
|
||
// Pairing code persistence (localStorage)
|
||
// ============================================================
|
||
function readPin() {
|
||
try {
|
||
var p = localStorage.getItem(PIN_KEY);
|
||
return /^\d{6}$/.test(p || '') ? p : '';
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
function writePin(p) {
|
||
try {
|
||
if (/^\d{6}$/.test(p)) localStorage.setItem(PIN_KEY, p);
|
||
} catch (e) {}
|
||
}
|
||
function clearPin() {
|
||
try {
|
||
localStorage.removeItem(PIN_KEY);
|
||
} catch (e) {}
|
||
saveRecoverySession('');
|
||
}
|
||
|
||
// Recovery credentials apply to this random session only; they never request the computer's history list.
|
||
function readRecoverySession() {
|
||
try {
|
||
var saved = JSON.parse(localStorage.getItem(RECOVERY_KEY) || 'null');
|
||
if (saved && validSessionId(saved.sessionId) && validSessionId(saved.key)) return saved;
|
||
} catch (e) {}
|
||
return { sessionId: '', key: '' };
|
||
}
|
||
function validSessionId(id) {
|
||
return (
|
||
typeof id === 'string' &&
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
||
);
|
||
}
|
||
function saveRecoverySession(id, key) {
|
||
recoverySessionId = validSessionId(id) && validSessionId(key) ? id : '';
|
||
recoveryKey = recoverySessionId ? key : '';
|
||
try {
|
||
if (recoverySessionId)
|
||
localStorage.setItem(
|
||
RECOVERY_KEY,
|
||
JSON.stringify({ sessionId: recoverySessionId, key: recoveryKey }),
|
||
);
|
||
else localStorage.removeItem(RECOVERY_KEY);
|
||
} catch (e) {}
|
||
}
|
||
function clearRecoveryTimer() {
|
||
if (recoveryTimer) {
|
||
clearTimeout(recoveryTimer);
|
||
recoveryTimer = null;
|
||
}
|
||
}
|
||
function requestRecovery() {
|
||
clearRecoveryTimer();
|
||
if (!authed || document.hidden || recording || startSent || !recoverySessionId) return;
|
||
wsSendJSON({ type: 'recover', sessionId: recoverySessionId, recoveryKey: recoveryKey });
|
||
// A stale connection after wake-up may still show OPEN but never receives data; re-authenticate on timeout.
|
||
recoveryTimer = setTimeout(function () {
|
||
recoveryTimer = null;
|
||
if (!recording && authed && !document.hidden) {
|
||
var pin = readPin();
|
||
if (pin) connect(pin);
|
||
}
|
||
}, 8000);
|
||
}
|
||
function handleRecovery(msg) {
|
||
if (msg.sessionId !== recoverySessionId || recording || startSent) return;
|
||
clearRecoveryTimer();
|
||
clearWorkTimeout();
|
||
var recovery = msg.recovery || {};
|
||
awaitingResult = recovery.kind === 'pending';
|
||
updateRecordBtnUI();
|
||
if (recovery.kind === 'pending') {
|
||
setStatus(L.recovering, 'work');
|
||
if (!document.hidden) recoveryTimer = setTimeout(requestRecovery, 1500);
|
||
} else if (recovery.kind === 'completed') {
|
||
showResult(recovery.text);
|
||
setStatus(L.recovered, 'ok');
|
||
} else if (recovery.kind === 'failed') {
|
||
setStatus(recovery.hasAudioRecording ? L.recoveryRetry : L.recoveryUnavailable, 'error');
|
||
} else {
|
||
saveRecoverySession('');
|
||
setStatus(L.recoveryUnavailable, 'error');
|
||
}
|
||
}
|
||
|
||
function shouldKeepAwake() {
|
||
return recording && !document.hidden && wakeLockSwitch && wakeLockSwitch.checked;
|
||
}
|
||
function releaseWakeLock() {
|
||
wakeLockGeneration++;
|
||
wakeLockPending = null;
|
||
var previous = wakeLock;
|
||
wakeLock = null;
|
||
if (previous) previous.release().catch(function () {});
|
||
}
|
||
function acquireWakeLock() {
|
||
if (!shouldKeepAwake() || wakeLock || wakeLockPending !== null) return;
|
||
if (!navigator.wakeLock || !navigator.wakeLock.request) {
|
||
if (wakeLockHint) wakeLockHint.textContent = L.wakeLockUnavailable;
|
||
return;
|
||
}
|
||
var generation = wakeLockGeneration;
|
||
wakeLockPending = generation;
|
||
navigator.wakeLock
|
||
.request('screen')
|
||
.then(function (sentinel) {
|
||
if (wakeLockPending === generation) wakeLockPending = null;
|
||
if (generation !== wakeLockGeneration || !shouldKeepAwake()) {
|
||
sentinel.release().catch(function () {});
|
||
return;
|
||
}
|
||
wakeLock = sentinel;
|
||
if (wakeLockHint) wakeLockHint.textContent = L.wakeLockActive;
|
||
sentinel.addEventListener('release', function () {
|
||
if (wakeLock !== sentinel) return;
|
||
wakeLock = null;
|
||
if (shouldKeepAwake() && wakeLockHint) wakeLockHint.textContent = L.wakeLockUnavailable;
|
||
});
|
||
})
|
||
.catch(function () {
|
||
if (wakeLockPending === generation) wakeLockPending = null;
|
||
if (generation === wakeLockGeneration && shouldKeepAwake() && wakeLockHint) {
|
||
wakeLockHint.textContent = L.wakeLockUnavailable;
|
||
}
|
||
});
|
||
}
|
||
function initWakeLockSwitch() {
|
||
if (!wakeLockSwitch) return;
|
||
try {
|
||
wakeLockSwitch.checked = localStorage.getItem(WAKE_LOCK_KEY) !== '0';
|
||
} catch (e) {
|
||
wakeLockSwitch.checked = true;
|
||
}
|
||
if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint;
|
||
wakeLockSwitch.addEventListener('change', function () {
|
||
try {
|
||
localStorage.setItem(WAKE_LOCK_KEY, wakeLockSwitch.checked ? '1' : '0');
|
||
} catch (e) {}
|
||
if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint;
|
||
if (wakeLockSwitch.checked) acquireWakeLock();
|
||
else releaseWakeLock();
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// Screen switching
|
||
// ============================================================
|
||
function showScreen(which) {
|
||
screenPin.classList.toggle('active', which === 'pin');
|
||
screenRec.classList.toggle('active', which === 'rec');
|
||
screenOffline.classList.toggle('active', which === 'offline');
|
||
}
|
||
|
||
// ============================================================
|
||
// Mode (toggle / hold)
|
||
// ============================================================
|
||
// ============================================================
|
||
// Insert-on-PC toggle (off = only return text to the phone, don't type at the PC cursor)
|
||
// ============================================================
|
||
function readInsert() {
|
||
try {
|
||
return localStorage.getItem(INSERT_KEY) !== '0';
|
||
} catch (e) {
|
||
return true;
|
||
}
|
||
}
|
||
function writeInsert(v) {
|
||
try {
|
||
localStorage.setItem(INSERT_KEY, v ? '1' : '0');
|
||
} catch (e) {}
|
||
}
|
||
// Send the current toggle value to the PC (only while connected): once when entering the recording screen, then immediately on every change.
|
||
function sendInsertConfig() {
|
||
wsSendJSON({ type: 'set_insert', value: insertSwitch ? insertSwitch.checked : true });
|
||
}
|
||
function initInsertSwitch() {
|
||
if (!insertSwitch) return;
|
||
insertSwitch.checked = readInsert();
|
||
insertSwitch.addEventListener('change', function () {
|
||
writeInsert(insertSwitch.checked);
|
||
sendInsertConfig();
|
||
});
|
||
}
|
||
|
||
function readMode() {
|
||
var m = null;
|
||
try {
|
||
m = localStorage.getItem(MODE_KEY);
|
||
} catch (e) {}
|
||
// An explicitly saved phone-side mode wins; on first visit, corrupted value or disabled
|
||
// storage, follow the PC's current default. Never write the inherited value back, or later
|
||
// PC-side changes would stop taking effect.
|
||
if (m === 'hold' || m === 'toggle') return m;
|
||
return window.__OL_DEFAULT_MODE__ === 'hold' ? 'hold' : 'toggle';
|
||
}
|
||
function writeMode(m) {
|
||
mode = m;
|
||
try {
|
||
localStorage.setItem(MODE_KEY, m);
|
||
} catch (e) {}
|
||
syncModeUI();
|
||
}
|
||
function syncModeUI() {
|
||
var btns = modeSwitch.querySelectorAll('.mode-btn');
|
||
for (var i = 0; i < btns.length; i++) {
|
||
btns[i].classList.toggle('active', btns[i].getAttribute('data-mode') === mode);
|
||
}
|
||
if (mode === 'hold') {
|
||
recTip.textContent = L.tipHold;
|
||
recordLabel.textContent = recording ? L.labelHoldRec : L.labelHoldIdle;
|
||
recordBtn.style.touchAction = 'none'; // prevent scrolling in hold mode
|
||
} else {
|
||
recTip.textContent = L.tipToggle;
|
||
recordLabel.textContent = recording ? L.labelToggleRec : L.labelToggleIdle;
|
||
recordBtn.style.touchAction = 'manipulation';
|
||
}
|
||
}
|
||
|
||
// A manual switch on the phone is saved as a local preference and keeps taking precedence over the PC default.
|
||
modeSwitch.addEventListener('click', function (e) {
|
||
var t = e.target.closest('.mode-btn');
|
||
if (!t) return;
|
||
var m = t.getAttribute('data-mode');
|
||
if (m === mode) return;
|
||
// When switching mode while recording, stop safely first (cancel this take to avoid inconsistent state)
|
||
if (recording) cancelRecording();
|
||
writeMode(m);
|
||
});
|
||
|
||
// ============================================================
|
||
// Status text / level
|
||
// ============================================================
|
||
function setStatus(text, kind) {
|
||
statusText.textContent = text;
|
||
// Clear the icon/three-dot animation on every status change; the caller (applyStatusKind) re-enables them as needed.
|
||
if (statusIcon) statusIcon.hidden = true;
|
||
if (statusDots) statusDots.hidden = true;
|
||
statusBar.classList.remove('is-error', 'is-ok', 'is-work');
|
||
if (kind === 'error') statusBar.classList.add('is-error');
|
||
else if (kind === 'ok') statusBar.classList.add('is-ok');
|
||
else if (kind === 'work') statusBar.classList.add('is-work');
|
||
}
|
||
function setLevel(v) {
|
||
if (typeof v !== 'number' || isNaN(v)) return;
|
||
v = Math.max(0, Math.min(1, v));
|
||
levelBar.style.width = (v * 100).toFixed(1) + '%';
|
||
}
|
||
|
||
// Strip the leading emoji icon from status strings (e.g. '🎤 Recording' → 'Recording'); the DOM icon/animation renders it instead.
|
||
function stripLeadingIcon(s) {
|
||
return String(s).replace(/^\S+\s+/, '');
|
||
}
|
||
|
||
// Final text returned by the PC after insertion, shown below the status area; cleared when a new recording starts.
|
||
function showResult(text) {
|
||
if (!resultWrap) return;
|
||
if (!text) {
|
||
clearResult();
|
||
return;
|
||
}
|
||
resultText.textContent = text;
|
||
resultWrap.hidden = false;
|
||
}
|
||
function clearResult() {
|
||
if (!resultWrap) return;
|
||
resultWrap.hidden = true;
|
||
resultText.textContent = '';
|
||
if (resultCopy) {
|
||
resultCopy.classList.remove('copied');
|
||
resultCopy.textContent = L.copy || '复制';
|
||
}
|
||
}
|
||
|
||
// A few seconds after done, return to "ready" automatically so the next recording can start right away.
|
||
var readyTimer = null;
|
||
function scheduleReady() {
|
||
if (readyTimer) clearTimeout(readyTimer);
|
||
readyTimer = setTimeout(function () {
|
||
readyTimer = null;
|
||
if (!recording && authed) setStatus(L.ready, null);
|
||
}, 2500);
|
||
}
|
||
// Every record/stop/cancel entry point must clear readyTimer, otherwise the late
|
||
// return-to-ready timer from the previous done would overwrite newer states like "transcribing…".
|
||
function clearReadyTimer() {
|
||
if (readyTimer) {
|
||
clearTimeout(readyTimer);
|
||
readyTimer = null;
|
||
}
|
||
}
|
||
|
||
// Timer that clears the busy state: tracked so a new status clears it, preventing stacked
|
||
// busy timers or a late timer from overwriting the new state.
|
||
var busyTimer = null;
|
||
|
||
// Client-side fallback timeout for transcribe/polish: if the server never replies done/error
|
||
// (orphaned session, crashed process), show a generic error after 30s and return to ready.
|
||
var workTimer = null;
|
||
function armWorkTimeout() {
|
||
clearWorkTimeout();
|
||
workTimer = setTimeout(function () {
|
||
workTimer = null;
|
||
if (!recording && authed) {
|
||
if (startSent) failRecording('❌ ' + L.errGeneric, true);
|
||
else if (recoverySessionId) requestRecovery();
|
||
else {
|
||
awaitingResult = false;
|
||
updateRecordBtnUI();
|
||
setStatus('❌ ' + L.errGeneric, 'error');
|
||
setLevel(0);
|
||
}
|
||
scheduleReady();
|
||
}
|
||
}, 30000);
|
||
}
|
||
function clearWorkTimeout() {
|
||
if (workTimer) {
|
||
clearTimeout(workTimer);
|
||
workTimer = null;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// WebSocket
|
||
// ============================================================
|
||
function wsSendJSON(obj) {
|
||
if (ws && ws.readyState === 1) {
|
||
try {
|
||
ws.send(JSON.stringify(obj));
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
|
||
// Connect watchdog: if the wss handshake or auth doesn't finish within 12s, it is almost
|
||
// always the phone not trusting the computer certificate (iOS Safari doesn't reuse the
|
||
// page-level certificate exception for wss). Return to the pairing screen with a clear
|
||
// message instead of spinning on "connecting" forever.
|
||
var connectTimer = null;
|
||
function armConnectTimeout() {
|
||
clearConnectTimeout();
|
||
connectTimer = setTimeout(function () {
|
||
connectTimer = null;
|
||
if (!authed) {
|
||
closeWS();
|
||
showScreen('pin');
|
||
showPinError(L.errConnTimeout);
|
||
resetConnectBtn();
|
||
}
|
||
}, 12000);
|
||
}
|
||
function clearConnectTimeout() {
|
||
if (connectTimer) {
|
||
clearTimeout(connectTimer);
|
||
connectTimer = null;
|
||
}
|
||
}
|
||
|
||
function connect(pin) {
|
||
lastPin = pin;
|
||
closeWS(); // tear down the old connection
|
||
authed = false;
|
||
busy = false;
|
||
awaitingResult = false;
|
||
|
||
var url = 'wss://' + location.host + '/ws';
|
||
try {
|
||
ws = new WebSocket(url);
|
||
} catch (e) {
|
||
showPinError(L.errConnCreate);
|
||
resetConnectBtn();
|
||
return;
|
||
}
|
||
ws.binaryType = 'arraybuffer';
|
||
armConnectTimeout(); // watchdog: a stalled handshake/auth usually means the certificate isn't trusted
|
||
|
||
ws.onopen = function () {
|
||
// Handshake immediately after connecting
|
||
wsSendJSON({ type: 'hello', pin: pin, prefer: mode });
|
||
};
|
||
|
||
ws.onmessage = function (ev) {
|
||
if (typeof ev.data !== 'string') return; // downstream messages are text only
|
||
var msg;
|
||
try {
|
||
msg = JSON.parse(ev.data);
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
handleMessage(msg);
|
||
};
|
||
|
||
ws.onerror = function () {
|
||
// onclose usually follows onerror; handle the UI in one place in the close handler
|
||
};
|
||
|
||
ws.onclose = function () {
|
||
clearConnectTimeout();
|
||
clearReadyTimer();
|
||
clearWorkTimeout();
|
||
clearRecoveryTimer();
|
||
if (busyTimer) {
|
||
clearTimeout(busyTimer);
|
||
busyTimer = null;
|
||
}
|
||
var wasAuthed = authed;
|
||
authed = false;
|
||
recording = false;
|
||
awaitingResult = false;
|
||
detachHoldEnd();
|
||
resetRemoteStreamState();
|
||
teardownAudio();
|
||
if (wasAuthed) {
|
||
// Disconnected after entering the recording screen → offline screen
|
||
offlineReason.textContent = recoverySessionId ? L.offlineRecording : L.offlineSub;
|
||
showScreen('offline');
|
||
} else {
|
||
// Closed before auth (handshake rejected / untrusted certificate / network drop).
|
||
// Always show a clear message — otherwise (especially Android Chrome, which fires
|
||
// onclose immediately for untrusted self-signed wss) the user just sees the button
|
||
// flash back to "Connect" with no idea what happened.
|
||
showScreen('pin');
|
||
showPinError(L.errConnFail);
|
||
}
|
||
resetConnectBtn();
|
||
};
|
||
}
|
||
|
||
function closeWS() {
|
||
clearConnectTimeout();
|
||
clearReadyTimer();
|
||
clearWorkTimeout();
|
||
clearRecoveryTimer();
|
||
if (busyTimer) {
|
||
clearTimeout(busyTimer);
|
||
busyTimer = null;
|
||
}
|
||
recording = false;
|
||
awaitingResult = false;
|
||
detachHoldEnd();
|
||
resetRemoteStreamState();
|
||
teardownAudio();
|
||
if (ws) {
|
||
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
|
||
try {
|
||
ws.close();
|
||
} catch (e) {}
|
||
ws = null;
|
||
}
|
||
}
|
||
|
||
function handleMessage(msg) {
|
||
if (!msg || typeof msg.type !== 'string') return;
|
||
|
||
switch (msg.type) {
|
||
case 'auth':
|
||
if (msg.ok) {
|
||
authed = true;
|
||
busy = false;
|
||
clearConnectTimeout();
|
||
writePin(lastPin); // pairing succeeded → remember the code so refreshes skip retyping
|
||
enterRecScreen();
|
||
requestRecovery();
|
||
} else {
|
||
authed = false;
|
||
clearPin(); // pairing code invalid (wrong/locked) → clear it so auto-reconnect doesn't fail again
|
||
var reason = msg.reason === 'locked' ? L.errPinLocked : L.errPinWrong;
|
||
closeWS();
|
||
showScreen('pin');
|
||
showPinError(reason);
|
||
resetConnectBtn();
|
||
}
|
||
break;
|
||
|
||
case 'status':
|
||
applyStatusKind(msg);
|
||
break;
|
||
|
||
case 'started':
|
||
handleStarted(msg.sessionId, msg.recoveryKey);
|
||
break;
|
||
|
||
case 'recovery':
|
||
handleRecovery(msg);
|
||
break;
|
||
|
||
case 'level':
|
||
setLevel(msg.value);
|
||
break;
|
||
|
||
case 'busy':
|
||
clearWorkTimeout();
|
||
busy = true;
|
||
recording = false;
|
||
awaitingResult = false;
|
||
resetRemoteStreamState();
|
||
teardownAudioCapture(); // stop capture but keep the ctx
|
||
updateRecordBtnUI();
|
||
setStatus(fmt(L.busy, { reason: msg.reason || L.busyDefault }), 'error');
|
||
// Clear the busy state shortly so retries are possible. Track the timer in busyTimer and clear on re-entry to avoid stacking.
|
||
if (busyTimer) clearTimeout(busyTimer);
|
||
busyTimer = setTimeout(function () {
|
||
busyTimer = null;
|
||
busy = false;
|
||
updateRecordBtnUI();
|
||
if (!recording) setStatus(L.ready, null);
|
||
}, 1500);
|
||
break;
|
||
|
||
case 'result':
|
||
// Final text returned by the PC after insertion; shows this run's result to the phone user.
|
||
showResult(msg.text);
|
||
awaitingResult = false;
|
||
clearRecoveryTimer();
|
||
updateRecordBtnUI();
|
||
break;
|
||
}
|
||
}
|
||
|
||
function applyStatusKind(msg) {
|
||
// A real status clears the busy fallback timer so a late fire can't overwrite it with "ready".
|
||
if (busyTimer) {
|
||
clearTimeout(busyTimer);
|
||
busyTimer = null;
|
||
busy = false;
|
||
updateRecordBtnUI();
|
||
}
|
||
switch (msg.kind) {
|
||
case 'recording':
|
||
setStatus(stripLeadingIcon(L.statusRecording), 'work');
|
||
break;
|
||
case 'transcribing':
|
||
if (recording) {
|
||
recording = false;
|
||
detachHoldEnd();
|
||
resetRemoteStreamState();
|
||
teardownAudioCapture();
|
||
}
|
||
awaitingResult = true;
|
||
updateRecordBtnUI();
|
||
setStatus(stripLeadingIcon(L.statusTranscribing), 'work');
|
||
if (statusDots) statusDots.hidden = false; // transcribing: three-dot loader
|
||
armWorkTimeout(); // re-arm the fallback timeout while working, in case the server goes silent
|
||
break;
|
||
case 'polishing':
|
||
setStatus(L.statusPolishing, 'work'); // keep ✨ while polishing
|
||
armWorkTimeout(); // same as above
|
||
break;
|
||
case 'done':
|
||
awaitingResult = false;
|
||
updateRecordBtnUI();
|
||
clearWorkTimeout(); // normal completion, clear the fallback timeout
|
||
var n = typeof msg.insertedChars === 'number' ? msg.insertedChars : 0;
|
||
setStatus(stripLeadingIcon(fmt(L.statusDone, { n: n })), 'ok');
|
||
if (statusIcon) {
|
||
statusIcon.src = '/done.png';
|
||
statusIcon.hidden = false;
|
||
} // done: checkmark image
|
||
setLevel(0);
|
||
scheduleReady();
|
||
break;
|
||
case 'error':
|
||
awaitingResult = false;
|
||
updateRecordBtnUI();
|
||
clearWorkTimeout(); // server reported an error explicitly, clear the fallback timeout
|
||
if (recording || startSent) failRecording('❌ ' + (msg.message || L.errGeneric), true);
|
||
else {
|
||
resetRemoteStreamState();
|
||
setStatus('❌ ' + (msg.message || L.errGeneric), 'error');
|
||
setLevel(0);
|
||
}
|
||
break;
|
||
default:
|
||
if (msg.message) setStatus(msg.message, null);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Screen state helpers
|
||
// ============================================================
|
||
function isPinScreen() {
|
||
return screenPin.classList.contains('active');
|
||
}
|
||
|
||
function enterRecScreen() {
|
||
showPinError('');
|
||
showScreen('rec');
|
||
syncModeUI();
|
||
updateRecordBtnUI();
|
||
setStatus(L.ready, null);
|
||
setLevel(0);
|
||
sendInsertConfig(); // sync the insert-on-PC toggle to the computer when entering the recording screen
|
||
}
|
||
|
||
// ============================================================
|
||
// PIN screen interactions
|
||
// ============================================================
|
||
pinInput.addEventListener('input', function () {
|
||
// Keep digits only
|
||
var v = pinInput.value.replace(/\D+/g, '').slice(0, 6);
|
||
if (v !== pinInput.value) pinInput.value = v;
|
||
showPinError('');
|
||
});
|
||
pinInput.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') doConnect();
|
||
});
|
||
btnConnect.addEventListener('click', doConnect);
|
||
|
||
function doConnect() {
|
||
var pin = (pinInput.value || '').replace(/\D+/g, '');
|
||
if (pin.length !== 6) {
|
||
showPinError(L.errPinFormat);
|
||
return;
|
||
}
|
||
showPinError('');
|
||
btnConnect.disabled = true;
|
||
btnConnect.textContent = L.btnConnecting;
|
||
connect(pin);
|
||
}
|
||
|
||
function showPinError(text) {
|
||
if (!text) {
|
||
pinError.hidden = true;
|
||
pinError.textContent = '';
|
||
} else {
|
||
pinError.hidden = false;
|
||
pinError.textContent = text;
|
||
}
|
||
}
|
||
function resetConnectBtn() {
|
||
btnConnect.disabled = false;
|
||
btnConnect.textContent = L.btnConnect;
|
||
}
|
||
|
||
// Reconnect
|
||
btnReconnect.addEventListener('click', function () {
|
||
showScreen('pin');
|
||
showPinError('');
|
||
resetConnectBtn();
|
||
var p = lastPin || readPin();
|
||
if (p) {
|
||
pinInput.value = p;
|
||
doConnect(); // reconnect directly with the saved code, no extra tap
|
||
}
|
||
});
|
||
|
||
// Copy the certificate download link — handy for opening in another browser or sending to yourself.
|
||
function fallbackCopyText(text, cb) {
|
||
try {
|
||
var ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
if (cb) cb();
|
||
} catch (e) {}
|
||
}
|
||
if (copyCertBtn) {
|
||
copyCertBtn.addEventListener('click', function () {
|
||
var url = location.origin + '/cert.mobileconfig';
|
||
var ok = function () {
|
||
copyCertBtn.textContent = L.helpCopied;
|
||
setTimeout(function () {
|
||
copyCertBtn.textContent = L.helpCopyLink;
|
||
}, 1500);
|
||
};
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(url).then(ok, function () {
|
||
fallbackCopyText(url, ok);
|
||
});
|
||
} else {
|
||
fallbackCopyText(url, ok);
|
||
}
|
||
});
|
||
}
|
||
|
||
// One-tap copy of the result text: prefer navigator.clipboard (needs a secure context;
|
||
// this page is HTTPS), fall back to execCommand for old browsers (see fallbackCopyText).
|
||
if (resultCopy) {
|
||
resultCopy.addEventListener('click', function () {
|
||
var text = resultText.textContent || '';
|
||
if (!text) return;
|
||
var done = function () {
|
||
resultCopy.classList.add('copied');
|
||
resultCopy.textContent = L.copied || '已复制 ✓';
|
||
setTimeout(function () {
|
||
resultCopy.classList.remove('copied');
|
||
resultCopy.textContent = L.copy || '复制';
|
||
}, 1500);
|
||
};
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(done, function () {
|
||
fallbackCopyText(text, done);
|
||
});
|
||
} else {
|
||
fallbackCopyText(text, done);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// Record button interactions (toggle / hold)
|
||
// ============================================================
|
||
function updateRecordBtnUI() {
|
||
recordBtn.classList.toggle('recording', recording);
|
||
recordBtn.classList.toggle('busy', (busy || awaitingResult) && !recording);
|
||
recordBtn.disabled = (busy || awaitingResult) && !recording;
|
||
if (recording) {
|
||
recordLabel.textContent = mode === 'hold' ? L.labelHoldRec : L.labelToggleRec;
|
||
} else {
|
||
recordLabel.textContent = mode === 'hold' ? L.labelHoldIdle : L.labelToggleIdle;
|
||
}
|
||
}
|
||
|
||
// Toggle mode: click to switch
|
||
recordBtn.addEventListener('click', function () {
|
||
if (mode !== 'toggle') return;
|
||
if (!authed || busy || awaitingResult) return;
|
||
if (recording) stopRecording();
|
||
else startRecording();
|
||
});
|
||
|
||
// Hold mode: press to start; release/cancel to stop.
|
||
// Key point: a document-level listener backs up the "release" event. On mobile,
|
||
// setPointerCapture can be lost during animations/reflows/system permission dialogs, so
|
||
// recordBtn's own pointerup never fires — the recording keeps running after the finger
|
||
// lifts. Instead, attach one-shot document-level pointerup/pointercancel on press so the
|
||
// recording ends wherever the pointer is released.
|
||
var holdEndHandler = null;
|
||
function attachHoldEnd() {
|
||
if (holdEndHandler) return;
|
||
holdEndHandler = function () {
|
||
if (recording)
|
||
stopRecording(); // stopRecording detaches it internally
|
||
else detachHoldEnd();
|
||
};
|
||
document.addEventListener('pointerup', holdEndHandler, true);
|
||
document.addEventListener('pointercancel', holdEndHandler, true);
|
||
}
|
||
function detachHoldEnd() {
|
||
if (!holdEndHandler) return;
|
||
document.removeEventListener('pointerup', holdEndHandler, true);
|
||
document.removeEventListener('pointercancel', holdEndHandler, true);
|
||
holdEndHandler = null;
|
||
}
|
||
|
||
recordBtn.addEventListener('pointerdown', function (e) {
|
||
if (mode !== 'hold') return;
|
||
if (!authed || busy || awaitingResult) return;
|
||
e.preventDefault();
|
||
attachHoldEnd();
|
||
if (!recording) startRecording();
|
||
});
|
||
|
||
// ============================================================
|
||
// Recording flow
|
||
// ============================================================
|
||
// Fallback timeout for Promises that may stay "pending forever". On mobile, audioCtx.resume()
|
||
// / getUserMedia() may neither resolve nor reject when the screen locks, the app backgrounds,
|
||
// or the mic is busy, leaving the whole ensureAudio chain stuck — no start command, no capsule
|
||
// on the PC, and the page stuck on "preparing microphone…". On timeout, fail, reset state and
|
||
// prompt a retry instead of waiting forever.
|
||
function withTimeout(promise, ms, tag) {
|
||
return new Promise(function (resolve, reject) {
|
||
var timer = setTimeout(function () {
|
||
var err = new Error(tag || 'TIMEOUT');
|
||
err.name = tag || 'TIMEOUT';
|
||
reject(err);
|
||
}, ms);
|
||
promise.then(
|
||
function (v) {
|
||
clearTimeout(timer);
|
||
resolve(v);
|
||
},
|
||
function (e) {
|
||
clearTimeout(timer);
|
||
reject(e);
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
function startRecording() {
|
||
if (recording || startSent || awaitingResult) return;
|
||
if (!ws || ws.readyState !== 1) {
|
||
setStatus(L.connLost, 'error');
|
||
return;
|
||
}
|
||
// Set state optimistically so iOS calls resume() within the gesture's synchronous stack
|
||
recording = true;
|
||
clearRecoveryTimer();
|
||
acquireWakeLock();
|
||
resetRemoteStreamState();
|
||
clearReadyTimer(); // keep the previous done's late return-to-ready timer from overwriting this run
|
||
clearWorkTimeout(); // new recording started; invalidate the previous run's fallback timeout
|
||
updateRecordBtnUI();
|
||
setStatus(L.preparingMic, 'work');
|
||
clearResult(); // clear the previous result so the new recording doesn't show old text
|
||
|
||
var gen = audioGen;
|
||
withTimeout(ensureAudio(), MIC_PREP_TIMEOUT_MS, 'TIMEOUT')
|
||
.then(function () {
|
||
if (gen !== audioGen) return;
|
||
if (!recording) {
|
||
// Cancelled/released while preparing
|
||
teardownAudioCapture();
|
||
return;
|
||
}
|
||
wsSendJSON({ type: 'start' });
|
||
startSent = true; // start is out; only now does stopRecording need to send a matching stop
|
||
setStatus(L.preparingBackend, 'work');
|
||
})
|
||
.catch(function (err) {
|
||
if (gen !== audioGen) return;
|
||
recording = false;
|
||
resetRemoteStreamState();
|
||
// A timeout usually means the audioCtx is wedged (resume never settles); rebuild it
|
||
// from scratch or the next retry sticks on the same broken ctx. Other errors only
|
||
// need the capture chain stopped.
|
||
if (err && err.name === 'TIMEOUT') resetAudioContext();
|
||
else teardownAudioCapture();
|
||
updateRecordBtnUI();
|
||
setStatus(err && err.name === 'TIMEOUT' ? L.micTimeout : micErrorText(err), 'error');
|
||
});
|
||
}
|
||
|
||
function stopRecording() {
|
||
detachHoldEnd();
|
||
if (!recording) return;
|
||
clearReadyTimer(); // keep a late return-to-ready timer from overwriting "transcribing…"
|
||
recording = false;
|
||
updateRecordBtnUI();
|
||
teardownAudioCapture();
|
||
// start not yet sent (hold released before ensureAudio finished) → treat as a local cancel:
|
||
// don't send an orphan stop, or the PC has no session, never replies done/error, and the UI
|
||
// sticks on "transcribing…" forever.
|
||
if (!startSent) {
|
||
resetRemoteStreamState();
|
||
setStatus(L.ready, null);
|
||
setLevel(0);
|
||
return;
|
||
}
|
||
awaitingResult = true;
|
||
updateRecordBtnUI();
|
||
if (remoteSessionId) {
|
||
wsSendJSON({ type: 'stop' });
|
||
resetRemoteStreamState();
|
||
enterTranscribing();
|
||
} else {
|
||
// ACK not yet arrived: keep the first PCM chunks, flush them in order after ACK, then queue stop after the audio frames.
|
||
finishAfterStarted = 'stop';
|
||
setStatus(L.preparingBackend, 'work');
|
||
setLevel(0);
|
||
armWorkTimeout();
|
||
}
|
||
}
|
||
|
||
function cancelRecording() {
|
||
detachHoldEnd();
|
||
awaitingResult = false;
|
||
clearRecoveryTimer();
|
||
saveRecoverySession('');
|
||
if (!recording && !startSent) {
|
||
teardownAudioCapture();
|
||
resetRemoteStreamState();
|
||
return;
|
||
}
|
||
clearReadyTimer();
|
||
clearWorkTimeout();
|
||
recording = false;
|
||
updateRecordBtnUI();
|
||
teardownAudioCapture();
|
||
if (startSent) {
|
||
wsSendJSON({ type: 'cancel' });
|
||
if (remoteSessionId) resetRemoteStreamState();
|
||
else {
|
||
finishAfterStarted = 'cancel';
|
||
clearPendingPcm();
|
||
}
|
||
} else {
|
||
resetRemoteStreamState();
|
||
}
|
||
setStatus(L.cancelled, null);
|
||
setLevel(0);
|
||
}
|
||
|
||
function micErrorText(err) {
|
||
var name = err && err.name ? err.name : '';
|
||
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
||
return L.micDenied;
|
||
}
|
||
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
||
return L.micNotFound;
|
||
}
|
||
if (name === 'NotReadableError') {
|
||
return L.micBusy;
|
||
}
|
||
return fmt(L.micUnknown, { name: name ? '(' + name + ')' : '' });
|
||
}
|
||
|
||
// ============================================================
|
||
// Audio: acquire devices + build the capture chain
|
||
// ============================================================
|
||
// Ensure AudioContext / getUserMedia / capture nodes are ready and start streaming.
|
||
// Must be called from within a user-gesture stack (startRecording is gesture-triggered).
|
||
function ensureAudio() {
|
||
var gen = audioGen;
|
||
// getUserMedia unsupported
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||
return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器'));
|
||
}
|
||
|
||
// 1) AudioContext (iOS needs resume within a gesture)
|
||
if (!audioCtx) {
|
||
var AC = window.AudioContext || window.webkitAudioContext;
|
||
if (!AC) {
|
||
return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器'));
|
||
}
|
||
audioCtx = new AC();
|
||
audioCtx.onstatechange = function () {
|
||
if (audioCtx && recording && startSent && audioCtx.state !== 'running') {
|
||
interruptRecording();
|
||
}
|
||
};
|
||
}
|
||
|
||
// Note: after a call/Siri, iOS Safari puts the ctx in a private 'interrupted' state that a
|
||
// 'suspended'-only check misses, leaving recordings silently silent — try resume for any
|
||
// non-running state.
|
||
var resumeP =
|
||
audioCtx.state !== 'running' ? audioCtx.resume().catch(function () {}) : Promise.resolve();
|
||
|
||
return resumeP
|
||
.then(function () {
|
||
if (gen !== audioGen) return null;
|
||
// 2) Mic stream (reuse if present)
|
||
if (mediaStream) return mediaStream;
|
||
// Use the generation captured when preparation began; a late stream after stop/cancel must be released, never overwrite the next recording.
|
||
return navigator.mediaDevices
|
||
.getUserMedia({
|
||
audio: {
|
||
channelCount: 1,
|
||
echoCancellation: true,
|
||
noiseSuppression: true,
|
||
autoGainControl: true,
|
||
},
|
||
video: false,
|
||
})
|
||
.then(function (stream) {
|
||
if (gen !== audioGen) {
|
||
try {
|
||
stream.getTracks().forEach(function (t) {
|
||
t.stop();
|
||
});
|
||
} catch (e) {}
|
||
return null; // the next step's null check drops it
|
||
}
|
||
mediaStream = stream;
|
||
stream.getTracks().forEach(function (track) {
|
||
track.onended = function () {
|
||
if (recording) interruptRecording();
|
||
};
|
||
});
|
||
return stream;
|
||
});
|
||
})
|
||
.then(function (stream) {
|
||
// 3) Build the capture graph (skip if already built). audioCtx may have been nulled by
|
||
// resetAudioContext after a prep timeout (this getUserMedia resolved late); bail out to
|
||
// avoid building on a null ctx.
|
||
if (gen !== audioGen || sourceNode || !audioCtx || !stream) return;
|
||
sourceNode = audioCtx.createMediaStreamSource(stream);
|
||
return buildCaptureGraph();
|
||
});
|
||
}
|
||
|
||
// Build AudioWorklet (preferred) or ScriptProcessor (fallback)
|
||
function buildCaptureGraph() {
|
||
var gen = audioGen;
|
||
var inSr = audioCtx.sampleRate || 48000;
|
||
|
||
// Prefer AudioWorklet
|
||
if (audioCtx.audioWorklet && typeof AudioWorkletNode !== 'undefined') {
|
||
return loadWorklet()
|
||
.then(function () {
|
||
if (gen !== audioGen) return;
|
||
workletNode = new AudioWorkletNode(audioCtx, 'ol-pcm-worklet', {
|
||
numberOfInputs: 1,
|
||
numberOfOutputs: 0,
|
||
channelCount: 1,
|
||
processorOptions: { inSr: inSr, targetSr: TARGET_SR },
|
||
});
|
||
workletNode.port.onmessage = function (e) {
|
||
// e.data is the converted Int16 LE ArrayBuffer
|
||
if (gen === audioGen) sendAudio(e.data);
|
||
};
|
||
sourceNode.connect(workletNode);
|
||
usingWorklet = true;
|
||
})
|
||
.catch(function () {
|
||
if (gen !== audioGen) return;
|
||
// worklet load failed → fall back to ScriptProcessor
|
||
usingWorklet = false;
|
||
buildScriptProcessor(inSr);
|
||
});
|
||
}
|
||
|
||
// No audioWorklet: use the fallback directly
|
||
usingWorklet = false;
|
||
buildScriptProcessor(inSr);
|
||
return Promise.resolve();
|
||
}
|
||
|
||
// ---- AudioWorklet processor (loaded from a string via Blob URL) ----
|
||
function loadWorklet() {
|
||
if (workletUrl) return audioCtx.audioWorklet.addModule(workletUrl);
|
||
|
||
var code =
|
||
'class OlPcmWorklet extends AudioWorkletProcessor {' +
|
||
' constructor(o){' +
|
||
' super();' +
|
||
' var p=(o&&o.processorOptions)||{};' +
|
||
' this.inSr=p.inSr||sampleRate;' +
|
||
' this.targetSr=p.targetSr||16000;' +
|
||
' this.ratio=this.inSr/this.targetSr;' +
|
||
' this.phase=0;' + // current fractional phase
|
||
' this.last=0;' + // last sample of the previous chunk (for cross-chunk stitching)
|
||
' this.hasLast=false;' +
|
||
' }' +
|
||
' process(inputs){' +
|
||
' var ch=inputs[0]&&inputs[0][0];' +
|
||
' if(!ch||ch.length===0){return true;}' +
|
||
' var ratio=this.ratio;' +
|
||
' var phase=this.phase;' +
|
||
' var prev=this.last;' +
|
||
' var hasPrev=this.hasLast;' +
|
||
' var n=ch.length;' +
|
||
// Upper bound on the output sample count
|
||
' var outCap=Math.ceil((n+1)/ratio)+2;' +
|
||
' var pcm=new ArrayBuffer(outCap*2);' +
|
||
' var dv=new DataView(pcm);' +
|
||
' var oi=0;' +
|
||
// Linear interpolation: phase advances in "input sample" units, step=inSr/16000
|
||
// i=floor(phase), frac=phase-i; a=sample[i], b=sample[i+1]
|
||
// Across chunks i may be -1; use prev as a.
|
||
' while(true){' +
|
||
' var i=Math.floor(phase);' +
|
||
' var frac=phase-i;' +
|
||
' var a,b;' +
|
||
' if(i+1>=n){break;}' + // need both i and i+1 inside the chunk (or a uses prev)
|
||
' if(i<0){' +
|
||
' if(!hasPrev){phase+=ratio;continue;}' +
|
||
' a=prev;b=ch[0];' +
|
||
' }else{' +
|
||
' a=ch[i];b=ch[i+1];' +
|
||
' }' +
|
||
' var s=a+(b-a)*frac;' +
|
||
' if(s>1)s=1;else if(s<-1)s=-1;' +
|
||
' dv.setInt16(oi*2, (s*32767)|0, true);' +
|
||
' oi++;' +
|
||
' phase+=ratio;' +
|
||
' }' +
|
||
// Keep the remainder: pull phase back relative to the next chunk's start
|
||
' this.phase=phase-n;' +
|
||
' this.last=ch[n-1];' +
|
||
' this.hasLast=true;' +
|
||
' if(oi>0){' +
|
||
' var out=pcm.slice(0,oi*2);' +
|
||
' this.port.postMessage(out,[out]);' +
|
||
' }' +
|
||
' return true;' +
|
||
' }' +
|
||
'}' +
|
||
'registerProcessor("ol-pcm-worklet", OlPcmWorklet);';
|
||
|
||
workletUrl = URL.createObjectURL(new Blob([code], { type: 'application/javascript' }));
|
||
return audioCtx.audioWorklet.addModule(workletUrl);
|
||
}
|
||
|
||
// ---- ScriptProcessor fallback ----
|
||
function buildScriptProcessor(inSr) {
|
||
scriptNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
||
resampleState.phase = 0;
|
||
resampleState.last = 0;
|
||
resampleState.hasLast = false;
|
||
|
||
scriptNode.onaudioprocess = function (e) {
|
||
if (!recording) return;
|
||
var input = e.inputBuffer.getChannelData(0);
|
||
var buf = resampleToInt16LE(input, inSr);
|
||
if (buf && buf.byteLength) sendAudio(buf);
|
||
};
|
||
// ScriptProcessor only fires when connected to the destination (silent gain avoids playback)
|
||
sourceNode.connect(scriptNode);
|
||
var silent = audioCtx.createGain();
|
||
silent.gain.value = 0;
|
||
scriptNode.connect(silent);
|
||
silent.connect(audioCtx.destination);
|
||
scriptNode._silentGain = silent;
|
||
}
|
||
|
||
// Main-thread linear-interpolation resampling (for ScriptProcessor), same logic as the worklet
|
||
function resampleToInt16LE(ch, inSr) {
|
||
var ratio = inSr / TARGET_SR;
|
||
var phase = resampleState.phase;
|
||
var prev = resampleState.last;
|
||
var hasPrev = resampleState.hasLast;
|
||
var n = ch.length;
|
||
if (n === 0) return null;
|
||
|
||
var outCap = Math.ceil((n + 1) / ratio) + 2;
|
||
var pcm = new ArrayBuffer(outCap * 2);
|
||
var dv = new DataView(pcm);
|
||
var oi = 0;
|
||
|
||
while (true) {
|
||
var i = Math.floor(phase);
|
||
var frac = phase - i;
|
||
var a, b;
|
||
if (i + 1 >= n) break;
|
||
if (i < 0) {
|
||
if (!hasPrev) {
|
||
phase += ratio;
|
||
continue;
|
||
}
|
||
a = prev;
|
||
b = ch[0];
|
||
} else {
|
||
a = ch[i];
|
||
b = ch[i + 1];
|
||
}
|
||
var s = a + (b - a) * frac;
|
||
if (s > 1) s = 1;
|
||
else if (s < -1) s = -1;
|
||
dv.setInt16(oi * 2, (s * 32767) | 0, true);
|
||
oi++;
|
||
phase += ratio;
|
||
}
|
||
|
||
resampleState.phase = phase - n;
|
||
resampleState.last = ch[n - 1];
|
||
resampleState.hasLast = true;
|
||
|
||
return oi > 0 ? pcm.slice(0, oi * 2) : null;
|
||
}
|
||
|
||
function clearPendingPcm() {
|
||
pendingPcm = [];
|
||
pendingPcmBytes = 0;
|
||
}
|
||
|
||
function resetRemoteStreamState() {
|
||
startSent = false;
|
||
remoteSessionId = '';
|
||
remoteSequence = 0;
|
||
finishAfterStarted = '';
|
||
clearPendingPcm();
|
||
}
|
||
|
||
function enterTranscribing() {
|
||
setStatus(stripLeadingIcon(L.statusTranscribing), 'work');
|
||
if (statusDots) statusDots.hidden = false;
|
||
setLevel(0);
|
||
armWorkTimeout();
|
||
}
|
||
|
||
function failRecording(message, notifyBackend) {
|
||
var waitingForAck = startSent && !remoteSessionId;
|
||
recording = false;
|
||
awaitingResult = false;
|
||
detachHoldEnd();
|
||
teardownAudioCapture();
|
||
if (notifyBackend && startSent) wsSendJSON({ type: 'cancel' });
|
||
if (waitingForAck) {
|
||
finishAfterStarted = 'cancel';
|
||
clearPendingPcm();
|
||
} else {
|
||
resetRemoteStreamState();
|
||
}
|
||
updateRecordBtnUI();
|
||
setStatus(message, 'error');
|
||
setLevel(0);
|
||
}
|
||
|
||
function sendRemoteFrame(buf) {
|
||
if (!ws || ws.readyState !== 1 || !remoteSessionId) return false;
|
||
try {
|
||
ws.send(buildAudioFrame(remoteSessionId, remoteSequence, buf));
|
||
remoteSequence++;
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function flushPendingPcm() {
|
||
var queued = pendingPcm;
|
||
clearPendingPcm();
|
||
for (var i = 0; i < queued.length; i++) {
|
||
if (!sendRemoteFrame(queued[i])) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function handleStarted(sessionId, key) {
|
||
if (!startSent) {
|
||
clearPendingPcm();
|
||
return;
|
||
}
|
||
if (finishAfterStarted === 'cancel') {
|
||
resetRemoteStreamState();
|
||
updateRecordBtnUI();
|
||
return;
|
||
}
|
||
if (remoteSessionId) return;
|
||
if (
|
||
typeof sessionId !== 'string' ||
|
||
!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
||
sessionId,
|
||
)
|
||
) {
|
||
failRecording('❌ ' + L.errGeneric, true);
|
||
return;
|
||
}
|
||
|
||
remoteSessionId = sessionId;
|
||
saveRecoverySession(sessionId, key);
|
||
remoteSequence = 0;
|
||
if (!flushPendingPcm()) {
|
||
interruptRecording();
|
||
return;
|
||
}
|
||
if (finishAfterStarted === 'stop') {
|
||
wsSendJSON({ type: 'stop' });
|
||
resetRemoteStreamState();
|
||
enterTranscribing();
|
||
} else if (recording) {
|
||
setStatus(stripLeadingIcon(L.statusRecording), 'work');
|
||
} else {
|
||
wsSendJSON({ type: 'cancel' });
|
||
resetRemoteStreamState();
|
||
}
|
||
}
|
||
|
||
// Send binary audio frames; buffer at most 128 KiB before the start ACK so a cold start doesn't swallow the first word.
|
||
function sendAudio(buf) {
|
||
if (!recording || !buf || !buf.byteLength) return;
|
||
if (!ws || ws.readyState !== 1) {
|
||
interruptRecording();
|
||
return;
|
||
}
|
||
if (remoteSessionId) {
|
||
if (!sendRemoteFrame(buf)) interruptRecording();
|
||
else updateLocalLevel(buf);
|
||
return;
|
||
}
|
||
if (pendingPcmBytes + buf.byteLength > PCM_QUEUE_MAX_BYTES) {
|
||
failRecording(L.pcmQueueOverflow, true);
|
||
return;
|
||
}
|
||
pendingPcm.push(buf);
|
||
pendingPcmBytes += buf.byteLength;
|
||
updateLocalLevel(buf);
|
||
}
|
||
|
||
function buildAudioFrame(sessionId, sequence, pcm) {
|
||
var hex = sessionId.replace(/-/g, '');
|
||
if (!/^[0-9a-fA-F]{32}$/.test(hex)) throw new Error('invalid session id');
|
||
var frame = new ArrayBuffer(28 + pcm.byteLength);
|
||
var view = new DataView(frame);
|
||
view.setUint8(0, 0x4f);
|
||
view.setUint8(1, 0x4c);
|
||
view.setUint8(2, 0x32);
|
||
view.setUint8(3, 0x30);
|
||
for (var i = 0; i < 16; i++) view.setUint8(4 + i, parseInt(hex.slice(i * 2, i * 2 + 2), 16));
|
||
var high = Math.floor(sequence / 0x100000000);
|
||
var low = sequence >>> 0;
|
||
view.setUint32(20, high, false);
|
||
view.setUint32(24, low, false);
|
||
new Uint8Array(frame, 28).set(new Uint8Array(pcm));
|
||
return frame;
|
||
}
|
||
|
||
// Local level visualization: compute RMS from the Int16 PCM about to be uploaded. In remote
|
||
// mode the PC has no mic level source (no local cpal), so the phone's own audio drives the
|
||
// level bar — real time, independent of backend events.
|
||
var lastLevelAt = 0;
|
||
function updateLocalLevel(buf) {
|
||
var now = window.performance && performance.now ? performance.now() : 0;
|
||
if (now && now - lastLevelAt < 50) return; // cap at ~20Hz to avoid excessive DOM updates
|
||
lastLevelAt = now;
|
||
var n = buf.byteLength >> 1;
|
||
if (n === 0) return;
|
||
var dv = new DataView(buf);
|
||
var sum = 0;
|
||
for (var i = 0; i < n; i++) {
|
||
var s = dv.getInt16(i * 2, true) / 32768;
|
||
sum += s * s;
|
||
}
|
||
var rms = Math.sqrt(sum / n);
|
||
setLevel(Math.min(1, rms * 3.5)); // modest gain so normal speech visibly moves the bar
|
||
}
|
||
|
||
// ============================================================
|
||
// Audio teardown
|
||
// ============================================================
|
||
// Stop only capture/streaming (disconnect nodes); keep audioCtx & mediaStream for a quick restart.
|
||
function teardownAudioCapture() {
|
||
audioGen++; // stop/cancel also invalidates in-flight resume, mic, worklet and prep-timeout callbacks.
|
||
releaseWakeLock();
|
||
if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint;
|
||
try {
|
||
if (workletNode) {
|
||
workletNode.port.onmessage = null;
|
||
workletNode.disconnect();
|
||
}
|
||
} catch (e) {}
|
||
workletNode = null;
|
||
|
||
try {
|
||
if (scriptNode) {
|
||
scriptNode.onaudioprocess = null;
|
||
scriptNode.disconnect();
|
||
if (scriptNode._silentGain) {
|
||
try {
|
||
scriptNode._silentGain.disconnect();
|
||
} catch (e2) {}
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
scriptNode = null;
|
||
|
||
try {
|
||
if (sourceNode) sourceNode.disconnect();
|
||
} catch (e) {}
|
||
// Null out sourceNode; the next ensureAudio recreates it from the stream
|
||
sourceNode = null;
|
||
|
||
// Reset the fallback resample state
|
||
resampleState.phase = 0;
|
||
resampleState.last = 0;
|
||
resampleState.hasLast = false;
|
||
}
|
||
|
||
// Full release (on disconnect): stop the mic tracks and close the ctx.
|
||
function teardownAudio() {
|
||
teardownAudioCapture();
|
||
if (mediaStream) {
|
||
try {
|
||
var tracks = mediaStream.getTracks();
|
||
for (var i = 0; i < tracks.length; i++) tracks[i].stop();
|
||
} catch (e) {}
|
||
mediaStream = null;
|
||
}
|
||
// Don't force-close the ctx (some browsers are slow to create a new one); just suspend when needed
|
||
if (audioCtx && audioCtx.state === 'running') {
|
||
try {
|
||
audioCtx.suspend();
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
|
||
// Hard reset after a prep timeout: stop the mic tracks and fully close audioCtx so the next
|
||
// ensureAudio rebuilds from zero. Unlike teardownAudio, this closes and nulls audioCtx — the
|
||
// timeout root cause is usually a broken ctx (resume never settles), and keeping it only
|
||
// wedges the next attempt.
|
||
function resetAudioContext() {
|
||
teardownAudioCapture();
|
||
if (mediaStream) {
|
||
try {
|
||
var tracks = mediaStream.getTracks();
|
||
for (var i = 0; i < tracks.length; i++) tracks[i].stop();
|
||
} catch (e) {}
|
||
mediaStream = null;
|
||
}
|
||
if (audioCtx) {
|
||
try {
|
||
audioCtx.close();
|
||
} catch (e) {}
|
||
audioCtx = null;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Screen lock / backgrounding ends this recording, keeping what the PC already received.
|
||
// ============================================================
|
||
function interruptRecording() {
|
||
var hadStarted = startSent;
|
||
if (recording) stopRecording();
|
||
else if (startSent && remoteSessionId) {
|
||
wsSendJSON({ type: 'stop' });
|
||
resetRemoteStreamState();
|
||
awaitingResult = true;
|
||
teardownAudioCapture();
|
||
updateRecordBtnUI();
|
||
}
|
||
// Release the old tracks after a system interruption; the next user-initiated recording re-acquires the mic.
|
||
teardownAudio();
|
||
if (hadStarted) setStatus(L.interrupted, 'work');
|
||
}
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (document.hidden) {
|
||
if (recording) interruptRecording();
|
||
releaseWakeLock();
|
||
clearRecoveryTimer();
|
||
clearWorkTimeout();
|
||
} else {
|
||
if (recording) acquireWakeLock();
|
||
if (authed) requestRecovery();
|
||
else if (!ws || ws.readyState > 1) {
|
||
var pin = readPin();
|
||
if (pin) connect(pin);
|
||
}
|
||
}
|
||
});
|
||
window.addEventListener('pagehide', function () {
|
||
if (recording) interruptRecording();
|
||
releaseWakeLock();
|
||
});
|
||
|
||
// ============================================================
|
||
// Init
|
||
// ============================================================
|
||
function init() {
|
||
// iOS Safari quirk workaround: certificate trust for in-page wss doesn't apply on the
|
||
// page's first load — the first connect stalls at the TLS handshake and times out, and one
|
||
// manual refresh fixes it (confirmed via logs: the first TCP arrives without upgrade; after
|
||
// refresh the WS upgrade succeeds immediately). Automate that refresh: silently reload once
|
||
// per browser session on first load, then init + auto-connect so the wss handshake succeeds.
|
||
// The sessionStorage marker guarantees a single reload with no loop; a manual refresh (same
|
||
// tab) won't retrigger it, only a new tab/reopen will.
|
||
var reloadedOnce = false;
|
||
try {
|
||
reloadedOnce = sessionStorage.getItem('ol_reloaded_once') === '1';
|
||
} catch (e) {}
|
||
if (!reloadedOnce) {
|
||
// Read back right after writing: if sessionStorage is disabled (write throws / doesn't
|
||
// stick) the marker never lands and reloading would loop forever — skip the reload and continue init.
|
||
var marked = false;
|
||
try {
|
||
sessionStorage.setItem('ol_reloaded_once', '1');
|
||
marked = sessionStorage.getItem('ol_reloaded_once') === '1';
|
||
} catch (e) {}
|
||
if (marked) {
|
||
location.reload();
|
||
return;
|
||
}
|
||
}
|
||
|
||
applyStaticI18n();
|
||
syncModeUI();
|
||
initInsertSwitch();
|
||
initWakeLockSwitch();
|
||
showScreen('pin');
|
||
showPinError('');
|
||
// Last successful pairing code → autofill and reconnect, so a refresh/reopen skips retyping
|
||
var saved = readPin();
|
||
if (saved) {
|
||
pinInput.value = saved;
|
||
doConnect();
|
||
} else {
|
||
// Autofocus the PIN field (some mobile browsers block it; ignore failures)
|
||
setTimeout(function () {
|
||
try {
|
||
pinInput.focus();
|
||
} catch (e) {}
|
||
}, 200);
|
||
}
|
||
}
|
||
|
||
init();
|
||
})();
|