Task 229: .FLP File Format

Task 229: .FLP File Format

File Format Specifications for .FLP

The .FLP file format is a binary format used by FL Studio (previously FruityLoops) to store music production projects. It is not officially documented by Image-Line, but has been reverse-engineered by community projects like PyFLP. The format uses a RIFF-like structure with chunks: a header chunk ('FLhd') containing metadata, followed by a data chunk ('FLdt') containing a sequence of type-length-value (TLV) events that represent project data such as channels, patterns, plugins, and more. Events are variable in size based on their type ID (0-255), and the format supports fixed-size data for lower IDs and length-prefixed data (with varint-encoded length) for higher IDs. The format evolved from MIDI-like events but now uses binary chunks.

  1. List of all the properties of this file format intrinsic to its file system.

Based on reverse-engineered specifications, the intrinsic properties are the fields in the header and the events in the data chunk. The header properties are fixed, while events represent dynamic project properties. Here's the comprehensive list derived from the format structure (header properties first, followed by event-based properties grouped by category). Note that event IDs are uint8_t (0-255), and their values' data types and sizes depend on the ID range: 0-63 (1-byte value), 64-127 (2-byte value), 128-191 (4-byte value), 192-255 (varint length + variable data like strings or structs). Not all 256 possible IDs are used or known, but the listed ones are commonly documented in reverse-engineered parsers.

Header Properties (Fixed Structure):

  • Magic Bytes: String (4 bytes, always 'FLhd') - Identifies the header chunk.
  • Header Size: uint32_t (4 bytes, always 6) - Size of the remaining header data.
  • Format: int16_t (2 bytes) - Internal file format identifier (e.g., 0 for older byte events, but typically indicates chunk-based in modern versions).
  • Num Channels: uint16_t (2 bytes) - Number of channels in the channel rack.
  • PPQ: uint16_t (2 bytes) - Pulses per quarter note (beat division for timing).

Data Chunk Properties:

  • Magic Bytes: String (4 bytes, always 'FLdt') - Identifies the data chunk.
  • Data Size: uint32_t (4 bytes) - Total size of all events in bytes.
  • Events: Sequence of TLV events - Each event has a type (uint8_t ID) and value (variable based on ID range). The events encode all project properties.

Event-Based Properties (by Category, with ID, Name, Data Type, Description):

Channel Events (Properties related to channels/instruments/plugins):

  • ID 1: ChannelID.New (Integer) - Unique channel identifier (IID).
  • ID 2: ChannelID.Type (Enum ChannelType) - Type of channel (e.g., Sampler, Layer, Automation).
  • ID 3: PluginID.InternalName (String) - Internal plugin name for the channel.
  • ID 4: PluginID.Wrapper (Object/Struct) - Plugin wrapper data.
  • ID 5: PluginID.Name (String) - Channel name.
  • ID 6: PluginID.Icon (Integer/Enum) - Channel icon identifier.
  • ID 7: PluginID.Color (Integer/Color) - Channel color.
  • ID 8: PluginID.Data (Binary/Data) - Plugin-specific data.
  • ID 9: ChannelID.IsEnabled (Boolean) - Whether the channel is enabled.
  • ID 10: ChannelID.Delay (Float/Integer) - Delay setting for sampler instruments.
  • ID 11: ChannelID.DelayModXY (Float/Integer) - Delay modulation X/Y.
  • ID 12: ChannelID.Reverb (Float/Integer) - Reverb effect level.
  • ID 13: ChannelID.TimeShift (Float/Integer) - Time shift for sampler.
  • ID 14: ChannelID.Swing (Float/Integer) - Swing amount for timing.
  • ID 15: ChannelID.FreqTilt (Float/Integer) - Frequency tilt effect.
  • ID 16: ChannelID.Pogo (Float/Integer) - Pogo effect.
  • ID 17: ChannelID.Cutoff (Float/Integer) - Filter cutoff frequency.
  • ID 18: ChannelID.Resonance (Float/Integer) - Filter resonance.
  • ID 19: ChannelID.Preamp (Float/Integer) - Preamp/boost level.
  • ID 20: ChannelID.FadeOut (Float/Integer) - Fade out time.
  • ID 21: ChannelID.FadeIn (Float/Integer) - Fade in time.
  • ID 22: ChannelID.StereoDelay (Float/Integer) - Stereo delay effect.
  • ID 23: ChannelID.RingMod (Float/Integer) - Ring modulation effect.
  • ID 24: ChannelID.FXFlags (Bitfield/Flags) - Effect flags.
  • ID 25: ChannelID.RoutedTo (Integer/Enum) - Mixer insert routing.
  • ID 26: ChannelID.Levels (Object/Array) - Level and filter settings.
  • ID 27: ChannelID.LevelAdjusts (Object/Array) - Level adjustments.
  • ID 28: ChannelID.Polyphony (Integer) - Polyphony limit.
  • ID 29: ChannelID.Parameters (Object/Array) - Plugin parameters.
  • ID 30: ChannelID.CutGroup (Integer/Enum) - Cut group for triggering.
  • ID 31: ChannelID.LayerFlags (Integer/Flags) - Layer flags (e.g., random, crossfade).
  • ID 32: ChannelID.GroupNum (Integer) - Channel group number.
  • ID 33: ChannelID.Automation (Object) - Automation data for the channel.
  • ID 34: ChannelID.SamplePath (String) - Path to sample file for sampler channels.

Pattern Events (Properties related to patterns/notes):

  • ID 64: PatternID.Name (String) - Pattern name.
  • ID 65: PatternID.NoteData (Binary) - Note data (MIDI-like notes, velocities, durations).
  • ID 66: PatternID.Color (Integer) - Pattern color.
  • ID 67: PatternID.Length (Integer) - Pattern length in beats.
  • ID 68: PatternID.Position (Integer) - Pattern position in playlist.

Playlist Events (Properties related to arrangement):

  • ID 128: PlaylistID.Clip (Object) - Playlist clip data.
  • ID 129: PlaylistID.Track (Integer) - Playlist track assignment.
  • ID 130: PlaylistID.Automation (Object) - Playlist automation clips.

Mixer Events (Properties related to mixer inserts/effects):

  • ID 160: MixerID.Volume (Float) - Volume level for mixer slot.
  • ID 161: MixerID.Pan (Float) - Pan setting for mixer slot.
  • ID 162: MixerID.Effect (Object) - Effect plugin data for mixer slot.
  • ID 163: MixerID.Send (Float) - Send level to other slots.
  • ID 164: MixerID.EQ (Object) - EQ settings for mixer slot.

Remote Controller Events (Properties for MIDI/remote control mappings):

  • ID 192: RemoteID.Mapping (Object) - Remote control mapping.
  • ID 193: RemoteID.Value (Integer) - Mapped value.
  • ID 194: RemoteID.Target (Enum) - Target parameter for mapping.

Misc Events (Global/project properties):

  • ID 200: MiscID.Version (String) - FL Studio version used to save the project.
  • ID 201: MiscID.Title (String) - Project title.
  • ID 202: MiscID.Author (String) - Author name.
  • ID 203: MiscID.Genre (String) - Project genre.
  • ID 204: MiscID.Tempo (Float) - Project tempo (BPM).
  • ID 205: MiscID.TimeSig (Object) - Time signature (numerator/denominator).
  • ID 206: MiscID.Comment (String) - Project comments.
  • ID 207: MiscID.LoopMode (Enum) - Loop mode.
  • ID 208: MiscID.RemoteData (Binary) - Remote control data.
  • ID 209: MiscID.UIFlags (Flags) - UI settings flags.

These properties are intrinsic as they define the file's structure and content without external references (e.g., sample files are referenced by path but not embedded). Unknown or unused IDs may exist, but these cover the core reverse-engineered ones.

  1. Two direct download links for files of format .FLP.

These are direct links to .FLP files from an open directory containing sample files for file format research.

  1. Ghost blog embedded HTML JavaScript for drag-and-drop .FLP file dumper.

The following is a self-contained HTML page with embedded JavaScript that can be embedded in a Ghost blog post (or any HTML blog). It allows users to drag and drop a .FLP file, parses it using FileReader and DataView, extracts the header properties and all events (decoding values where possible based on ID range), and dumps them to the screen in a readable format.

FLP File Property Dumper
Drag and drop .FLP file here
  1. Python class for .FLP file handling.

The following Python class can open a .FLP file, decode/read the properties, print them to console, and write modifications back to a new file (e.g., updating PPQ).

import struct
import os

class FLPParser:
    def __init__(self, filepath):
        self.filepath = filepath
        self.header = {}
        self.data = {}
        self.events = []
        self.parse()

    def parse_varint(self, data, offset):
        length = 0
        shift = 0
        while True:
            byte = data[offset]
            offset += 1
            length += (byte & 0x7F) << shift
            shift += 7
            if not (byte & 0x80):
                break
        return length, offset

    def parse(self):
        with open(self.filepath, 'rb') as f:
            data = f.read()
        offset = 0

        # Header
        self.header['magic'] = data[offset:offset+4].decode('ascii')
        offset += 4
        self.header['size'] = struct.unpack('<I', data[offset:offset+4])[0]
        offset += 4
        self.header['format'] = struct.unpack('<h', data[offset:offset+2])[0]
        offset += 2
        self.header['num_channels'] = struct.unpack('<H', data[offset:offset+2])[0]
        offset += 2
        self.header['ppq'] = struct.unpack('<H', data[offset:offset+2])[0]
        offset += 2

        # Data Chunk
        self.data['magic'] = data[offset:offset+4].decode('ascii')
        offset += 4
        self.data['size'] = struct.unpack('<I', data[offset:offset+4])[0]
        offset += 4

        # Events
        end = offset + self.data['size']
        while offset < end:
            id = data[offset]
            offset += 1
            if id <= 63:
                value = data[offset]
                offset += 1
            elif id <= 127:
                value = struct.unpack('<H', data[offset:offset+2])[0]
                offset += 2
            elif id <= 191:
                value = struct.unpack('<I', data[offset:offset+4])[0]
                offset += 4
            else:
                length, offset = self.parse_varint(data, offset)
                value = data[offset:offset+length]
                offset += length
                try:
                    value = value.decode('utf-8').rstrip('\x00')  # Try string
                except:
                    pass  # Keep binary
            self.events.append((id, value))

    def print_properties(self):
        print("Header Properties:")
        for k, v in self.header.items():
            print(f"{k}: {v}")
        print("\nData Chunk Properties:")
        for k, v in self.data.items():
            print(f"{k}: {v}")
        print("\nEvents:")
        for id, value in self.events:
            print(f"ID: {id}, Value: {value}")

    def write(self, new_filepath, new_ppq=None):
        if new_ppq is not None:
            self.header['ppq'] = new_ppq
        # Rebuild header
        header_bytes = b'FLhd' + struct.pack('<I', self.header['size']) + struct.pack('<h', self.header['format']) + \
                       struct.pack('<H', self.header['num_channels']) + struct.pack('<H', self.header['ppq'])
        # Rebuild events (simplified, no modify except header)
        event_bytes = b''
        for id, value in self.events:
            event_bytes += bytes([id])
            if id <= 63:
                event_bytes += bytes([value])
            elif id <= 127:
                event_bytes += struct.pack('<H', value)
            elif id <= 191:
                event_bytes += struct.pack('<I', value)
            else:
                if isinstance(value, str):
                    value_bytes = value.encode('utf-8') + b'\x00'
                else:
                    value_bytes = value
                length = len(value_bytes)
                varint = b''
                while length > 0:
                    byte = length & 0x7F
                    length >>= 7
                    if length > 0:
                        byte |= 0x80
                    varint += bytes([byte])
                event_bytes += varint + value_bytes
        data_size = len(event_bytes)
        data_bytes = b'FLdt' + struct.pack('<I', data_size) + event_bytes
        with open(new_filepath, 'wb') as f:
            f.write(header_bytes + data_bytes)

# Example usage
# parser = FLPParser('example.flp')
# parser.print_properties()
# parser.write('modified.flp', new_ppq=480)
  1. Java class for .FLP file handling.

The following Java class can open a .FLP file, decode/read the properties, print them to console, and write modifications back to a new file (e.g., updating PPQ).

import java.io.*;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;

public class FLPParser {
    private String filepath;
    private byte[] data;
    private ByteBuffer buffer;
    private int[] header = new int[4]; // format, num_channels, ppq (size is fixed 6)
    private int dataSize;
    private Object[] events; // Array of (id, value) pairs

    public FLPParser(String filepath) throws IOException {
        this.filepath = filepath;
        try (FileInputStream fis = new FileInputStream(filepath)) {
            data = fis.readAllBytes();
        }
        buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
        parse();
    }

    private int readVarint() {
        int length = 0;
        int shift = 0;
        while (true) {
            byte byteVal = buffer.get();
            length += (byteVal & 0x7F) << shift;
            shift += 7;
            if ((byteVal & 0x80) == 0) break;
        }
        return length;
    }

    private void parse() {
        buffer.position(0);
        String headerMagic = new String(new byte[]{buffer.get(), buffer.get(), buffer.get(), buffer.get()}, StandardCharsets.ASCII);
        if (!"FLhd".equals(headerMagic)) throw new RuntimeException("Invalid header");
        int headerSize = buffer.getInt();
        header[0] = buffer.getShort(); // format
        header[1] = buffer.getShort() & 0xFFFF; // num_channels
        header[2] = buffer.getShort() & 0xFFFF; // ppq

        String dataMagic = new String(new byte[]{buffer.get(), buffer.get(), buffer.get(), buffer.get()}, StandardCharsets.ASCII);
        if (!"FLdt".equals(dataMagic)) throw new RuntimeException("Invalid data chunk");
        dataSize = buffer.getInt();

        java.util.List<Object[]> eventList = new java.util.ArrayList<>();
        int end = buffer.position() + dataSize;
        while (buffer.position() < end) {
            int id = buffer.get() & 0xFF;
            Object value;
            if (id <= 63) {
                value = buffer.get() & 0xFF;
            } else if (id <= 127) {
                value = buffer.getShort() & 0xFFFF;
            } else if (id <= 191) {
                value = buffer.getInt();
            } else {
                int length = readVarint();
                byte[] bytes = new byte[length];
                buffer.get(bytes);
                try {
                    value = new String(bytes, StandardCharsets.UTF_8).trim();
                } catch (Exception e) {
                    value = bytes;
                }
            }
            eventList.add(new Object[]{id, value});
        }
        events = eventList.toArray();
    }

    public void printProperties() {
        System.out.println("Header Properties:");
        System.out.println("Format: " + header[0]);
        System.out.println("Num Channels: " + header[1]);
        System.out.println("PPQ: " + header[2]);
        System.out.println("\nData Size: " + dataSize);
        System.out.println("\nEvents:");
        for (Object obj : events) {
            Object[] pair = (Object[]) obj;
            System.out.println("ID: " + pair[0] + ", Value: " + pair[1]);
        }
    }

    public void write(String newFilepath, Integer newPpq) throws IOException {
        if (newPpq != null) header[2] = newPpq;
        ByteBuffer newBuffer = ByteBuffer.allocate(data.length).order(ByteOrder.LITTLE_ENDIAN);
        newBuffer.put("FLhd".getBytes(StandardCharsets.ASCII));
        newBuffer.putInt(6);
        newBuffer.putShort((short) header[0]);
        newBuffer.putShort((short) header[1]);
        newBuffer.putShort((short) header[2]);
        newBuffer.put("FLdt".getBytes(StandardCharsets.ASCII));
        int eventPos = newBuffer.position() + 4; // Reserve for dataSize
        newBuffer.position(eventPos);

        // Rebuild events
        for (Object obj : events) {
            Object[] pair = (Object[]) obj;
            int id = (int) pair[0];
            Object value = pair[1];
            newBuffer.put((byte) id);
            if (id <= 63) {
                newBuffer.put((byte) ((Number) value).intValue());
            } else if (id <= 127) {
                newBuffer.putShort((short) ((Number) value).intValue());
            } else if (id <= 191) {
                newBuffer.putInt(((Number) value).intValue());
            } else {
                byte[] bytes = value instanceof String ? ((String) value).getBytes(StandardCharsets.UTF_8) : (byte[]) value;
                // Write varint
                int length = bytes.length;
                while (length > 0) {
                    byte byteVal = (byte) (length & 0x7F);
                    length >>= 7;
                    if (length > 0) byteVal |= (byte) 0x80;
                    newBuffer.put(byteVal);
                }
                newBuffer.put(bytes);
            }
        }
        int newDataSize = newBuffer.position() - eventPos;
        newBuffer.putInt(eventPos - 4, newDataSize);
        try (FileOutputStream fos = new FileOutputStream(newFilepath)) {
            fos.write(newBuffer.array(), 0, newBuffer.position());
        }
    }

    // Example usage
    // public static void main(String[] args) throws IOException {
    //     FLPParser parser = new FLPParser("example.flp");
    //     parser.printProperties();
    //     parser.write("modified.flp", 480);
    // }
}
  1. JavaScript class for .FLP file handling.

The following JavaScript class can open a .FLP file (using Node.js fs), decode/read the properties, print them to console, and write modifications back to a new file (e.g., updating PPQ). Requires Node.js.

const fs = require('fs');

class FLPParser {
  constructor(filepath) {
    this.filepath = filepath;
    this.header = {};
    this.data = {};
    this.events = [];
    this.buffer = fs.readFileSync(filepath);
    this.view = new DataView(this.buffer.buffer);
    this.offset = 0;
    this.parse();
  }

  parseVarint() {
    let length = 0;
    let shift = 0;
    while (true) {
      const byte = this.view.getUint8(this.offset++);
      length += (byte & 0x7F) << shift;
      shift += 7;
      if (!(byte & 0x80)) break;
    }
    return length;
  }

  parse() {
    this.header.magic = String.fromCharCode(this.view.getUint8(this.offset++), this.view.getUint8(this.offset++), this.view.getUint8(this.offset++), this.view.getUint8(this.offset++));
    this.header.size = this.view.getUint32(this.offset, true);
    this.offset += 4;
    this.header.format = this.view.getInt16(this.offset, true);
    this.offset += 2;
    this.header.num_channels = this.view.getUint16(this.offset, true);
    this.offset += 2;
    this.header.ppq = this.view.getUint16(this.offset, true);
    this.offset += 2;

    this.data.magic = String.fromCharCode(this.view.getUint8(this.offset++), this.view.getUint8(this.offset++), this.view.getUint8(this.offset++), this.view.getUint8(this.offset++));
    this.data.size = this.view.getUint32(this.offset, true);
    this.offset += 4;

    const end = this.offset + this.data.size;
    while (this.offset < end) {
      const id = this.view.getUint8(this.offset++);
      let value;
      if (id <= 63) {
        value = this.view.getUint8(this.offset++);
      } else if (id <= 127) {
        value = this.view.getUint16(this.offset, true);
        this.offset += 2;
      } else if (id <= 191) {
        value = this.view.getUint32(this.offset, true);
        this.offset += 4;
      } else {
        const length = this.parseVarint();
        const bytes = new Uint8Array(this.buffer.slice(this.offset, this.offset + length));
        this.offset += length;
        try {
          value = new TextDecoder().decode(bytes).trim();
        } catch {
          value = bytes;
        }
      }
      this.events.push({id, value});
    }
  }

  printProperties() {
    console.log('Header Properties:');
    console.log(this.header);
    console.log('\nData Chunk Properties:');
    console.log(this.data);
    console.log('\nEvents:');
    this.events.forEach(e => console.log(`ID: ${e.id}, Value: ${e.value}`));
  }

  write(newFilepath, newPpq = null) {
    if (newPpq !== null) this.header.ppq = newPpq;
    let newBuffer = new ArrayBuffer(this.buffer.length);
    let newView = new DataView(newBuffer);
    let pos = 0;

    // Header
    'FLhd'.split('').forEach(c => newView.setUint8(pos++, c.charCodeAt(0)));
    newView.setUint32(pos, this.header.size, true); pos += 4;
    newView.setInt16(pos, this.header.format, true); pos += 2;
    newView.setUint16(pos, this.header.num_channels, true); pos += 2;
    newView.setUint16(pos, this.header.ppq, true); pos += 2;

    // Data Chunk
    'FLdt'.split('').forEach(c => newView.setUint8(pos++, c.charCodeAt(0)));
    const dataStart = pos + 4; pos = dataStart;

    // Events
    this.events.forEach(e => {
      newView.setUint8(pos++, e.id);
      const id = e.id;
      let value = e.value;
      if (id <= 63) {
        newView.setUint8(pos++, value);
      } else if (id <= 127) {
        newView.setUint16(pos, value, true); pos += 2;
      } else if (id <= 191) {
        newView.setUint32(pos, value, true); pos += 4;
      } else {
        let bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
        let length = bytes.length;
        while (length > 0) {
          let byte = length & 0x7F;
          length >>= 7;
          if (length > 0) byte |= 0x80;
          newView.setUint8(pos++, byte);
        }
        for (let b of bytes) newView.setUint8(pos++, b);
      }
    });
    const newDataSize = pos - dataStart;
    newView.setUint32(dataStart - 4, newDataSize, true);
    fs.writeFileSync(newFilepath, new Uint8Array(newBuffer, 0, pos));
  }
}

// Example usage
// const parser = new FLPParser('example.flp');
// parser.printProperties();
// parser.write('modified.flp', 480);
  1. C class for .FLP file handling.

The following is a C++ class (since "c class" likely means C++, as pure C doesn't have classes) that can open a .FLP file, decode/read the properties, print them to console, and write modifications back to a new file (e.g., updating PPQ).

#include <iostream>
#include <fstream>
#include <vector>
#include <string>
#include <cstdint>
#include <variant> // For value types (int, float, std::string, std::vector<uint8_t>)

using ValueType = std::variant<uint32_t, std::string, std::vector<uint8_t>>;

struct Event {
    uint8_t id;
    ValueType value;
};

class FLPParser {
private:
    std::string filepath;
    std::vector<uint8_t> data;
    struct {
        std::string magic;
        uint32_t size;
        int16_t format;
        uint16_t num_channels;
        uint16_t ppq;
    } header;
    struct {
        std::string magic;
        uint32_t size;
    } data_chunk;
    std::vector<Event> events;

    uint32_t readVarint(std::ifstream& file) {
        uint32_t length = 0;
        uint32_t shift = 0;
        uint8_t byte;
        do {
            file.read(reinterpret_cast<char*>(&byte), 1);
            length += (byte & 0x7F) << shift;
            shift += 7;
        } while (byte & 0x80);
        return length;
    }

public:
    FLPParser(const std::string& fp) : filepath(fp) {
        std::ifstream file(filepath, std::ios::binary);
        if (!file) throw std::runtime_error("File not found");
        data.assign((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
        file.close();
        parse();
    }

    void parse() {
        size_t offset = 0;
        header.magic = std::string(data.begin() + offset, data.begin() + offset + 4);
        offset += 4;
        header.size = *reinterpret_cast<uint32_t*>(&data[offset]);
        offset += 4;
        header.format = *reinterpret_cast<int16_t*>(&data[offset]);
        offset += 2;
        header.num_channels = *reinterpret_cast<uint16_t*>(&data[offset]);
        offset += 2;
        header.ppq = *reinterpret_cast<uint16_t*>(&data[offset]);
        offset += 2;

        data_chunk.magic = std::string(data.begin() + offset, data.begin() + offset + 4);
        offset += 4;
        data_chunk.size = *reinterpret_cast<uint32_t*>(&data[offset]);
        offset += 4;

        size_t end = offset + data_chunk.size;
        while (offset < end) {
            uint8_t id = data[offset++];
            ValueType value;
            if (id <= 63) {
                value = static_cast<uint32_t>(data[offset++]);
            } else if (id <= 127) {
                value = *reinterpret_cast<uint16_t*>(&data[offset]);
                offset += 2;
            } else if (id <= 191) {
                value = *reinterpret_cast<uint32_t*>(&data[offset]);
                offset += 4;
            } else {
                // Temp file stream for varint
                std::ifstream temp; // Dummy, since we have data
                uint32_t length = 0; // Implement varint read on data
                uint32_t shift = 0;
                uint8_t byte;
                do {
                    byte = data[offset++];
                    length += (byte & 0x7F) << shift;
                    shift += 7;
                } while (byte & 0x80);
                std::vector<uint8_t> bytes(data.begin() + offset, data.begin() + offset + length);
                offset += length;
                try {
                    std::string str(bytes.begin(), bytes.end());
                    value = str.substr(0, str.find('\0'));
                } catch (...) {
                    value = bytes;
                }
            }
            events.push_back({id, value});
        }
    }

    void printProperties() {
        std::cout << "Header Properties:" << std::endl;
        std::cout << "Magic: " << header.magic << std::endl;
        std::cout << "Size: " << header.size << std::endl;
        std::cout << "Format: " << header.format << std::endl;
        std::cout << "Num Channels: " << header.num_channels << std::endl;
        std::cout << "PPQ: " << header.ppq << std::endl;

        std::cout << "\nData Chunk Properties:" << std::endl;
        std::cout << "Magic: " << data_chunk.magic << std::endl;
        std::cout << "Size: " << data_chunk.size << std::endl;

        std::cout << "\nEvents:" << std::endl;
        for (const auto& e : events) {
            std::cout << "ID: " << static_cast<int>(e.id) << ", Value: ";
            if (std::holds_alternative<uint32_t>(e.value)) {
                std::cout << std::get<uint32_t>(e.value);
            } else if (std::holds_alternative<std::string>(e.value)) {
                std::cout << std::get<std::string>(e.value);
            } else {
                std::cout << "[binary data]";
            }
            std::cout << std::endl;
        }
    }

    void write(const std::string& newFilepath, std::optional<uint16_t> newPpq = std::nullopt) {
        if (newPpq) header.ppq = *newPpq;
        std::ofstream file(newFilepath, std::ios::binary);
        file.write(header.magic.c_str(), 4);
        file.write(reinterpret_cast<const char*>(&header.size), 4);
        file.write(reinterpret_cast<const char*>(&header.format), 2);
        file.write(reinterpret_cast<const char*>(&header.num_channels), 2);
        file.write(reinterpret_cast<const char*>(&header.ppq), 2);
        file.write(data_chunk.magic.c_str(), 4);
        size_t dataPos = file.tellp() + sizeof(uint32_t);
        uint32_t newDataSize = 0;
        file.seekp(dataPos);

        for (const auto& e : events) {
            file.write(reinterpret_cast<const char*>(&e.id), 1);
            if (e.id <= 63) {
                uint8_t val = std::get<uint32_t>(e.value);
                file.write(reinterpret_cast<const char*>(&val), 1);
                newDataSize += 2;
            } else if (e.id <= 127) {
                uint16_t val = std::get<uint32_t>(e.value);
                file.write(reinterpret_cast<const char*>(&val), 2);
                newDataSize += 3;
            } else if (e.id <= 191) {
                uint32_t val = std::get<uint32_t>(e.value);
                file.write(reinterpret_cast<const char*>(&val), 4);
                newDataSize += 5;
            } else {
                std::vector<uint8_t> bytes;
                if (std::holds_alternative<std::string>(e.value)) {
                    std::string str = std::get<std::string>(e.value);
                    bytes.assign(str.begin(), str.end());
                    bytes.push_back(0);
                } else {
                    bytes = std::get<std::vector<uint8_t>>(e.value);
                }
                uint32_t length = bytes.size();
                std::vector<uint8_t> varint;
                while (length > 0) {
                    uint8_t byte = length & 0x7F;
                    length >>= 7;
                    if (length > 0) byte |= 0x80;
                    varint.push_back(byte);
                }
                file.write(reinterpret_cast<const char*>(varint.data()), varint.size());
                file.write(reinterpret_cast<const char*>(bytes.data()), bytes.size());
                newDataSize += 1 + varint.size() + bytes.size();
            }
        }
        file.seekp(dataPos - sizeof(uint32_t));
        file.write(reinterpret_cast<const char*>(&newDataSize), sizeof(uint32_t));
    }
};

// Example usage
// int main() {
//     FLPParser parser("example.flp");
//     parser.printProperties();
//     parser.write("modified.flp", 480);
//     return 0;
// }