DEV Community

loading...
Cover image for A cross-plattform Soundboard written in Go and Svelte

A cross-plattform Soundboard written in Go and Svelte

Kevin Schweikert
・6 min read

GitHub logo kevinschweikert / go-soundboard

A cross-plattform Soundboard written in Go and Svelte

💡 The idea

The "need" for this application arosed, when i wanted to have some fun and use a soundboard for some web conferences with friends and family. I am running on Linux and i couldn't find a software which worked like I wanted it to work. So I decided to write my own and practice my skills in my first real Go project.
It should just output some sound files with the push of a button. Then I could use the Jack Audio Connection Kit

JACK Audio Connection Kit (or JACK; a recursive acronym) is a professional sound server daemon that provides real-time, low-latency connections for both audio and MIDI data between applications that use its API.

Wikipedia

to route that into my virtual input. Fortunately this was super easy because, when I was running it for the first time, it showed up as an own Jack client. So I just had to make the connection, like in the following example. The PulseAudio JACK Sink and the PulseAudio JACK Source are my virtual in- and output. They are set in my system as in- and output device as well. Then I can connect system (Microphone) and alsa-jack.jackP.122733.0 (my soundboard application) into the virtual input. It's also connected to my system output to hear the sounds myself. Then, in the meeting software i just have to select PulseAudio JACK Source as the microphone input and the participants will hear me as well as my awesome sounds!

System In Out

JACK Routing

💾 The Server

The server is written in Go. It's a simple HTTP-Server which serves the Web-UI and creates a websocket endpoint for the control messages. I used the package Beep to play the audio files in a folder and Gorilla Websocket for easy websocket handling.

When you start the application it searches for all the files in the specified folder. For that i created a package called audio and some structs to hold the necessary information.

package audio

// SoundFile holds a sound struct
type SoundFile struct {
    Path      string `json:"path"`
    Name      string `json:"name"`
    Extension string `json:"extension"`
    ID        int    `json:"id"`
}

After i collected all the SoundFiles i created a new SoundDirectory to keep things more compact and have a reference to the folder file path

// SoundDirectory collects all SoundFiles from a specific path
type SoundDirectory struct {
    SoundFiles []SoundFile `json:"soundfiles"`
    Path       string      `json:"path"`
}

Then a new Panel object is created like in this Beep example but slightly modified to also hold the newly created SoundDirectory and instead of a streamer i used the mixer to only resample one stream instead of every file stream. To learn more about the Beep package, look at the Wiki

// Panel holds all Player structs like mixer, ctrl and Volume
type Panel struct {
    speakerSampleRate beep.SampleRate
    mixer             *beep.Mixer
    ctrl              *beep.Ctrl
    Volume            *effects.Volume
    SoundDir          SoundDirectory
}

// NewPanel returns a pointer to a Panel struct
func NewPanel(speakerSampleRate int, dir SoundDirectory) *Panel {
    mixer := &beep.Mixer{}
    ctrl := &beep.Ctrl{Streamer: mixer}
    volume := &effects.Volume{Streamer: mixer, Base: 2}
    return &Panel{beep.SampleRate(speakerSampleRate), mixer, ctrl, volume, dir}
}

In the main function I parse some command line flags, get all the audio files from the specified folder (code is not shown in this article), instantiate a new audio.Panel struct and pass this to the handleWebsocket function. After this, I start the server. There is some other code to serve the static files from the web interface, but I decided to keep that out of the scope of this article.

// Define and parse the command line flags
folderPath := flag.String("path", "./sounds", "path to sound files")
speakerSampleRate := flag.Int("samplerate", 48000, "Output Samplerate in Hz")
buffSize := flag.Int("buff", 256, "Output buffer size in bytes")
port := flag.Int("port", 8000, "Port to listen for the webinterface")
flag.Parse()

// create a new SoundDirectory
dir, err := audio.GetFilesInFolder(*folderPath)
if err != nil {
    log.Println(err)
}

// create a new Panel
ap := audio.NewPanel(*speakerSampleRate, dir)
err = ap.Init(*buffSize)
if err != nil {
    log.Println(err)
}

http.HandleFunc("/websocket", handleWebSocket([OTHER ARGUMENTS], ap))
log.Printf("Server listening on 0.0.0.0:%d", *port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))

From there I can now send the SoundFile slice through the websocket connection with marshalling it into a Msg struct with some additional information.

// Switch constants
const (
    Load   = "load"
    Play   = "play"
    Error  = "error"
    Volume = "volume"
    Stop   = "stop"
)

// Msg struct to marshal and unmarshal the websocket json data
type Msg struct {
    Type       string            `json:"type"`
    Msg        string            `json:"msg"`
    SoundFiles []audio.SoundFile `json:"soundfiles"`
    Volume     float64           `json:"volume"`
}

As you can see, I defined my own message protocol. Every message has to have a type and with this information i know how to use this message. For example in a switch statement.

I read the JSON object from the connection with c.ReadJSON() and put the Type field in the switch statement. After this i can decide, what to do with the message.

For example, when the Msg is of Type: Play i use the function PlaySound() from my audio.Panel and give it the first file from the SoundFiles array (That's my solution to reuse the SoundFiles field multiple times. As an array of multiple files in the Load command or an array with only one item in the Play command).

If there is an error while trying to play SoundFile i create a new message with Type: Error and the error text itself in the message field. This is sent to my frontend and is handled with a notification for the user. But there are more possibilities like a message box with a kind of log of all the error messages.

//c is the pointer to the websocket client connection with the type *websocket.Conn

payload := new(Msg)
err := c.ReadJSON(payload)

switch Msg.Type {
    case Play:
        err := ap.PlaySound(payload.SoundFiles[0])
            if err != nil {
                c.WriteJSON(Msg{
                    Type: Error,
                    Msg:  err.Error(),
                })
            }
    case Load:
        ....
    .
    .
    .
}

✏️ The UI

Because, i have no idea how to build a Desktop UI i decided to build a web interface with my favorite JavaScript Framework Svelte. The Web-UI is served from my application and connects to the /websocket route to receive all the necessary data, which is also processed in a switch statement. In a simpler form it looks like this:

<script>
    // Import the SoundButton component
    import SoundButton from "./SoundButton.svelte";

    // variable to hold the soundfile array
    let sounds = [] 

    // create websocket connection with location.host to work when served from localhost or other interface
    const websocket = new WebSocket("ws://" + location.host + "/websocket");

    //Define onmessage event handling
    websocket.onmessage = function(event) {

        // data will be the json object representing the go Msg struct
        const data = JSON.parse(event.data);

        // same switch logic like in Go
        switch (data.type) {
            case "load":
            // put the soundfiles array into the global "sound" variable
            sounds = data.soundfiles;
            break;
        case "volume":
            ...
            break;
        .
        .
        .
      }
    };

    const playSound = (msg) => {
        ws.send(JSON.stringify(msg))
    }

</script>

<!-- for each array object in sounds create a SoundButton component and pass in this object -->
{#each sounds as sound}
      <SoundButton on:play={playSound} soundFile={sound}/>
{/each}

For each array object in sounds, Svelte will create a <SoundButton/> component. If the array changes, the buttons will dynamically change as well. Also, you notice that the comomponent has a custom event on:play. It will be fired when the button is clicked and send some data with the event. In Svelte you can just create an event dispatcher and name your custom event, so you can listen to it wherever you want to use the component. The SoundButton component looks something like this:

<script>
    import { createEventDispatcher } from "svelte";
    const dispatch = createEventDispatcher()

    export let soundFile = {} 

    const playSound = () => {
        const playMsg = {
            type: "play",
            soundfiles: [soundFile],
        };
        dispatch("play", playMsg);
     };
</script>

<button on:click={playSound}>
    {soundFile.name}
</button>

I know that this is a very basic explanation how everything works but i want to keep it short and basic. If there are any questions I'm happy to help and explain! Have a look at the full code at github:

GitHub logo kevinschweikert / go-soundboard

A cross-plattform Soundboard written in Go and Svelte

🔧 Usage

go build -o [EXECUTABLE_NAME]
./[EXECUTABLE_NAME] [FLAGS]

OR

go run .

Start the server application with these possible flags:

   -buff int
        Output buffer size in bytes (default 256)
  -path string
        path to sound files (default "./sounds")
  -port int
        Port to listen for the web interface (default 8000)
  -samplerate int
        Output Samplerate in Hz (default 48000)

The go to localhost:8000 and you should see this:

Go Soundboard Webinterface

🎊 The End

This is my first post and public project i am showing to you. There is still so many things i could make better but I'm happy to hear your thoughts! I like to hear your suggestions or constructive critisicism about my code, the idea and the article itself! I'm working on building executables for every system, so it's easier to use for everyone and have fun with it!

Cheers!

Discussion (0)