146 lines
5.5 KiB
Python
146 lines
5.5 KiB
Python
from __future__ import annotations
|
|
import struct
|
|
from .events import (
|
|
EventID,
|
|
encode_byte_event,
|
|
encode_word_event,
|
|
encode_dword_event,
|
|
encode_text_event,
|
|
encode_data_event,
|
|
encode_varint,
|
|
encode_notes_block,
|
|
)
|
|
from .project import FLPProject, Pattern, Note
|
|
|
|
|
|
class FLPWriter:
|
|
def __init__(self, project: FLPProject):
|
|
self.project = project
|
|
self._events: list[bytes] = []
|
|
|
|
def build(self) -> bytes:
|
|
self._events = []
|
|
self._write_project_header()
|
|
self._write_patterns()
|
|
self._write_channels()
|
|
return self._serialize()
|
|
|
|
def _add_event(self, data: bytes):
|
|
self._events.append(data)
|
|
|
|
def _write_project_header(self):
|
|
p = self.project
|
|
self._add_event(encode_text_event(EventID.FLVersion, p.fl_version))
|
|
self._add_event(encode_dword_event(EventID.FLBuild, 1773))
|
|
self._add_event(encode_byte_event(EventID.Licensed, 1))
|
|
self._add_event(encode_dword_event(EventID.Tempo, int(p.tempo * 1000)))
|
|
self._add_event(encode_byte_event(EventID.LoopActive, 1))
|
|
self._add_event(encode_word_event(EventID.Pitch, 0))
|
|
self._add_event(encode_byte_event(EventID.PanLaw, 0))
|
|
if p.title:
|
|
self._add_event(encode_text_event(EventID.Title, p.title))
|
|
if p.genre:
|
|
self._add_event(encode_text_event(EventID.Genre, p.genre))
|
|
if p.artists:
|
|
self._add_event(encode_text_event(EventID.Artists, p.artists))
|
|
if p.comments:
|
|
self._add_event(encode_text_event(EventID.Comments, p.comments))
|
|
|
|
def _write_patterns(self):
|
|
p = self.project
|
|
for pat in p.patterns:
|
|
self._add_event(encode_word_event(EventID.PatNew, pat.index))
|
|
if pat.name:
|
|
self._add_event(encode_text_event(EventID.PatName, pat.name))
|
|
for ch_idx, notes in pat.notes.items():
|
|
if notes:
|
|
notes_data = encode_notes_block(
|
|
ch_idx,
|
|
[n.to_dict() if isinstance(n, Note) else n for n in notes],
|
|
ppq=p.ppq,
|
|
)
|
|
self._add_event(encode_data_event(EventID.PatNotes, notes_data))
|
|
|
|
def _write_channels(self):
|
|
p = self.project
|
|
for ch in p.channels:
|
|
self._add_event(encode_word_event(EventID.ChNew, ch.index))
|
|
self._add_event(encode_byte_event(EventID.ChType, ch.channel_type))
|
|
|
|
if ch.plugin:
|
|
self._add_event(
|
|
encode_text_event(EventID.PluginInternalName, ch.plugin.internal_name)
|
|
)
|
|
if ch.plugin.plugin_data:
|
|
self._add_event(
|
|
encode_data_event(EventID.PluginData, ch.plugin.plugin_data)
|
|
)
|
|
elif ch.plugin.internal_name == "Fruity Wrapper":
|
|
self._add_event(
|
|
encode_text_event(EventID.PluginName, ch.plugin.display_name)
|
|
)
|
|
wrapper_data = self._build_wrapper_stub(ch.plugin.display_name)
|
|
self._add_event(encode_data_event(EventID.PluginData, wrapper_data))
|
|
else:
|
|
self._add_event(
|
|
encode_text_event(EventID.PluginName, ch.plugin.display_name)
|
|
)
|
|
plugin_data = self._build_native_plugin_stub(ch.plugin.internal_name)
|
|
self._add_event(encode_data_event(EventID.PluginData, plugin_data))
|
|
|
|
if ch.plugin.color:
|
|
self._add_event(
|
|
encode_dword_event(EventID.PluginColor, ch.plugin.color)
|
|
)
|
|
|
|
self._add_event(encode_text_event(EventID.ChName, ch.name))
|
|
self._add_event(encode_byte_event(EventID.ChIsEnabled, 1 if ch.enabled else 0))
|
|
self._add_event(encode_byte_event(EventID.ChRoutedTo, ch.mixer_track & 0xFF))
|
|
self._add_event(encode_word_event(EventID.ChVolWord, ch.volume))
|
|
self._add_event(encode_byte_event(EventID.ChRootNote, ch.root_note))
|
|
|
|
def _build_wrapper_stub(self, plugin_name: str) -> bytes:
|
|
# Minimal VST wrapper state - FL Studio will initialize the plugin fresh
|
|
# 10 params with default values
|
|
stub = struct.pack("<II", 10, 1) # param_count=10, unknown=1
|
|
stub += struct.pack("<II", 20, 0) # version=20, flags=0
|
|
stub += b"\xff\xff\xff\xff\xff\xff\xff\xff" # GUID placeholder
|
|
stub += b"\x0c\x00\x0c\x00\x0c\x00\x0c\x00" # padding
|
|
stub += b"\x00" * 16 # zeros
|
|
return stub
|
|
|
|
def _build_native_plugin_stub(self, internal_name: str) -> bytes:
|
|
# Minimal native plugin state
|
|
stub = struct.pack("<II", 10, 1)
|
|
stub += struct.pack("<II", 20, 0)
|
|
stub += b"\xff\xff\xff\xff\xff\xff\xff\xff"
|
|
stub += b"\x0c\x00\x0c\x00\x0c\x00\x0c\x00"
|
|
stub += b"\x00" * 16
|
|
return stub
|
|
|
|
def _serialize(self) -> bytes:
|
|
num_channels = len(self.project.channels)
|
|
ppq = self.project.ppq
|
|
|
|
header = struct.pack(
|
|
"<4sIhHH",
|
|
b"FLhd",
|
|
6,
|
|
0,
|
|
num_channels,
|
|
ppq,
|
|
)
|
|
|
|
all_events = b"".join(self._events)
|
|
total_size = len(all_events)
|
|
|
|
data_header = b"FLdt" + struct.pack("<I", total_size)
|
|
|
|
return header + data_header + all_events
|
|
|
|
def write(self, filepath: str):
|
|
data = self.build()
|
|
with open(filepath, "wb") as f:
|
|
f.write(data)
|
|
return filepath
|