Paper Tunes - Store Music on Paper and Stream Them Offline!
by Makestreme in Living > Music
379 Views, 8 Favorites, 0 Comments
Paper Tunes - Store Music on Paper and Stream Them Offline!
What if you could carry an entire song around on a piece of paper? No Spotify. No cloud storage. No Wi-Fi. No cellular network. Just a sheet of paper covered in QR codes.
That was the crazy idea that started this project. The result is what I call a QR music cassette, eight QR codes printed on paper that contain everything needed to play a song on a phone without internet.
I took the same concept and designed a small LoRa-based music network using ESP32s and LoRa modules. The compressed music can be transferred wirelessly between the devices without Wi-Fi, cellular infrastructure, or an internet connection.
The project therefore has two ways of getting music onto your phone:
- Scan it from paper: scan the eight QR codes and reconstruct the song.
- Send it over LoRa: request the song from another device and transfer it directly over a long-range radio link.
It's an experiment in what happens when you remove the infrastructure we normally take for granted and try to build a completely independent way of storing and sharing digital media. Or what I call, the Doomsday Spotify.
Listen to the scanned music yourself! (attached below)
Let's build it.
Downloads
Supplies
Hardware
- 2 x ESP32 development boards: Espressif ESP32 DevKitC / official purchase options
- 2 x REYAX RYLR998 LoRa modules: REYAX RYLR998 official product page
Amazon: Purchase Link
Other distributors: Purchase Link
- Jumper wires
- USB cable
- A smartphone with USB-C
- A computer: used to encode the music and generate the QR cassette
- A printer: used to print the QR cassette
Software
- Python 3: Download Python
- Meta's EnCodec: EnCodec on GitHub
- Python libraries used by the encoder/decoder: listed in the project's requirements.txt
- Arduino IDE: Arduino IDE
Note: The RYLR998 itself is an 868/915 MHz module with UART control via AT commands. Make sure you purchase/use the version appropriate for your region and configure it according to your local radio regulations.
Can a QR Code Hold a Song?
Before building anything, let's look at the ridiculous amount of data we're trying to squeeze into a tiny QR code.
I started with a 2-minute-and-7-second song. As an MP3, it takes up about 2.9 MB of storage. Now compare that with a maximum size Version 40 QR code. Depending on the encoding and error-correction settings, we're talking about 3 KB of useful payload.
So the challenge is to fit a 3 MB song into a 3 KB QR code.
If we tried to put the MP3 into the QR code directly, we'd need roughly 1,000 version 40 QR codes for this one song. That's obviously not going to make a very practical music cassette. So we need to first compress the music file to take up less space.
There are two broad approaches to compression:
- Lossless compression reduces the size of data without throwing anything away. When you decompress it, you get back the exact original data.
- Lossy compression deliberately throws away information that is considered less important in exchange for much smaller files.
At first, it might seem like we could simply ZIP the MP3 and put the compressed file into the QR code. Unfortunately, that doesn't get us anywhere close to our goal. MP3 is already a lossy format, so lossless compression has very little redundancy left to exploit.
Instead of trying to compress the MP3 even further, I wanted to see if I could represent the audio in a completely different way. One that was designed specifically to squeeze audio into an extremely small amount of data.
Compressing the Music With EnCodec
I decided to use a specific type of neural network to represent the entire song into a tiny space. It's called an Autoencoder.
It consists of an encoder and a decoder. The encoder takes the original audio waveform and squeezes it down into a much smaller representation. This representation is made up of discrete codes that contain the information needed by the decoder to reconstruct the audio.
So instead of putting the entire audio file into the QR code, I put these compressed codes into it. This is called latent data. Then, when I want to play the song, the app can take those codes and run them through the decoder to reconstruct the audio. We can adjust the bandwidth to play with the file size and quality.
For my first test, I used a bandwidth of 3 kbps. The result was pretty surprising. My roughly 2.9 MB MP3 was reduced to around 21 KB of latent data. That's already roughly 1000 times smaller than the original file.
But 21 KB is still much larger than the roughly 3 KB that I can fit into a single QR code. So I tried pushing it even further. I dropped the bandwidth to 1.5 kbps, which brought the representation down to around 13 KB. But smaller doesn't necessarily mean better.
I decoded both versions and listened to them. The 1.5 kbps version was heavily degraded and sounded quite bad. The 3 kbps version, on the other hand, sounded surprisingly good considering how aggressively it had been compressed.
I've attached both versions below, listen yourself!
So I decided to use 3 kbps as the starting point for the rest of the project. That left me with a compressed music file of roughly 21 KB. Still too big for one QR code, but now we're getting somewhere.
Files
I've included the encoder and decoder scripts with this project. The encoder takes an audio file and produces the compressed representation, while the decoder takes that representation and reconstructs the audio.
The important parameter to experiment with is the bandwidth:
For example:
will produce a smaller representation, while:
will use more data and generally give better quality.
One important detail: EnCodec is not simply a ZIP-like compressor. It's a trained neural audio codec designed to reconstruct audio from a compact representation.
Turning the Music Into a Paper Cassette
Our song has gone from 2.9 MB down to about 21 KB, which is an enormous reduction, but a maximum capacity QR code can only hold around 3 KB of binary data.
At this point, I tried to see whether I could squeeze more data out of a single QR code.
Removing QR error correction
QR codes contain redundant information that allows them to remain readable even when part of the code is damaged. There are four error correction levels: L, M, Q and H, with higher levels sacrificing some data capacity for greater error tolerance.
So I thought, what if I remove as much of that redundancy as possible?
I switched to the lowest error correction level, L. This gives us the maximum capacity available in a standard QR code, which is just about 3.3 KB.
And even if I could remove all the error-correction information, I'd still be nowhere near the seven fold increase in capacity I needed. More importantly, doing so would make the printed code much less tolerant of damage and printing imperfections.
So I started looking for something beyond the traditional black-and-white QR code.
What about color?
That's when I came across JAB Codes.
JAB Codes are a newer type of 2D barcode that use multiple colors instead of just black and white. In principle, using multiple colors lets each cell represent more information, which means you can pack substantially more data into the same physical area. This looked very promising.
So I wrote a Python program to generate a JAB Code containing my compressed music data. It looked like terrible! It basically looked like television static.
The problem wasn't just that it looked crazy. The individual colored cells became so small and densely packed that a normal smartphone camera struggled to reliably resolve them.
So I went back to the simplest solution.
Instead of making one QR code hold more data, why not use more QR codes?
A 21 KB file can be split across roughly eight QR codes while keeping each individual code within the standard capacity.
And that gave me another idea.
The paper cassette
I wanted the result to feel more like a physical music format rather than just a sheet covered in barcodes.
So I took inspiration from something that existed long before streaming: double sided physical media.
Some vinyl records had to be flipped over halfway through the listening experience. So I decided to do the same thing with my QR cassette.
I'll put:
Side A
- QR 1
- QR 2
- QR 3
- QR 4
Side B
- QR 5
- QR 6
- QR 7
- QR 8
Each QR code also starts with a tiny identifier containing its number. That way, the app doesn't have to assume that the codes were scanned in a particular order. It can simply read the IDs and reconstruct.
Making the QR Music Cassette
Now that we have our music compressed down to around 21 KB, it's time to turn that data into something physical.
Splitting the music into eight pieces
First, the compressed data is divided into eight chunks.
Each chunk gets a small identifier at the beginning:
The IDs are important because the decoder side needs to know which piece belongs where. It doesn't matter what order the QR codes are scanned in the app can read the IDs and put everything back together in the correct sequence.
I then arrange the eight QR codes as four codes on each side:
This gives us a nice physical metaphor: Side A and Side B, just like a traditional cassette or vinyl record.
Generating the cassette
I wrote a small Python script that takes the compressed music.raw file, splits it into eight chunks, adds the IDs, generates the QR codes, and lays them out into two printable PNG images.
The script automatically adjusts the size of the QR codes based on the amount of data. This is useful because the final compressed file won't necessarily be exactly the same size for every song.
I also leave the required clear space around each QR code. This is called the quiet zone, and standard QR guidance calls for at least four modules of clear space around the code.
I then printed the two sides on ordinary paper at full size.
We have successfully stored an entire song, physically printed onto a piece of paper!
Downloads
Building the Off-Grid LoRa Music Network
The QR cassette solves one half of the problem: how do we physically store music without relying on the internet?
But I also wanted a way to send music from one place to another without Wi-Fi or cellular networks.
For that, I turned to LoRa. LoRa is a long-range, low-power wireless technology designed for sending relatively small amounts of data over long distances.
The basic system looks like this:
The transmitter stores a collection of compressed music files. When I request a song, the ESP32 reads the compressed data and sends it packet-by-packet through the Lora module (RYLR998).
The receiver gets those packets, puts them back in the correct order, and passes the completed compressed file to the phone. The receiving side can then decode it with the same decoder we used for the QR cassette.
Why Reyax Lora module?
Reyax makes great communiccation modules that are easy to program and use for hobbyists. In this case, I'm using two Reyax RYLR998 modules. You can purchase them from this link.
The interesting thing about LoRa for this project isn't its speed, it's the fact that the two devices don't need an existing network between them.
There's no router in between them, no cloud server and no cellular tower involved in the data transfer. The two LoRa modules communicate directly.
That makes it a useful technology for an off-grid experiment like this.
Of course, there's a significant trade-off: LoRa is slow. A normal 2.9 MB MP3 would take hours to transfer this way. But after compression, our song is only around 21 KB.
Building the LoRa Hardware
For this proof of concept, I wanted to keep the hardware as simple as possible.
Each end of the wireless link consists of an ESP32 and a REYAX RYLR998 LoRa module.
One ESP32 acts as the transmitter and the other acts as the receiver. The transmitter has access to the compressed music files and sends them over LoRa when requested. The receiving ESP32 receives the data and passes it to a PC over USB, where a small Python program reconstructs and decodes the music.
The ESP32 handles the communication with the RYLR998, while the RYLR998 handles the actual LoRa radio link.
For this proof of concept, the PC does most of the heavy lifting on the receiving side. This avoids having to build a full smartphone application before we know that the underlying system works.
Wiring the RYLR998
The RYLR998 communicates with the ESP32 through UART.
For my setup, the connections are:
RYLR998 -> ESP32
VDD -> 3.3V
GND -> GND
TXD -> ESP32 RX
RXD -> ESP32 TX
The TX and RX lines are crossed because the transmitter of one device connects to the receiver of the other.
Once everything is wired up, the ESP32 can communicate with the RYLR998 using its AT command interface. This means the ESP32 doesn't need to implement the LoRa radio protocol itself. It simply tells the module what to send, and the module handles the radio communication.
Important: LoRa frequency, transmit power, bandwidth, duty cycle, and other radio parameters are regulated differently in different countries. Use settings permitted in your region. For my build, I configured the modules for operation within the applicable Indian regulations.
Program the LoRa Transmitter
Now that the hardware is connected, we can program the first ESP32 to act as the music transmitter.
The transmitter stores the compressed music file, music.raw, in its LittleFS filesystem. This is the same latent-data file produced by the encoder earlier.
The transmitter doesn't need to understand the contents of the file. As far as the ESP32 is concerned, it's simply a binary file that needs to be transferred reliably.
The communication starts when the receiving ESP32 sends a simple GET
The transmitter then:
- Opens music.raw from LittleFS.
- Calculates the file size and number of packets required.
- Sends this information to the receiver using a META message.
- Waits for the receiver to respond with READY.
- Reads the file in small chunks.
- Base64-encodes each chunk so it can be safely transmitted through the RYLR998's ASCII AT-command interface.
- Sends each chunk as a numbered DATA packet.
- Waits for an ACK from the receiver before sending the next packet.
- Retries a packet if its acknowledgement doesn't arrive.
- Sends an END message when the complete file has been transmitted.
The transmitter waits for confirmation after every packet, so if something is lost over the radio link, that packet can be sent again rather than having to restart the entire transfer.
The important part is that we're transmitting the compressed EnCodec representation, not the original MP3. A several-megabyte music file would be impractical to send over this type of low-bandwidth link, whereas our compressed file is only a few tens of kilobytes.
Transmitter code
I've attached the complete transmitter code transmitter.ino
Before uploading the sketch, make sure the music.raw file is uploaded to the ESP32's LittleFS filesystem.
Once uploaded, the transmitter waits for a GET request from the receiving ESP32.
Downloads
Program the LoRa Transmitter
The second ESP32 acts as the receiver and as a bridge between the LoRa radio and the PC.
When it starts up, it sends a GET request to the transmitter. Once the transmitter responds with the file metadata, the receiver creates a new file called: received.raw in its own LittleFS filesystem.
It then waits for the incoming DATA packets.
For each packet, the receiver:
- Checks the packet number.
- Makes sure it is the packet expected next.
- Decodes the Base64 payload back into binary data.
- Writes those bytes to received.raw.
- Sends an acknowledgement for that packet.
- Moves on to the next packet.
If the transmitter accidentally sends a packet again because it didn't receive the previous acknowledgement, the receiver recognizes it as a duplicate and simply sends the acknowledgement again without writing the data twice.
This gives us a simple reliable file-transfer system on top of the LoRa connection.
When the receiver gets the END message, it closes the file and verifies that the number of bytes received matches the size announced at the beginning of the transfer.
If everything matches, the receiver sends DONE back to the transmitter.
At this point, the complete compressed music file exists on the receiver ESP32 as received.raw
Sending the file to the PC
The receiver then has one more job.
It sends the reconstructed binary file to the PC over the ESP32's USB serial connection.
To make this easy for the Python program to identify, the receiver wraps the binary data in a very simple frame:
The Python program can therefore read the exact number of bytes that belong to the music file without confusing them with the ESP32's normal debug messages.
I've attached the complete receiver code receiver.ino
Downloads
Receiving and Decoding the Music on the PC
For this proof of concept, I decided not to build a dedicated Android application yet. Instead, I use a Python program on the PC as the receiving application.
The program has two modes.
Mode 1: Receive music over LoRa
In this mode, the PC communicates with the receiver ESP32 over USB.
The ESP32 receives the compressed music data over LoRa, reconstructs the complete received.raw file, and sends it to the PC. The Python program then decodes it using EnCodec and saves the reconstructed song.
Mode 2: Read a QR music cassette
The same program can also read the printed cassette.
For this mode, I provide the program with two images:
The program reads the ID at the beginning of every QR payload, so the codes don't have to be supplied in exactly the right order. It sorts them by ID, strips off the ID byte, joins the eight chunks, and reconstructs the original music.raw file. The resulting file then goes through exactly the same EnCodec decoding pipeline as the LoRa version.
Python receiver application
The complete PC program is attached as:
python_receiver.py
Install the required packages:
Then run:
You'll get a simple menu:
For the QR option, the program asks for the two cassette images:
It then detects the QR codes, checks their IDs, reconstructs the file, and decodes the audio.
Downloads
Conclusion
And that's the complete system.
What started as a simple question, can an entire song fit inside a QR code? turned into a much bigger experiment in storing and sharing music without the infrastructure we normally depend on.
The interesting part is seeing how far we can push a system when we remove the infrastructure that we normally take for granted.
A piece of paper can store a song.
A couple of small radios can move that song through the air.
And a receiver can reconstruct and play it, all without the internet.
There are plenty of ways I'd like to improve this project in the future, from better audio compression and faster transfers to making an actual android app for the receiving end.
If you build your own version or come up with an interesting way to improve it, I'd love to see what you come up with.
Thanks for following along, and happy building!