cTune v1.0: Part 1 - Design
Table of Contents
ctune
is Linux based internet radio stream player for the console entirely written in C. It uses the RadioBrowser API as a source for searching streams and getting station information.
This is part 1 of 3 blog posts detailing the design philosophy and choices taken for cTune's development.
All UML diagrams are made with PlantUML.
1. Problem space
First, why? - Two reasons: (a) to learn C which is an arguably important language in the history of software development and (b) create a radio stream player in the same vein as the really cool cmus player. I use the latter for playing my music library so something similar to play radio streams would be a nice complement.
The mandatory functional requirement are as follows:
- Play radio streams
- Control playback volume
- Search radio streams from RadioBrowser
- Browse radio streams available in RadioBrowser
The optional functional requirement are as follows:
- Keep a locally stored list of favourite radio streams
- Edit entries in favourites
- Check state of a stream in the favourites
- Create custom local-only entries in the favourites
- Ability to resume playing stream from last session's exit
- Keeping volume state between session
This is not an exhaustive list but covers enough of the foundations to produce, at the very least, a radio stream player with the bare necessities.
2. Design philosophy
At this stage of the development process I am unfamiliar with the C language aside with some cross-over concepts from C++. I have also never done any sort of sound processing. I know this will unquestionably lead to some pretty bad assumptions and design choices.
The best approach is to account for these failings early and, hopefully, avoid huge future time-sinks. An impasses during the implementation phase is incredibly infuriating and more so when it is because the design is inflexible and/or based on wrong abstractions. Assuming fallibility can actually make things easier later on.
2.1 Designing for longevity
Here longevity is mostly about ease of maintenance. Let's say one of the external libraries used becomes broken and/or abandoned; its functionality would need to be replaced by either swapping in another similar library or re-writing all of the original functionality that the software depends on.
In the worst case scenario, where the library calls are peppered everywhere inside the software, it would mean:
- All of the calls needing to be hunted down and replaced to fit the new API,
- Code dependent on the calls needing editing/re-implementing especially in the cases where the new API calls are not a 1-to-1 replacement,
- New code = new bugs
In any case, to avoid the worst of all that, I think it's generally wiser to keep external dependencies wrapped behind local interfaces and not leak their non-standard data types/objects. This is very much inline with the "Liskov substitution principle" since substitutability is a big part of what is trying to be achieved here.
As long as the wrapper API's contract is not broken (e.g.: returning a NULL string when the API contract says strings will never return as NULL) then code inside the wrapper can be adapted to work with a new library's API without messing up the rest of the software.
2.2 Designing for redundancy
Some dependencies such as the stream decoding and output to the system's sound server can be made into binary modules so that they can be side loaded as plugins to the already compiled application. This is inspired by the similar approach taken in cmus.
A plugin system enables, to a larger extent, the future expandability of the software. New 'players' and sound 'outputs' be added to adapt and fit different host configurations. If more than one plugin type is available for a host, it offers a fallback solution in the case where the default plugin is sub-optimal (or broken) for the host system.
As an extra bonus it also makes supporting new players or sound outputs a lot easier: only knowledge of the required interface and the plugin system is needed for development instead of the complete inner workings of the software. Good for third parties!
2.3 Designing for failure
By coming in with the mindset that the code will both suck and fail, a lot of future headaches can be avoided by taking a defensive coding stance along with a healthy dose of aggressive logging (describing the what/where of what is going on at important execution points).
So what does this look like in practice?
- Methods check their arguments and any returns from external calls,
- Debug logging for any key method entry point (exceptions: avoid when inside loops, called from loops or any other instance where it will spam the log)
- Error logging for all possible major failure points including a description detailing the what and where,
- A unified error system for the application (e.g.: error codes defined and used consistently throughout)
int foo( void * data, int i ) {
log( debug, "[foo( %p, %d )] starting...", data, i );
if( data == NULL || i < 0 ) {
log( error, "[foo( %p, %d )] Unexpected arg(s) passed.", data, i );
return -ERROR_BAD_ARGS
}
struct DataStore data_store;
if( !loadData( data, &data_store ) ) {
log( error, "[foo( %p, %d )] failed to load data into store.", data, i );
return -ERROR_PACKING_FAILED;
}
int err_no = 0;
if( ( err_no = processData( &data_store, i ) ) < 0 ) {
log( error, "[foo( %p, %d )] failed to process store: %s.", data, i, my_strerr( ret ) );
return -ERROR_PROCESSING_FAILED;
}
return ERROR_NONE;
}
This makes bug hunting a hell of a lot easier!
3. Core architecture
If software is boiled down do its core abstraction it's just data inputs and outputs with some processing in between.
It's then possible to chain these processes creating a data pipeline by connecting one output to another process's input and so on. Pipelines can split into forks and join up.
When approaching a problem I personally find it easier to understand what the data is, its flow and how it needs to be processed first and foremost. This way I can come up with a reasonably sound architectural design I can continually trust even when I get lost in the implementation (coding stage).
3.1 Component pipeline
When building something unfamiliar it is important to start in terms of generalities (highest abstraction) and dig down towards the details in stages as understanding of the domain grows. Doing some exploratory prototyping is a good way to get a good feel of things and find any holes in any assumptions made. This tends to lead to better architectural design choices down the line. In simpler terms; it's good to experiment and test things before hand.
Before starting I looked a bit into both how to play streams and how to play sound in a linux system. For decoding/playing streams I settled on the ffmpeg library and, for the sound output, I picked SDL2, PulseAudio and, optionally, ALSA if I can be bothered.
Now that these choices are made, what sort of data is coming in and what sort of data needs to come out?: Radio station information comes in, radio stream plays out. This means that something is needed to get the radio station information and something is needed to play the streams detailed in said information...
Now it's just a matter of drilling down and breaking up things into sub-tasks until the components are detailed enough to identify all the major moving pieces whilst conveying the information in a clear and concise manner. In other words, too much details is as bad as too little as it locks you into in an inflexible design too soon.
As the radio station information is fetched from the RadioBrowser web API over the internet, network IO will be involved. A 'NetworkUtils' component can be added as an abstraction for the querying and fetching of the raw data from the API over the internet. Also, the radio stream data input needs to be added to the player.
Looking at the RadioBrowser API there are two possible formats for the data returned: XML and JSON. I chose to go the JSON route as I wanted to play a bit with the json-c
library. Regardless, the data returned from a query needs to be parsed from the given format into something more local and manageable such as a DTO (Data Transfer Object).
As for the 'RadioPlayer' component, its process can be split into 2 areas of concerns:
- a player that fetches a stream and decodes it into PCM data,
- a sound output system that interacts with the system's audio server
Now that a pipeline is defined there is still the issue of how to control what's going on inside of it. To pass instruction from the operator to the key components ('RadioBrowser' and 'RadioPlayer') a 'Controller' is required. This will be the hub interface from which the UI can latch on later.
The calls for parsing the JSON data into DTO(s) should really be kept within the scope of just 1 component. 'RadioBrowser' makes more sense as parsing JSON has got nothing to do with network IO.
3.2 Peripheral components
There are still a few things to append around that core pipeline in order to satisfy the more loosely connected and optional requirement. Namely:
- Settings:
- loading/saving the application's configuration and session variables,
- loading/saving the operator's bookmarked favourite radio stations,
- Plugin: where all the plugin management and loading happens for the player(s) and audio output(s).
The component dealing with the settings (Settings) can also act as gateway interface to the plugin system; what plugin gets loaded is dictated by the application's configuration. As for the plugins, the Player and AudioOut components from the previous diagram (fig. 8) can be replaced by the plugin files and their respective interfaces connected to them instead.
4. Logger library
Last but definitely not least; the logger. This is meant to be used throughout the application so it's best to keep it on it's own separate thread so that if the main application blocks, the logger will continue writing whatever messages have managed to get through to the output file.
For this to happen the design is based around asynchronous access to a message queue. Meaning threading will be involved to make the whole thing work.
The plan is to send the logging message to the queue when and wherever a logging call happens. Meanwhile, the 'worker' thread grabs messages from the queue and writes them to the log file, only stopping when there are no more to process. The file writing operation should only be resumed when there are new messages in the queue. For signalling this, a callback is added between the LogQueue data-structure and the worker thread (LogWriter).
5. Development of UI ideas
At this stage the "UI design" amounts to little more than ideas and some incomplete hand-drawn sketches of various graphical interface functionalities. It's sort of like a 'bucket of ideas', if you will.
I tend to continuously think over things and how to go about them as I write backend code.
I'd equivalise this process as a 1-person brainstorm; I continuously pit previous ideas against new ones eventually getting to an actual final UI design. It bears to mention UI/UX design does not come easy to me so it takes a bit of time to bring anything into focus.
6. Summary
The application is put together in a 2-tiered architectural fashion with the frontend and backend as the respecive tiers. The plugins will be loaded by the backend and all parts of the application will have access to the Logger library instance.