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 @@
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);
}
});