Build a Radio Broadcast System With NodeJS, React and Socket.IO

Build a Radio Broadcast System With NodeJS, React and Socket.IO

In this article, I will show you how to build a radio broadcasting system with Express and React.

Our radio station will consist of 1 host and multiple listeners. The host streams their microphone to the server, which broadcasts to each listener. We can do this over WebSockets with socket.io. Also, the server loads music from a local folder to stream to the listeners over HTTP.

illustration1.png

Building the Backend

To get started, we need to install the following packages from NPM.

npm i express react-feather jnaudiostream react-router-dom socket.io socket.io-client throttle uuid

Let's create an index.js file in a new folder named server. Here we will set up an Express app and wrap a socket.io server around it. Then we listen for a socket connection and define a route where clients can access the HTTP music stream.

import express from "express";
import http from "http";
import { Server as IOServer } from "socket.io";

const PORT = 3000;
const app = express();
const server = http.createServer(app);
const io = new IOServer(server);

(async () => {
    io.on("connection", (socket) => {
        console.log("New listener connected");
    });

    app.get("/stream", (req, res) => {
        res.status(200).json({Message: "Hello Radio!"});
    });

    server.listen(PORT, () => {
        console.log(`Listening on port ${PORT}`);
    });
})();

export {};

Live streaming audio

We'll create a Queue class to handle opening music files and streaming them to each client at the same rate. For this, we can create a file named queue.js in the same server folder.

Here are all the methods we will implement declared in Typescript style.

declare class Queue {
    constructor();
    broadcast(chunk: Buffer): void;
    addClient(): {
        id: string;
        client: PassThrough;
    };
    removeClient(id: string): void;
    loadTracks(dir: string): Promise<void>;
    getTrackBitrate(filepath: string): Promise<number>;
    getNextTrack(): {
        filepath: string;
        bitrate: number;
    };
    pause(): void;
    resume(): void;
    started(): boolean;
    play(useNewTrack?: boolean): void;
    loadTrackStream(): void;
    start(): Promise<void>;
}

Let's now break this all down.

Handling clients

We can create a PassThrough stream for each client where we can write the audio data, and the client can still read it.

We will also define a method to write a chunk of data to every connected client.

class Queue {
    constructor() {
        this.clients = new Map();
    }

    // ...

    broadcast(chunk) {
        this.clients.forEach((client) => {
            client.write(chunk);
        });
    }

    // ...
}

Loading files

We need a method to get the names of all the MP3 files in a directory and get their bitrates. Having the bitrate allows us to control the rate of audio streaming. Instead of handling the timing ourselves, we will use the Throttle package.

import { readdir } from "fs/promises";
import { extname, join } from "path";

class Queue {
    constructor() {
        this.clients = new Map();
        this.tracks = [];
    }

    // ...

    async loadTracks(dir) {
        let filenames = await readdir(dir);
        filenames = filenames.filter(
            (filename) => extname(filename) === ".mp3"
        );

        // Add directory name back to filenames
        const filepaths = filenames.map((filename) => join(dir, filename));

        const promises = filepaths.map(async (filepath) => {
            const bitrate = await this.getTrackBitrate(filepath);

            return { filepath, bitrate };
        });

        this.tracks = await Promise.all(promises);
        console.log(`Loaded ${this.tracks.length} tracks`);
    }

    // ...
}

And to get the bitrate, we'll use ffprobe via the @dropb/ffprobe wrapper. We must install that along with ffprobe-static, which will download the ffprobe binaries and return their location.

npm i ffprobe-static @dropb/ffprobe

Use the following code to implement the getTrackBitrate method:

import { ffprobe } from "@dropb/ffprobe";
import ffprobeStatic from "ffprobe-static";

ffprobe.path = ffprobeStatic.path;

class Queue {

    // ...

    async getTrackBitrate(filepath) {
        const data = await ffprobe(filepath);
        const bitrate = data?.format?.bit_rate;

        return bitrate ? parseInt(bitrate) : 128000;
    }

    // ...

}

Getting the next track

We use an index to know which track we should play next. By default, the index is 0.

getNextTrack() {
    // Loop back to the first track
    if (this.index >= this.tracks.length - 1) {
        this.index = 0;
    }

    const track = this.tracks[this.index++];
    this.currentTrack = track;

    return track;
}

Create a File Stream

We now use fs to create a read stream from the track's filepath.

loadTrackStream() {
    const track = this.currentTrack;
    if (!track) return;

    console.log("Starting audio stream");
    this.stream = createReadStream(track.filepath);
}

Spectacular streaming

Now we'll instantiate a new Throttle where we will pipe the file stream. The constructor for Throttle takes in bps (bytes per second), so we divide bitrate by 8.

Then we listen for the data event and call this.broadcast on every chunk received. We use the data event to send the data to multiple streams, as only one Writable can consume the typical .pipe.

And once there is no more data left, we start again by loading the next track, getting the stream and finally streaming.

async start() {
    const track = this.currentTrack;
    if (!track) return;

    this.playing = true;
    this.throttle = new Throttle(track.bitrate / 8);

    this.stream
        .pipe(this.throttle)
        .on("data", (chunk) => this.broadcast(chunk))
        .on("end", () => this.play(true))
        .on("error", () => this.play(true));
}

Controlling playback

To pause the playback, all we have to do is end the throttle. But we also have to remove the end listener to avoid this.play(true) being executed.

pause() {
    if (!this.started() || !this.playing) return;
    this.playing = false;
    console.log("Paused");
    this.throttle.removeAllListeners("end");
    this.throttle.end();
}

We also need a guard clause to check if a throttle exists and if we have loaded a track.

started() {
    return this.stream && this.throttle && this.currentTrack;
}

And now, to resume playback, we can use the call the start method we defined earlier.

resume() {
    if (!this.started() || this.playing) return;
    console.log("Resumed");
    this.start();
}

Finishing touches

We can now define the play method, which either resumes playback or starts playing the next track.

play(useNewTrack = false) {
    if (useNewTrack || !this.currentTrack) {
        console.log("Playing new track");
        this.getNextTrack();
        this.loadTrackStream();
        this.start();
    } else {
        this.resume();
    }
}

And in the file, we can export a singleton instance of Queue.

// ...

class Queue {
   // ...
}

const queue = new Queue();
export default queue;

The Server

Back in our main server file, we can import our queue, load the tracks and implement the /stream route, which pipes the PassThrough created by the queue to the HTTP response.

(async () => {
    await queue.loadTracks("tracks");
    queue.play();

    // ...

    app.get("/stream", (req, res) => {
        const { id, client } = queue.addClient();

        res.set({
            "Content-Type": "audio/mp3",
            "Transfer-Encoding": "chunked",
        }).status(200);

        client.pipe(res);

        req.on("close", () => {
            queue.removeClient(id);
        });
    });

    // ...

})()

Frontend routing

In the main.jsx file (if you're using Vite), we define three routes using react-router-dom.

// src/main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import Root from "./App";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Host from "./routes/host";
import Listen from "./routes/listen";
import "./index.css";

const router = createBrowserRouter([
    {
        path: "/",
        element: <Root />,
    },
    {
        path: "/host",
        element: <Host />,
    },
    {
        path: "/listen",
        element: <Listen />,
    },
]);

ReactDOM.createRoot(document.getElementById("root")).render(
    <React.StrictMode>
        <RouterProvider router={router} />
    </React.StrictMode>
);

Root component

Our root component is simple. We place a header and create buttons to take the user to the other pages. Note that we use react-feather for icons and TailwindCSS for styling.

// src/App.jsx

import React from "react";
import { Link } from "react-router-dom";
import { Mic, Volume2 } from "react-feather";

const buttonClassName =
    "rounded-3xl p-4 w-full font-bold bg-gray-500 hover:bg-gray-400 border-4 border-gray-600 font-mono flex flex-row items-center";
const iconClassName = "p-3 bg-gray-600 mr-4 rounded-full inline";

function App() {
    return (
        <div className="flex flex-col items-center h-full">
            <h1 className="text-5xl font-bold mb-5">Radio Broadcast</h1>
            <div className="space-y-3 flex flex-col items-center w-max">
                <Link to="/host" className="w-full">
                    <button type="button" className={buttonClassName}>
                        <div className={iconClassName}>
                            <Mic size={32} />
                        </div>
                        Become the host
                    </button>
                </Link>
                <Link to="/listen" className="w-full">
                    <button type="button" className={buttonClassName}>
                        <div className={iconClassName}>
                            <Volume2 size={32} />
                        </div>
                        Tune in
                    </button>
                </Link>
            </div>
        </div>
    );
}

export default App;

Streaming microphone

To send the audio data to the server in real-time, we can use jnaudiostream, a package I published on NPM.

We can create a component (MicrophoneButton) that will communicate with the server using socket.io-client. Inside an effect, we can send the buffer header and subsequent data packets when they are received.

// src/components/MicrophoneButton.jsx

import React, { useEffect, useRef, useState } from "react";
import { Mic, MicOff } from "react-feather";
import { AudioRecorder } from "jnaudiostream";

export default function MicrophoneButton({ socket }) {
    const recorderRef = useRef(new AudioRecorder({}, 100)); // 1ms

    useEffect(() => {
        if (!socket) return;
        const recorder = recorderRef.current;

        recorder.onReady = (packet) => {
            console.log("Recording started!");
            console.log(packet);
            console.log("Header size: " + packet.data.size + "bytes");
            socket.emit("bufferHeader", packet);
        };

        recorder.onBuffer = (packet) => {
            socket.emit("stream", packet);
        };
    }, [socket]);

    // ...

}

Now we can add an onClick handler to the record button, which will toggle the recorder state. Here is the final code for the MicrophoneButton component.

import React, { useEffect, useRef, useState } from "react";
import { Mic, MicOff } from "react-feather";
import { AudioRecorder } from "jnaudiostream";

export default function MicrophoneButton({ socket }) {
    const [recording, setRecording] = useState(false);

    const recorderRef = useRef(new AudioRecorder({}, 100)); // 1ms

    useEffect(() => {
        if (!socket) return;
        const recorder = recorderRef.current;

        recorder.onReady = (packet) => {
            console.log("Recording started!");
            console.log(packet);
            console.log("Header size: " + packet.data.size + "bytes");
            socket.emit("bufferHeader", packet);
        };

        recorder.onBuffer = (packet) => {
            socket.emit("stream", packet);
        };
    }, [socket]);

    const onClickMic = () => {
        const recorder = recorderRef.current;
        setRecording(!recorder.recording);
        recorder.recording
            ? recorder.stopRecording()
            : recorder.startRecording();
    };

    const Icon = recording ? MicOff : Mic;

    const title = `${recording ? "Stop" : "Start"} recording`;

    return (
        <div className="flex flex-col items-center w-full">
            <button
                type="button"
                title={title}
                onClick={onClickMic}
                className="rounded-full p-5 bg-transparent border-2 border-blue-300 focus:border-red-600 hover:bg-gray-700"
            >
                <Icon size={64} className="color-white" />
            </button>
        </div>
    );
}

Socket.IO Server

On the server side, we can listen for the bufferHeader and stream events and broadcast the data to the other clients. We also listen for a control event which we'll use later.

// ...

io.on("connection", (socket) => {
    console.log("New listener connected");

    // Every new streamer must receive the header
    if (queue.bufferHeader) {
        socket.emit("bufferHeader", queue.bufferHeader);
    }

    socket.on("bufferHeader", (header) => {
        queue.bufferHeader = header;
        socket.broadcast.emit("bufferHeader", queue.bufferHeader);
    });

    socket.on("stream", (packet) => {
        // Only broadcast microphone if a header has been received
        if (!queue.bufferHeader) return;

        // Audio stream from host microphone
        socket.broadcast.emit("stream", packet);
    });

    socket.on("control", (command) => {
        switch (command) {
            case "pause":
                queue.pause();
                break;
            case "resume":
                queue.resume();
                break;
        }
    });
});

// ...

Music Control

Alongside recording the microphone, we need buttons to send commands to the socket.io server.

// src/components/MusicControls.jsx

import React from "react";
import { Play, Pause } from "react-feather";

const buttonClassName =
    "rounded-lg border-gray-200 bg-slate-700 hover:bg-slate-600 px-6 py-2";

export default function MusicControls({ socket }) {
    const execCommand = (command) => {
        socket.emit("control", command);
    };

    return (
        <div className="flex flex-col justify-center items-center w-full mt-8">
            <div className="flex flex-row justify-center space-x-3">
                <button
                    type="button"
                    onClick={() => execCommand("resume")}
                    className={buttonClassName}
                >
                    <Play size={28} />
                </button>
                <button
                    type="button"
                    onClick={() => execCommand("pause")}
                    className={buttonClassName}
                >
                    <Pause size={28} />
                </button>
            </div>
        </div>
    );
}

The Host route

And in our main component for the route, we can instantiate a client for socket.io. We connect to the base URL of the server.

// src/routes/host.jsx

import { useRef } from "react";
import MicrophoneButton from "../components/MicrophoneButton";
import MusicControls from "../components/MusicControls";
import { io } from "socket.io-client";
import { Radio } from "react-feather";

const URL = "http://localhost:3000";

function Host() {
    const socketRef = useRef(io(URL));

    return (
        <div className="flex flex-col items-center h-full">
            <h1 className="text-5xl font-bold">Broadcast</h1>
            <Radio size={48} className="mb-4" />
            <MicrophoneButton socket={socketRef.current} />
            <MusicControls socket={socketRef.current} />
        </div>
    );
}

export default Host;

By the way, if your frontend app and backend are on different ports (such as Vite on port 5173 and the Express app on port 3000), you will need to set a CORS property on the socket.io server.

const io = new IOServer(server, {
    cors: {
        origin: "http://localhost:5173",
    },
});

Tuning in

To fully listen to the broadcast, the user needs the host's microphone audio from socket.io and the HTTP stream for the music.

For tuning in to the host microphone, we can use the AudioStreamer class from jnaudiostream. We then listen for the bufferHeader and stream events to pass to the streamer object, similar to an example at the repository.

// src/hooks/useAudioStreamer.js

import { AudioStreamer } from "jnaudiostream";
import { useEffect, useRef } from "react";

const useAudioStreamer = (socket) => {
    const streamerRef = useRef(new AudioStreamer());

    useEffect(() => {
        const streamer = streamerRef.current;

        socket.on("bufferHeader", (packet) => {
            if (streamer.mediaBuffer) {
                return;
            }

            streamer.setBufferHeader(packet);
            streamer.playStream();
        });

        socket.on("stream", (packet) => {
            if (!streamer.mediaBuffer) {
                return;
            }
            streamer.receiveBuffer(packet);
        });

        return () => {
            socket.off("bufferHeader");
            socket.off("stream");
        };
    });
};

export default useAudioStreamer;

Note that streamer.mediaBuffer is available once we have set a header buffer.

And now, when listening to the HTTP music stream, we only use an audio element.

Add the following code to create the final route component.

// src/routes/listen.jsx

import { useRef } from "react";
import { io } from "socket.io-client";
import useAudioStreamer from "../hooks/useAudioStreamer";
import { Headphones } from "react-feather";

const URL = "http://localhost:3000";

function Listen() {
    const socketRef = useRef(io(URL));
    useAudioStreamer(socketRef.current);

    return (
        <div className="flex flex-col items-center h-full">
            <h1 className="text-5xl font-bold">Listening...</h1>
            <div className="rounded-full bg-gray-700 p-6 mt-5">
                <Headphones size={50} />
            </div>
            <audio src={`${URL}/stream`} autoPlay />
        </div>
    );
}

export default Listen;

Final notes

To summarise, we streamed audio from a host microphone and broadcast it to all connected listeners. Then we created a queue engine to handle streaming music to HTTP clients. Finally, we built an interface to tie everything together.

Here are some additional points to consider:

  • Authorisation for hosts
  • Adding or removing tracks from the queue
  • Displaying the current track
  • Allowing more than one queue (station)

Before you leave, you can find the entire code for this project here. Until next time.

References

github.com/WoolDoughnut310/jnaudiostream

socket.io/docs/v4/handling-cors

blog.logrocket.com/online-radio-server-pure..

github.com/michael-gillett/node-stream-player

tailwindcss.com/docs/guides/vite

github.com/TooTallNate/node-throttle

Did you find this article valuable?

Support Wool Doughnut by becoming a sponsor. Any amount is appreciated!