diff --git a/.gitignore b/.gitignore index ce44819..b085c08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.exe -sounds/** \ No newline at end of file +sounds/** +conf.json diff --git a/README.MD b/README.MD index 75b23c3..3491b09 100644 --- a/README.MD +++ b/README.MD @@ -1,2 +1,28 @@ +![Soundr Logo](/resources/logo.svg "Soundr Logo") + # Soundr -An opensource audio server meant for professional applications \ No newline at end of file +Soundr is a simple, open-source, cross-platform audio playing server written in go. +It aims to be simple to setup and work in many envoriments. It is also designed to be +easy to use and maintain. +Soundr is able to play multiple audio files at the same time. It is able to intigrate well as it uses a REST endpoint. +Swagger Documentation for that endpoint is available in `apiDocs.yml`. +The software it self is written in go and uses the BEEP library. It is made to be shipped as a single executable. +Another target was a minimal dependency tree. +Initally it was written to be used with [Bitfocus Companion](https://bitfocus.io/companion) in a more professional envoriment. (A client for Companion is currently WiP) + +# Installation +Installation is as simple as it gets as it is a single executable. +Download one of the releases, drop your sounds into the /sounds folder and run the executable. + +# Configuration +TODO, no config yet. Soon ports and other settings will be added. + +# Usage +Drop your sounds into the /sounds. You can play them by sending a GET request to the /v1/play endpoint. +You need to know the base64 encoded file name of the sound you want to play. You can get started by querying /v1/list. It will return a list of all sounds with their respective base64 encoded file name. +Use that base64 as the `file` parameter in the request. + +**Note**: The sounds must be in the format `*.mp3` (more will be supported soon :tm:). + +# ToDo +- [ ] Add support for other audio formats \ No newline at end of file diff --git a/apiDocs.yml b/apiDocs.yml new file mode 100644 index 0000000..3fd5dda --- /dev/null +++ b/apiDocs.yml @@ -0,0 +1,144 @@ +openapi: '3.0.2' +info: + title: Soundr + version: '1.0' +servers: + - url: http://localhost:8082/v1/ +paths: + /play: + get: + summary: Plays a sound by it's base64'd name. Will load it to buffer first if not already loaded. + parameters: + - in: query + name: file + description: A base64 encoded version of the file name + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + id: + type: number + description: The ID of the playing sound + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + reason: + type: string + description: The error message, in this case probably "file not found" + /buffer: + get: + summary: Loads a sound into the buffer. + parameters: + - in: query + name: file + description: A base64 encoded version of the file name + schema: + type: string + responses: + '200': + description: OK + + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + reason: + type: string + description: The error message, in this case probably "file not found" + /bufferAll: + get: + summary: Loads all sounds into the buffer. + responses: + '200': + description: OK + /stop: + get: + summary: Stops a given sound by it's ID. + parameters: + - in: query + name: id + description: The ID of the sound to stop + schema: + type: number + responses: + '200': + description: OK + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + reason: + type: string + description: The error message + /stopAll: + get: + summary: Stops all sounds. + responses: + '200': + description: OK + /current: + get: + summary: Gets the current playing sound(s). + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + sounds: + type: array + items: + type: object + properties: + id: + type: number + description: The ID of the sound + name: + type: string + description: The name of the sound + loaded: + type: boolean + description: Whether the sound is loaded into the buffer + /list: + get: # TODO REWORK!!!!! + summary: Lists all sounds in the buffer. + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + sounds: + type: array + items: + type: object + properties: + name: + type: string + description: The name of the sound + base64: + type: string + description: The base64 version of the name + url: + type: string + description: The URL to the sound \ No newline at end of file diff --git a/handlerFunctions.go b/handlerFunctions.go new file mode 100644 index 0000000..23f72d8 --- /dev/null +++ b/handlerFunctions.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/faiface/beep" + "github.com/faiface/beep/mp3" + "github.com/faiface/beep/speaker" +) + +func BufferSound(file string) bool { + _, ok := streamMap[file] + if !ok { + fmt.Println("Not in memory, loading") + f, err := os.Open("./sounds/" + file) + if err != nil { + log.Fatal(err) + } + + fmt.Println("Opened file") + streamer, format, _ := mp3.Decode(f) + speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) + + fmt.Println("Decoded file") + buffer := beep.NewBuffer(format) + buffer.Append(streamer) + streamer.Close() + fmt.Println("Bufferd file") + + // Save to streamMap + streamMap[file] = streamBuf{ + Streamer: streamer, + Format: format, + Buffer: buffer, + } + return (true) + } else { + return (false) + } +} + +func PlaySound(file string, index int) int { + playbacks[index] = playback{ + File: file, + IsLoaded: false, + Streamer: nil, + Control: nil, + } + + fmt.Println("Playing sound: " + file) + var buffer *beep.Buffer + BufferSound(file) + buffer = streamMap[file].Buffer + streamer := streamMap[file].Streamer + + fmt.Println("Trying to play sound") + shot := buffer.Streamer(0, buffer.Len()) + + done := make(chan bool) + ctrl := &beep.Ctrl{Streamer: beep.Seq(shot, beep.Callback(func() { + done <- true + })), Paused: false} + + playbacks[index] = playback{ + File: file, + IsLoaded: true, + Streamer: streamer, + Control: ctrl, + } + speaker.Play(ctrl) + <-done + fmt.Println("Finished playing sound: " + file) + delete(playbacks, index) + return 1 +} diff --git a/resources/logo.svg b/resources/logo.svg new file mode 100644 index 0000000..ec4cabb --- /dev/null +++ b/resources/logo.svg @@ -0,0 +1,181 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/soundr.go b/soundr.go index ba4659e..dd13de7 100644 --- a/soundr.go +++ b/soundr.go @@ -1,22 +1,14 @@ package main import ( - "encoding/base64" "encoding/json" "errors" "fmt" - "html" - "io/ioutil" "log" "net/http" "os" - "strconv" - "sync" - "time" "github.com/faiface/beep" - "github.com/faiface/beep/mp3" - "github.com/faiface/beep/speaker" ) type playback struct { @@ -38,198 +30,66 @@ type streamBuf struct { Buffer *beep.Buffer } +type Configuration struct { + Port int +} + var playbacks map[int]playback -var mapMutex = sync.Mutex{} var streamMap map[string]streamBuf -func BufferSound(file string) bool { - _, ok := streamMap[file] - if !ok { - fmt.Println("Not in memory, loading") - f, err := os.Open("./sounds/" + file) - if err != nil { - log.Fatal(err) - } - - fmt.Println("Opened file") - streamer, format, err := mp3.Decode(f) - speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) - - fmt.Println("Decoded file") - buffer := beep.NewBuffer(format) - buffer.Append(streamer) - streamer.Close() - fmt.Println("Bufferd file") - - // Save to streamMap - streamMap[file] = streamBuf{ - Streamer: streamer, - Format: format, - Buffer: buffer, - } - return (true) - } else { - return (false) - } -} - -func PlaySound(file string, index int) int { - playbacks[index] = playback{ - File: file, - IsLoaded: false, - Streamer: nil, - Control: nil, - } - - fmt.Println("Playing sound: " + file) - var buffer *beep.Buffer - BufferSound(file) - buffer = streamMap[file].Buffer - streamer := streamMap[file].Streamer - - fmt.Println("Trying to play sound") - shot := buffer.Streamer(0, buffer.Len()) - - done := make(chan bool) - ctrl := &beep.Ctrl{Streamer: beep.Seq(shot, beep.Callback(func() { - done <- true - })), Paused: false} - - playbacks[index] = playback{ - File: file, - IsLoaded: true, - Streamer: streamer, - Control: ctrl, - } - speaker.Play(ctrl) - <-done - fmt.Println("Finished playing sound: " + file) - delete(playbacks, index) - return 1 -} - func main() { + fmt.Println("Welcome to Soundr!") + playbacks = make(map[int]playback) streamMap = make(map[string]streamBuf) - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Query().Get("name"))) - }) + // Create /sounds if not exists + if _, err := os.Stat("./sounds"); os.IsNotExist(err) { + fmt.Println("Created /sounds folder") + os.Mkdir("./sounds", 0777) + } - http.HandleFunc("/v1/play", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - var cnt = r.URL.Query().Get("file") - bytArr, err := base64.StdEncoding.DecodeString(cnt) - if err != nil { - log.Fatal(err) + // Handle config + fmt.Println("Opening conf.json") + file, fOpenError := os.Open("conf.json") // Try to open the file + + if errors.Is(fOpenError, os.ErrNotExist) { // If it does not exist, create it + fmt.Println("Creating conf.json") + file, fOpenError = os.Create("conf.json") + if fOpenError != nil { + log.Fatal(fOpenError) } - fmt.Println(string(bytArr[:])) - t, err := os.Stat("./sounds/" + string(bytArr[:])) - t = t - if !errors.Is(err, os.ErrNotExist) { - var currIndex = len(playbacks) - fmt.Fprintf(w, "{\"status\":\"ok\", \"id\":%d}", currIndex) + defer file.Close() + fmt.Println("Writing to conf.json") + // Write the default config to the file + json.NewEncoder(file).Encode(Configuration{ + Port: 8080, + }) + fmt.Println("Wrote to conf.json") + } + defer file.Close() + // Decode the config + decoder := json.NewDecoder(file) + configuration := Configuration{} + err := decoder.Decode(&configuration) - go PlaySound(string(bytArr[:]), currIndex) + if err != nil { + fmt.Println("error:", err) + } - } else { - fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"file not found\"}") - } - - }) - - http.HandleFunc("/v1/buffer", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - var cnt = r.URL.Query().Get("file") - bytArr, err := base64.StdEncoding.DecodeString(cnt) - if err != nil { - log.Fatal(err) - } - t, err := os.Stat("./sounds/" + string(bytArr[:])) - t = t - if !errors.Is(err, os.ErrNotExist) { - fmt.Fprintf(w, "{\"status\":\"ok\"}") - go BufferSound(string(bytArr[:])) - } else { - fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"file not found\"}") - } - - }) - - http.HandleFunc("/v1/stopAll", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - playbacks = make(map[int]playback) - fmt.Fprintf(w, "{\"status\":\"ok\"}") - speaker.Clear() - //fmt.Fprintf(w, "{\"status\":\"ok\", \"id\":%d}", currIndex) - - }) - - http.HandleFunc("/v1/stop", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - var cnt, err = strconv.Atoi(r.URL.Query().Get("id")) - if err != nil { - fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"invalid id\"}") - } - - value, ok := playbacks[cnt] - if !ok { - fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"audio not playing\"}") - } else { - fmt.Fprintf(w, "{\"status\":\"ok\", \"id\":%d}", value) - value.Control.Paused = true - value.Control.Streamer = nil - delete(playbacks, cnt) - } - //fmt.Fprintf(w, "{\"status\":\"ok\", \"id\":%d}", currIndex) - - }) - - http.HandleFunc("/v1/current", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - var tempResultSet map[int]playbackWebReturn - tempResultSet = make(map[int]playbackWebReturn) - - for index, element := range playbacks { - tempResultSet[index] = playbackWebReturn{File: element.File, IsLoaded: element.IsLoaded, Id: index} - } - - j, err := json.Marshal(tempResultSet) - if err != nil { - fmt.Printf("Error: %s", err.Error()) - } else { - fmt.Println(string(j)) - } - fmt.Fprintf(w, string(j)) - }) - - http.HandleFunc("/v1/list", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - var temp [][3]string - files, err := ioutil.ReadDir("./sounds/") - if err != nil { - log.Fatal(err) - } - - for _, f := range files { - var soundObj [3]string - soundObj[0] = f.Name() - soundObj[1] = base64.StdEncoding.EncodeToString([]byte(f.Name())) - soundObj[2] = r.URL.Host + "/v1/play?file=" + soundObj[1] - temp = append(temp, soundObj) - } - - j, err := json.Marshal(temp) - if err != nil { - fmt.Printf("Error: %s", err.Error()) - } else { - fmt.Println(string(j)) - } - fmt.Fprintf(w, string(j)) - }) - log.Fatal(http.ListenAndServe(":8081", nil)) + // Web server stuff + // Play route, takes file as parameter, file is base64 encoded + http.HandleFunc("/v1/play", handlePlay) + // Buffer route, buffers file + http.HandleFunc("/v1/buffer", handleBuffer) + http.HandleFunc("/v1/bufferAll", handleBufferAll) + http.HandleFunc("/v1/stop", handleStop) + http.HandleFunc("/v1/stopAll", handleStopAll) + http.HandleFunc("/v1/current", handleCurrent) + http.HandleFunc("/v1/list", handleListing) + fmt.Println("Listening on port " + fmt.Sprint(configuration.Port)) + log.Fatal(http.ListenAndServe(":"+fmt.Sprint(configuration.Port), nil)) } diff --git a/webRoutes.go b/webRoutes.go new file mode 100644 index 0000000..1efb930 --- /dev/null +++ b/webRoutes.go @@ -0,0 +1,208 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "strconv" + + "github.com/faiface/beep/speaker" +) + +func handlePlay(w http.ResponseWriter, r *http.Request) { + // Rejct everything else then GET requests + if r.Method != "GET" { + http.Error(w, "Method is not supported.", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") // Set the content type to json + var cnt = r.URL.Query().Get("file") // Retrieve the file name from the query string + bytArr, err := base64.StdEncoding.DecodeString(cnt) // Decode the base64 string + if err != nil { + log.Fatal(err) + } + + t, err := os.Stat("./sounds/" + string(bytArr[:])) // Check if the file exists + if errors.Is(err, os.ErrNotExist) { + w.WriteHeader(400) + fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"file not found\"}") + return + } + + if t.IsDir() { // Make sure it is not a folder we are trying to play + w.WriteHeader(400) + fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"target is folder\"}") + return + } + + var currIndex = len(playbacks) // Create a new index for the playback + fmt.Fprintf(w, "{\"status\":\"ok\", \"id\":%d}", currIndex) // Return a JSON object to the user + + go PlaySound(string(bytArr[:]), currIndex) // Play the sound + +} + +// Handle Buffering + +func handleBufferAll(w http.ResponseWriter, r *http.Request) { + // Rejct everything else then GET requests + if r.Method != "GET" { + http.Error(w, "Method is not supported.", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") // Set the content type to json + + var temp []string + files, err := ioutil.ReadDir("./sounds/") // Read the directory + if err != nil { + log.Fatal(err) + } + // Loop through the files and add the file name to the temp array + // Also triggers the buffer process for the file + for _, f := range files { + temp = append(temp, f.Name()) + go BufferSound(f.Name()) + } + // Return the amount of files buffered + fmt.Fprintf(w, "{\"status\":\"ok\", \"amount\":%d}", len(temp)) + +} + +func handleBuffer(w http.ResponseWriter, r *http.Request) { + // Rejct everything else then GET requests + if r.Method != "GET" { + http.Error(w, "Method is not supported.", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") // Set the content type to json + var cnt = r.URL.Query().Get("file") // Retrieve the file name from the query string + bytArr, err := base64.StdEncoding.DecodeString(cnt) // Decode the base64 string + if err != nil { + log.Fatal(err) + } + + t, err := os.Stat("./sounds/" + string(bytArr[:])) // Check if the file exists + if errors.Is(err, os.ErrNotExist) { + w.WriteHeader(400) + fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"file not found\"}") + return + } + + if t.IsDir() { // Make sure it is not a folder we are trying to play + w.WriteHeader(400) + fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"target is folder\"}") + return + } + + fmt.Fprintf(w, "{\"status\":\"ok\"}") + go BufferSound(string(bytArr[:])) + +} + +// Handeling Stop + +func handleStop(w http.ResponseWriter, r *http.Request) { + // Rejct everything else then GET requests + if r.Method != "GET" { + http.Error(w, "Method is not supported.", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") // Set the content type to json + var cnt, err = strconv.Atoi(r.URL.Query().Get("id")) // Retrieve the id, first convert it to an int + if err != nil { + fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"invalid id\"}") + } + + value, ok := playbacks[cnt] // Get value from playbacks map + if !ok { + w.WriteHeader(400) + fmt.Fprintf(w, "{\"status\":\"fail\", \"reason\":\"audio not playing\"}") + } else { + fmt.Fprintf(w, "{\"status\":\"ok\"}") + // Stop by pausing first then, set the streamer to nil. Finally delete it from the map + value.Control.Paused = true + value.Control.Streamer = nil + delete(playbacks, cnt) + } +} + +func handleStopAll(w http.ResponseWriter, r *http.Request) { + // Rejct everything else then GET requests + if r.Method != "GET" { + http.Error(w, "Method is not supported.", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") // Set the content type to json + + // Pause and stop all playbacks + for _, v := range playbacks { + v.Control.Paused = true + v.Control.Streamer = nil + } + speaker.Clear() // Clear the speaker and make it shut up + + // Reset the map + playbacks = make(map[int]playback) + + fmt.Fprintf(w, "{\"status\":\"ok\"}") +} + +func handleCurrent(w http.ResponseWriter, r *http.Request) { + // Rejct everything else then GET requests + if r.Method != "GET" { + http.Error(w, "Method is not supported.", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") // Set the content type to json + + var tempResultSet map[int]playbackWebReturn = make(map[int]playbackWebReturn) // Create a new map to store the results + // Iterate through the playbacks map and add important information to the tempResultSet map + for index, element := range playbacks { + tempResultSet[index] = playbackWebReturn{File: element.File, IsLoaded: element.IsLoaded, Id: index} + } + // Convert the map to a JSON object and return it to the user + j, err := json.Marshal(tempResultSet) + if err != nil { + fmt.Printf("Error: %s", err.Error()) + } else { + fmt.Println(string(j)) + fmt.Fprintf(w, string(j)) + } +} + +func handleListing(w http.ResponseWriter, r *http.Request) { + // Rejct everything else then GET requests + if r.Method != "GET" { + http.Error(w, "Method is not supported.", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") // Set the content type to json + + var temp [][3]string + files, err := ioutil.ReadDir("./sounds/") // Find all files in the sounds directory + if err != nil { + log.Fatal(err) + } + + // Add the file data to the temp array + for _, f := range files { + var soundObj [3]string + soundObj[0] = f.Name() + soundObj[1] = base64.StdEncoding.EncodeToString([]byte(f.Name())) + soundObj[2] = r.URL.Host + "/v1/play?file=" + soundObj[1] + temp = append(temp, soundObj) + } + // Convert the array to a JSON object and return it to the user + j, err := json.Marshal(temp) + if err != nil { + fmt.Printf("Error: %s", err.Error()) + } else { + fmt.Println(string(j)) + fmt.Fprintf(w, string(j)) + } +}