diff --git a/Dockerfile b/Dockerfile index 644424f..f525567 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN dpkg --add-architecture i386 && \ pulseaudio \ unzip \ ffmpeg \ + pulseaudio-utils \ xdotool \ nodejs \ npm \ diff --git a/README.md b/README.md index 9473cc8..bbc49f1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ cd ~/webclient_css_ze/streaming-agent && npm install ./start-signaling.sh ## 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 ## pkill -f signaling-server.js diff --git a/client.html b/client.html index da0eed3..40161ba 100644 --- a/client.html +++ b/client.html @@ -69,7 +69,7 @@
Click to capture mouse | ESC to release

FAQ

-
+

What is this?

This is the unloze webclient. You can play Zombie escape, Minigame and Zombie Riot with it. Visit our website at: https://unloze.com

@@ -93,6 +93,7 @@

Keybinds

v: crouch key currently

+

k: microphone key. Browser will ask for microphone permission on first use

@@ -107,12 +108,73 @@ let sessionId = null; 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) { statusEl.textContent = msg; console.log('[client]', msg); } - // ── WebRTC ──────────────────────────────────────────────────────────── async function setupPeerConnection() { pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], @@ -154,6 +216,7 @@ }; sigWs.onmessage = async e => { + if (e.data instanceof Blob) return; const msg = JSON.parse(e.data); if (msg.type === 'session_created') { @@ -185,7 +248,10 @@ }; sigWs.onerror = () => setStatus('Connection error'); - sigWs.onclose = () => setStatus('Disconnected'); + sigWs.onclose = () => { + stopMic(); + setStatus('Disconnected'); + }; } function waitForIce() { @@ -228,6 +294,7 @@ window.addEventListener('blur', () => { heldKeys.forEach(key => sendInput({ type: 'key_up', key })); heldKeys.clear(); + stopMic(); }); let lastMouseMove = 0; @@ -296,6 +363,9 @@ if (!pointerLocked) return; e.preventDefault(); const key = KEY_MAP[e.code] || e.code.toLowerCase(); + if (e.code === 'KeyK' && !e.repeat) { + startMic(); + } heldKeys.add(key); sendInput({ type: 'key_down', key }); }); @@ -308,6 +378,9 @@ if (!pointerLocked) return; e.preventDefault(); const key = KEY_MAP[e.code] || e.code.toLowerCase(); + if (e.code === 'KeyK') { + stopMic(); + } heldKeys.delete(key); sendInput({ type: 'key_up', key }); }); diff --git a/start.sh b/start.sh index 1a328ab..057cbbd 100755 --- a/start.sh +++ b/start.sh @@ -3,12 +3,23 @@ rm -f /tmp/.X99-lock 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 & sleep 1 export DISPLAY=:99 matchbox-window-manager -use_titlebar no & sleep 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 & sleep 5 python3 ~/streaming-agent/gst_agent.py diff --git a/streaming-agent/gst_agent.py b/streaming-agent/gst_agent.py index 5d042ac..98bbbea 100755 --- a/streaming-agent/gst_agent.py +++ b/streaming-agent/gst_agent.py @@ -229,6 +229,7 @@ async def run_signaling(session_id, video_track_name, audio_track_name): 'sessionId': session_id, 'videoTrackName': video_track_name, 'audioTrackName': audio_track_name, + 'containerName': os.environ.get('CONTAINER_NAME', 'css-test'), })) log.info('Registered with signaling server') diff --git a/streaming-agent/signaling-server.js b/streaming-agent/signaling-server.js index f77faff..8f37a41 100644 --- a/streaming-agent/signaling-server.js +++ b/streaming-agent/signaling-server.js @@ -2,6 +2,7 @@ * Signaling Server * * Handles two tracks: video and game audio. + * Also relays browser microphone audio to container PulseAudio via pacat. * All Cloudflare API calls happen server-side. * * 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 fetch = require('node-fetch'); const path = require('path'); +const { spawn } = require('child_process'); const PORT = process.env.PORT || 3000; const CF_APP_ID = process.env.CF_APP_ID || ''; const CF_APP_TOKEN = process.env.CF_APP_TOKEN || ''; 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) { console.error('[sig] CF_APP_ID and CF_APP_TOKEN must be set'); process.exit(1); @@ -57,12 +70,68 @@ app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'client.html' const agentSessions = 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 => { let role = null; let agentSessionId = 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; try { msg = JSON.parse(raw); } catch { return; } @@ -73,9 +142,17 @@ wss.on('connection', ws => { agentSessionId: msg.sessionId, videoTrackName: msg.videoTrackName, audioTrackName: msg.audioTrackName, + containerName: msg.containerName || 'css-test', 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') { @@ -147,6 +224,7 @@ wss.on('connection', ws => { console.log(`[sig] agent disconnected — sessionId=${agentSessionId}`); } if (role === 'browser' && browserSessId) { + stopPacat(browserSessId); browserToAgent.delete(browserSessId); } });