added support for microphone
This commit is contained in:
parent
f456c587a8
commit
0f98ae862f
@ -11,6 +11,7 @@ RUN dpkg --add-architecture i386 && \
|
|||||||
pulseaudio \
|
pulseaudio \
|
||||||
unzip \
|
unzip \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
pulseaudio-utils \
|
||||||
xdotool \
|
xdotool \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
|
|||||||
@ -15,7 +15,7 @@ cd ~/webclient_css_ze/streaming-agent && npm install
|
|||||||
./start-signaling.sh
|
./start-signaling.sh
|
||||||
|
|
||||||
## second run
|
## second run
|
||||||
docker run -d --name css-test -e SIG_URL=ws://172.17.0.1:3000 -e CF_APP_ID= -e CF_APP_TOKEN= -e CAP_FPS=60 -e CAP_W=800 -e CAP_H=600 --cpus="8.0" -v /home/gameservers/css_ze/cstrike/maps:/home/ubuntu/Counter-Strike\ Source/cstrike/maps:ro -v /home/gameservers/css_ze/cstrike/materials:/home/ubuntu/Counter-Strike\ Source/cstrike/materials:ro -v /home/gameservers/css_ze/cstrike/models:/home/ubuntu/Counter-Strike\ Source/cstrike/models:ro -v /home/gameservers/css_ze/cstrike/sound:/home/ubuntu/Counter-Strike\ Source/cstrike/sound:ro css-client
|
docker run -d --name css-test -e SIG_URL=ws://172.17.0.1:3000 -e CONTAINER_NAME=css-test -e CF_APP_ID= -e CF_APP_TOKEN= -e CAP_FPS=60 -e CAP_W=800 -e CAP_H=600 --cpus="8.0" -v /home/gameservers/css_ze/cstrike/maps:/home/ubuntu/Counter-Strike\ Source/cstrike/maps:ro -v /home/gameservers/css_ze/cstrike/materials:/home/ubuntu/Counter-Strike\ Source/cstrike/materials:ro -v /home/gameservers/css_ze/cstrike/models:/home/ubuntu/Counter-Strike\ Source/cstrike/models:ro -v /home/gameservers/css_ze/cstrike/sound:/home/ubuntu/Counter-Strike\ Source/cstrike/sound:ro css-client
|
||||||
|
|
||||||
## docker logs -f css-test
|
## docker logs -f css-test
|
||||||
## pkill -f signaling-server.js
|
## pkill -f signaling-server.js
|
||||||
|
|||||||
77
client.html
77
client.html
@ -93,6 +93,7 @@
|
|||||||
<div class="faq-item">
|
<div class="faq-item">
|
||||||
<h3>Keybinds</h3>
|
<h3>Keybinds</h3>
|
||||||
<p>v: crouch key currently</p>
|
<p>v: crouch key currently</p>
|
||||||
|
<p>k: microphone key. Browser will ask for microphone permission on first use</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -107,12 +108,73 @@
|
|||||||
let sessionId = null;
|
let sessionId = null;
|
||||||
let pointerLocked = false;
|
let pointerLocked = false;
|
||||||
|
|
||||||
|
// ── Microphone / PTT ──────────────────────────────────────────────────
|
||||||
|
let micStream = null;
|
||||||
|
let audioContext = null;
|
||||||
|
let micProcessor = null;
|
||||||
|
let micActive = false;
|
||||||
|
|
||||||
|
async function initMic() {
|
||||||
|
if (micStream) return true;
|
||||||
|
try {
|
||||||
|
micStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
sampleRate: 48000,
|
||||||
|
channelCount: 1,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
},
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[mic] getUserMedia failed:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMic() {
|
||||||
|
if (micActive) return;
|
||||||
|
const ok = await initMic();
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||||
|
const source = audioContext.createMediaStreamSource(micStream);
|
||||||
|
|
||||||
|
micProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
micProcessor.onaudioprocess = e => {
|
||||||
|
if (!micActive) return;
|
||||||
|
const float32 = e.inputBuffer.getChannelData(0);
|
||||||
|
const int16 = new Int16Array(float32.length);
|
||||||
|
for (let i = 0; i < float32.length; i++) {
|
||||||
|
int16[i] = Math.max(-32768, Math.min(32767, float32[i] * 32768));
|
||||||
|
}
|
||||||
|
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
|
||||||
|
sigWs.send(int16.buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.connect(micProcessor);
|
||||||
|
micProcessor.connect(audioContext.destination);
|
||||||
|
micActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMic() {
|
||||||
|
if (!micActive) return;
|
||||||
|
micActive = false;
|
||||||
|
if (micProcessor) { micProcessor.disconnect(); micProcessor = null; }
|
||||||
|
if (audioContext) { audioContext.close(); audioContext = null; }
|
||||||
|
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
|
||||||
|
sigWs.send(JSON.stringify({ type: 'mic_stop' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebRTC ────────────────────────────────────────────────────────────
|
||||||
function setStatus(msg) {
|
function setStatus(msg) {
|
||||||
statusEl.textContent = msg;
|
statusEl.textContent = msg;
|
||||||
console.log('[client]', msg);
|
console.log('[client]', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebRTC ────────────────────────────────────────────────────────────
|
|
||||||
async function setupPeerConnection() {
|
async function setupPeerConnection() {
|
||||||
pc = new RTCPeerConnection({
|
pc = new RTCPeerConnection({
|
||||||
iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }],
|
iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }],
|
||||||
@ -154,6 +216,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
sigWs.onmessage = async e => {
|
sigWs.onmessage = async e => {
|
||||||
|
if (e.data instanceof Blob) return;
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
|
|
||||||
if (msg.type === 'session_created') {
|
if (msg.type === 'session_created') {
|
||||||
@ -185,7 +248,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
sigWs.onerror = () => setStatus('Connection error');
|
sigWs.onerror = () => setStatus('Connection error');
|
||||||
sigWs.onclose = () => setStatus('Disconnected');
|
sigWs.onclose = () => {
|
||||||
|
stopMic();
|
||||||
|
setStatus('Disconnected');
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForIce() {
|
function waitForIce() {
|
||||||
@ -228,6 +294,7 @@
|
|||||||
window.addEventListener('blur', () => {
|
window.addEventListener('blur', () => {
|
||||||
heldKeys.forEach(key => sendInput({ type: 'key_up', key }));
|
heldKeys.forEach(key => sendInput({ type: 'key_up', key }));
|
||||||
heldKeys.clear();
|
heldKeys.clear();
|
||||||
|
stopMic();
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastMouseMove = 0;
|
let lastMouseMove = 0;
|
||||||
@ -296,6 +363,9 @@
|
|||||||
if (!pointerLocked) return;
|
if (!pointerLocked) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
||||||
|
if (e.code === 'KeyK' && !e.repeat) {
|
||||||
|
startMic();
|
||||||
|
}
|
||||||
heldKeys.add(key);
|
heldKeys.add(key);
|
||||||
sendInput({ type: 'key_down', key });
|
sendInput({ type: 'key_down', key });
|
||||||
});
|
});
|
||||||
@ -308,6 +378,9 @@
|
|||||||
if (!pointerLocked) return;
|
if (!pointerLocked) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
||||||
|
if (e.code === 'KeyK') {
|
||||||
|
stopMic();
|
||||||
|
}
|
||||||
heldKeys.delete(key);
|
heldKeys.delete(key);
|
||||||
sendInput({ type: 'key_up', key });
|
sendInput({ type: 'key_up', key });
|
||||||
});
|
});
|
||||||
|
|||||||
11
start.sh
11
start.sh
@ -3,12 +3,23 @@
|
|||||||
rm -f /tmp/.X99-lock
|
rm -f /tmp/.X99-lock
|
||||||
rm -f /tmp/.X11-unix/X99
|
rm -f /tmp/.X11-unix/X99
|
||||||
|
|
||||||
|
#no wine crash dialog in case of nosteam crashing.
|
||||||
|
wine reg add "HKCU\\Software\\Wine\\WineDbg" /v ShowCrashDialog /t REG_DWORD /d 0 /f
|
||||||
|
|
||||||
Xvfb :99 -screen 0 800x600x24 &
|
Xvfb :99 -screen 0 800x600x24 &
|
||||||
sleep 1
|
sleep 1
|
||||||
export DISPLAY=:99
|
export DISPLAY=:99
|
||||||
matchbox-window-manager -use_titlebar no &
|
matchbox-window-manager -use_titlebar no &
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
pulseaudio --start --exit-idle-time=-1
|
pulseaudio --start --exit-idle-time=-1
|
||||||
|
sleep 1
|
||||||
|
# virtual microphone
|
||||||
|
pactl load-module module-null-sink sink_name=mic_sink
|
||||||
|
pactl load-module module-virtual-source source_name=virtual_mic master=mic_sink.monitor
|
||||||
|
pactl set-default-source virtual_mic
|
||||||
|
|
||||||
|
sleep 1
|
||||||
wine ~/Counter-Strike\ Source/revLoader.exe &
|
wine ~/Counter-Strike\ Source/revLoader.exe &
|
||||||
sleep 5
|
sleep 5
|
||||||
python3 ~/streaming-agent/gst_agent.py
|
python3 ~/streaming-agent/gst_agent.py
|
||||||
|
|||||||
@ -229,6 +229,7 @@ async def run_signaling(session_id, video_track_name, audio_track_name):
|
|||||||
'sessionId': session_id,
|
'sessionId': session_id,
|
||||||
'videoTrackName': video_track_name,
|
'videoTrackName': video_track_name,
|
||||||
'audioTrackName': audio_track_name,
|
'audioTrackName': audio_track_name,
|
||||||
|
'containerName': os.environ.get('CONTAINER_NAME', 'css-test'),
|
||||||
}))
|
}))
|
||||||
log.info('Registered with signaling server')
|
log.info('Registered with signaling server')
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
* Signaling Server
|
* Signaling Server
|
||||||
*
|
*
|
||||||
* Handles two tracks: video and game audio.
|
* Handles two tracks: video and game audio.
|
||||||
|
* Also relays browser microphone audio to container PulseAudio via pacat.
|
||||||
* All Cloudflare API calls happen server-side.
|
* All Cloudflare API calls happen server-side.
|
||||||
*
|
*
|
||||||
* Run: CF_APP_ID=xxx CF_APP_TOKEN=xxx node signaling-server.js
|
* Run: CF_APP_ID=xxx CF_APP_TOKEN=xxx node signaling-server.js
|
||||||
@ -14,12 +15,24 @@ const http = require('http');
|
|||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const fetch = require('node-fetch');
|
const fetch = require('node-fetch');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const CF_APP_ID = process.env.CF_APP_ID || '';
|
const CF_APP_ID = process.env.CF_APP_ID || '';
|
||||||
const CF_APP_TOKEN = process.env.CF_APP_TOKEN || '';
|
const CF_APP_TOKEN = process.env.CF_APP_TOKEN || '';
|
||||||
const CF_BASE = `https://rtc.live.cloudflare.com/v1/apps/${CF_APP_ID}`;
|
const CF_BASE = `https://rtc.live.cloudflare.com/v1/apps/${CF_APP_ID}`;
|
||||||
|
|
||||||
|
// Container's PulseAudio sink to write mic audio into
|
||||||
|
// pacat writes raw PCM to the null sink which virtual_mic reads from
|
||||||
|
const PACAT_ARGS = [
|
||||||
|
'--playback',
|
||||||
|
'--device=mic_sink',
|
||||||
|
'--format=s16le',
|
||||||
|
'--rate=48000',
|
||||||
|
'--channels=1',
|
||||||
|
'--latency-msec=20',
|
||||||
|
];
|
||||||
|
|
||||||
if (!CF_APP_ID || !CF_APP_TOKEN) {
|
if (!CF_APP_ID || !CF_APP_TOKEN) {
|
||||||
console.error('[sig] CF_APP_ID and CF_APP_TOKEN must be set');
|
console.error('[sig] CF_APP_ID and CF_APP_TOKEN must be set');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@ -57,12 +70,68 @@ app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'client.html'
|
|||||||
const agentSessions = new Map();
|
const agentSessions = new Map();
|
||||||
const browserToAgent = new Map();
|
const browserToAgent = new Map();
|
||||||
|
|
||||||
|
// Per-browser pacat process for mic audio
|
||||||
|
// browserSessionId -> pacat child process
|
||||||
|
const pacatProcesses = new Map();
|
||||||
|
|
||||||
|
function startPacat(browserSessId) {
|
||||||
|
// Find the container ID for this browser session
|
||||||
|
// pacat runs inside the Docker container via docker exec
|
||||||
|
const agentId = browserToAgent.get(browserSessId);
|
||||||
|
const entry = agentSessions.get(agentId);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
// Get container name from agent — agent sends it on register
|
||||||
|
const containerName = entry.containerName || 'css-test';
|
||||||
|
|
||||||
|
const pacat = spawn('docker', [
|
||||||
|
'exec', '-i', containerName,
|
||||||
|
'pacat', ...PACAT_ARGS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
pacat.stderr.on('data', d => {
|
||||||
|
console.error(`[pacat:${browserSessId}]`, d.toString().trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
pacat.on('close', code => {
|
||||||
|
console.log(`[pacat:${browserSessId}] exited with code ${code}`);
|
||||||
|
pacatProcesses.delete(browserSessId);
|
||||||
|
});
|
||||||
|
|
||||||
|
pacatProcesses.set(browserSessId, pacat);
|
||||||
|
console.log(`[sig] pacat started for browser session ${browserSessId}`);
|
||||||
|
return pacat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPacat(browserSessId) {
|
||||||
|
const pacat = pacatProcesses.get(browserSessId);
|
||||||
|
if (pacat) {
|
||||||
|
pacat.kill();
|
||||||
|
pacatProcesses.delete(browserSessId);
|
||||||
|
console.log(`[sig] pacat stopped for browser session ${browserSessId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
wss.on('connection', ws => {
|
wss.on('connection', ws => {
|
||||||
let role = null;
|
let role = null;
|
||||||
let agentSessionId = null;
|
let agentSessionId = null;
|
||||||
let browserSessId = null;
|
let browserSessId = null;
|
||||||
|
|
||||||
ws.on('message', async raw => {
|
ws.on('message', async (raw, isBinary) => {
|
||||||
|
// Binary messages are raw PCM audio from browser microphone
|
||||||
|
if (isBinary) {
|
||||||
|
if (browserSessId) {
|
||||||
|
let pacat = pacatProcesses.get(browserSessId);
|
||||||
|
if (!pacat) {
|
||||||
|
pacat = startPacat(browserSessId);
|
||||||
|
}
|
||||||
|
if (pacat && pacat.stdin.writable) {
|
||||||
|
pacat.stdin.write(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let msg;
|
let msg;
|
||||||
try { msg = JSON.parse(raw); } catch { return; }
|
try { msg = JSON.parse(raw); } catch { return; }
|
||||||
|
|
||||||
@ -73,9 +142,17 @@ wss.on('connection', ws => {
|
|||||||
agentSessionId: msg.sessionId,
|
agentSessionId: msg.sessionId,
|
||||||
videoTrackName: msg.videoTrackName,
|
videoTrackName: msg.videoTrackName,
|
||||||
audioTrackName: msg.audioTrackName,
|
audioTrackName: msg.audioTrackName,
|
||||||
|
containerName: msg.containerName || 'css-test',
|
||||||
agentWs: ws,
|
agentWs: ws,
|
||||||
});
|
});
|
||||||
console.log(`[sig] agent registered — sessionId=${agentSessionId} video=${msg.videoTrackName} audio=${msg.audioTrackName}`);
|
console.log(`[sig] agent registered — sessionId=${agentSessionId} container=${msg.containerName || 'css-test'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (msg.type === 'mic_stop') {
|
||||||
|
// Browser released PTT key — stop pacat
|
||||||
|
if (browserSessId) {
|
||||||
|
stopPacat(browserSessId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (msg.type === 'browser_connect') {
|
else if (msg.type === 'browser_connect') {
|
||||||
@ -147,6 +224,7 @@ wss.on('connection', ws => {
|
|||||||
console.log(`[sig] agent disconnected — sessionId=${agentSessionId}`);
|
console.log(`[sig] agent disconnected — sessionId=${agentSessionId}`);
|
||||||
}
|
}
|
||||||
if (role === 'browser' && browserSessId) {
|
if (role === 'browser' && browserSessId) {
|
||||||
|
stopPacat(browserSessId);
|
||||||
browserToAgent.delete(browserSessId);
|
browserToAgent.delete(browserSessId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user