Makestreme
Published © GPL3+

Paper Tunes - Store Music on Paper and Stream Them Offline!

Can you store an entire music file on a piece of paper forever? Let's find out!

AdvancedFull instructions provided6 hours230
Paper Tunes - Store Music on Paper and Stream Them Offline!

Things used in this project

Hardware components

ESP32S
Espressif ESP32S
×1
RYLR998 Lora module
×1

Software apps and online services

VS Code
Microsoft VS Code
Arduino IDE
Arduino IDE

Story

Read more

Code

Encoder

Python
Python encoder code
from __future__ import annotations

import os
import struct
import subprocess
import tempfile
from pathlib import Path

import numpy as np
import soundfile as sf
import torch
from encodec import EncodecModel
from encodec.utils import convert_audio

# =========================================================
# CONFIG
# =========================================================

INPUT_AUDIO = "input_music.mp3"      # can be mp3, wav, etc.
OUTPUT_RAW = "music.raw"

BANDWIDTH = 1.5    # 1.5, 3, 6, 12, 24
BIT_DEPTH = 10
MODEL_SR = 24000

BW_TO_NQ = {
    1.5: 2,
    3.0: 4,
    6.0: 8,
    12.0: 16,
    24.0: 32,
}

MAGIC = b"OCS1"
VERSION = 1

# =========================================================
# BIT PACKING
# =========================================================

def pack_bits(values: np.ndarray, bits_per_value: int = 10) -> bytes:
    """Pack uint integers into a compact little-endian bitstream."""
    bitstream = 0
    bitcount = 0
    out = bytearray()

    mask = (1 << bits_per_value) - 1

    for v in values:
        bitstream |= (int(v) & mask) << bitcount
        bitcount += bits_per_value

        while bitcount >= 8:
            out.append(bitstream & 0xFF)
            bitstream >>= 8
            bitcount -= 8

    if bitcount > 0:
        out.append(bitstream & 0xFF)

    return bytes(out)

# =========================================================
# HELPERS
# =========================================================

def decode_input_to_temp_wav(input_path: str, temp_wav_path: str) -> None:
    """
    Decode any ffmpeg-supported input to mono 24 kHz WAV.
    This avoids torchaudio/TorchCodec issues and keeps input handling simple.
    """
    cmd = [
        "ffmpeg",
        "-y",
        "-i", input_path,
        "-ac", "1",
        "-ar", str(MODEL_SR),
        temp_wav_path,
    ]
    subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

# =========================================================
# MAIN
# =========================================================

def main() -> None:
    input_path = Path(INPUT_AUDIO)
    if not input_path.exists():
        raise FileNotFoundError(f"Missing input audio: {input_path}")

    if BANDWIDTH not in BW_TO_NQ:
        raise ValueError(f"Unsupported bandwidth: {BANDWIDTH}")

    n_q = BW_TO_NQ[BANDWIDTH]

    print("Loading EnCodec model...")
    model = EncodecModel.encodec_model_24khz()
    model.set_target_bandwidth(BANDWIDTH)
    model.eval()

    with tempfile.TemporaryDirectory() as td:
        temp_wav = str(Path(td) / "input_mono_24k.wav")

        print("Decoding input audio to temporary WAV...")
        decode_input_to_temp_wav(str(input_path), temp_wav)

        print("Loading temporary WAV...")
        wav_np, sr = sf.read(temp_wav, always_2d=True)

        # soundfile gives [samples, channels]; EnCodec expects [channels, samples]
        wav_np = wav_np.T
        wav = torch.from_numpy(wav_np).float()

        wav = convert_audio(
            wav,
            sr,
            model.sample_rate,
            model.channels
        )

        wav = wav.unsqueeze(0)  # [B, C, T]

        print("Encoding audio...")
        with torch.inference_mode():
            encoded_frames = model.encode(wav)

        frame_lengths = []
        all_tokens = []

        for codes, scale in encoded_frames:
            # codes: [B, n_q, T]
            if codes.shape[1] != n_q:
                raise RuntimeError(
                    f"Unexpected number of codebooks. Got {codes.shape[1]}, expected {n_q}."
                )

            frame_lengths.append(int(codes.shape[-1]))

            flat = codes.squeeze(0).cpu().numpy().astype(np.uint16).reshape(-1)
            all_tokens.append(flat)

        tokens = np.concatenate(all_tokens) if all_tokens else np.array([], dtype=np.uint16)

        print(f"Frame count: {len(frame_lengths)}")
        print(f"Total tokens: {len(tokens)}")

        packed = pack_bits(tokens, bits_per_value=BIT_DEPTH)

        # =====================================================
        # FILE FORMAT
        # =====================================================
        # magic[4]              = b'OCS1'
        # version uint16        = 1
        # bandwidth_milli uint16= 3000 for 3.0 kbps
        # sample_rate uint32    = 24000
        # n_q uint16            = 4 at 3 kbps
        # num_frames uint32
        # total_tokens uint32
        # frame_lengths[num_frames] as uint32 each
        # packed token bitstream
        # =====================================================

        header = bytearray()
        header += MAGIC
        header += struct.pack("<H", VERSION)
        header += struct.pack("<H", int(BANDWIDTH * 1000))
        header += struct.pack("<I", model.sample_rate)
        header += struct.pack("<H", n_q)
        header += struct.pack("<I", len(frame_lengths))
        header += struct.pack("<I", int(len(tokens)))

        for flen in frame_lengths:
            header += struct.pack("<I", int(flen))

        with open(OUTPUT_RAW, "wb") as f:
            f.write(header)
            f.write(packed)

    size_kb = os.path.getsize(OUTPUT_RAW) / 1024
    print(f"Saved: {OUTPUT_RAW}")
    print(f"Final size: {size_kb:.2f} KB")

if __name__ == "__main__":
    main()

Decoder

Python
Decoder code
from __future__ import annotations

import struct
from pathlib import Path

import numpy as np
import soundfile as sf
import torch
from encodec import EncodecModel

# =========================================================
# CONFIG
# =========================================================

INPUT_RAW = "music_1_5.raw"
OUTPUT_WAV = "reconstructed.wav"

BIT_DEPTH = 10
MAGIC = b"OCS1"

BW_TO_NQ = {
    1500: 2,
    3000: 4,
    6000: 8,
    12000: 16,
    24000: 32,
}

# =========================================================
# BIT UNPACKING
# =========================================================

def unpack_bits(data: bytes, bits_per_value: int = 10, expected_values: int | None = None) -> np.ndarray:
    """Unpack a little-endian bitstream into integers."""
    values = []
    bitstream = 0
    bitcount = 0
    mask = (1 << bits_per_value) - 1

    for b in data:
        bitstream |= b << bitcount
        bitcount += 8

        while bitcount >= bits_per_value:
            values.append(bitstream & mask)
            bitstream >>= bits_per_value
            bitcount -= bits_per_value

            if expected_values is not None and len(values) >= expected_values:
                return np.array(values, dtype=np.uint16)

    return np.array(values, dtype=np.uint16)

# =========================================================
# HEADER PARSING
# =========================================================

def parse_header(blob: bytes):
    """
    Returns:
        version, bandwidth_milli, sample_rate, n_q, num_frames, total_tokens, frame_lengths, payload_offset
    """
    offset = 0

    magic = blob[offset:offset + 4]
    offset += 4
    if magic != MAGIC:
        raise ValueError("Not a valid cassette file (bad magic).")

    version = struct.unpack_from("<H", blob, offset)[0]
    offset += 2

    bandwidth_milli = struct.unpack_from("<H", blob, offset)[0]
    offset += 2

    sample_rate = struct.unpack_from("<I", blob, offset)[0]
    offset += 4

    n_q = struct.unpack_from("<H", blob, offset)[0]
    offset += 2

    num_frames = struct.unpack_from("<I", blob, offset)[0]
    offset += 4

    total_tokens = struct.unpack_from("<I", blob, offset)[0]
    offset += 4

    frame_lengths = []
    for _ in range(num_frames):
        flen = struct.unpack_from("<I", blob, offset)[0]
        offset += 4
        frame_lengths.append(int(flen))

    return (
        version,
        bandwidth_milli,
        sample_rate,
        n_q,
        num_frames,
        total_tokens,
        frame_lengths,
        offset,
    )

# =========================================================
# MAIN
# =========================================================

def main() -> None:
    input_path = Path(INPUT_RAW)
    if not input_path.exists():
        raise FileNotFoundError(f"Missing input raw file: {input_path}")

    blob = input_path.read_bytes()

    (
        version,
        bandwidth_milli,
        sample_rate,
        n_q,
        num_frames,
        total_tokens,
        frame_lengths,
        payload_offset,
    ) = parse_header(blob)

    if bandwidth_milli not in BW_TO_NQ:
        raise ValueError(f"Unsupported bandwidth in file: {bandwidth_milli / 1000:.3f} kbps")

    expected_n_q = BW_TO_NQ[bandwidth_milli]
    if n_q != expected_n_q:
        raise ValueError(f"Header n_q mismatch: file has {n_q}, expected {expected_n_q}")

    payload = blob[payload_offset:]

    tokens = unpack_bits(payload, bits_per_value=BIT_DEPTH, expected_values=total_tokens)

    if len(tokens) < total_tokens:
        raise ValueError(f"Token stream truncated: got {len(tokens)}, expected {total_tokens}")

    tokens = tokens[:total_tokens]

    print(f"Version: {version}")
    print(f"Bandwidth: {bandwidth_milli / 1000:.3f} kbps")
    print(f"Sample rate: {sample_rate}")
    print(f"Codebooks: {n_q}")
    print(f"Frames: {num_frames}")
    print(f"Tokens: {len(tokens)}")

    encoded_frames = []
    idx = 0

    for flen in frame_lengths:
        count = n_q * flen
        frame_tokens = tokens[idx:idx + count]
        idx += count

        if len(frame_tokens) != count:
            raise ValueError("Token stream ended early while rebuilding frames.")

        codes = torch.tensor(frame_tokens, dtype=torch.long).reshape(1, n_q, flen)
        encoded_frames.append((codes, None))

    print("Loading EnCodec model...")
    model = EncodecModel.encodec_model_24khz()
    model.set_target_bandwidth(bandwidth_milli / 1000.0)
    model.eval()

    print("Decoding audio...")
    with torch.inference_mode():
        decoded = model.decode(encoded_frames)

    decoded = decoded.squeeze(0).cpu().numpy().T  # [T, C]

    sf.write(OUTPUT_WAV, decoded, sample_rate)
    print(f"Saved: {OUTPUT_WAV}")

if __name__ == "__main__":
    main()

Lora transmitter

Arduino
#include <Arduino.h>
#include <LittleFS.h>
#include <HardwareSerial.h>


HardwareSerial LoRa(2);

#define LORA_RX_PIN 16
#define LORA_TX_PIN 17

#define LORA_BAUD 115200


// ---------- LoRa addresses ----------

#define MY_ADDRESS          1
#define RECEIVER_ADDRESS    2

#define NETWORK_ID          18


// ---------- Radio configuration ----------

// IMPORTANT:
// Use a frequency and RF configuration permitted
// in your country/region.
#define LORA_FREQUENCY      866000000

// SF7, BW125, CR4/5, preamble 12
#define LORA_PARAMETER      "7,7,1,12"

// Conservative starting RF power.
// Verify your actual module/antenna/regulatory limits.
#define LORA_POWER          10


// ---------- File ----------

#define MUSIC_FILE          "/music.raw"


// ---------- Packet size ----------

// RYLR998 accepts ASCII payloads up to 240 bytes.
//
// Base64 expands the binary data by about 4/3.
//
// 160 bytes raw -> 216 Base64 characters.
//
// Adding:
//     DATA|65535|
// still keeps us below the 240-byte limit.
//
// Keep this at 160 unless you have a good reason to change it.
#define RAW_CHUNK_SIZE      160


// ---------- Reliability ----------

#define ACK_TIMEOUT_MS      8000
#define MAX_RETRIES         5

// Deliberately conservative for the prototype.
// You should optimize this later after measuring
// airtime and ensuring compliance with local regulations.
#define TX_GAP_MS           40000


// ============================================================
// BASE64
// ============================================================

const char BASE64_TABLE[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";


String base64Encode(
    const uint8_t *data,
    size_t length
)
{
    String output;

    output.reserve(
        ((length + 2) / 3) * 4
    );

    for (size_t i = 0; i < length; i += 3)
    {
        uint32_t value = 0;

        value |=
        ((uint32_t)data[i]) << 16;

        if (i + 1 < length)
        {
            value |=
            ((uint32_t)data[i + 1]) << 8;
        }

        if (i + 2 < length)
        {
            value |=
            (uint32_t)data[i + 2];
        }


        output +=
        BASE64_TABLE[
            (value >> 18) & 0x3F
        ];

        output +=
        BASE64_TABLE[
            (value >> 12) & 0x3F
        ];


        if (i + 1 < length)
        {
            output +=
            BASE64_TABLE[
                (value >> 6) & 0x3F
            ];
        }
        else
        {
            output += '=';
        }


        if (i + 2 < length)
        {
            output +=
            BASE64_TABLE[
                value & 0x3F
            ];
        }
        else
        {
            output += '=';
        }
    }

    return output;
}


// ============================================================
// SEND AT COMMAND
// ============================================================

void sendCommand(
    const String &command
)
{
    LoRa.print(command);
    LoRa.print("\r\n");

    Serial.print(">> ");
    Serial.println(command);
}


// ============================================================
// READ ONE LINE FROM RYLR998
// ============================================================

String readLoRaLine(
    unsigned long timeout
)
{
    unsigned long start =
    millis();

    String line;


    while (
        millis() - start <
        timeout
    )
    {
        while (LoRa.available())
        {
            char c =
            LoRa.read();


            if (c == '\n')
            {
                line.trim();

                if (line.length() > 0)
                {
                    return line;
                }

                line = "";
            }
            else if (c != '\r')
            {
                line += c;
            }
        }

        delay(1);
    }


    return "";
}


// ============================================================
// PARSE +RCV LINE
//
// RYLR998 example:
//
// +RCV=2,3,GET,-52,10
//
// We only need:
//
// GET
//
// ============================================================

String extractReceivedData(
    const String &line
)
{
    if (!line.startsWith("+RCV="))
    {
        return "";
    }


    int p1 =
    line.indexOf(',');

    if (p1 < 0)
    {
        return "";
    }


    int p2 =
    line.indexOf(
        ',',
        p1 + 1
    );

    if (p2 < 0)
    {
        return "";
    }


    int p3 =
    line.indexOf(
        ',',
        p2 + 1
    );


    if (p3 < 0)
    {
        return line.substring(
            p2 + 1
        );
    }


    return line.substring(
        p2 + 1,
        p3
    );
}


// ============================================================
// WAIT FOR A SPECIFIC MESSAGE
// ============================================================

bool waitForMessage(
    const String &expected,
    unsigned long timeout
)
{
    unsigned long start =
    millis();


    while (
        millis() - start <
        timeout
    )
    {
        String line =
        readLoRaLine(100);


        if (line.length() == 0)
        {
            continue;
        }


        Serial.print("<< ");
        Serial.println(line);


        String data =
        extractReceivedData(line);


        if (data == expected)
        {
            return true;
        }
    }


    return false;
}


// ============================================================
// INITIALIZE LORA
// ============================================================

void configureLoRa()
{
    Serial.println();
    Serial.println(
        "Configuring RYLR998..."
    );


    sendCommand("AT");
    delay(500);


    sendCommand(
        "AT+ADDRESS=" +
        String(MY_ADDRESS)
    );

    delay(300);


    sendCommand(
        "AT+NETWORKID=" +
        String(NETWORK_ID)
    );

    delay(300);


    sendCommand(
        "AT+BAND=" +
        String(LORA_FREQUENCY)
    );

    delay(300);


    sendCommand(
        "AT+PARAMETER=" +
        String(LORA_PARAMETER)
    );

    delay(300);


    sendCommand(
        "AT+CRFOP=" +
        String(LORA_POWER)
    );

    delay(500);


    Serial.println(
        "LoRa configuration complete."
    );
}


// ============================================================
// SEND ONE PACKET
// ============================================================

bool sendPacket(
    uint16_t sequence,
    const String &encodedData
)
{
    String packet =
    "DATA|" +
    String(sequence) +
    "|" +
    encodedData;


    Serial.println();
    Serial.println(
        "Sending packet " +
        String(sequence)
    );

    Serial.println(
        "Payload size: " +
        String(packet.length())
    );


    for (
        int attempt = 1;
    attempt <= MAX_RETRIES;
    attempt++
    )
    {
        Serial.println(
            "Attempt " +
            String(attempt) +
            "/" +
            String(MAX_RETRIES)
        );


        sendCommand(
            "AT+SEND=" +
            String(RECEIVER_ADDRESS) +
            "," +
            String(packet.length()) +
            "," +
            packet
        );


        unsigned long start =
        millis();


        while (
            millis() - start <
            ACK_TIMEOUT_MS
        )
        {
            String line =
            readLoRaLine(100);


            if (line.length() == 0)
            {
                continue;
            }


            Serial.print("<< ");
            Serial.println(line);


            String data =
            extractReceivedData(line);


            String expectedAck =
            "ACK|" +
            String(sequence);


            if (data == expectedAck)
            {
                Serial.println(
                    "ACK received."
                );


                // Conservative pacing.
                delay(TX_GAP_MS);

                return true;
            }
        }


        Serial.println(
            "ACK timeout."
        );


        if (attempt < MAX_RETRIES)
        {
            Serial.println(
                "Retrying packet..."
            );
        }
    }


    return false;
}


// ============================================================
// SEND FILE METADATA
// ============================================================

bool sendMetadata(
    size_t fileSize,
    uint16_t packetCount
)
{
    String meta =
    "META|" +
    String(fileSize) +
    "|" +
    String(packetCount);


    Serial.println();
    Serial.println(
        "Sending metadata..."
    );


    sendCommand(
        "AT+SEND=" +
        String(RECEIVER_ADDRESS) +
        "," +
        String(meta.length()) +
        "," +
        meta
    );


    Serial.println(
        "Waiting for READY..."
    );


    if (
        waitForMessage(
            "READY",
            10000
        )
    )
    {
        Serial.println(
            "Receiver is ready."
        );

        return true;
    }


    Serial.println(
        "Receiver did not respond."
    );

    return false;
}


// ============================================================
// SEND COMPLETE FILE
// ============================================================

bool sendFile()
{
    Serial.println();
    Serial.println(
        "================================"
    );
    Serial.println(
        "Starting music transfer"
    );
    Serial.println(
        "================================"
    );


    if (
        !LittleFS.exists(
            MUSIC_FILE
        )
    )
    {
        Serial.println(
            "ERROR: music.raw not found."
        );

        return false;
    }


    File file =
    LittleFS.open(
        MUSIC_FILE,
        "r"
    );


    if (!file)
    {
        Serial.println(
            "ERROR: Cannot open music.raw."
        );

        return false;
    }


    size_t fileSize =
    file.size();


    uint16_t packetCount =
    (
        fileSize +
        RAW_CHUNK_SIZE -
        1
    ) /
    RAW_CHUNK_SIZE;


    Serial.println(
        "File: " +
        String(MUSIC_FILE)
    );

    Serial.println(
        "File size: " +
        String(fileSize) +
        " bytes"
    );

    Serial.println(
        "Packet size: " +
        String(RAW_CHUNK_SIZE) +
        " bytes"
    );

    Serial.println(
        "Packets: " +
        String(packetCount)
    );


    // ----------------------------------------
    // Tell receiver about the file.
    // ----------------------------------------

    if (
        !sendMetadata(
            fileSize,
            packetCount
        )
    )
    {
        file.close();

        return false;
    }


    // ----------------------------------------
    // Transfer packets.
    // ----------------------------------------

    uint8_t buffer[
        RAW_CHUNK_SIZE
    ];


    for (
        uint16_t sequence = 0;
    sequence < packetCount;
    sequence++
    )
    {
        size_t remaining =
        fileSize -
        file.position();


        size_t bytesToRead =
        min(
            (size_t)RAW_CHUNK_SIZE,
            remaining
        );


        size_t bytesRead =
        file.read(
            buffer,
            bytesToRead
        );


        if (
            bytesRead !=
            bytesToRead
        )
        {
            Serial.println(
                "ERROR: File read failed."
            );

            file.close();

            return false;
        }


        String encoded =
        base64Encode(
            buffer,
            bytesRead
        );


        Serial.println();
        Serial.println(
            "Progress: " +
            String(sequence + 1) +
            " / " +
            String(packetCount)
        );


        if (
            !sendPacket(
                sequence,
                encoded
            )
        )
        {
            Serial.println(
                "ERROR: Transfer failed at packet " +
                String(sequence)
            );

            file.close();

            return false;
        }
    }


    file.close();


    // ----------------------------------------
    // Tell receiver we're finished.
    // ----------------------------------------

    String endPacket =
    "END";


    sendCommand(
        "AT+SEND=" +
        String(RECEIVER_ADDRESS) +
        "," +
        String(endPacket.length()) +
        "," +
        endPacket
    );


    Serial.println(
        "Waiting for DONE..."
    );


    if (
        waitForMessage(
            "DONE",
            10000
        )
    )
    {
        Serial.println();
        Serial.println(
            "================================"
        );
        Serial.println(
            "TRANSFER COMPLETE"
        );
        Serial.println(
            "================================"
        );

        return true;
    }


    Serial.println(
        "Receiver did not confirm completion."
    );

    return false;
}


// ============================================================
// SETUP
// ============================================================

void setup()
{
    Serial.begin(
        115200
    );


    delay(1000);


    Serial.println();
    Serial.println(
        "================================"
    );
    Serial.println(
        "OFF-GRID MUSIC TRANSMITTER"
    );
    Serial.println(
        "================================"
    );


    // ----------------------------------------
    // Initialize LittleFS
    // ----------------------------------------

    if (
        !LittleFS.begin(
            true
        )
    )
    {
        Serial.println(
            "ERROR: LittleFS mount failed."
        );

        while (true)
        {
            delay(1000);
        }
    }


    Serial.println(
        "LittleFS mounted."
    );


    // ----------------------------------------
    // Start UART connected to RYLR998
    // ----------------------------------------

    LoRa.begin(
        LORA_BAUD,
        SERIAL_8N1,
        LORA_RX_PIN,
        LORA_TX_PIN
    );


    Serial.println(
        "LoRa UART started."
    );


    // ----------------------------------------
    // Configure RYLR998
    // ----------------------------------------

    configureLoRa();


    // ----------------------------------------
    // Check music file
    // ----------------------------------------

    if (
        LittleFS.exists(
            MUSIC_FILE
        )
    )
    {
        File file =
        LittleFS.open(
            MUSIC_FILE,
            "r"
        );


        if (file)
        {
            Serial.println(
                "Found music.raw: " +
                String(file.size()) +
                " bytes"
            );

            file.close();
        }
    }
    else
    {
        Serial.println(
            "WARNING: music.raw not found."
        );

        Serial.println(
            "Upload music.raw to LittleFS."
        );
    }


    Serial.println();
    Serial.println(
        "TRANSMITTER READY"
    );

    Serial.println(
        "Waiting for GET request..."
    );
}


// ============================================================
// LOOP
// ============================================================

void loop()
{
    String line =
    readLoRaLine(500);


    if (line.length() == 0)
    {
        return;
    }


    Serial.print("<< ");
    Serial.println(line);


    String data =
    extractReceivedData(line);


    // --------------------------------------------------------
    // Receiver requested the music file.
    // --------------------------------------------------------

    if (data == "GET")
    {
        Serial.println();
        Serial.println(
            "Received GET request."
        );


        bool success =
        sendFile();


        if (success)
        {
            Serial.println(
                "Ready for next request."
            );
        }
        else
        {
            Serial.println(
                "Transfer failed."
            );
        }
    }
}

Lora receiver

Arduino
#include <Arduino.h>
#include <LittleFS.h>
#include <HardwareSerial.h>


// ---------- ESP32 UART connected to RYLR998 ----------

HardwareSerial LoRa(2);

#define LORA_RX_PIN 16
#define LORA_TX_PIN 17

#define LORA_BAUD 115200


// ---------- LoRa addresses ----------

#define MY_ADDRESS          2
#define TRANSMITTER_ADDRESS 1

#define NETWORK_ID          18


// ---------- Radio configuration ----------

// IMPORTANT:
// Use a frequency and RF configuration permitted
// in your country/region.
#define LORA_FREQUENCY      866000000

// SF7, BW125, CR4/5, preamble 12
#define LORA_PARAMETER      "7,7,1,12"

// Conservative starting RF power.
// Verify your actual module/antenna/regulatory limits.
#define LORA_POWER          10


// ---------- Files ----------

#define OUTPUT_FILE         "/received.raw"


// ---------- Packet ----------

#define RAW_CHUNK_SIZE      160

// Prevent accepting absurdly large files by accident.
#define MAX_FILE_SIZE       (1024UL * 1024UL)


// ============================================================
// BASE64
// ============================================================

int base64Value(char c)
{
    if (c >= 'A' && c <= 'Z')
        return c - 'A';

    if (c >= 'a' && c <= 'z')
        return c - 'a' + 26;

    if (c >= '0' && c <= '9')
        return c - '0' + 52;

    if (c == '+')
        return 62;

    if (c == '/')
        return 63;

    return -1;
}


size_t base64Decode(
    const String &input,
    uint8_t *output,
    size_t maxOutput
)
{
    size_t outputIndex = 0;

    for (
        size_t i = 0;
    i + 3 < input.length();
    i += 4
    )
    {
        int a = base64Value(input[i]);
        int b = base64Value(input[i + 1]);

        int c =
        input[i + 2] == '='
        ? -1
        : base64Value(input[i + 2]);

        int d =
        input[i + 3] == '='
        ? -1
        : base64Value(input[i + 3]);


        if (a < 0 || b < 0)
        {
            break;
        }


        uint32_t value =
        ((uint32_t)a << 18) |
        ((uint32_t)b << 12);


        if (c >= 0)
        {
            value |=
            ((uint32_t)c << 6);
        }


        if (d >= 0)
        {
            value |=
            (uint32_t)d;
        }


        if (
            outputIndex <
            maxOutput
        )
        {
            output[outputIndex++] =
            (value >> 16) & 0xFF;
        }


        if (
            c >= 0 &&
            outputIndex <
            maxOutput
        )
        {
            output[outputIndex++] =
            (value >> 8) & 0xFF;
        }


        if (
            d >= 0 &&
            outputIndex <
            maxOutput
        )
        {
            output[outputIndex++] =
            value & 0xFF;
        }
    }


    return outputIndex;
}


// ============================================================
// LORA COMMAND
// ============================================================

void sendCommand(
    const String &command
)
{
    LoRa.print(command);
    LoRa.print("\r\n");

    Serial.print(">> ");
    Serial.println(command);
}


// ============================================================
// READ ONE LINE FROM RYLR998
// ============================================================

String readLoRaLine(
    unsigned long timeout
)
{
    unsigned long start =
    millis();

    String line;


    while (
        millis() - start <
        timeout
    )
    {
        while (LoRa.available())
        {
            char c =
            LoRa.read();


            if (c == '\n')
            {
                line.trim();

                if (line.length() > 0)
                {
                    return line;
                }

                line = "";
            }
            else if (c != '\r')
            {
                line += c;
            }
        }

        delay(1);
    }


    return "";
}


// ============================================================
// PARSE +RCV
//
// Example:
//
// +RCV=1,3,GET,-52,10
//
// returns:
//
// GET
// ============================================================

String extractReceivedData(
    const String &line
)
{
    if (!line.startsWith("+RCV="))
    {
        return "";
    }


    int p1 =
    line.indexOf(',');

    if (p1 < 0)
    {
        return "";
    }


    int p2 =
    line.indexOf(
        ',',
        p1 + 1
    );

    if (p2 < 0)
    {
        return "";
    }


    int p3 =
    line.indexOf(
        ',',
        p2 + 1
    );


    if (p3 < 0)
    {
        return line.substring(
            p2 + 1
        );
    }


    return line.substring(
        p2 + 1,
        p3
    );
}


// ============================================================
// CONFIGURE RYLR998
// ============================================================

void configureLoRa()
{
    Serial.println();
    Serial.println(
        "Configuring RYLR998..."
    );


    sendCommand("AT");
    delay(500);


    sendCommand(
        "AT+ADDRESS=" +
        String(MY_ADDRESS)
    );

    delay(300);


    sendCommand(
        "AT+NETWORKID=" +
        String(NETWORK_ID)
    );

    delay(300);


    sendCommand(
        "AT+BAND=" +
        String(LORA_FREQUENCY)
    );

    delay(300);


    sendCommand(
        "AT+PARAMETER=" +
        String(LORA_PARAMETER)
    );

    delay(300);


    sendCommand(
        "AT+CRFOP=" +
        String(LORA_POWER)
    );

    delay(500);


    Serial.println(
        "LoRa configuration complete."
    );
}


// ============================================================
// SEND ACK
// ============================================================

void sendAck(
    uint16_t sequence
)
{
    String ack =
    "ACK|" +
    String(sequence);


    sendCommand(
        "AT+SEND=" +
        String(TRANSMITTER_ADDRESS) +
        "," +
        String(ack.length()) +
        "," +
        ack
    );
}


// ============================================================
// SEND READY
// ============================================================

void sendReady()
{
    String ready =
    "READY";


    sendCommand(
        "AT+SEND=" +
        String(TRANSMITTER_ADDRESS) +
        "," +
        String(ready.length()) +
        "," +
        ready
    );
}


// ============================================================
// SEND DONE
// ============================================================

void sendDone()
{
    String done =
    "DONE";


        sendCommand(
            "AT+SEND=" +
            String(TRANSMITTER_ADDRESS) +
            "," +
            String(done.length()) +
            "," +
            done
        );
}


// ============================================================
// REQUEST FILE
// ============================================================

void requestFile()
{
    String get =
    "GET";


    Serial.println();
    Serial.println(
        "Requesting music file..."
    );


    sendCommand(
        "AT+SEND=" +
        String(TRANSMITTER_ADDRESS) +
        "," +
        String(get.length()) +
        "," +
        get
    );
}


// ============================================================
// PARSE META
//
// META|file_size|packet_count
// ============================================================

bool parseMetadata(
    const String &data,
    size_t &fileSize,
    uint16_t &packetCount
)
{
    if (
        !data.startsWith(
            "META|"
        )
    )
    {
        return false;
    }


    int p1 =
    data.indexOf('|');

    int p2 =
    data.indexOf(
        '|',
        p1 + 1
    );


    if (
        p1 < 0 ||
        p2 < 0
    )
    {
        return false;
    }


    String sizeString =
    data.substring(
        p1 + 1,
        p2
    );


    String packetString =
    data.substring(
        p2 + 1
    );


    fileSize =
    strtoul(
        sizeString.c_str(),
            nullptr,
            10
    );


    packetCount =
    packetString.toInt();


    if (
        fileSize == 0 ||
        fileSize > MAX_FILE_SIZE
    )
    {
        Serial.println(
            "Invalid file size."
        );

        return false;
    }


    if (packetCount == 0)
    {
        Serial.println(
            "Invalid packet count."
        );

        return false;
    }


    return true;
}


// ============================================================
// RECEIVE STATE
// ============================================================

File outputFile;

size_t expectedFileSize = 0;
size_t receivedFileSize = 0;

uint16_t expectedPacket = 0;
uint16_t totalPackets = 0;

bool receivingFile = false;


// ============================================================
// START FILE TRANSFER
// ============================================================

bool startFileTransfer(
    size_t fileSize,
    uint16_t packetCount
)
{
    Serial.println();
    Serial.println(
        "================================"
    );
    Serial.println(
        "Incoming music file"
    );
    Serial.println(
        "================================"
    );


    Serial.println(
        "File size: " +
        String(fileSize) +
        " bytes"
    );


    Serial.println(
        "Packets: " +
        String(packetCount)
    );


    if (
        LittleFS.exists(
            OUTPUT_FILE
        )
    )
    {
        LittleFS.remove(
            OUTPUT_FILE
        );
    }


    outputFile =
    LittleFS.open(
        OUTPUT_FILE,
        "w"
    );


    if (!outputFile)
    {
        Serial.println(
            "ERROR: Could not create output file."
        );

        return false;
    }


    expectedFileSize =
    fileSize;

    receivedFileSize =
    0;

    expectedPacket =
    0;

    totalPackets =
    packetCount;

    receivingFile =
    true;


    sendReady();

    return true;
}


// ============================================================
// PARSE AND PROCESS DATA PACKET
//
// DATA|packet_number|base64_data
// ============================================================

void processDataPacket(
    const String &data
)
{
    if (!receivingFile)
    {
        return;
    }


    int p1 =
    data.indexOf('|');


    int p2 =
    data.indexOf(
        '|',
        p1 + 1
    );


    if (
        p1 < 0 ||
        p2 < 0
    )
    {
        Serial.println(
            "Malformed DATA packet."
        );

        return;
    }


    uint16_t sequence =
    data.substring(
        p1 + 1,
        p2
    ).toInt();


    String encoded =
    data.substring(
        p2 + 1
    );


    // --------------------------------------------------------
    // Duplicate packet.
    //
    // This can happen if the transmitter did not receive
    // our ACK and sent the packet again.
    // --------------------------------------------------------

    if (
        sequence <
        expectedPacket
    )
    {
        Serial.println(
            "Duplicate packet " +
            String(sequence)
        );


        sendAck(sequence);

        return;
    }


    // --------------------------------------------------------
    // Out-of-order packet.
    // --------------------------------------------------------

    if (
        sequence >
        expectedPacket
    )
    {
        Serial.println(
            "Unexpected packet " +
            String(sequence) +
            ", expected " +
            String(expectedPacket)
        );

        // Don't ACK it.
        // The transmitter will eventually retry.
        return;
    }


    // --------------------------------------------------------
    // Decode Base64.
    // --------------------------------------------------------

    uint8_t buffer[
        RAW_CHUNK_SIZE
    ];


    size_t decoded =
    base64Decode(
        encoded,
        buffer,
        sizeof(buffer)
    );


    if (decoded == 0)
    {
        Serial.println(
            "ERROR: Base64 decode failed."
        );

        return;
    }


    // --------------------------------------------------------
    // Make sure we don't write more than the announced file
    // size.
    // --------------------------------------------------------

    if (
        receivedFileSize +
        decoded >
        expectedFileSize
    )
    {
        Serial.println(
            "ERROR: Packet exceeds expected file size."
        );

        return;
    }


    // --------------------------------------------------------
    // Write bytes to LittleFS.
    // --------------------------------------------------------

    size_t written =
    outputFile.write(
        buffer,
        decoded
    );


    if (
        written != decoded
    )
    {
        Serial.println(
            "ERROR: File write failed."
        );

        return;
    }


    receivedFileSize +=
    decoded;


    Serial.println(
        "Received packet " +
        String(sequence) +
        " (" +
        String(decoded) +
        " bytes)"
    );


    Serial.println(
        "Progress: " +
        String(receivedFileSize) +
        " / " +
        String(expectedFileSize)
    );


    // --------------------------------------------------------
    // Tell transmitter this packet was received.
    // --------------------------------------------------------

    sendAck(
        expectedPacket
    );


    expectedPacket++;
}


// ============================================================
// FINISH FILE TRANSFER
// ============================================================

bool finishTransfer()
{
    if (!receivingFile)
    {
        return false;
    }


    outputFile.flush();
    outputFile.close();

    receivingFile =
    false;


    Serial.println();
    Serial.println(
        "================================"
    );
    Serial.println(
        "TRANSFER COMPLETE"
    );
    Serial.println(
        "================================"
    );


    Serial.println(
        "Received: " +
        String(receivedFileSize) +
        " bytes"
    );


    Serial.println(
        "Expected: " +
        String(expectedFileSize) +
        " bytes"
    );


    if (
        receivedFileSize !=
        expectedFileSize
    )
    {
        Serial.println(
            "ERROR: File size mismatch."
        );

        return false;
    }


    Serial.println(
        "File reconstructed successfully."
    );


    sendDone();


    // Also send a simple notification to the PC.
    Serial.println(
        "FILE_READY"
    );


    return true;
}


// ============================================================
// SEND FILE TO PC
//
// Framing format:
//
// BEGIN_RAW:<size>\n
// <binary bytes>
// END_RAW\n
//
// This allows the Python program on the PC to extract
// the reconstructed music.raw file from the serial stream.
//
// IMPORTANT:
// The binary section is written using Serial.write(),
// not Serial.println().
// ============================================================

bool sendFileToPC()
{
    if (!LittleFS.exists(OUTPUT_FILE))
    {
        Serial.println(
            "ERROR: received.raw not found."
        );

        return false;
    }


    File file =
    LittleFS.open(
        OUTPUT_FILE,
        "r"
    );


    if (!file)
    {
        Serial.println(
            "ERROR: Could not open received.raw."
        );

        return false;
    }


    size_t size =
    file.size();


    // Flush any previous serial output first.
    Serial.flush();


    // Header.
    Serial.print(
        "BEGIN_RAW:"
    );

    Serial.print(size);

    Serial.print(
        "\n"
    );

    Serial.flush();


    // Binary file.
    uint8_t buffer[
        RAW_CHUNK_SIZE
    ];


    while (file.available())
    {
        size_t count =
...

This file has been truncated, please download it to see its full contents.

Python receiver

Python
from __future__ import annotations

import struct
import sys
from pathlib import Path

import cv2
import numpy as np
import soundfile as sf
import torch

from encodec import EncodecModel


# ============================================================
# CONFIGURATION
# ============================================================

RAW_OUTPUT = Path("received.raw")
WAV_OUTPUT = Path("reconstructed.wav")

BIT_DEPTH = 10
MAGIC = b"OCS1"

BW_TO_NQ = {
    1500: 2,
    3000: 4,
    6000: 8,
    12000: 16,
    24000: 32,
}


# ============================================================
# BIT UNPACKING
# ============================================================

def unpack_bits(
    data: bytes,
    bits_per_value: int = 10,
    expected_values: int | None = None,
) -> np.ndarray:

    values = []

    bitstream = 0
    bitcount = 0

    mask = (1 << bits_per_value) - 1

    for byte in data:

        bitstream |= byte << bitcount
        bitcount += 8

        while bitcount >= bits_per_value:

            values.append(
                bitstream & mask
            )

            bitstream >>= bits_per_value
            bitcount -= bits_per_value

            if (
                expected_values is not None
                and len(values) >= expected_values
            ):
                return np.array(
                    values,
                    dtype=np.uint16,
                )

    return np.array(
        values,
        dtype=np.uint16,
    )


# ============================================================
# HEADER PARSING
# ============================================================

def parse_header(blob: bytes):

    offset = 0

    if len(blob) < 20:
        raise ValueError(
            "File is too small to contain a valid header."
        )

    magic = blob[
        offset:
        offset + 4
    ]

    offset += 4

    if magic != MAGIC:
        raise ValueError(
            "Invalid music file: bad magic."
        )

    version = struct.unpack_from(
        "<H",
        blob,
        offset,
    )[0]

    offset += 2

    bandwidth_milli = struct.unpack_from(
        "<H",
        blob,
        offset,
    )[0]

    offset += 2

    sample_rate = struct.unpack_from(
        "<I",
        blob,
        offset,
    )[0]

    offset += 4

    n_q = struct.unpack_from(
        "<H",
        blob,
        offset,
    )[0]

    offset += 2

    num_frames = struct.unpack_from(
        "<I",
        blob,
        offset,
    )[0]

    offset += 4

    total_tokens = struct.unpack_from(
        "<I",
        blob,
        offset,
    )[0]

    offset += 4

    frame_lengths = []

    for _ in range(num_frames):

        frame_length = struct.unpack_from(
            "<I",
            blob,
            offset,
        )[0]

        offset += 4

        frame_lengths.append(
            int(frame_length)
        )

    return (
        version,
        bandwidth_milli,
        sample_rate,
        n_q,
        num_frames,
        total_tokens,
        frame_lengths,
        offset,
    )


# ============================================================
# ENCODEC DECODER
# ============================================================

def decode_music(
    input_path: Path,
    output_path: Path,
):

    print()
    print("=" * 60)
    print("DECODING MUSIC")
    print("=" * 60)

    blob = input_path.read_bytes()

    print(
        f"Input size: {len(blob):,} bytes"
    )

    (
        version,
        bandwidth_milli,
        sample_rate,
        n_q,
        num_frames,
        total_tokens,
        frame_lengths,
        payload_offset,
    ) = parse_header(blob)

    print(
        f"Version:     {version}"
    )

    print(
        f"Bandwidth:   {bandwidth_milli / 1000:.3f} kbps"
    )

    print(
        f"Sample rate: {sample_rate} Hz"
    )

    print(
        f"Codebooks:   {n_q}"
    )

    print(
        f"Frames:      {num_frames}"
    )

    print(
        f"Tokens:      {total_tokens}"
    )

    if bandwidth_milli not in BW_TO_NQ:
        raise ValueError(
            f"Unsupported bandwidth: "
            f"{bandwidth_milli / 1000:.3f} kbps"
        )

    expected_n_q = BW_TO_NQ[
        bandwidth_milli
    ]

    if n_q != expected_n_q:
        raise ValueError(
            "Number of codebooks does not "
            "match the bandwidth."
        )

    payload = blob[
        payload_offset:
    ]

    tokens = unpack_bits(
        payload,
        bits_per_value=BIT_DEPTH,
        expected_values=total_tokens,
    )

    if len(tokens) < total_tokens:
        raise ValueError(
            "Token stream is truncated."
        )

    tokens = tokens[
        :total_tokens
    ]

    # --------------------------------------------------------
    # Reconstruct EnCodec frames
    # --------------------------------------------------------

    encoded_frames = []

    index = 0

    for frame_length in frame_lengths:

        token_count = (
            n_q * frame_length
        )

        frame_tokens = tokens[
            index:
            index + token_count
        ]

        index += token_count

        if len(frame_tokens) != token_count:
            raise ValueError(
                "Not enough tokens to rebuild frame."
            )

        codes = torch.tensor(
            frame_tokens,
            dtype=torch.long,
        ).reshape(
            1,
            n_q,
            frame_length,
        )

        encoded_frames.append(
            (codes, None)
        )

    # --------------------------------------------------------
    # Decode
    # --------------------------------------------------------

    print(
        "Loading EnCodec..."
    )

    model = (
        EncodecModel
        .encodec_model_24khz()
    )

    model.set_target_bandwidth(
        bandwidth_milli / 1000.0
    )

    model.eval()

    print(
        "Reconstructing audio..."
    )

    with torch.inference_mode():

        decoded = model.decode(
            encoded_frames
        )

    decoded = (
        decoded
        .squeeze(0)
        .cpu()
        .numpy()
        .T
    )

    sf.write(
        output_path,
        decoded,
        sample_rate,
    )

    duration = (
        len(decoded) /
        sample_rate
    )

    print(
        f"Saved: {output_path}"
    )

    print(
        f"Duration: {duration:.2f} seconds"
    )


# ============================================================
# QR CODE DECODING
# ============================================================

def decode_qr_image(
    image_path: Path,
):

    image = cv2.imread(
        str(image_path)
    )

    if image is None:
        raise FileNotFoundError(
            f"Could not open image: {image_path}"
        )

    detector = cv2.QRCodeDetector()

    results = []

    # Newer OpenCV versions provide byte-oriented
    # multi-QR decoding, which is useful here because
    # our QR payload is arbitrary binary data.

    try:

        (
            ok,
            decoded_info,
            points,
            _,
        ) = detector.detectAndDecodeBytesMulti(
            image
        )

        if ok:

            for data in decoded_info:

                if data is None:
                    continue

                if isinstance(data, str):
                    data = data.encode(
                        "latin1"
                    )

                data = bytes(data)

                if len(data) < 1:
                    continue

                qr_id = data[0]

                if not 1 <= qr_id <= 8:
                    continue

                results.append(
                    (
                        qr_id,
                        data[1:],
                    )
                )

    except AttributeError:

        raise RuntimeError(
            "Your OpenCV version does not "
            "support byte-oriented multi-QR "
            "decoding. Install a recent "
            "opencv-python version."
        )

    return results


# ============================================================
# READ SIDE A + SIDE B
# ============================================================

def read_qr_cassette():

    print()
    print("=" * 60)
    print("QR MUSIC CASSETTE")
    print("=" * 60)

    print()
    print(
        "Enter the path to the Side A image."
    )

    side_a = Path(
        input(
            "Side A: "
        ).strip().strip('"')
    )

    print()
    print(
        "Enter the path to the Side B image."
    )

    side_b = Path(
        input(
            "Side B: "
        ).strip().strip('"')
    )

    if not side_a.exists():
        raise FileNotFoundError(
            f"Side A not found: {side_a}"
        )

    if not side_b.exists():
        raise FileNotFoundError(
            f"Side B not found: {side_b}"
        )

    print()
    print(
        "Scanning Side A..."
    )

    side_a_codes = decode_qr_image(
        side_a
    )

    print(
        f"Found {len(side_a_codes)} QR codes."
    )

    for qr_id, data in side_a_codes:
        print(
            f"  QR {qr_id}: "
            f"{len(data)} bytes"
        )

    print()
    print(
        "Scanning Side B..."
    )

    side_b_codes = decode_qr_image(
        side_b
    )

    print(
        f"Found {len(side_b_codes)} QR codes."
    )

    for qr_id, data in side_b_codes:
        print(
            f"  QR {qr_id}: "
            f"{len(data)} bytes"
        )

    # --------------------------------------------------------
    # Combine both sides.
    # --------------------------------------------------------

    all_codes = (
        side_a_codes +
        side_b_codes
    )

    chunks = {}

    for qr_id, data in all_codes:

        if qr_id in chunks:

            raise ValueError(
                f"Duplicate QR ID detected: "
                f"{qr_id}"
            )

        chunks[qr_id] = data

    # --------------------------------------------------------
    # Make sure every QR exists.
    # --------------------------------------------------------

    missing = [
        i
        for i in range(1, 9)
        if i not in chunks
    ]

    if missing:

        raise RuntimeError(
            "Missing QR codes: " +
            ", ".join(
                str(x)
                for x in missing
            )
        )

    # --------------------------------------------------------
    # Reconstruct original music.raw.
    # --------------------------------------------------------

    print()
    print(
        "Reconstructing music data..."
    )

    reconstructed = b"".join(
        chunks[i]
        for i in range(1, 9)
    )

    RAW_OUTPUT.write_bytes(
        reconstructed
    )

    print(
        f"Reconstructed: "
        f"{len(reconstructed):,} bytes"
    )

    print(
        f"Saved as: {RAW_OUTPUT}"
    )

    # --------------------------------------------------------
    # Decode it.
    # --------------------------------------------------------

    decode_music(
        RAW_OUTPUT,
        WAV_OUTPUT,
    )


# ============================================================
# LORA MODE
# ============================================================

def receive_lora_file():
    """
    The LoRa receiver ESP32 reconstructs received.raw
    and sends:

        BEGIN_RAW:<size>
        <binary data>

    over USB serial.

    This function is intentionally kept separate from the
    QR cassette reader so both input methods end up producing
    the same music.raw file.
    """

    import serial

    SERIAL_PORT = input(
        "ESP32 serial port (e.g. COM5): "
    ).strip()

    BAUD_RATE = 115200

    print()
    print(
        f"Opening {SERIAL_PORT}..."
    )

    try:

        ser = serial.Serial(
            SERIAL_PORT,
            BAUD_RATE,
            timeout=1,
        )

    except serial.SerialException as e:

        raise RuntimeError(
            f"Could not open serial port: {e}"
        )

    try:

        print()
        print(
            "Waiting for reconstructed file..."
        )

        while True:

            line = ser.readline()

            if not line:
                continue

            text = line.decode(
                "ascii",
                errors="ignore",
            ).strip()

            print(
                f"ESP32: {text}"
            )

            if not text.startswith(
                "BEGIN_RAW:"
            ):
                continue

            size_text = text[
                len("BEGIN_RAW:"):
            ]

            expected_size = int(
                size_text
            )

            print()
            print(
                f"Receiving "
                f"{expected_size:,} bytes..."
            )

            data = bytearray()

            while len(data) < expected_size:

                remaining = (
                    expected_size -
                    len(data)
                )

                chunk = ser.read(
                    min(
                        remaining,
                        4096,
                    )
                )

                if not chunk:
                    raise TimeoutError(
                        "Timed out while "
                        "receiving file."
                    )

                data.extend(
                    chunk
                )

                percent = (
                    len(data) /
                    expected_size *
                    100
                )

                print(
                    f"\r"
                    f"{len(data):,}/"
                    f"{expected_size:,} "
                    f"({percent:.1f}%)",
                    end="",
                    flush=True,
                )

            print()

            RAW_OUTPUT.write_bytes(
                data
            )

            print(
                f"Saved: {RAW_OUTPUT}"
            )

            break

    finally:

        ser.close()

    decode_music(
        RAW_OUTPUT,
        WAV_OUTPUT,
    )


# ============================================================
# MAIN MENU
# ============================================================

def main():

    while True:

        print()
        print("=" * 60)
        print(
            "       OFF-GRID MUSIC RECEIVER"
        )
        print("=" * 60)

        print(
            "1. Receive music over LoRa"
        )

        print(
            "2. Read QR music cassette"
        )

        print(
            "3. Exit"
        )

        print()

        choice = input(
            "Select: "
        ).strip()


        try:

            if choice == "1":

                receive_lora_file()

            elif choice == "2":

                read_qr_cassette()

            elif choice == "3":

                print(
                    "Goodbye."
                )

                break

            else:

                print(
                    "Invalid choice."
                )

        except Exception as e:

            print()
            print(
                "ERROR:"
            )

            print(e)


if __name__ == "__main__":
    main()

Cassette generator

Python
import math
from pathlib import Path

import qrcode
from PIL import Image, ImageDraw, ImageFont

def create_cassette_side(qrs, title, output_path):
    """
    Composites exactly 4 square QR images into a 2x2 grid with a large title at the top.
    """
    qr_size = qrs[0].width
    
    # Grid dimensions (2x2)
    grid_w = qr_size * 2
    grid_h = qr_size * 2
    
    # Allocate space for the title
    title_h = 80
    total_w = grid_w
    total_h = grid_h + title_h
    
    img = Image.new("RGB", (total_w, total_h), "white")
    draw = ImageDraw.Draw(img)
    
    # Attempt to load a large, readable font (fallback gracefully for older Pillow versions)
    try:
        font = ImageFont.truetype("arial.ttf", 60)
    except OSError:
        try:
            font = ImageFont.load_default(size=60)
        except TypeError:
            font = ImageFont.load_default()
            
    # Calculate text bounding box to center the title
    try:
        bbox = draw.textbbox((0, 0), title, font=font)
        tw = bbox[2] - bbox[0]
        th = bbox[3] - bbox[1]
    except AttributeError:
        # Fallback for very old PIL versions
        tw, th = 120, 20
        
    draw.text(((total_w - tw) // 2, (title_h - th) // 2), title, fill="black", font=font)
    
    # Paste QRs into the 2x2 layout underneath the title
    img.paste(qrs[0], (0, title_h))
    img.paste(qrs[1], (qr_size, title_h))
    img.paste(qrs[2], (0, title_h + qr_size))
    img.paste(qrs[3], (qr_size, title_h + qr_size))
    
    img.save(output_path)

def main():
    raw_file = Path("music.raw")
    if not raw_file.exists():
        raise FileNotFoundError("Input file 'music.raw' not found in the current directory.")
        
    data = raw_file.read_bytes()
    
    # Calculate chunk size to ensure exactly 8 splits
    chunk_size = math.ceil(len(data) / 8)
    chunks = [data[i : i + chunk_size] for i in range(0, len(data), chunk_size)]
    
    # Pad out the array just in case the file was extremely small or empty
    while len(chunks) < 8:
        chunks.append(b"")
        
    qr_images = []
    
    print("Generating QR Codes...")
    for i in range(8):
        # Prefix the payload with a 1-byte ID (1 through 8)
        payload = bytes([i + 1]) + chunks[i]
        
        qr = qrcode.QRCode(
            version=None,
            error_correction=qrcode.constants.ERROR_CORRECT_L, # Lowest error correction for maximum density
            box_size=8,
            border=3
        )
        
        # optimize=0 guarantees strict byte encoding without accidental alphanumeric mixing
        qr.add_data(payload, optimize=0) 
        qr.make(fit=True)
        
        qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
        qr_images.append(qr_img)
        
    # Standardize image dimensions to prevent grid distortion 
    # (The last QR code might generate a smaller version matrix if the trailing payload is small)
    max_size = max(img.width for img in qr_images)
    
    uniform_qrs = []
    for img in qr_images:
        if img.width != max_size:
            padded = Image.new("RGB", (max_size, max_size), "white")
            # Center the smaller QR inside the max-size bounding box
            padded.paste(img, ((max_size - img.width) // 2, (max_size - img.height) // 2))
            uniform_qrs.append(padded)
        else:
            uniform_qrs.append(img)
            
    print("Compositing cassettes...")
    create_cassette_side(uniform_qrs[0:4], "SIDE A", "cassette_side_a.png")
    create_cassette_side(uniform_qrs[4:8], "SIDE B", "cassette_side_b.png")
    
    print("Done! Saved cassette_side_a.png and cassette_side_b.png.")

if __name__ == "__main__":
    main()

Credits

Makestreme
16 projects • 20 followers
I make DIY projects that are fun and interesting! An engineer by profession :) youtube.com/@makestreme

Comments