first commit
This commit is contained in:
commit
5c37f2f73f
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
test
|
156
README.md
Normal file
156
README.md
Normal file
@ -0,0 +1,156 @@
|
||||
# pearCast - A Peer-to-Peer Audio Broadcasting App
|
||||
|
||||
`pearCast` is a decentralized, peer-to-peer (P2P) audio broadcasting application that enables users to broadcast and listen to live audio streams directly from their web browser without relying on centralized servers. Using Hyperswarm for P2P networking and the Web Audio API for audio capture and playback, `pearCast` allows users to create and join audio broadcast stations effortlessly.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Create or Join a Station**: Host a broadcast or tune into an existing one.
|
||||
- **Microphone Selection**: Broadcasters can select and switch between available audio input devices.
|
||||
- **Real-time Audio Streaming**: Capture and stream live audio to all connected listeners.
|
||||
- **Decentralized Networking**: Peer-to-peer connections using Hyperswarm, eliminating the need for a centralized server.
|
||||
- **Error Handling**: Logs peer disconnections and connection resets without crashing.
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **[Hyperswarm](https://github.com/hyperswarm/hyperswarm)**: For discovering and connecting peers based on a shared "topic" key, ensuring direct connections without the need for central servers.
|
||||
- **Web Audio API**: A powerful API for capturing and processing live audio in the browser, allowing real-time audio streaming.
|
||||
- **Bootstrap**: For responsive and user-friendly UI elements.
|
||||
- **JavaScript & Node.js**: Application logic, error handling, and P2P networking.
|
||||
- **Pear CLI**: (Optional) If you want to run this as a P2P desktop app using [Pear CLI](https://github.com/pearjs/pear).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [User Guide](#user-guide)
|
||||
- [Creating a Broadcast Station](#creating-a-broadcast-station)
|
||||
- [Joining a Broadcast Station](#joining-a-broadcast-station)
|
||||
- [Changing Audio Input](#changing-audio-input)
|
||||
- [Technical Details](#technical-details)
|
||||
- [How P2P Connections are Handled](#how-p2p-connections-are-handled)
|
||||
- [Audio Capture and Streaming](#audio-capture-and-streaming)
|
||||
- [Error Handling and Disconnection Logging](#error-handling-and-disconnection-logging)
|
||||
- [Code Structure](#code-structure)
|
||||
- [Example Screenshots](#example-screenshots)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: Required to install dependencies and run the app.
|
||||
- **Pear CLI**: (Optional) Use the [Pear CLI](https://github.com/pearjs/pear) if working with a P2P or desktop runtime.
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the Repository**:
|
||||
```bash
|
||||
git clone https://git.ssh.surf/snxraven/pearCast.git
|
||||
cd pearCast
|
||||
```
|
||||
|
||||
2. **Install Dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Run the App**:
|
||||
```bash
|
||||
pear run --dev .
|
||||
```
|
||||
|
||||
> Note: If you’re not using the Pear CLI, you can serve `index.html` through a local web server (e.g., using `Live Server` extension in VSCode or a simple HTTP server).
|
||||
|
||||
---
|
||||
|
||||
## User Guide
|
||||
|
||||
### Creating a Broadcast Station
|
||||
|
||||
1. **Click "Create Station"**: Initiates a new station and begins capturing audio from the microphone.
|
||||
2. **View Station ID**: Once created, the station will display a unique ID (based on a cryptographic key), which can be shared with others to join.
|
||||
3. **Audio Input Selection**: Choose the desired microphone input from a dropdown menu, then click "Apply" to switch.
|
||||
4. **Leaving the Broadcast**: Click "Leave Broadcast" to end the session, which will also disconnect all connected peers.
|
||||
|
||||
### Joining a Broadcast Station
|
||||
|
||||
1. **Click "Join Station"**: Opens a modal window.
|
||||
2. **Enter Station ID**: Input the ID shared by the broadcaster and click "Join" to connect.
|
||||
3. **Listen to Broadcast**: Audio from the broadcast will begin streaming in real-time.
|
||||
4. **Leaving the Broadcast**: The listener can leave the broadcast by closing the connection in the browser or stopping playback.
|
||||
|
||||
### Changing Audio Input
|
||||
|
||||
**For Broadcasters Only**: Broadcasters can change their microphone input while streaming. Simply select a different device in the "Audio Input Source" dropdown and click "Apply" to switch. The broadcast will automatically start using the newly selected device.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### How P2P Connections are Handled
|
||||
|
||||
The core networking functionality uses **Hyperswarm**. Each station (both broadcaster and listener) connects to a unique "topic" based on a cryptographic key. Hyperswarm manages discovery and connection setup without central servers by joining a Distributed Hash Table (DHT). Key components include:
|
||||
|
||||
1. **Generating a Station ID**: When a station is created, `crypto.randomBytes(32)` generates a 32-byte cryptographic key that uniquely identifies the broadcast. Hyperswarm joins this topic in "server mode" for the broadcaster and "client mode" for listeners.
|
||||
|
||||
2. **Peer Connections**: Both broadcaster and listener connections are managed by Hyperswarm's `swarm.on('connection')` event, which initiates a stream for sending or receiving audio data.
|
||||
|
||||
3. **Handling Disconnects**: Each connection includes error handling and disconnect logging. A connection reset (ECONNRESET) or manual disconnect is logged without crashing the app.
|
||||
|
||||
### Audio Capture and Streaming
|
||||
|
||||
Using the **Web Audio API**, `pearCast` captures and processes audio from the microphone and streams it to connected peers.
|
||||
|
||||
1. **Audio Context Setup**: When a station is created, an `AudioContext` is initialized. This manages audio data flow, including selecting the appropriate microphone input.
|
||||
|
||||
2. **Real-time Audio Processing**: Audio is captured as raw data in `Float32Array` format, then buffered and streamed in chunks. The broadcaster's `processor.onaudioprocess` event transmits audio data to all connected peers.
|
||||
|
||||
3. **Playback for Listeners**: When a listener receives audio data, it’s buffered and played via an `AudioBufferSourceNode` connected to the `AudioContext`, enabling real-time audio streaming without delays.
|
||||
|
||||
### Error Handling and Disconnection Logging
|
||||
|
||||
- **Graceful Peer Disconnects**: Each connection uses an `on('error')` handler that logs disconnect events, preventing crashes from unexpected disconnects (e.g., `ECONNRESET`).
|
||||
- **Empty Buffer Signal**: To notify listeners that a broadcast has ended, the broadcaster sends an empty buffer to all connected peers before stopping the stream. Listeners handle this signal by stopping playback.
|
||||
|
||||
|
||||
## Code Structure
|
||||
|
||||
### HTML (index.html)
|
||||
|
||||
`index.html` contains the core layout and UI components:
|
||||
|
||||
- **Station Controls**: Create or join a station and leave the broadcast.
|
||||
- **Audio Input Selection**: Available to broadcasters only, allowing them to change input sources.
|
||||
- **Bootstrap Modal**: Provides a user-friendly modal interface for joining a station with a specific ID.
|
||||
|
||||
### JavaScript (app.js)
|
||||
|
||||
`app.js` contains the main application logic:
|
||||
|
||||
- **Station Management**: Functions to create or join stations, handle connections, and manage disconnects.
|
||||
- **Audio Capture & Processing**: Configures audio context, captures microphone data, and processes audio buffers.
|
||||
- **Error Handling**: Includes connection reset handling and graceful disconnect logging.
|
||||
- **Audio Source Selection**: Enables broadcasters to choose from available audio input devices.
|
||||
|
||||
---
|
||||
|
||||
## Example Screenshots
|
||||
|
||||
| Feature | Screenshot |
|
||||
|------------------------|--------------------------------------------|
|
||||
| **Create Station** | ![Create Station](./screenshots/create.png)|
|
||||
| **Join Station Modal** | ![Join Station](./screenshots/join.png) |
|
||||
| **Audio Input Control**| ![Audio Input](./screenshots/input.png) |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Connection Reset Errors**:
|
||||
- If you encounter `ECONNRESET` errors, they are logged as peer disconnections. Check if a peer has disconnected unexpectedly.
|
||||
|
||||
2. **No Audio Device Detected**:
|
||||
- Ensure your browser has permission to access the microphone, and refresh the device list if necessary.
|
||||
|
||||
3. **Audio Source Changes Not Applying**:
|
||||
- If changing the audio input source doesn't take effect, ensure you click "Apply" after selecting the device.
|
242
app.js
Normal file
242
app.js
Normal file
@ -0,0 +1,242 @@
|
||||
import Hyperswarm from 'hyperswarm';
|
||||
import crypto from 'hypercore-crypto';
|
||||
import b4a from 'b4a';
|
||||
|
||||
let swarm;
|
||||
let micStream;
|
||||
let audioContext;
|
||||
let isBroadcasting = false;
|
||||
let conns = [];
|
||||
let currentDeviceId = null; // To store the selected audio device ID
|
||||
let accumulatedBuffer = b4a.alloc(0); // Buffer for accumulating received audio data
|
||||
const topic = crypto.randomBytes(32);
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById('create-station').addEventListener('click', () => {
|
||||
setupStation();
|
||||
});
|
||||
|
||||
document.getElementById('leave-stream').addEventListener('click', () => {
|
||||
stopBroadcast();
|
||||
leaveStation();
|
||||
});
|
||||
|
||||
document.getElementById('join-station-button').addEventListener('click', joinStation);
|
||||
document.getElementById('apply-audio-source').addEventListener('click', applyAudioSource);
|
||||
|
||||
// Populate the audio input source dropdown for the broadcaster
|
||||
populateAudioInputSources();
|
||||
});
|
||||
|
||||
// Function to populate audio input sources
|
||||
async function populateAudioInputSources() {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputSelect = document.getElementById('audio-input-select');
|
||||
audioInputSelect.innerHTML = ''; // Clear existing options
|
||||
devices.forEach((device) => {
|
||||
if (device.kind === 'audioinput') {
|
||||
const option = document.createElement('option');
|
||||
option.value = device.deviceId;
|
||||
option.textContent = device.label || `Microphone ${audioInputSelect.length + 1}`;
|
||||
audioInputSelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
// Set default device ID to the first option
|
||||
currentDeviceId = audioInputSelect.value;
|
||||
} catch (err) {
|
||||
console.error("Error enumerating devices:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to apply selected audio source
|
||||
async function applyAudioSource() {
|
||||
const selectedDeviceId = document.getElementById('audio-input-select').value;
|
||||
if (selectedDeviceId !== currentDeviceId) {
|
||||
currentDeviceId = selectedDeviceId;
|
||||
stopBroadcast(); // Stop current stream
|
||||
startBroadcast(); // Restart stream with new device
|
||||
}
|
||||
}
|
||||
|
||||
// Function to start broadcasting from the microphone
|
||||
async function startBroadcast() {
|
||||
if (isBroadcasting) stopBroadcast(); // Stop any existing broadcast
|
||||
|
||||
try {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { deviceId: currentDeviceId ? { exact: currentDeviceId } : undefined },
|
||||
});
|
||||
const source = audioContext.createMediaStreamSource(micStream);
|
||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
const audioData = event.inputBuffer.getChannelData(0);
|
||||
const buffer = b4a.from(new Float32Array(audioData).buffer);
|
||||
|
||||
// Send audio data to all connections
|
||||
for (const conn of conns) {
|
||||
conn.write(buffer);
|
||||
}
|
||||
};
|
||||
|
||||
isBroadcasting = true;
|
||||
console.log("Broadcasting started.");
|
||||
} catch (err) {
|
||||
console.error("Error accessing microphone:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to stop broadcasting and clean up resources
|
||||
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); // Reset accumulated buffer
|
||||
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)); // Send an empty buffer as a stop signal
|
||||
}
|
||||
}
|
||||
|
||||
// Function to create a broadcasting station
|
||||
async function setupStation() {
|
||||
swarm = new Hyperswarm();
|
||||
swarm.join(topic, { client: false, server: true });
|
||||
|
||||
// Show broadcaster controls
|
||||
document.getElementById('broadcaster-controls').classList.remove('d-none');
|
||||
|
||||
// Update UI
|
||||
document.getElementById('station-info').textContent = `Station ID: ${b4a.toString(topic, 'hex')}`;
|
||||
document.getElementById('setup').classList.add('d-none');
|
||||
document.getElementById('controls').classList.remove('d-none');
|
||||
|
||||
// Start broadcasting as soon as the station is created
|
||||
startBroadcast();
|
||||
|
||||
// Listen for incoming connections
|
||||
swarm.on('connection', (conn) => {
|
||||
conns.push(conn);
|
||||
conn.once('close', () => {
|
||||
conns.splice(conns.indexOf(conn), 1);
|
||||
console.log("Peer disconnected.");
|
||||
});
|
||||
conn.on('data', handleData); // Use handleData function to process incoming data
|
||||
|
||||
// Add error handler to log disconnects and suppress crashes
|
||||
conn.on('error', (err) => {
|
||||
if (err.code === 'ECONNRESET') {
|
||||
console.log("Peer connection reset by remote peer.");
|
||||
} else {
|
||||
console.error("Connection error:", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Function to leave the station and stop broadcasting
|
||||
function leaveStation() {
|
||||
if (swarm) swarm.destroy();
|
||||
document.getElementById('setup').classList.remove('d-none');
|
||||
document.getElementById('controls').classList.add('d-none');
|
||||
|
||||
// Hide broadcaster controls
|
||||
document.getElementById('broadcaster-controls').classList.add('d-none');
|
||||
|
||||
stopBroadcast();
|
||||
console.log("Left the station.");
|
||||
}
|
||||
|
||||
// Function to handle incoming data from peers
|
||||
function handleData(data) {
|
||||
if (data.length === 0) {
|
||||
console.log("Received stop command from peer");
|
||||
stopBroadcast();
|
||||
} else {
|
||||
processIncomingAudioData(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to process and play incoming audio data
|
||||
function processIncomingAudioData(data) {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
|
||||
accumulatedBuffer = b4a.concat([accumulatedBuffer, data]);
|
||||
|
||||
while (accumulatedBuffer.byteLength >= 4) {
|
||||
const chunkSize = accumulatedBuffer.byteLength;
|
||||
const audioData = new Float32Array(accumulatedBuffer.slice(0, chunkSize).buffer);
|
||||
accumulatedBuffer = accumulatedBuffer.slice(chunkSize);
|
||||
|
||||
const buffer = audioContext.createBuffer(1, audioData.length, audioContext.sampleRate);
|
||||
buffer.copyToChannel(audioData, 0);
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to join an existing station
|
||||
async function joinStation() {
|
||||
const stationId = document.getElementById('station-id').value;
|
||||
if (!stationId) {
|
||||
alert("Please enter a station ID.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the station ID to a topic buffer
|
||||
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');
|
||||
|
||||
// Hide broadcaster controls for listener
|
||||
document.getElementById('broadcaster-controls').classList.add('d-none');
|
||||
|
||||
swarm.on('connection', (conn) => {
|
||||
conn.on('data', (data) => {
|
||||
processIncomingAudioData(data);
|
||||
});
|
||||
|
||||
// Add error handler for listener connections
|
||||
conn.on('error', (err) => {
|
||||
if (err.code === 'ECONNRESET') {
|
||||
console.log("Peer connection reset by remote peer.");
|
||||
} else {
|
||||
console.error("Connection error:", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Hide the modal after joining
|
||||
const joinModal = document.getElementById('joinModal');
|
||||
const modalInstance = bootstrap.Modal.getInstance(joinModal);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
}
|
||||
}
|
7
assets/bootstrap.bundle.min.js
vendored
Normal file
7
assets/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
assets/bootstrap.min.css
vendored
Normal file
6
assets/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
74
index.html
Normal file
74
index.html
Normal file
@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link href="./assets/bootstrap.min.css" rel="stylesheet">
|
||||
<title>pearCast</title>
|
||||
<style>
|
||||
#titlebar {
|
||||
-webkit-app-region: drag;
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
background-color: #343a40;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
pear-ctrl[data-platform="darwin"] {
|
||||
float: left;
|
||||
margin-top: 15px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-dark text-light">
|
||||
<div id="titlebar">
|
||||
<pear-ctrl></pear-ctrl>
|
||||
</div>
|
||||
<div class="container mt-5 text-center">
|
||||
<h1>pearCast</h1>
|
||||
<div id="setup" class="btn-group mt-4">
|
||||
<button id="create-station" class="btn btn-primary">Create Station</button>
|
||||
<button id="open-join-modal" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#joinModal">Join Station</button>
|
||||
</div>
|
||||
<div id="controls" class="d-none mt-4">
|
||||
<p id="station-info"></p>
|
||||
<button id="leave-stream" class="btn btn-danger mt-2">Leave Broadcast</button>
|
||||
|
||||
<!-- Broadcaster-only Controls -->
|
||||
<div id="broadcaster-controls" class="mt-3 d-none">
|
||||
<label for="audio-input-select" class="form-label">Select Audio Input Source:</label>
|
||||
<select id="audio-input-select" class="form-select"></select>
|
||||
<button id="apply-audio-source" class="btn btn-success mt-2">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Join Modal -->
|
||||
<div class="modal fade" id="joinModal" tabindex="-1" aria-labelledby="joinModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark text-light">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="joinModalLabel">Join a Station</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" id="station-id" class="form-control" placeholder="Enter Station ID">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="join-station-button">Join</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JavaScript Bundle (includes Popper) -->
|
||||
<script src="./assets/bootstrap.bundle.min.js"></script>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "hypercastify",
|
||||
"main": "index.html",
|
||||
"pear": {
|
||||
"name": "hypercastify",
|
||||
"type": "desktop",
|
||||
"gui": {
|
||||
"backgroundColor": "#1F2430",
|
||||
"height": "540",
|
||||
"width": "720"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"dev": "pear run -d .",
|
||||
"test": "brittle test/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"brittle": "^3.0.0",
|
||||
"pear-interface": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.7",
|
||||
"bootstrap": "^5.3.3",
|
||||
"hypercore-crypto": "^3.4.2",
|
||||
"hyperswarm": "^4.8.4",
|
||||
"pear-stdio": "^1.0.1",
|
||||
"stream": "^0.0.3"
|
||||
}
|
||||
}
|
BIN
screenshots/create.png
Normal file
BIN
screenshots/create.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
BIN
screenshots/input.png
Normal file
BIN
screenshots/input.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 133 KiB |
BIN
screenshots/join.png
Normal file
BIN
screenshots/join.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
Loading…
Reference in New Issue
Block a user