Refactor application to integrate WebRTC for audio streaming and enhance overall functionality

**Overview:**

This commit represents a comprehensive refactoring of the application to improve real-time audio streaming capabilities. The key change is the integration of WebRTC for peer-to-peer audio streaming while using Hyperswarm exclusively for signaling. This transition addresses efficiency, reliability, and scalability issues present in the original implementation.

**Old Method:**

- **Audio Streaming via Hyperswarm Data Channels:**
  - The original code used Hyperswarm for both signaling and streaming audio data.
  - Audio data was captured from the microphone, converted to binary, and transmitted over Hyperswarm connections.
  - Listeners received the audio data chunks and processed them to play back the audio.
  - **Issues:**
    - Inefficient for real-time audio streaming due to Hyperswarm's limitations for media data.
    - Higher latency and potential synchronization problems.
    - Difficulty managing peer connections and media streams effectively.

**New Method:**

- **Integration of WebRTC for Audio Streaming:**
  - Implemented `RTCPeerConnection` instances for efficient, real-time, peer-to-peer audio streaming.
  - Used Hyperswarm solely for signaling to establish and manage connections.
  - Audio tracks are now transmitted over WebRTC connections, leveraging optimized protocols for media.
  - **Benefits:**
    - Improved audio quality and reduced latency.
    - Enhanced NAT traversal and firewall compatibility via ICE servers.
    - Better management of media streams and peer connections.

**Key Changes:**

1. **WebRTC Implementation:**

   - **Broadcaster Side:**
     - Created `RTCPeerConnection` instances for each listener.
     - Added local audio tracks from the microphone to the peer connections.
     - Managed signaling messages (`offer`, `answer`, `candidate`) received via Hyperswarm.
     - Handled ICE candidate exchange and connection state changes.

   - **Listener Side:**
     - Created an `RTCPeerConnection` to connect to the broadcaster.
     - Added a transceiver with `recvonly` direction to receive audio streams.
     - Managed signaling messages and ICE candidates.
     - Played received audio streams using HTML `<audio>` elements.

2. **Signaling via Hyperswarm:**

   - Utilized Hyperswarm connections for exchanging signaling messages in JSON format.
   - Messages include `offer`, `answer`, and `candidate` types.
   - Ensured proper serialization and deserialization of signaling data.

3. **ICE Candidate Handling:**

   - Implemented ICE candidate queuing to handle candidates arriving before the remote description is set.
   - Stored incoming ICE candidates in queues and processed them after setting the remote description.
   - Added detailed logging for ICE candidate exchange and connection states.

4. **Peer Count Accuracy:**

   - Updated the `updatePeerCount()` function to use `conns.length`, reflecting active Hyperswarm connections.
   - Ensured the peer count updates immediately when connections are established or closed.
   - Improved UI feedback regarding the number of connected peers.

5. **Audio Input Switching Without Disconnecting Peers:**

   - Modified the `applyAudioSource()` function to replace audio tracks in existing peer connections without restarting the station.
   - Obtained a new audio stream with the selected input device.
   - Used `RTCRtpSender.replaceTrack()` to update the audio track in each peer connection.
   - Stopped old audio tracks to free up resources.
   - Allowed broadcasters to switch microphones seamlessly without interrupting listeners' audio.

6. **Error Handling and Debugging Improvements:**

   - Added extensive logging throughout the code to trace execution flow and internal state.
   - Wrapped asynchronous operations in `try...catch` blocks to handle errors gracefully.
   - Provided informative console messages for successful operations and errors.

7. **User Interface Adjustments:**

   - Retained existing UI elements and controls.
   - Updated event listeners to align with the new logic.
   - Provided real-time updates to station information and peer count.

**Benefits of the New Method:**

- **Enhanced Audio Quality and Performance:**
  - Leveraging WebRTC provides better audio streaming capabilities optimized for real-time communication.
  - Reduced latency and improved synchronization.

- **Scalability and Reliability:**
  - Proper handling of peer connections and media streams improves the application's scalability.
  - Robust error handling ensures better reliability under various network conditions.

- **Improved User Experience:**
  - Listeners experience uninterrupted audio even when broadcasters change input devices.
  - Accurate peer count provides broadcasters with immediate feedback on their audience size.

**Testing and Verification:**

- Tested the application with multiple broadcasters and listeners to ensure proper functionality.
- Verified that audio streams initiate correctly and continue even after input device changes.
- Confirmed that peer counts update accurately on both broadcaster and listener sides.
- Ensured that no errors appear in the console logs during normal operation.
This commit is contained in:
Raven Scott 2024-11-24 02:29:16 -05:00
parent 461884f5e6
commit e6891e81c3
3 changed files with 410 additions and 263 deletions

664
app.js
View File

@ -1,18 +1,21 @@
// app.js
import Hyperswarm from 'hyperswarm'; import Hyperswarm from 'hyperswarm';
import crypto from 'hypercore-crypto'; import crypto from 'hypercore-crypto';
import b4a from 'b4a'; import b4a from 'b4a';
let swarm; let swarm;
let micStream;
let audioContext;
let isBroadcasting = false;
let conns = [];
let currentDeviceId = null; // To store the selected audio input device ID
let accumulatedBuffer = b4a.alloc(0); // Buffer for accumulating received audio data
let stationKey = crypto.randomBytes(32); // Default random key for the station let stationKey = crypto.randomBytes(32); // Default random key for the station
let currentDeviceId = null; // To store the selected audio input device ID
let isBroadcasting = false;
let localStream; // For broadcaster's audio stream
let peerConnections = {}; // Store WebRTC peer connections
let iceCandidateQueues = {}; // Store ICE candidate queues
let conns = []; // Store Hyperswarm connections
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
console.log("DOM fully loaded and parsed");
document.getElementById('create-station').addEventListener('click', () => { document.getElementById('create-station').addEventListener('click', () => {
console.log("Create Station button clicked");
const createStationModal = new bootstrap.Modal(document.getElementById('createStationModal')); const createStationModal = new bootstrap.Modal(document.getElementById('createStationModal'));
createStationModal.show(); createStationModal.show();
}); });
@ -20,12 +23,14 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById('generate-new-key').addEventListener('click', () => { document.getElementById('generate-new-key').addEventListener('click', () => {
stationKey = crypto.randomBytes(32); stationKey = crypto.randomBytes(32);
document.getElementById('existing-key').value = b4a.toString(stationKey, 'hex'); document.getElementById('existing-key').value = b4a.toString(stationKey, 'hex');
console.log("New station key generated");
}); });
document.getElementById('create-station-button').addEventListener('click', () => { document.getElementById('create-station-button').addEventListener('click', () => {
const existingKey = document.getElementById('existing-key').value.trim(); const existingKey = document.getElementById('existing-key').value.trim();
stationKey = existingKey ? b4a.from(existingKey, 'hex') : stationKey; stationKey = existingKey ? b4a.from(existingKey, 'hex') : stationKey;
console.log("Creating station with key:", b4a.toString(stationKey, 'hex'));
setupStation(stationKey); setupStation(stationKey);
const createStationModal = bootstrap.Modal.getInstance(document.getElementById('createStationModal')); const createStationModal = bootstrap.Modal.getInstance(document.getElementById('createStationModal'));
@ -33,31 +38,22 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
document.getElementById('leave-stream').addEventListener('click', () => { document.getElementById('leave-stream').addEventListener('click', () => {
stopBroadcast(); console.log("Leave Stream button clicked");
leaveStation(); leaveStation();
}); });
document.getElementById('join-station-button').addEventListener('click', joinStation); document.getElementById('join-station-button').addEventListener('click', () => {
console.log("Join Station button clicked");
joinStation();
const joinModal = bootstrap.Modal.getInstance(document.getElementById('joinModal'));
joinModal.hide();
});
document.getElementById('apply-audio-source').addEventListener('click', applyAudioSource); document.getElementById('apply-audio-source').addEventListener('click', applyAudioSource);
// document.getElementById('apply-output-device').addEventListener('click', applyOutputDevice);
populateAudioInputSources(); populateAudioInputSources();
populateAudioOutputSources();
}); });
// Update peer count in the UI
function updatePeerCount() {
const peerCount = conns.length;
const stationInfoElement = document.getElementById('station-info');
if (isBroadcasting) {
stationInfoElement.textContent = `Station ID: ${b4a.toString(stationKey, 'hex')}\nConnected Peers: ${peerCount}`;
} else {
stationInfoElement.textContent = `Station ID: ${b4a.toString(stationKey, 'hex')}\nConnected Peers: ${peerCount}`;
}
console.log(`Peer count updated: ${peerCount}`);
}
// Populate audio input sources
async function populateAudioInputSources() { async function populateAudioInputSources() {
try { try {
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
@ -72,268 +68,420 @@ async function populateAudioInputSources() {
} }
}); });
currentDeviceId = audioInputSelect.value; currentDeviceId = audioInputSelect.value;
console.log("Audio input devices populated");
} catch (err) { } catch (err) {
console.error("Error enumerating devices:", err); console.error("Error enumerating devices:", err);
} }
} }
// Populate audio output sources // Updated applyAudioSource function
async function populateAudioOutputSources() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioOutputSelect = document.getElementById('audio-output-select');
audioOutputSelect.innerHTML = '';
devices.forEach((device) => {
if (device.kind === 'audiooutput') {
// const option = document.createElement('option');
// option.value = device.deviceId;
// option.textContent = device.label || `Speaker ${audioOutputSelect.length + 1}`;
// audioOutputSelect.appendChild(option);
}
});
} catch (err) {
console.error("Error enumerating output devices:", err);
}
}
// Apply the selected audio output device
// Apply the selected audio output device
async function applyOutputDevice() {
const selectedDeviceId = document.getElementById('audio-output-select').value;
try {
if (!selectedDeviceId) {
console.warn("No output device selected.");
return;
}
// Ensure there is a valid audio element
const audioElement = document.createElement('audio');
audioElement.autoplay = true; // Play automatically when data is received
audioElement.controls = true; // Debugging purposes (can be removed)
document.body.appendChild(audioElement); // Add temporarily for testing
// Set the audio sink to the selected device
if (typeof audioElement.setSinkId === 'function') {
await audioElement.setSinkId(selectedDeviceId);
console.log(`Audio output device applied: ${selectedDeviceId}`);
} else {
console.error("setSinkId is not supported in this browser.");
}
} catch (err) {
console.error("Error applying audio output device:", err);
}
}
// Apply the selected audio input source
async function applyAudioSource() { async function applyAudioSource() {
const selectedDeviceId = document.getElementById('audio-input-select').value; const selectedDeviceId = document.getElementById('audio-input-select').value;
if (selectedDeviceId !== currentDeviceId) { if (selectedDeviceId !== currentDeviceId) {
currentDeviceId = selectedDeviceId; currentDeviceId = selectedDeviceId;
stopBroadcast(); if (isBroadcasting) {
startBroadcast(); console.log("Applying new audio source:", selectedDeviceId);
} try {
} // Get the new audio stream
const newStream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: currentDeviceId ? { exact: currentDeviceId } : undefined },
});
console.log("New audio stream obtained");
// Start broadcasting from the microphone // Replace tracks in existing peer connections
async function startBroadcast() { for (const remoteKey in peerConnections) {
if (isBroadcasting) stopBroadcast(); const peerConnection = peerConnections[remoteKey];
const senders = peerConnection.getSenders();
for (const sender of senders) {
if (sender.track && sender.track.kind === 'audio') {
const newTrack = newStream.getAudioTracks()[0];
await sender.replaceTrack(newTrack);
console.log(`Replaced audio track for peer ${remoteKey}`);
}
}
}
try { // Stop the old audio tracks
audioContext = new (window.AudioContext || window.webkitAudioContext)(); if (localStream) {
micStream = await navigator.mediaDevices.getUserMedia({ localStream.getTracks().forEach((track) => track.stop());
audio: { deviceId: currentDeviceId ? { exact: currentDeviceId } : undefined }, console.log("Old audio tracks stopped");
}); }
const source = audioContext.createMediaStreamSource(micStream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
source.connect(processor); // Update the localStream
processor.connect(audioContext.destination); localStream = newStream;
console.log("localStream updated with new audio stream");
processor.onaudioprocess = (event) => { } catch (err) {
const audioData = event.inputBuffer.getChannelData(0); console.error("Error applying new audio source:", err);
const buffer = b4a.from(new Float32Array(audioData).buffer); alert("Failed to apply new audio source. Please try again.");
for (const conn of conns) {
conn.write(buffer);
} }
};
isBroadcasting = true;
console.log("Broadcasting started.");
} catch (err) {
console.error("Error starting broadcast:", err);
}
}
// Stop broadcasting
function stopBroadcast() {
if (!isBroadcasting) return;
broadcastStopSignal();
if (micStream) {
micStream.getTracks().forEach((track) => track.stop());
micStream = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
accumulatedBuffer = b4a.alloc(0);
isBroadcasting = false;
console.log("Broadcasting stopped.");
}
// Broadcast a stop signal to all peers
function broadcastStopSignal() {
for (const conn of conns) {
conn.write(Buffer.alloc(0));
}
}
function processIncomingAudioData(data) {
// Ensure audioContext is initialized
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
console.log("AudioContext initialized.");
}
// Accumulate incoming data
accumulatedBuffer = b4a.concat([accumulatedBuffer, data]);
// Debug log for accumulated buffer size
console.log("Accumulated buffer size:", accumulatedBuffer.byteLength);
// Process audio data in chunks
while (accumulatedBuffer.byteLength >= 4096) {
try {
const chunkSize = 4096; // Process fixed-size chunks
const audioData = new Float32Array(accumulatedBuffer.slice(0, chunkSize).buffer);
accumulatedBuffer = accumulatedBuffer.slice(chunkSize); // Remove processed data
const sampleRate = audioContext.sampleRate || 44100; // Use context sample rate or default
const buffer = audioContext.createBuffer(1, audioData.length, sampleRate);
// Copy data to the audio buffer
buffer.copyToChannel(audioData, 0);
// Create and configure audio buffer source
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
source.start();
// Debug log for processed audio chunk
console.log("Audio chunk processed and played. Chunk size:", chunkSize);
} catch (err) {
console.error("Error processing audio data:", err);
break; // Stop processing on error to prevent cascading issues
} }
} }
} }
// Setup a broadcasting station // Update peer count using conns.length
async function setupStation(key) { function updatePeerCount() {
swarm = new Hyperswarm(); const peerCount = conns.length;
swarm.join(key, { client: false, server: true }); const stationInfoElement = document.getElementById('station-info');
if (isBroadcasting) {
document.getElementById('broadcaster-controls').classList.remove('d-none'); stationInfoElement.textContent = `Station ID: ${b4a.toString(stationKey, 'hex')}\nConnected Peers: ${peerCount}`;
document.getElementById('setup').classList.add('d-none'); } else {
document.getElementById('controls').classList.remove('d-none'); stationInfoElement.textContent = `Connected Peers: ${peerCount}`;
}
startBroadcast(); console.log(`Peer count updated: ${peerCount}`);
updatePeerCount(); }
swarm.on('connection', (conn) => { async function setupStation(key) {
conns.push(conn); try {
console.log("Peer connected. Total peers:", conns.length); console.log("Setting up station...");
updatePeerCount(); // Initialize Hyperswarm
swarm = new Hyperswarm();
conn.once('close', () => { swarm.join(key, { client: false, server: true });
conns.splice(conns.indexOf(conn), 1);
console.log("Peer disconnected. Total peers:", conns.length); // Get user media (audio input)
updatePeerCount(); localStream = await navigator.mediaDevices.getUserMedia({
}); audio: { deviceId: currentDeviceId ? { exact: currentDeviceId } : undefined },
});
conn.on('data', handleData); console.log("Local audio stream obtained");
conn.on('error', (err) => { isBroadcasting = true;
if (err.code === 'ECONNRESET') {
console.log("Peer connection reset by remote peer."); swarm.on('connection', (conn) => {
} else { console.log("New connection established");
console.error("Connection error:", err); conns.push(conn);
} updatePeerCount(); // Update peer count when a new connection is established
conns.splice(conns.indexOf(conn), 1); const remoteKey = conn.remotePublicKey.toString('hex');
updatePeerCount(); console.log("Remote key:", remoteKey);
});
}); // Initialize ICE candidate queue
iceCandidateQueues[remoteKey] = [];
// Handle incoming signaling data
conn.on('data', async (data) => {
console.log("Received data from peer:", data.toString());
const message = JSON.parse(data.toString());
await handleSignalingData(conn, message);
});
conn.on('close', () => {
console.log("Connection closed with peer:", remoteKey);
// Clean up peer connection when connection closes
if (peerConnections[remoteKey]) {
peerConnections[remoteKey].close();
delete peerConnections[remoteKey];
}
delete iceCandidateQueues[remoteKey];
conns.splice(conns.indexOf(conn), 1);
updatePeerCount(); // Update peer count when a connection is closed
});
conn.on('error', (err) => {
console.error("Connection error with peer:", remoteKey, err);
if (peerConnections[remoteKey]) {
peerConnections[remoteKey].close();
delete peerConnections[remoteKey];
}
delete iceCandidateQueues[remoteKey];
conns.splice(conns.indexOf(conn), 1);
updatePeerCount(); // Update peer count on error
});
});
document.getElementById('broadcaster-controls').classList.remove('d-none');
document.getElementById('setup').classList.add('d-none');
document.getElementById('controls').classList.remove('d-none');
document.getElementById('station-info').textContent = `Station ID: ${b4a.toString(key, 'hex')}`;
console.log("Station setup complete");
} catch (err) {
console.error("Error setting up station:", err);
alert("Failed to set up station. Please try again.");
}
}
function stopBroadcast() {
console.log("Broadcast stopped");
// Close all peer connections
Object.values(peerConnections).forEach((pc) => pc.close());
peerConnections = {};
// Stop local media tracks
if (localStream) {
localStream.getTracks().forEach((track) => track.stop());
localStream = null;
console.log("Local media tracks stopped");
}
isBroadcasting = false;
}
async function handleSignalingData(conn, message, peerConnection = null) {
try {
const remoteKey = conn.remotePublicKey.toString('hex');
console.log("Handling signaling data:", message.type, "from", remoteKey);
if (!peerConnection) {
peerConnection = peerConnections[remoteKey];
}
if (message.type === 'offer') {
// Received an offer from a listener (only for broadcaster)
console.log("Creating new RTCPeerConnection for remote key:", remoteKey);
const configuration = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
};
peerConnection = new RTCPeerConnection(configuration);
peerConnections[remoteKey] = peerConnection;
// Add local stream tracks to peer connection
localStream.getTracks().forEach((track) => {
peerConnection.addTrack(track, localStream);
console.log("Added track to peer connection:", track);
});
// Handle ICE candidates
peerConnection.onicecandidate = ({ candidate }) => {
if (candidate) {
console.log("Sending ICE candidate to peer:", candidate);
conn.write(JSON.stringify({ type: 'candidate', candidate }));
} else {
console.log("All ICE candidates have been sent to peer");
}
};
peerConnection.oniceconnectionstatechange = () => {
console.log("Broadcaster ICE connection state changed to:", peerConnection.iceConnectionState);
};
// Set remote description
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.offer));
console.log("Set remote description with offer from peer");
// Process any queued ICE candidates
if (iceCandidateQueues[remoteKey]) {
console.log("Processing queued ICE candidates");
for (const candidate of iceCandidateQueues[remoteKey]) {
await peerConnection.addIceCandidate(candidate);
console.log("Added queued ICE candidate");
}
iceCandidateQueues[remoteKey] = [];
}
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
console.log("Created and set local description with answer");
// Send the answer back to the listener
conn.write(JSON.stringify({ type: 'answer', answer }));
console.log("Sent answer to peer");
} else if (message.type === 'candidate') {
// Received an ICE candidate
const candidate = new RTCIceCandidate(message.candidate);
console.log("Received ICE candidate from peer:", candidate);
if (peerConnection && peerConnection.remoteDescription && peerConnection.remoteDescription.type) {
await peerConnection.addIceCandidate(candidate);
console.log("Added ICE candidate to peer connection");
} else {
console.log("Remote description not set yet, queuing ICE candidate");
if (!iceCandidateQueues[remoteKey]) {
iceCandidateQueues[remoteKey] = [];
}
iceCandidateQueues[remoteKey].push(candidate);
}
} else if (message.type === 'answer') {
// Received an answer from the broadcaster (only for listener)
console.log("Received answer from broadcaster");
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.answer));
console.log("Set remote description with answer from broadcaster");
// Process any queued ICE candidates
if (iceCandidateQueues[remoteKey]) {
console.log("Processing queued ICE candidates");
for (const candidate of iceCandidateQueues[remoteKey]) {
await peerConnection.addIceCandidate(candidate);
console.log("Added queued ICE candidate");
}
iceCandidateQueues[remoteKey] = [];
}
}
} catch (err) {
console.error("Error handling signaling data:", err);
}
} }
// Leave the station
function leaveStation() { function leaveStation() {
if (swarm) swarm.destroy(); console.log("Leaving station...");
if (swarm) {
swarm.destroy();
console.log("Swarm destroyed");
}
// Close all peer connections
Object.values(peerConnections).forEach((pc) => pc.close());
peerConnections = {};
// Close all Hyperswarm connections
conns.forEach((conn) => conn.destroy());
conns = [];
// Stop local media tracks
if (localStream) {
localStream.getTracks().forEach((track) => track.stop());
localStream = null;
console.log("Local media tracks stopped");
}
isBroadcasting = false;
document.getElementById('setup').classList.remove('d-none'); document.getElementById('setup').classList.remove('d-none');
document.getElementById('controls').classList.add('d-none'); document.getElementById('controls').classList.add('d-none');
document.getElementById('broadcaster-controls').classList.add('d-none'); document.getElementById('broadcaster-controls').classList.add('d-none');
stopBroadcast(); document.getElementById('listener-controls').classList.add('d-none');
document.getElementById('station-info').textContent = '';
console.log("Left the station."); console.log("Left the station.");
} }
// Handle incoming data from peers
function handleData(data) {
if (data.length === 0) {
stopBroadcast();
} else {
processIncomingAudioData(data);
}
}
// Join an existing station
async function joinStation() { async function joinStation() {
const stationId = document.getElementById('station-id').value; try {
if (!stationId) { const stationId = document.getElementById('station-id').value;
alert("Please enter a station ID."); if (!stationId) {
return; alert("Please enter a station ID.");
return;
}
console.log("Joining station with ID:", stationId);
const topicBuffer = b4a.from(stationId, 'hex');
swarm = new Hyperswarm();
swarm.join(topicBuffer, { client: true, server: false });
swarm.on('connection', (conn) => {
console.log("Connected to broadcaster");
conns.push(conn);
updatePeerCount(); // Update peer count when a new connection is established
const remoteKey = conn.remotePublicKey.toString('hex');
console.log("Remote key:", remoteKey);
// Initialize ICE candidate queue
iceCandidateQueues[remoteKey] = [];
const configuration = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
};
const peerConnection = new RTCPeerConnection(configuration);
peerConnections[remoteKey] = peerConnection;
// Add transceiver to receive audio
peerConnection.addTransceiver('audio', { direction: 'recvonly' });
// Handle incoming tracks (audio streams)
peerConnection.ontrack = (event) => {
console.log("Received remote track");
const [remoteStream] = event.streams;
// Play the remote audio stream
const audioElement = document.createElement('audio');
audioElement.srcObject = remoteStream;
audioElement.autoplay = true;
document.body.appendChild(audioElement);
console.log("Audio element created and playback started");
};
// Handle ICE candidates
peerConnection.onicecandidate = ({ candidate }) => {
if (candidate) {
console.log("Sending ICE candidate to broadcaster:", candidate);
conn.write(JSON.stringify({ type: 'candidate', candidate }));
} else {
console.log("All ICE candidates have been sent to broadcaster");
}
};
peerConnection.oniceconnectionstatechange = () => {
console.log("Listener ICE connection state changed to:", peerConnection.iceConnectionState);
};
// Handle signaling data from broadcaster
conn.on('data', async (data) => {
console.log("Received data from broadcaster:", data.toString());
const message = JSON.parse(data.toString());
await handleSignalingData(conn, message, peerConnection);
});
conn.on('close', () => {
console.log("Connection closed with broadcaster");
peerConnection.close();
delete peerConnections[remoteKey];
delete iceCandidateQueues[remoteKey];
conns.splice(conns.indexOf(conn), 1);
updatePeerCount(); // Update peer count when a connection is closed
});
conn.on('error', (err) => {
console.error("Connection error with broadcaster:", err);
peerConnection.close();
delete peerConnections[remoteKey];
delete iceCandidateQueues[remoteKey];
conns.splice(conns.indexOf(conn), 1);
updatePeerCount(); // Update peer count on error
});
// Start signaling process
initiateOffer(conn, peerConnection, remoteKey);
updatePeerCount();
});
document.getElementById('station-info').textContent = `Connected to Station: ${stationId}`;
document.getElementById('setup').classList.add('d-none');
document.getElementById('controls').classList.remove('d-none');
document.getElementById('listener-controls').classList.remove('d-none');
console.log("Joined station successfully");
} catch (err) {
console.error("Error joining station:", err);
alert("Failed to join station. Please try again.");
} }
const topicBuffer = b4a.from(stationId, 'hex');
swarm = new Hyperswarm();
swarm.join(topicBuffer, { client: true, server: false });
document.getElementById('station-info').textContent = `Connected to Station: ${stationId}`;
document.getElementById('setup').classList.add('d-none');
document.getElementById('controls').classList.remove('d-none');
document.getElementById('broadcaster-controls').classList.add('d-none');
// document.getElementById('listener-controls').classList.remove('d-none'); // Ensure listener controls are visible
// Populate audio output devices
await populateAudioOutputSources();
swarm.on('connection', (conn) => {
conns.push(conn);
console.log("Peer connected. Total peers:", conns.length);
updatePeerCount();
conn.on('data', (data) => processIncomingAudioData(data));
conn.once('close', () => {
conns.splice(conns.indexOf(conn), 1);
console.log("Peer disconnected. Total peers:", conns.length);
updatePeerCount();
});
conn.on('error', (err) => {
if (err.code === 'ECONNRESET') {
console.log("Peer connection reset by remote peer.");
} else {
console.error("Connection error:", err);
}
conns.splice(conns.indexOf(conn), 1);
updatePeerCount();
});
});
const joinModal = bootstrap.Modal.getInstance(document.getElementById('joinModal'));
if (joinModal) joinModal.hide();
updatePeerCount();
} }
async function initiateOffer(conn, peerConnection, remoteKey) {
try {
console.log("Initiating offer to broadcaster");
// Handle ICE candidates
peerConnection.onicecandidate = ({ candidate }) => {
if (candidate) {
console.log("Sending ICE candidate to broadcaster:", candidate);
conn.write(JSON.stringify({ type: 'candidate', candidate }));
} else {
console.log("All ICE candidates have been sent to broadcaster");
}
};
peerConnection.oniceconnectionstatechange = () => {
console.log("Listener ICE connection state changed to:", peerConnection.iceConnectionState);
};
// Create offer
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
console.log("Created and set local description with offer");
// Send offer to broadcaster
conn.write(JSON.stringify({ type: 'offer', offer }));
console.log("Sent offer to broadcaster");
// Process any queued ICE candidates
if (iceCandidateQueues[remoteKey]) {
console.log("Processing queued ICE candidates");
for (const candidate of iceCandidateQueues[remoteKey]) {
await peerConnection.addIceCandidate(candidate);
console.log("Added queued ICE candidate");
}
iceCandidateQueues[remoteKey] = [];
}
} catch (err) {
console.error("Error initiating offer:", err);
}
}

View File

@ -46,10 +46,7 @@
<!-- Listener-only Controls --> <!-- Listener-only Controls -->
<div id="listener-controls" class="mt-3 d-none"> <div id="listener-controls" class="mt-3 d-none">
<select id="audio-output-select" class="form-select" style="display: none;"></select> <!-- You can add listener-specific controls here -->
<!-- <label for="audio-output-select" class="form-label">Select Audio Output Device:</label>
<button id="apply-output-device" class="btn btn-success mt-2">Apply</button> -->
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,7 +7,9 @@
"gui": { "gui": {
"backgroundColor": "#1F2430", "backgroundColor": "#1F2430",
"height": "540", "height": "540",
"width": "720" "width": "720",
"links": ["http://127.0.0.1", "http://localhost", "https://ka-f.fontawesome.com", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://stackpath.bootstrapcdn.com", "ttps://code.jquery.com"]
} }
}, },
"type": "module", "type": "module",