Files
links/static_src/jbot/hooks/usePushToTalk.js
T
junvandCopilot a87b4643fd feat: port JBOT AI robot face into links as Django app
- Add jbot Django app (jbot/__init__.py, apps.py, urls.py, api_urls.py)
- Python backend (jbot/views.py): TTS 2.0, ASR 1.0 BigASR, LLM/memory via OpenRouter
- React SPA frontend at /jbot/ (static_src/jbot/): RobotFace, ChatPanel, expressions,
  voices, push-to-talk, history, memory, settings pages
- Vite entry jbot: static_src/jbot/main.jsx → dist/jbot.js + dist/jbot.css
- react-router-dom added; BrowserRouter basename=/jbot for SPA routing
- core/settings.py: added jbot to INSTALLED_APPS
- core/urls.py: /jbot/ + /api/jbot/ URL includes
- pyproject.toml: websocket-client>=1.9.0 for BigASR binary WS protocol
- Dockerfile: ffmpeg added to production apt-get for WebM→PCM audio conversion
- k8s/manifest.yaml: VOLC_APP_ID, VOLC_ACCESS_TOKEN, OPENROUTER_API_KEY env vars
  via jbot-credentials Secret; LLM_MODEL, VOLC_TTS_VOICE, VOLC_ASR_RESOURCE defaults

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-17 14:13:37 +10:00

95 lines
3.4 KiB
JavaScript

import { useRef, useState, useCallback } from 'react';
export function usePushToTalk() {
const [isRecording, setIsRecording] = useState(false);
const streamRef = useRef(null); // mic stream, acquired on demand
const recorderRef = useRef(null);
const recognitionRef = useRef(null);
const chunksRef = useRef([]);
const transcriptRef = useRef('');
// Resolves once the mic stream is acquired and MediaRecorder is running.
// Throws if mic permission is denied.
const start = useCallback(async () => {
if (isRecording) return;
chunksRef.current = [];
transcriptRef.current = '';
// ── Acquire mic stream (on-demand, not pre-warmed) ───────────
// If the previous stream is still active (same session, held open) reuse it;
// otherwise request a new one — this is where the brief "preparing" delay happens.
if (!streamRef.current?.active) {
streamRef.current = await navigator.mediaDevices.getUserMedia({ audio: true });
}
// ── MediaRecorder ────────────────────────────────────────────
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus' : 'audio/webm';
const recorder = new MediaRecorder(streamRef.current, { mimeType });
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
recorder.start(100);
recorderRef.current = recorder;
// ── SpeechRecognition: real-time transcript ──────────────────
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
const sr = new SR();
sr.lang = 'zh-CN';
sr.interimResults = true;
sr.continuous = true;
sr.onresult = (e) => {
let final = '';
for (const res of e.results) {
if (res.isFinal) final += res[0].transcript;
}
if (final) transcriptRef.current = final;
};
sr.onerror = (e) => { console.warn('SpeechRecognition error:', e.error); };
sr.start();
recognitionRef.current = sr;
}
setIsRecording(true);
// start() resolves here — caller knows mic is live and recording has begun
}, [isRecording]);
// Returns { transcript, audioBase64, mimeType }
const stop = useCallback(() => new Promise((resolve) => {
setIsRecording(false);
const transcript = transcriptRef.current;
try { recognitionRef.current?.stop(); } catch {}
recognitionRef.current = null;
// Release mic stream so the OS indicator light turns off
streamRef.current?.getTracks().forEach(t => t.stop());
streamRef.current = null;
const recorder = recorderRef.current;
if (!recorder) {
return resolve({ transcript, audioBase64: null, mimeType: null });
}
recorder.onstop = async () => {
const mimeType = recorder.mimeType;
if (chunksRef.current.length === 0) {
return resolve({ transcript, audioBase64: null, mimeType });
}
const blob = new Blob(chunksRef.current, { type: mimeType });
const reader = new FileReader();
reader.onloadend = () => {
resolve({ transcript, audioBase64: reader.result.split(',')[1], mimeType });
};
reader.readAsDataURL(blob);
};
recorder.stop();
recorderRef.current = null;
}), []);
return { isRecording, start, stop };
}