ts-flp 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -160,6 +160,14 @@ interface PluginRef {
160
160
  | `getFlVersion(parsed)` | Get the FL Studio version string |
161
161
  | `getPPQ(parsed)` | Get the PPQ (Pulses Per Quarter note) |
162
162
 
163
+ ### Write behavior
164
+
165
+ - `undefined` leaves the corresponding value unchanged.
166
+ - `null` clears text metadata, leaves BPM unchanged, and resets time values to zero.
167
+ - Missing metadata and timestamp events are created in the project preamble.
168
+ - BPM values are validated against the limits supported by the detected FL Studio version.
169
+ - Invalid dates, negative work times, and non-finite numeric values throw an error.
170
+
163
171
  ## Constraints and limitations
164
172
 
165
173
  ### What this library does
@@ -197,6 +205,9 @@ src/
197
205
  # Install dependencies
198
206
  pnpm install
199
207
 
208
+ # Add at least one private .flp file directly to test_projs/
209
+ # These fixtures are ignored by Git and are required by the integration tests.
210
+
200
211
  # Type checking
201
212
  pnpm run typecheck
202
213
 
@@ -215,7 +226,7 @@ pnpm run build
215
226
 
216
227
  ## License
217
228
 
218
- [GPL-3.0](LICENSE)
229
+ [MIT](LICENSE)
219
230
 
220
231
  ---
221
232
 
package/dist/index.cjs CHANGED
@@ -65,6 +65,9 @@ var WORD = 64;
65
65
  var DWORD = 128;
66
66
  var TEXT = 192;
67
67
  var DATA = 208;
68
+ var FIXED_SIZE_OVERRIDES = {
69
+ 172: 3
70
+ };
68
71
  var NEW_TEXT_IDS = [
69
72
  TEXT + 49,
70
73
  // ArrangementID.Name
@@ -211,6 +214,7 @@ var EVENT_ID = {
211
214
  // 236
212
215
  };
213
216
  var EVENT_KIND = {
217
+ [172]: "unknown",
214
218
  // Project events
215
219
  [EVENT_ID.PROJECT_LOOP_ACTIVE]: "u8",
216
220
  [EVENT_ID.PROJECT_SHOW_INFO]: "u8",
@@ -319,6 +323,10 @@ function getEventKind(eventId) {
319
323
  }
320
324
  }
321
325
  function getEventFixedSize(eventId) {
326
+ const override = FIXED_SIZE_OVERRIDES[eventId];
327
+ if (override !== void 0) {
328
+ return override;
329
+ }
322
330
  if (eventId < WORD) {
323
331
  return 1;
324
332
  } else if (eventId < DWORD) {
@@ -595,62 +603,9 @@ function varIntSize(value) {
595
603
  }
596
604
 
597
605
  // src/parser/FlpParser.ts
598
- var KNOWN_DWORD_IDS = new Set(
599
- Object.keys(EVENT_KIND).map(Number).filter((id) => id >= DWORD && id < TEXT)
600
- );
601
606
  var FLP_HEADER_MAGIC = "FLhd";
602
607
  var FLP_DATA_MAGIC = "FLdt";
603
608
  var FLP_HEADER_SIZE = 6;
604
- var LOOKAHEAD_BYTES = 200;
605
- var LOOKAHEAD_VARINT_MAX = 1e5;
606
- function readVarIntRaw(buf, off, end) {
607
- let shift = 0;
608
- let size = 0;
609
- const start = off;
610
- while (true) {
611
- if (off >= end) return { size: -1, bytesRead: 0 };
612
- const byte = buf[off];
613
- off++;
614
- size |= (byte & 127) << shift;
615
- shift += 7;
616
- if (!(byte & 128)) break;
617
- }
618
- return { size, bytesRead: off - start };
619
- }
620
- function scoreAlignment(buf, off, end, bytesToCheck) {
621
- const limit = Math.min(off + bytesToCheck, end);
622
- let textDataCount = 0;
623
- let consecutiveSmallByte = 0;
624
- let maxConsecutiveSmallByte = 0;
625
- while (off < limit) {
626
- const id = buf[off];
627
- if (id < WORD) {
628
- off += 2;
629
- if (id < 32 && !KNOWN_DWORD_IDS.has(id)) {
630
- consecutiveSmallByte++;
631
- maxConsecutiveSmallByte = Math.max(maxConsecutiveSmallByte, consecutiveSmallByte);
632
- } else {
633
- consecutiveSmallByte = 0;
634
- }
635
- } else if (id < DWORD) {
636
- off += 3;
637
- consecutiveSmallByte = 0;
638
- } else if (id < TEXT) {
639
- off += 5;
640
- consecutiveSmallByte = 0;
641
- } else {
642
- const vi = readVarIntRaw(buf, off + 1, end);
643
- if (vi.size >= 0 && vi.size < LOOKAHEAD_VARINT_MAX && off + 1 + vi.bytesRead + vi.size <= end) {
644
- textDataCount++;
645
- off = off + 1 + vi.bytesRead + vi.size;
646
- } else {
647
- return -100;
648
- }
649
- consecutiveSmallByte = 0;
650
- }
651
- }
652
- return textDataCount * 10 - maxConsecutiveSmallByte * 3;
653
- }
654
609
  function parseFlp(buffer) {
655
610
  const reader = new BinaryReader(buffer);
656
611
  const headerMagic = reader.readBytes(4).toString("ascii");
@@ -662,8 +617,8 @@ function parseFlp(buffer) {
662
617
  throw new Error(`Invalid header size: expected ${FLP_HEADER_SIZE}, got ${headerSize}`);
663
618
  }
664
619
  const format = reader.readI16LE();
665
- const channelCount = reader.readU16LE();
666
- const ppq = reader.readU16LE();
620
+ reader.readU16LE();
621
+ reader.readU16LE();
667
622
  if (format < -1 || format > 80) {
668
623
  throw new Error(`Invalid file format: ${format}`);
669
624
  }
@@ -690,39 +645,10 @@ function parseFlp(buffer) {
690
645
  const eventId = reader.readU8();
691
646
  let payloadSize;
692
647
  let headerSize2;
693
- if (eventId < WORD) {
694
- payloadSize = 1;
648
+ const fixedSize = getEventFixedSize(eventId);
649
+ if (fixedSize >= 0) {
650
+ payloadSize = fixedSize;
695
651
  headerSize2 = 1;
696
- } else if (eventId < DWORD) {
697
- payloadSize = 2;
698
- headerSize2 = 1;
699
- } else if (eventId < TEXT) {
700
- if (KNOWN_DWORD_IDS.has(eventId)) {
701
- payloadSize = 4;
702
- headerSize2 = 1;
703
- } else {
704
- const afterDword = eventStart + 1 + 4;
705
- const vi = readVarIntRaw(buffer, eventStart + 1, eventsEnd);
706
- if (vi.size < 0 || vi.size > LOOKAHEAD_VARINT_MAX || eventStart + 1 + vi.bytesRead + vi.size > eventsEnd) {
707
- payloadSize = 4;
708
- headerSize2 = 1;
709
- } else if (vi.size === 3) {
710
- payloadSize = 4;
711
- headerSize2 = 1;
712
- } else {
713
- const afterVarInt = eventStart + 1 + vi.bytesRead + vi.size;
714
- const dwordScore = scoreAlignment(buffer, afterDword, eventsEnd, LOOKAHEAD_BYTES);
715
- const varIntScore = scoreAlignment(buffer, afterVarInt, eventsEnd, LOOKAHEAD_BYTES);
716
- if (varIntScore > dwordScore + 2) {
717
- payloadSize = vi.size;
718
- reader.seek(eventStart + 1 + vi.bytesRead);
719
- headerSize2 = 1 + vi.bytesRead;
720
- } else {
721
- payloadSize = 4;
722
- headerSize2 = 1;
723
- }
724
- }
725
- }
726
652
  } else {
727
653
  const sizeStart = reader.tell();
728
654
  payloadSize = reader.readVarInt();
@@ -905,6 +831,46 @@ function createNumberPayload(value, kind) {
905
831
  }
906
832
 
907
833
  // src/api/ProjectApi.ts
834
+ var PROJECT_EVENT_ORDER = [
835
+ EVENT_ID.PROJECT_TEMPO,
836
+ EVENT_ID.PROJECT_TITLE,
837
+ EVENT_ID.PROJECT_GENRE,
838
+ EVENT_ID.PROJECT_ARTISTS,
839
+ EVENT_ID.PROJECT_COMMENTS,
840
+ EVENT_ID.PROJECT_TIMESTAMP
841
+ ];
842
+ function insertProjectEvent(parsed, event) {
843
+ const orderIndex = PROJECT_EVENT_ORDER.indexOf(event.id);
844
+ let insertionIndex = -1;
845
+ if (orderIndex >= 0) {
846
+ const laterIds = new Set(PROJECT_EVENT_ORDER.slice(orderIndex + 1));
847
+ insertionIndex = parsed.events.findIndex((candidate) => laterIds.has(candidate.id));
848
+ if (insertionIndex < 0) {
849
+ const previousIds = new Set(PROJECT_EVENT_ORDER.slice(0, orderIndex + 1));
850
+ for (let index = parsed.events.length - 1; index >= 0; index--) {
851
+ if (previousIds.has(parsed.events[index].id)) {
852
+ insertionIndex = index + 1;
853
+ break;
854
+ }
855
+ }
856
+ }
857
+ }
858
+ if (insertionIndex < 0) {
859
+ insertionIndex = parsed.events.findIndex((candidate) => candidate.id === EVENT_ID.CHANNEL_NEW);
860
+ }
861
+ if (insertionIndex < 0) {
862
+ const versionIndex = parsed.events.findIndex(
863
+ (candidate) => candidate.id === EVENT_ID.PROJECT_FL_VERSION
864
+ );
865
+ insertionIndex = versionIndex >= 0 ? versionIndex + 1 : parsed.events.length;
866
+ }
867
+ const events = [...parsed.events];
868
+ events.splice(insertionIndex, 0, event);
869
+ return { ...parsed, events };
870
+ }
871
+ function replaceEventPayload(parsed, target, payload) {
872
+ return patchEvents(parsed, (event) => event === target ? { ...event, payload } : event);
873
+ }
908
874
  function readBpm(parsed) {
909
875
  const tempoEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_TEMPO);
910
876
  if (tempoEvent) {
@@ -934,48 +900,97 @@ function readProjectMeta(parsed) {
934
900
  bpm: readBpm(parsed)
935
901
  };
936
902
  }
903
+ function compareVersion(version, expected) {
904
+ const actual = version.split(".").map((part) => Number.parseInt(part, 10));
905
+ const length = Math.max(actual.length, expected.length);
906
+ for (let index = 0; index < length; index++) {
907
+ const difference = (actual[index] ?? 0) - (expected[index] ?? 0);
908
+ if (difference !== 0) return Math.sign(difference);
909
+ }
910
+ return 0;
911
+ }
912
+ function validateBpm(parsed, bpm) {
913
+ if (!Number.isFinite(bpm)) {
914
+ throw new RangeError("BPM must be a finite number");
915
+ }
916
+ const legacyHighTempo = compareVersion(parsed.flVersion, [1, 4, 2]) >= 0 && compareVersion(parsed.flVersion, [11]) < 0;
917
+ const maximum = legacyHighTempo ? 999 : 522;
918
+ if (bpm < 10 || bpm > maximum) {
919
+ throw new RangeError(`BPM must be between 10 and ${maximum} for FL Studio ${parsed.flVersion}`);
920
+ }
921
+ if (parsed.flVersion !== "0.0.0" && compareVersion(parsed.flVersion, [3, 4]) < 0 && !Number.isInteger(bpm)) {
922
+ throw new TypeError(`FL Studio ${parsed.flVersion} does not support decimal BPM values`);
923
+ }
924
+ }
925
+ function writeTextProjectEvent(parsed, eventId, value) {
926
+ if (value === void 0) return parsed;
927
+ const payload = createTextPayload(value ?? "", parsed.useUnicode);
928
+ const existing = findFirstEvent(parsed, eventId);
929
+ return existing ? replaceEventPayload(parsed, existing, payload) : insertProjectEvent(parsed, createEvent(eventId, payload));
930
+ }
931
+ function writeProjectBpm(parsed, bpm) {
932
+ if (bpm === void 0 || bpm === null) return parsed;
933
+ validateBpm(parsed, bpm);
934
+ const scaledBpm = Math.round(bpm * 1e3);
935
+ const coarseBpm = Math.floor(scaledBpm / 1e3);
936
+ const fineBpm = scaledBpm % 1e3;
937
+ const modernEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_TEMPO);
938
+ const coarseEvent = findFirstEvent(parsed, EVENT_ID.PROJECT__TEMPO_COARSE);
939
+ const fineEvent = findFirstEvent(parsed, EVENT_ID.PROJECT__TEMPO_FINE);
940
+ let result = parsed;
941
+ if (modernEvent) {
942
+ result = replaceEventPayload(result, modernEvent, createNumberPayload(scaledBpm, "u32"));
943
+ }
944
+ if (coarseEvent) {
945
+ result = replaceEventPayload(result, coarseEvent, createNumberPayload(coarseBpm, "u16"));
946
+ }
947
+ if (fineEvent) {
948
+ result = replaceEventPayload(result, fineEvent, createNumberPayload(fineBpm, "u16"));
949
+ }
950
+ if (!modernEvent && !coarseEvent && !fineEvent) {
951
+ result = insertProjectEvent(
952
+ result,
953
+ createEvent(EVENT_ID.PROJECT_TEMPO, createNumberPayload(scaledBpm, "u32"))
954
+ );
955
+ } else if (!modernEvent && coarseEvent && !fineEvent && fineBpm > 0) {
956
+ const updatedCoarse = findFirstEvent(result, EVENT_ID.PROJECT__TEMPO_COARSE);
957
+ const index = result.events.indexOf(updatedCoarse) + 1;
958
+ const events = [...result.events];
959
+ events.splice(
960
+ index,
961
+ 0,
962
+ createEvent(EVENT_ID.PROJECT__TEMPO_FINE, createNumberPayload(fineBpm, "u16"))
963
+ );
964
+ result = { ...result, events };
965
+ } else if (!modernEvent && !coarseEvent && fineEvent) {
966
+ const updatedFine = findFirstEvent(result, EVENT_ID.PROJECT__TEMPO_FINE);
967
+ const index = result.events.indexOf(updatedFine);
968
+ const events = [...result.events];
969
+ events.splice(
970
+ index,
971
+ 0,
972
+ createEvent(EVENT_ID.PROJECT__TEMPO_COARSE, createNumberPayload(coarseBpm, "u16"))
973
+ );
974
+ result = { ...result, events };
975
+ }
976
+ return result;
977
+ }
937
978
  function writeProjectMeta(parsed, meta) {
938
- return patchEvents(parsed, (event) => {
939
- if (meta.name !== void 0 && event.id === EVENT_ID.PROJECT_TITLE) {
940
- return {
941
- ...event,
942
- payload: createTextPayload(meta.name ?? "", parsed.useUnicode)
943
- };
944
- }
945
- if (meta.description !== void 0 && event.id === EVENT_ID.PROJECT_COMMENTS) {
946
- return {
947
- ...event,
948
- payload: createTextPayload(meta.description ?? "", parsed.useUnicode)
949
- };
950
- }
951
- if (meta.artist !== void 0 && event.id === EVENT_ID.PROJECT_ARTISTS) {
952
- return {
953
- ...event,
954
- payload: createTextPayload(meta.artist ?? "", parsed.useUnicode)
955
- };
956
- }
957
- if (meta.genre !== void 0 && event.id === EVENT_ID.PROJECT_GENRE) {
958
- return {
959
- ...event,
960
- payload: createTextPayload(meta.genre ?? "", parsed.useUnicode)
961
- };
962
- }
963
- if (meta.bpm && Math.abs(meta.bpm) > 0 && event.id === EVENT_ID.PROJECT_TEMPO) {
964
- return {
965
- ...event,
966
- payload: createNumberPayload(Math.round(Math.abs(meta.bpm) * 1e3), "u32")
967
- };
968
- }
969
- return event;
970
- });
979
+ let result = parsed;
980
+ result = writeProjectBpm(result, meta.bpm);
981
+ result = writeTextProjectEvent(result, EVENT_ID.PROJECT_TITLE, meta.name);
982
+ result = writeTextProjectEvent(result, EVENT_ID.PROJECT_GENRE, meta.genre);
983
+ result = writeTextProjectEvent(result, EVENT_ID.PROJECT_ARTISTS, meta.artist);
984
+ result = writeTextProjectEvent(result, EVENT_ID.PROJECT_COMMENTS, meta.description);
985
+ return result;
971
986
  }
972
- var DELPHI_EPOCH = new Date(1899, 11, 30);
987
+ var DELPHI_EPOCH_MS = Date.UTC(1899, 11, 30);
973
988
  function delphiToDate(days) {
974
989
  const ms = days * 24 * 60 * 60 * 1e3;
975
- return new Date(DELPHI_EPOCH.getTime() + ms);
990
+ return new Date(DELPHI_EPOCH_MS + ms);
976
991
  }
977
992
  function dateToDelphiDays(date) {
978
- const ms = date.getTime() - DELPHI_EPOCH.getTime();
993
+ const ms = date.getTime() - DELPHI_EPOCH_MS;
979
994
  return ms / (24 * 60 * 60 * 1e3);
980
995
  }
981
996
  function readProjectTimeInfo(parsed) {
@@ -996,31 +1011,23 @@ function readProjectTimeInfo(parsed) {
996
1011
  };
997
1012
  }
998
1013
  function writeProjectTimeInfo(parsed, info) {
1014
+ if (info.creationDate === void 0 && info.workTimeSeconds === void 0) {
1015
+ return parsed;
1016
+ }
999
1017
  const current = readProjectTimeInfo(parsed);
1000
- return patchEvents(parsed, (event) => {
1001
- if (event.id !== EVENT_ID.PROJECT_TIMESTAMP) {
1002
- return event;
1003
- }
1004
- const writer = new BinaryWriter();
1005
- if (info.creationDate !== void 0 && info.creationDate !== null) {
1006
- writer.writeF64LE(dateToDelphiDays(info.creationDate));
1007
- } else if (current.creationDate !== null) {
1008
- writer.writeF64LE(dateToDelphiDays(current.creationDate));
1009
- } else {
1010
- writer.writeF64LE(0);
1011
- }
1012
- if (info.workTimeSeconds !== void 0 && info.workTimeSeconds !== null) {
1013
- writer.writeF64LE(info.workTimeSeconds / (24 * 60 * 60));
1014
- } else if (current.workTimeSeconds !== null) {
1015
- writer.writeF64LE(current.workTimeSeconds / (24 * 60 * 60));
1016
- } else {
1017
- writer.writeF64LE(0);
1018
- }
1019
- return {
1020
- ...event,
1021
- payload: writer.toBuffer()
1022
- };
1023
- });
1018
+ if (info.creationDate !== void 0 && info.creationDate !== null && Number.isNaN(info.creationDate.getTime())) {
1019
+ throw new RangeError("creationDate must be a valid Date");
1020
+ }
1021
+ if (info.workTimeSeconds !== void 0 && info.workTimeSeconds !== null && (!Number.isFinite(info.workTimeSeconds) || info.workTimeSeconds < 0)) {
1022
+ throw new RangeError("workTimeSeconds must be a finite, non-negative number");
1023
+ }
1024
+ const creationDateDays = info.creationDate === null ? 0 : info.creationDate !== void 0 ? dateToDelphiDays(info.creationDate) : current.creationDate !== null ? dateToDelphiDays(current.creationDate) : 0;
1025
+ const workTimeDays = info.workTimeSeconds === null ? 0 : info.workTimeSeconds !== void 0 ? info.workTimeSeconds / (24 * 60 * 60) : current.workTimeSeconds !== null ? current.workTimeSeconds / (24 * 60 * 60) : 0;
1026
+ const writer = new BinaryWriter();
1027
+ writer.writeF64LE(creationDateDays);
1028
+ writer.writeF64LE(workTimeDays);
1029
+ const timestampEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_TIMESTAMP);
1030
+ return timestampEvent ? replaceEventPayload(parsed, timestampEvent, writer.toBuffer()) : insertProjectEvent(parsed, createEvent(EVENT_ID.PROJECT_TIMESTAMP, writer.toBuffer()));
1024
1031
  }
1025
1032
  function listSamples(parsed) {
1026
1033
  const samples = [];