mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
- 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>
95 lines
3.4 KiB
JavaScript
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 };
|
|
}
|
|
|