From f6b53c3955f4ac052ce4180a2d0dadd9ae190568 Mon Sep 17 00:00:00 2001 From: srtk Date: Mon, 1 Dec 2025 09:52:19 +0530 Subject: [PATCH] moved server to C --- .gitignore | 5 +- airlink.c | 329 +++++++++++++++++++++++++++++++++++++++++++++++++++++ server.py | 148 ------------------------ 3 files changed, 332 insertions(+), 150 deletions(-) create mode 100644 airlink.c delete mode 100644 server.py diff --git a/.gitignore b/.gitignore index 350a679..d577445 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -app.png -logo.png +assets/ +build/ +airlink.py diff --git a/airlink.c b/airlink.c new file mode 100644 index 0000000..0b16865 --- /dev/null +++ b/airlink.c @@ -0,0 +1,329 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --- Configuration --- +#define PORT_VIDEO 5000 +#define PORT_AUDIO 5001 +#define DEVICE_VIDEO "/dev/video20" +#define SINK_AUDIO "AirLink_Sink" +#define SOURCE_MIC "AirLink_Mic" +#define BUFFER_SIZE 65535 + +// --- Globals --- +pid_t pid_video_process = 0; +pid_t pid_audio_process = 0; +int pipe_video_fd = -1; +int pipe_audio_fd = -1; +int socket_video = -1; +int socket_audio = -1; +int is_running = 1; + +// Stats tracking +typedef struct { + long long bytes_video; + long long bytes_audio; + int packets_video; + int packets_audio; + time_t last_log_time; +} TrafficStats; + +TrafficStats stats = {0, 0, 0, 0, 0}; + +// --- Helpers --- + +void kill_process(pid_t *pid) { + if (*pid > 0) { + kill(*pid, SIGTERM); + waitpid(*pid, NULL, 0); // Wait to prevent zombies + *pid = 0; + } +} + +// Generates a black screen on the virtual camera (used for Pause/Stop) +void render_black_screen() { + kill_process(&pid_video_process); + if (pipe_video_fd != -1) { close(pipe_video_fd); pipe_video_fd = -1; } + + printf("[STATE] Rendering black screen (Privacy Mode)\n"); + // We run this as a blocking system call because it's a short "state change" operation + char cmd[512]; + snprintf(cmd, sizeof(cmd), + "ffmpeg -hide_banner -loglevel error -f lavfi -i color=black:s=1280x720 " + "-pix_fmt yuyv422 -frames:v 5 -f v4l2 %s > /dev/null 2>&1", + DEVICE_VIDEO); + system(cmd); +} + +void cleanup(int sig) { + is_running = 0; + printf("\n[INFO] Shutting down services...\n"); + + kill_process(&pid_video_process); + kill_process(&pid_audio_process); + + // Clean up PulseAudio modules + char cmd[256]; + snprintf(cmd, sizeof(cmd), "pactl unload-module %s > /dev/null 2>&1", SINK_AUDIO); + system(cmd); + system("pactl unload-module AirLink_Mic > /dev/null 2>&1"); + + // Stop Avahi + system("pkill -f 'avahi-publish-service -s AirLink'"); + + // Final Black Screen + render_black_screen(); + + exit(0); +} + +// Spawns a process and returns the Write FD for its STDIN +pid_t spawn_worker(char *const cmd[], int *fd_in) { + int pipefd[2]; + if (pipe(pipefd) == -1) return -1; + + pid_t pid = fork(); + if (pid == 0) { + // Child + close(pipefd[1]); // Close write end + dup2(pipefd[0], STDIN_FILENO); // Redirect pipe read to STDIN + close(pipefd[0]); + + // Close other file descriptors to be safe + for (int i = 3; i < 256; i++) close(i); + + execvp(cmd[0], cmd); + exit(1); // Should not reach here + } else if (pid > 0) { + // Parent + close(pipefd[0]); // Close read end + *fd_in = pipefd[1]; // Keep write end + return pid; + } + return -1; +} + +void initialize_system() { + printf("[INIT] Configuring Kernel Modules and Audio Subsystem...\n"); + + // Video: Reload v4l2loopback + system("sudo modprobe -r v4l2loopback 2>/dev/null"); + char v4l2_cmd[256]; + snprintf(v4l2_cmd, sizeof(v4l2_cmd), + "sudo modprobe v4l2loopback video_nr=20 card_label=AirLink_Cam exclusive_caps=1"); + system(v4l2_cmd); + + // Audio: Null Sink + char pa_cmd[512]; + snprintf(pa_cmd, sizeof(pa_cmd), + "pactl load-module module-null-sink sink_name=%s " + "sink_properties=device.description='AirLink_Audio' rate=48000 channels=1 >/dev/null 2>&1", + SINK_AUDIO); + system(pa_cmd); + + // Audio: Remap Source (Virtual Mic) + snprintf(pa_cmd, sizeof(pa_cmd), + "pactl load-module module-remap-source source_name=%s " + "master=%s.monitor source_properties=device.description='AirLink_Mic' channels=1 >/dev/null 2>&1", + SOURCE_MIC, SINK_AUDIO); + system(pa_cmd); +} + +void start_video_stream() { + // Check if already running + if (pid_video_process > 0 && waitpid(pid_video_process, NULL, WNOHANG) == 0) return; + + // FFmpeg command to read raw H264 from stdin and write to V4L2 + char *cmd[] = { + "ffmpeg", + "-fflags", "nobuffer", + "-flags", "low_delay", + "-analyzeduration", "0", + "-probesize", "32", + "-hide_banner", + "-loglevel", "quiet", + "-f", "h264", + "-i", "pipe:0", + "-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2", + "-vsync", "0", + "-copyts", + "-pix_fmt", "yuyv422", + "-f", "v4l2", DEVICE_VIDEO, + NULL + }; + + pid_video_process = spawn_worker(cmd, &pipe_video_fd); + signal(SIGPIPE, SIG_IGN); // Prevent crash if ffmpeg dies +} + +void start_audio_stream() { + if (pid_audio_process > 0 && waitpid(pid_audio_process, NULL, WNOHANG) == 0) return; + + // Use a shell to pipe ffmpeg decoder directly to pacat + // Input: Raw PCM or Encoded Audio -> FFmpeg -> Raw PCM -> Pacat + // Based on Python: Raw s16le incoming -> Pacat + // If incoming is already raw PCM (s16le, 48k, mono), we can skip ffmpeg or just use it as pass-through. + // We will use the shell wrapper for simplicity of piping. + + int pipefd[2]; + if (pipe(pipefd) == -1) return; + + pid_audio_process = fork(); + if (pid_audio_process == 0) { + close(pipefd[1]); + dup2(pipefd[0], STDIN_FILENO); + + // Command: Decode (ensure format) -> Play + // We assume the Android app sends raw PCM (s16le) as per your Python script audio logic + execl("/bin/sh", "sh", "-c", + "ffmpeg -hide_banner -loglevel quiet -fflags nobuffer -f s16le -ar 48000 -ac 1 -i pipe:0 " + "-f s16le -ar 48000 -ac 1 pipe:1 | " + "pacat --playback --device=AirLink_Sink --rate=48000 --channels=1 --format=s16le --latency-msec=20", + NULL); + exit(1); + } + close(pipefd[0]); + pipe_audio_fd = pipefd[1]; +} + +void print_stats() { + time_t now = time(NULL); + if (stats.last_log_time == 0) stats.last_log_time = now; + + double delta = difftime(now, stats.last_log_time); + if (delta >= 1.0) { + double video_mbps = (stats.bytes_video * 8.0) / (1000.0 * 1000.0) / delta; + double audio_kbps = (stats.bytes_audio * 8.0) / 1000.0 / delta; + + printf("[STATS] Video: %6.2f Mbps (%d pps) | Audio: %6.2f Kbps (%d pps)\n", + video_mbps, stats.packets_video, + audio_kbps, stats.packets_audio); + + // Reset + stats.bytes_video = 0; + stats.bytes_audio = 0; + stats.packets_video = 0; + stats.packets_audio = 0; + stats.last_log_time = now; + } +} + +int main() { + signal(SIGINT, cleanup); + + initialize_system(); + + // Start Avahi Advertising (Background) + system("avahi-publish-service -s AirLink _procam._udp 5000 &"); + + // Create UDP Sockets + socket_video = socket(AF_INET, SOCK_DGRAM, 0); + socket_audio = socket(AF_INET, SOCK_DGRAM, 0); + + // Set large kernel buffers to prevent drops + int rcvbuf_video = 4 * 1024 * 1024; // 4MB + int rcvbuf_audio = 512 * 1024; // 512KB + setsockopt(socket_video, SOL_SOCKET, SO_RCVBUF, &rcvbuf_video, sizeof(rcvbuf_video)); + setsockopt(socket_audio, SOL_SOCKET, SO_RCVBUF, &rcvbuf_audio, sizeof(rcvbuf_audio)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + + addr.sin_port = htons(PORT_VIDEO); + if (bind(socket_video, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + perror("Bind Video Failed"); return 1; + } + + addr.sin_port = htons(PORT_AUDIO); + if (bind(socket_audio, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + perror("Bind Audio Failed"); return 1; + } + + printf("[READY] Server listening on Ports %d (Video) & %d (Audio)\n", PORT_VIDEO, PORT_AUDIO); + + start_video_stream(); + start_audio_stream(); + + unsigned char buffer[BUFFER_SIZE]; + fd_set readfds; + int max_fd = (socket_video > socket_audio ? socket_video : socket_audio) + 1; + + struct sockaddr_in src_addr; + socklen_t src_len; + + while (is_running) { + print_stats(); + + // Check process health and restart if needed + if (waitpid(pid_video_process, NULL, WNOHANG) > 0) start_video_stream(); + if (waitpid(pid_audio_process, NULL, WNOHANG) > 0) start_audio_stream(); + + FD_ZERO(&readfds); + FD_SET(socket_video, &readfds); + FD_SET(socket_audio, &readfds); + + struct timeval tv = {1, 0}; // 1 second timeout (allows stats to print even if idle) + int activity = select(max_fd, &readfds, NULL, NULL, &tv); + + if (activity < 0 && errno != EINTR) break; + + // --- Handle Video & Control --- + if (FD_ISSET(socket_video, &readfds)) { + src_len = sizeof(src_addr); + int n = recvfrom(socket_video, buffer, sizeof(buffer), 0, (struct sockaddr*)&src_addr, &src_len); + + if (n > 0) { + // Control Packet Handling + if (n < 16) { + if (memcmp(buffer, "PING", 4) == 0) { + sendto(socket_video, "PONG", 4, 0, (struct sockaddr*)&src_addr, src_len); + } + else if (memcmp(buffer, "HEARTBEAT", 9) == 0) { + sendto(socket_video, "ACK", 3, 0, (struct sockaddr*)&src_addr, src_len); + } + else if (memcmp(buffer, "PAUSE", 5) == 0 || memcmp(buffer, "BYE", 3) == 0) { + render_black_screen(); + } + } + else { + // Video Data + if (pid_video_process == 0) start_video_stream(); + write(pipe_video_fd, buffer, n); + + // Update Stats + stats.bytes_video += n; + stats.packets_video++; + } + } + } + + // --- Handle Audio --- + if (FD_ISSET(socket_audio, &readfds)) { + int n = recvfrom(socket_audio, buffer, sizeof(buffer), 0, NULL, NULL); + if (n > 0) { + // Audio Data (ignore control packets on this port usually) + if (n > 16) { + write(pipe_audio_fd, buffer, n); + + // Update Stats + stats.bytes_audio += n; + stats.packets_audio++; + } + } + } + } + + return 0; +} \ No newline at end of file diff --git a/server.py b/server.py deleted file mode 100644 index 6f125bd..0000000 --- a/server.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -import socket, subprocess, sys, time, signal, threading, select, math -from zeroconf import ServiceInfo, Zeroconf - -# Configuration -CONF = { - 'video_port': 5000, 'audio_port': 5001, - 'video_dev': '/dev/video20', 'audio_sink': 'AirLink_Sink', 'audio_src': 'AirLink_Mic' -} - -class AirLinkServer: - def __init__(self): - self.ip = self.get_lan_ip() - self.running = False - self.procs = {'video': None, 'audio': None, 'ffmpeg_audio': None} - self.sockets = {'video': None, 'audio': None} - self.stats = {'v_bytes': 0, 'a_bytes': 0, 'v_pkts': 0, 'a_pkts': 0, 'last_time': time.time()} - self.zc = None - self.lock = threading.Lock() - - def get_lan_ip(self): - """Finds LAN IP by prioritizing physical interfaces over VPN/Tun interfaces.""" - try: - # List global IPv4 addresses, prioritize eth/wlan - cmd = "ip -4 -o addr show scope global | awk '{print $2,$4}'" - out = subprocess.check_output(cmd, shell=True).decode().strip().split('\n') - candidates = [] - for line in out: - if not line: continue - iface, ip = line.split()[0], line.split()[1].split('/')[0] - if iface.startswith(('e', 'w')): return ip # Found physical interface - candidates.append(ip) - return candidates[0] if candidates else '127.0.0.1' - except: - # Fallback to standard socket connect - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: s.connect(("8.8.8.8", 80)); return s.getsockname()[0] - except: return '127.0.0.1' - finally: s.close() - - def setup_system(self): - print(f"[INIT] Configuring system on {self.ip}...") - try: - subprocess.run("sudo modprobe -r v4l2loopback; sudo modprobe v4l2loopback video_nr=20 card_label=AirLink_Cam exclusive_caps=1", shell=True, check=True, stderr=subprocess.DEVNULL) - print(f"[INFO] Video Device: {CONF['video_dev']}") - - subprocess.run(f"pactl load-module module-null-sink sink_name={CONF['audio_sink']} sink_properties=device.description='AirLink_Audio' rate=48000 channels=1", shell=True, stderr=subprocess.DEVNULL) - subprocess.run(f"pactl load-module module-remap-source source_name={CONF['audio_src']} master={CONF['audio_sink']}.monitor source_properties=device.description='AirLink_Mic' channels=1", shell=True, stderr=subprocess.DEVNULL) - print(f"[INFO] Audio Source: {CONF['audio_src']}") - return True - except Exception as e: - print(f"[ERR] Setup failed: {e}") - return False - - def paint_black(self): - with self.lock: - if self.procs['video']: - self.procs['video'].terminate() - self.procs['video'] = None - try: - subprocess.run(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=black:s=1280x720', '-pix_fmt', 'yuyv422', '-frames:v', '5', '-f', 'v4l2', CONF['video_dev']], check=False) - print("[INFO] Video stream stopped (Black Screen)") - except: pass - - def start_pipeline(self, p_type): - with self.lock: - if p_type == 'video' and (not self.procs['video'] or self.procs['video'].poll() is not None): - self.procs['video'] = subprocess.Popen( - ['ffmpeg', '-fflags', 'nobuffer', '-flags', 'low_delay', '-analyzeduration', '0', '-probesize', '32', - '-hide_banner', '-loglevel', 'fatal', '-f', 'h264', '-i', 'pipe:0', - '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2', - '-vsync', '0', '-copyts', '-pix_fmt', 'yuyv422', '-f', 'v4l2', CONF['video_dev']], - stdin=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0) - - elif p_type == 'audio' and (not self.procs['audio'] or self.procs['audio'].poll() is not None): - self.procs['ffmpeg_audio'] = subprocess.Popen( - ['ffmpeg', '-hide_banner', '-loglevel', 'fatal', '-fflags', 'nobuffer', '-f', 's16le', '-ar', '48000', '-ac', '1', '-i', 'pipe:0', '-f', 's16le', '-ar', '48000', '-ac', '1', 'pipe:1'], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0) - self.procs['audio'] = subprocess.Popen( - ['pacat', '--playback', f"--device={CONF['audio_sink']}", '--rate=48000', '--channels=1', '--format=s16le', '--latency-msec=20'], - stdin=self.procs['ffmpeg_audio'].stdout, stderr=subprocess.DEVNULL, bufsize=0) - - def log_stats(self): - now = time.time() - delta = now - self.stats['last_time'] - if delta >= 1.0: - v_mbps = (self.stats['v_bytes'] * 8) / (1000 * 1000) / delta - a_kbps = (self.stats['a_bytes'] * 8) / 1000 / delta - print(f"[STATS] Video: {v_mbps:6.2f} Mbps ({self.stats['v_pkts']} pps) | Audio: {a_kbps:6.2f} kbps ({self.stats['a_pkts']} pps)") - self.stats = {'v_bytes': 0, 'a_bytes': 0, 'v_pkts': 0, 'a_pkts': 0, 'last_time': now} - - def run(self): - if not self.setup_system(): return - self.running = True - - # Discovery - info = ServiceInfo("_procam._udp.local.", "AirLink._procam._udp.local.", addresses=[socket.inet_aton(self.ip)], port=CONF['video_port'], properties={'version': '2.0'}) - self.zc = Zeroconf(); self.zc.register_service(info) - - # Sockets - for k, port, buf in [('video', CONF['video_port'], 4*1024*1024), ('audio', CONF['audio_port'], 512*1024)]: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, buf) - s.bind(("0.0.0.0", port)) - s.setblocking(0) - self.sockets[k] = s - - self.start_pipeline('video'); self.start_pipeline('audio') - print(f"\n[READY] Server listening on {self.ip}:{CONF['video_port']}\n") - - while self.running: - self.log_stats() - # Ensure audio is alive - if self.procs['audio'] and self.procs['audio'].poll() is not None: self.start_pipeline('audio') - - ready, _, _ = select.select(list(self.sockets.values()), [], [], 0.1) - for sock in ready: - try: - data, addr = sock.recvfrom(65535) - - # Control Signals - if len(data) < 16: - if data == b'PING': sock.sendto(b'PONG', addr); continue - if data == b'HEARTBEAT': sock.sendto(b'ACK', addr); continue - if data in [b'PAUSE', b'BYE']: self.paint_black(); continue - - # Data Routing - if sock == self.sockets['video']: - self.stats['v_bytes'] += len(data); self.stats['v_pkts'] += 1 - if not self.procs['video'] or self.procs['video'].poll() is not None: self.start_pipeline('video') - if self.procs['video']: self.procs['video'].stdin.write(data); self.procs['video'].stdin.flush() - - elif sock == self.sockets['audio']: - self.stats['a_bytes'] += len(data); self.stats['a_pkts'] += 1 - if self.procs['ffmpeg_audio']: self.procs['ffmpeg_audio'].stdin.write(data); self.procs['ffmpeg_audio'].stdin.flush() - except Exception: pass - - def cleanup(self): - self.running = False - self.paint_black() - if self.zc: self.zc.close() - for mod in [CONF['audio_sink'], CONF['audio_src']]: subprocess.run(f"pactl unload-module {mod}", shell=True, stderr=subprocess.DEVNULL) - print("\n[INFO] Cleanup complete. Exiting.") - -if __name__ == "__main__": - server = AirLinkServer() - signal.signal(signal.SIGINT, lambda s,f: server.cleanup() or sys.exit(0)) - server.run() \ No newline at end of file