Explorar el Código

Added audio passthrough for kvm

TC hace 3 días
padre
commit
514c5e1fec

+ 4 - 1
remdeskd/go.mod

@@ -8,4 +8,7 @@ require (
 	github.com/vladimirvivien/go4vl v0.0.5
 )
 
-require golang.org/x/sys v0.31.0 // indirect
+require (
+	github.com/pion/opus v0.0.0-20250618074346-646586bb17bf // indirect
+	golang.org/x/sys v0.31.0 // indirect
+)

+ 2 - 0
remdeskd/go.sum

@@ -1,5 +1,7 @@
 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/pion/opus v0.0.0-20250618074346-646586bb17bf h1:aVhn89Yu8oTedA+E/Ge3LHk1FvFBThogBP/a0WkhGeI=
+github.com/pion/opus v0.0.0-20250618074346-646586bb17bf/go.mod h1:MF0ECGlX1vw71XHaPvRqZoeFED6QTwvFL71vbsd29yY=
 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
 github.com/vladimirvivien/go4vl v0.0.5 h1:jHuo/CZOAzYGzrSMOc7anOMNDr03uWH5c1B5kQ+Chnc=

+ 41 - 6
remdeskd/main.go

@@ -20,7 +20,7 @@ const defaultDevMode = true
 var (
 	developent        = flag.Bool("dev", defaultDevMode, "Enable development mode with local static files")
 	mode              = flag.String("mode", "usbkvm", "Mode of operation: kvm or capture")
-	usbKVMDeviceName  = flag.String("usbkvm", "/dev/ttyACM0", "USB KVM device file path")
+	usbKVMDeviceName  = flag.String("usbkvm", "/dev/ttyUSB0", "USB KVM device file path")
 	usbKVMBaudRate    = flag.Int("baudrate", 115200, "USB KVM baud rate")
 	captureDeviceName = flag.String("capture", "/dev/video0", "Video capture device file path")
 	usbKVM            *remdeshid.Controller
@@ -48,14 +48,12 @@ func init() {
 
 func main() {
 	flag.Parse()
-
 	// Initiate the HID controller
 	usbKVM = remdeshid.NewHIDController(&remdeshid.Config{
 		PortName:          *usbKVMDeviceName,
 		BaudRate:          *usbKVMBaudRate,
 		ScrollSensitivity: 0x01, // Set mouse scroll sensitivity
 	})
-
 	switch *mode {
 	case "cfgchip":
 		//Start the HID controller
@@ -64,10 +62,27 @@ func main() {
 			log.Fatal(err)
 		}
 
-		time.Sleep(2 * time.Second) // Wait for the controller to initialize
+		/*
+			log.Println("Starting in Configure Chip mode...")
+			time.Sleep(2 * time.Second) // Wait for the controller to initialize
+			_, err = usbKVM.WriteChipProperties()
+			if err != nil {
+				log.Fatalf("Failed to write chip properties: %v", err)
+				return
+			}
+		*/
+		time.Sleep(1 * time.Second) // Wait for the controller to initialize
+		log.Println("Updating chip baudrate to 115200...")
 		//Configure the HID controller
-		usbKVM.ConfigureChipTo19200()
+		err = usbKVM.ConfigureChipTo115200()
+		if err != nil {
+			log.Fatalf("Failed to configure chip baudrate: %v", err)
+			return
+		}
+		time.Sleep(1 * time.Second)
+		log.Println("Configuration command sent. Unplug the device and plug it back in to apply the changes.")
 	case "usbkvm":
+
 		log.Println("Starting in USB KVM mode...")
 
 		//Start the HID controller
@@ -78,7 +93,8 @@ func main() {
 
 		// Initiate the video capture device
 		videoCapture, err = usbcapture.NewInstance(&usbcapture.Config{
-			DeviceName: *captureDeviceName,
+			DeviceName:  *captureDeviceName,
+			AudioConfig: usbcapture.GetDefaultAudioConfig(),
 		})
 
 		if err != nil {
@@ -114,10 +130,29 @@ func main() {
 		// Start the web server
 		http.Handle("/", http.FileServer(webfs))
 		http.HandleFunc("/hid", usbKVM.HIDWebSocketHandler)
+		http.HandleFunc("/audio", videoCapture.AudioStreamingHandler)
 		http.HandleFunc("/stream", videoCapture.ServeVideoStream)
 		addr := ":9000"
 		log.Printf("Serving on http://localhost%s\n", addr)
 		log.Fatal(http.ListenAndServe(addr, nil))
+	case "list-audio-devices":
+		log.Println("Starting in List Audio Devices mode...")
+		//Get the audio devices
+		path, err := usbcapture.FindHDMICapturePCMPath()
+		if err != nil {
+			log.Fatalf("Failed to find HDMI capture PCM path: %v", err)
+		}
+		log.Printf("Found HDMI capture PCM path: %s\n", path)
+		//List all audio capture devices
+		captureDevs, err := usbcapture.ListCaptureDevices()
+		if err != nil {
+			log.Fatalf("Failed to list capture devices: %v", err)
+		}
+		log.Println("Available audio capture devices:")
+		for _, dev := range captureDevs {
+			log.Printf(" - %s\n", dev)
+		}
+
 	default:
 		log.Fatalf("Unknown mode: %s. Supported modes are: usbkvm, capture", *mode)
 	}

+ 46 - 9
remdeskd/mod/remdeshid/ch9329.go

@@ -6,7 +6,7 @@ import (
 	"time"
 )
 
-func (c *Controller) ConfigureChipTo19200() error {
+func (c *Controller) ConfigureChipTo115200() error {
 	// Send the command to get chip configuration and info
 	currentConfig, err := c.GetChipCurrentConfiguration()
 	if err != nil {
@@ -16,8 +16,8 @@ func (c *Controller) ConfigureChipTo19200() error {
 
 	// Modify baudrate bytes in the response
 	currentConfig[3] = 0x00 // Baudrate byte 1
-	currentConfig[4] = 0x00 // Baudrate byte 2
-	currentConfig[5] = 0x4B // Baudrate byte 3
+	currentConfig[4] = 0x01 // Baudrate byte 2
+	currentConfig[5] = 0xC2 // Baudrate byte 3
 	currentConfig[6] = 0x00 // Baudrate byte 4
 
 	time.Sleep(1 * time.Second) // Wait for a second before sending the command
@@ -43,10 +43,52 @@ func (c *Controller) ConfigureChipTo19200() error {
 		fmt.Printf("0x%02X ", b)
 	}
 	fmt.Println()
-	fmt.Println("Baudrate updated to 19200 successfully")
+	fmt.Println("Baudrate updated to 115200 successfully")
 	return nil
 }
 
+func (c *Controller) WriteChipProperties() ([]byte, error) {
+	manufacturerString := []byte{
+		0x57, 0xAB, 0x00, 0x0B,
+		0x00, // Set manufacturer string
+		0x07, // Length of the string
+		'i', 'm', 'u', 's', 'l', 'a', 'b',
+		0x00, // Checksum placeholder
+	}
+
+	manufacturerString[13] = calcChecksum(manufacturerString[:13])
+	// Send set manufacturer string
+	err := c.Send(manufacturerString)
+	if err != nil {
+		return nil, fmt.Errorf("failed to send manufacturer string: %v", err)
+	}
+	_, err = c.WaitForReply(0x0B)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get manufacturer string response: %v", err)
+	}
+
+	productString := []byte{
+		0x57, 0xAB, 0x00, 0x0B,
+		0x01, // Set product string
+		0x09, // Length of the string
+		'R', 'e', 'm', 'd', 'e', 's', 'K', 'V', 'M',
+		0x00, // Checksum placeholder
+	}
+
+	productString[15] = calcChecksum(productString[:15])
+	// Send set product string
+	err = c.Send(productString)
+	if err != nil {
+		return nil, fmt.Errorf("failed to send product string: %v", err)
+	}
+	_, err = c.WaitForReply(0x0B)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get product string response: %v", err)
+	}
+
+	return []byte("OK"), nil
+}
+
 // GetChipCurrentConfiguration retrieves the current configuration of the chip.
 // It sends a command to the chip and waits for a reply.
 // Note only the data portion of the response is returned, excluding the header and checksum.
@@ -117,11 +159,6 @@ func (c *Controller) ConstructAndSendCmd(HIDCommand *HIDCommand) ([]byte, error)
 		}
 		return c.SendKeyboardRelease(uint8(HIDCommand.Keycode))
 	case EventTypeMouseMove:
-		//if time.Now().UnixMilli()-c.lastCursorEventTime < MinCusorEventInterval {
-		//	// Ignore mouse move events that are too close together
-		//	return []byte{}, nil
-		//}
-
 		//Map mouse button state to HID state
 		leftPressed := (HIDCommand.MouseMoveButtonState & 0x01) != 0
 		middlePressed := (HIDCommand.MouseMoveButtonState & 0x02) != 0

+ 0 - 1
remdeskd/mod/remdeshid/handler.go

@@ -26,7 +26,6 @@ func (c *Controller) HIDWebSocketHandler(w http.ResponseWriter, r *http.Request)
 		return
 	}
 	defer conn.Close()
-
 	for {
 		_, message, err := conn.ReadMessage()
 		if err != nil {

+ 13 - 9
remdeskd/mod/remdeshid/remdeshid.go

@@ -20,11 +20,11 @@ func NewHIDController(config *Config) *Controller {
 	}
 
 	return &Controller{
-		Config:          config,
-		serialRunning:   false,
-		hidState:        defaultHidState,
-		writeQueue:      make(chan []byte, 32),
-		incomgDataQueue: make(chan []byte, 1024),
+		Config:            config,
+		serialRunning:     false,
+		hidState:          defaultHidState,
+		writeQueue:        make(chan []byte, 32),
+		incomingDataQueue: make(chan []byte, 1024),
 	}
 }
 
@@ -54,7 +54,7 @@ func (c *Controller) Connect() error {
 				return
 			}
 			if n > 0 {
-				c.incomgDataQueue <- buf[:n]
+				c.incomingDataQueue <- buf[:n]
 				//fmt.Print("Received bytes: ")
 				//for i := 0; i < n; i++ {
 				//	fmt.Printf("0x%02X ", buf[i])
@@ -99,8 +99,8 @@ func (c *Controller) Send(data []byte) error {
 
 func (c *Controller) ClearReadQueue() {
 	// Clear the incoming data queue
-	for len(c.incomgDataQueue) > 0 {
-		<-c.incomgDataQueue
+	for len(c.incomingDataQueue) > 0 {
+		<-c.incomingDataQueue
 	}
 }
 
@@ -118,7 +118,7 @@ func (c *Controller) WaitForReply(cmdByte byte) ([]byte, error) {
 	var reply []byte
 	for {
 		select {
-		case data := <-c.incomgDataQueue:
+		case data := <-c.incomingDataQueue:
 			reply = append(reply, data...)
 			// Check if we have received enough bytes for a complete packet
 			if len(reply) >= 7 {
@@ -148,6 +148,10 @@ func (c *Controller) WaitForReply(cmdByte byte) ([]byte, error) {
 							case succReplyByte:
 								return data, nil
 							case errorReplyByte:
+								fmt.Print("Reply: ")
+								for _, b := range reply {
+									fmt.Printf("0x%02X ", b)
+								}
 								return nil, fmt.Errorf("device returned error reply")
 							}
 						} else {

+ 8 - 4
remdeskd/mod/remdeshid/typedef.go

@@ -1,6 +1,8 @@
 package remdeshid
 
-import "github.com/tarm/serial"
+import (
+	"github.com/tarm/serial"
+)
 
 type EventType int
 
@@ -14,7 +16,7 @@ const (
 	EventTypeHIDCommand
 )
 
-const MinCusorEventInterval = 30 // Minimum interval between cursor events in milliseconds
+const MinCusorEventInterval = 25 // Minimum interval between cursor events in milliseconds
 
 type Config struct {
 	/* Serial port configs */
@@ -37,12 +39,14 @@ type HIDState struct {
 
 // Controller is a struct that represents a HID controller
 type Controller struct {
-	Config              *Config
+	Config *Config
+
+	/* Internal state */
 	serialPort          *serial.Port
 	hidState            HIDState // Current state of the HID device
 	serialRunning       bool
 	writeQueue          chan []byte
-	incomgDataQueue     chan []byte // Queue for incoming data
+	incomingDataQueue   chan []byte // Queue for incoming data
 	lastCursorEventTime int64
 }
 

+ 211 - 0
remdeskd/mod/usbcapture/audio_device.go

@@ -0,0 +1,211 @@
+package usbcapture
+
+import (
+	"bufio"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"os/exec"
+	"regexp"
+	"strings"
+
+	"github.com/gorilla/websocket"
+)
+
+// upgrader is used to upgrade HTTP connections to WebSocket connections
+var upgrader = websocket.Upgrader{
+	ReadBufferSize:  1024,
+	WriteBufferSize: 1024,
+	CheckOrigin: func(r *http.Request) bool {
+		return true
+	},
+}
+
+// ListCaptureDevices lists all available audio capture devices in the /dev/snd directory.
+func ListCaptureDevices() ([]string, error) {
+	files, err := os.ReadDir("/dev/snd")
+	if err != nil {
+		return nil, fmt.Errorf("failed to read /dev/snd: %w", err)
+	}
+
+	var captureDevs []string
+	for _, file := range files {
+		name := file.Name()
+		if strings.HasPrefix(name, "pcm") && strings.HasSuffix(name, "c") {
+			fullPath := "/dev/snd/" + name
+			captureDevs = append(captureDevs, fullPath)
+		}
+	}
+
+	return captureDevs, nil
+}
+
+// FindHDMICaptureCard searches for an HDMI capture card using the `arecord -l` command.
+func FindHDMICapturePCMPath() (string, error) {
+	out, err := exec.Command("arecord", "-l").Output()
+	if err != nil {
+		return "", fmt.Errorf("arecord -l failed: %w", err)
+	}
+
+	lines := strings.Split(string(out), "\n")
+	for _, line := range lines {
+		lower := strings.ToLower(line)
+		if strings.Contains(lower, "ms2109") || strings.Contains(lower, "ms2130") {
+			// Example line:
+			// card 1: MS2109 [MS2109], device 0: USB Audio [USB Audio]
+			parts := strings.Fields(line)
+			var cardNum, devNum string
+			for i := range parts {
+				if parts[i] == "card" && i+1 < len(parts) {
+					cardNum = parts[i+1][:1] // "1"
+				}
+				if parts[i] == "device" && i+1 < len(parts) {
+					devNum = strings.TrimSuffix(parts[i+1], ":") // "0"
+				}
+			}
+
+			if cardNum != "" && devNum != "" {
+				return fmt.Sprintf("/dev/snd/pcmC%vD%vc", cardNum, devNum), nil
+			}
+		}
+	}
+
+	return "", fmt.Errorf("no HDMI capture card found")
+}
+
+// Convert a PCM device name to a hardware device name.
+// Example: "pcmC1D0c" -> "hw:1,0"
+func pcmDeviceToHW(dev string) (string, error) {
+	// Regex to extract card and device numbers
+	re := regexp.MustCompile(`pcmC(\d+)D(\d+)[cp]`)
+	matches := re.FindStringSubmatch(dev)
+	if len(matches) < 3 {
+		return "", fmt.Errorf("invalid device format")
+	}
+	card := matches[1]
+	device := matches[2]
+	return fmt.Sprintf("hw:%s,%s", card, device), nil
+}
+
+func GetDefaultAudioConfig() *AudioConfig {
+	return &AudioConfig{
+		SampleRate:     48000,
+		Channels:       2,
+		BytesPerSample: 2,    // 16-bit
+		FrameSize:      1920, // 1920 samples per frame = 40ms @ 48kHz
+	}
+}
+
+// AudioStreamingHandler handles incoming WebSocket connections for audio streaming.
+func (i Instance) AudioStreamingHandler(w http.ResponseWriter, r *http.Request) {
+	conn, err := upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		log.Println("Failed to upgrade to websocket:", err)
+		return
+	}
+	defer conn.Close()
+
+	if i.audiostopchan != nil {
+		//Another instance already running
+		log.Println("Audio pipe already running, stopping previous instance")
+		i.audiostopchan <- true
+	}
+
+	//Get the capture card audio input
+	pcmdev, err := FindHDMICapturePCMPath()
+	if err != nil {
+		log.Println("Failed to find HDMI capture PCM path:", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	log.Println("Found HDMI capture PCM path:", pcmdev)
+
+	// Convert PCM device to hardware device name
+	hwdev, err := pcmDeviceToHW(pcmdev)
+	if err != nil {
+		log.Println("Failed to convert PCM device to hardware device:", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	log.Println("Using hardware device:", hwdev)
+
+	// Create a buffered reader to read audio data
+	i.audiostopchan = make(chan bool, 1)
+	log.Println("Starting audio pipe with arecord...")
+
+	// Start arecord with 24kHz, 16-bit, stereo
+	cmd := exec.Command("arecord",
+		"-f", "S16_LE", // Format: 16-bit little-endian
+		"-r", fmt.Sprint(i.Config.AudioConfig.SampleRate),
+		"-c", fmt.Sprint(i.Config.AudioConfig.Channels),
+		"-D", hwdev, // Use the hardware device
+	)
+
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		log.Println("Failed to get arecord stdout pipe:", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	if err := cmd.Start(); err != nil {
+		log.Println("Failed to start arecord:", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	reader := bufio.NewReader(stdout)
+	bufferSize := i.Config.AudioConfig.FrameSize * i.Config.AudioConfig.Channels * i.Config.AudioConfig.BytesPerSample
+	log.Printf("Buffer size: %d bytes (FrameSize: %d, Channels: %d, BytesPerSample: %d)",
+		bufferSize, i.Config.AudioConfig.FrameSize, i.Config.AudioConfig.Channels, i.Config.AudioConfig.BytesPerSample)
+	buf := make([]byte, bufferSize*2)
+
+	// Start a goroutine to handle WebSocket messages
+	log.Println("Listening for WebSocket messages...")
+	go func() {
+		_, msg, err := conn.ReadMessage()
+		if err == nil {
+			if string(msg) == "exit" {
+				log.Println("Received exit command from client")
+				i.audiostopchan <- true // Signal to stop the audio pipe
+				return
+			}
+		}
+	}()
+
+	log.Println("Starting audio capture loop...")
+	for {
+		select {
+		case <-i.audiostopchan:
+			log.Println("Audio pipe stopped")
+			goto DONE
+		default:
+			n, err := reader.Read(buf)
+			if err != nil {
+				log.Println("Read error:", err)
+				goto DONE
+			}
+
+			if n == 0 {
+				continue
+			}
+
+			//log.Println("Read bytes:", n, "size of buffer:", len(buf))
+			//Send only the bytes read to WebSocket
+			err = conn.WriteMessage(websocket.BinaryMessage, buf[:n])
+			if err != nil {
+				log.Println("WebSocket send error:", err)
+				goto DONE
+			}
+		}
+	}
+
+DONE:
+
+	i.audiostopchan <- true // Signal to stop the audio pipe
+	cmd.Process.Kill()
+	log.Println("Audio pipe finished")
+}

+ 50 - 0
remdeskd/mod/usbcapture/typedef.go

@@ -0,0 +1,50 @@
+package usbcapture
+
+import (
+	"context"
+
+	"github.com/vladimirvivien/go4vl/device"
+	"github.com/vladimirvivien/go4vl/v4l2"
+)
+
+// The capture resolution to open video device
+type CaptureResolution struct {
+	Width  int
+	Height int
+	FPS    int
+}
+
+type AudioConfig struct {
+	SampleRate     int
+	Channels       int
+	FrameSize      int
+	BytesPerSample int
+}
+
+type Config struct {
+	DeviceName      string       // The video device name, e.g., /dev/video0
+	AudioDeviceName string       // The audio device name, e.g., /dev/snd
+	AudioEnabled    bool         // Exists support for audio capture
+	AudioConfig     *AudioConfig // The audio configuration
+}
+
+type Instance struct {
+	/* Runtime configuration */
+	Config               *Config
+	SupportedResolutions []FormatInfo //The supported resolutions of the video device
+	Capturing            bool
+
+	/* Internals */
+	/* Video capture device */
+	camera             *device.Device
+	cameraStartContext context.CancelFunc
+	frames_buff        <-chan []byte
+	pixfmt             v4l2.FourCCType
+	width              int
+	height             int
+	streamInfo         string
+
+	/* audio capture device */
+	audiostopchan chan bool // Channel to stop audio capture
+
+}

+ 0 - 24
remdeskd/mod/usbcapture/usbcapture.go

@@ -16,30 +16,6 @@ import (
 	"github.com/vladimirvivien/go4vl/v4l2"
 )
 
-// The capture resolution to open video device
-type CaptureResolution struct {
-	Width  int
-	Height int
-	FPS    int
-}
-
-type Config struct {
-	DeviceName string
-}
-
-type Instance struct {
-	Config               *Config
-	SupportedResolutions []FormatInfo //The supported resolutions of the video device
-	Capturing            bool
-	camera               *device.Device
-	cameraStartContext   context.CancelFunc
-	frames_buff          <-chan []byte
-	pixfmt               v4l2.FourCCType
-	width                int
-	height               int
-	streamInfo           string
-}
-
 // NewInstance creates a new video capture instance
 func NewInstance(config *Config) (*Instance, error) {
 	if config == nil {

+ 114 - 3
remdeskd/www/index.html

@@ -26,9 +26,10 @@
     </style>
 </head>
 <body>
-
+    <button onclick="startAudioWebSocket()">Start Audio</button>
+    <button onclick="stopAudioWebSocket()">Stop Audio</button>
     <img id="remoteCapture" src="/stream" oncontextmenu="return false;"></img>
-   
+    
     <script>
         let socket;
         let protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
@@ -47,7 +48,7 @@
                 event: 2,
                 mouse_x: event.clientX,
                 mouse_y: event.clientY,
-                mouse_move_button_state: mouseButtonBits // Combine mouse button states into a single bitmask
+                //mouse_move_button_state: mouseButtonBits // Combine mouse button states into a single bitmask
             };
 
 
@@ -273,6 +274,116 @@
         $(document).ready(function(){
             startWebSocket();
         });
+
+
+        /* Audio Streaming Frontend */
+        let audioSocket;
+        let audioContext;
+        let audioQueue = [];
+        let audioPlaying = false;
+
+        function startAudioWebSocket() {
+            if (audioSocket) {
+                console.warn("Audio WebSocket already started");
+                return;
+            }
+            let protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
+            let port = window.location.port ? window.location.port : (protocol === 'wss' ? 443 : 80);
+            let audioSocketURL = `${protocol}://${window.location.hostname}:${port}/audio`;
+
+            audioSocket = new WebSocket(audioSocketURL);
+            audioSocket.binaryType = 'arraybuffer';
+
+            audioSocket.onopen = function() {
+                console.log("Audio WebSocket connected");
+                if (!audioContext) {
+                    audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 24000});
+                }
+            };
+
+            const MAX_AUDIO_QUEUE = 2;
+            audioSocket.onmessage = function(event) {
+                if (!audioContext) return;
+                let pcm = new Int16Array(event.data);
+                if (pcm.length === 0) {
+                    console.warn("Received empty PCM data");
+                    return;
+                }
+                if (pcm.length % 2 !== 0) {
+                    console.warn("Received PCM data with odd length, dropping last sample");
+                    pcm = pcm.slice(0, -1); // Drop last sample if odd length
+                }
+                // Convert Int16 PCM to Float32 [-1, 1]
+                let floatBuf = new Float32Array(pcm.length);
+                for (let i = 0; i < pcm.length; i++) {
+                    floatBuf[i] = pcm[i] / 32768;
+                }
+                // Limit queue size to prevent memory overflow
+                if (audioQueue.length >= MAX_AUDIO_QUEUE) {
+                    audioQueue.shift(); // Remove oldest audio buffer if queue is full
+                }
+                audioQueue.push(floatBuf);
+                if (!audioPlaying) {
+                    audioPlaying = true;
+                    playAudioQueue();
+                }
+                
+            };
+
+            audioSocket.onclose = function() {
+                console.log("Audio WebSocket closed");
+                audioSocket = null;
+                audioPlaying = false;
+                audioQueue = [];
+            };
+
+            audioSocket.onerror = function(e) {
+                console.error("Audio WebSocket error", e);
+            };
+        }
+
+        function playAudioQueue() {
+            if (!audioContext || audioQueue.length === 0) {
+                audioPlaying = false;
+                return;
+            }
+            let floatBuf = audioQueue.shift();
+            let channels = 2;
+            let frameCount = floatBuf.length / channels;
+            let buffer = audioContext.createBuffer(channels, frameCount, 48000);
+            for (let ch = 0; ch < channels; ch++) {
+                let channelData = buffer.getChannelData(ch);
+                for (let i = 0; i < frameCount; i++) {
+                    channelData[i] = floatBuf[i * channels + ch];
+                }
+            }
+            let source = audioContext.createBufferSource();
+            source.buffer = buffer;
+            source.connect(audioContext.destination);
+            source.onended = playAudioQueue;
+            source.start();
+        }
+
+        function stopAudioWebSocket() {
+            if (!audioSocket) {
+                console.warn("No audio WebSocket to stop");
+                return;
+            }
+
+            if (audioSocket.readyState === WebSocket.OPEN) {
+                audioSocket.send("exit");
+            }
+            audioSocket.onclose = null; // Prevent onclose from being called again
+            audioSocket.onerror = null; // Prevent onerror from being called again
+            audioSocket.close();
+            audioSocket = null;
+            audioPlaying = false;
+            audioQueue = [];
+            if (audioContext) {
+                audioContext.close();
+                audioContext = null;
+            }
+        }
     </script>
 </body>
 </html>