ts-flp 1.0.4 → 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) {
@@ -609,8 +617,8 @@ function parseFlp(buffer) {
609
617
  throw new Error(`Invalid header size: expected ${FLP_HEADER_SIZE}, got ${headerSize}`);
610
618
  }
611
619
  const format = reader.readI16LE();
612
- const channelCount = reader.readU16LE();
613
- const ppq = reader.readU16LE();
620
+ reader.readU16LE();
621
+ reader.readU16LE();
614
622
  if (format < -1 || format > 80) {
615
623
  throw new Error(`Invalid file format: ${format}`);
616
624
  }
@@ -630,7 +638,6 @@ function parseFlp(buffer) {
630
638
  const events = [];
631
639
  let flVersion = "0.0.0";
632
640
  let useUnicode = false;
633
- let detectedVersion = false;
634
641
  const eventsStart = reader.tell();
635
642
  const eventsEnd = eventsStart + eventsSize;
636
643
  while (reader.tell() < eventsEnd) {
@@ -638,14 +645,9 @@ function parseFlp(buffer) {
638
645
  const eventId = reader.readU8();
639
646
  let payloadSize;
640
647
  let headerSize2;
641
- if (eventId < WORD) {
642
- payloadSize = 1;
643
- headerSize2 = 1;
644
- } else if (eventId < DWORD) {
645
- payloadSize = 2;
646
- headerSize2 = 1;
647
- } else if (eventId < TEXT) {
648
- payloadSize = 4;
648
+ const fixedSize = getEventFixedSize(eventId);
649
+ if (fixedSize >= 0) {
650
+ payloadSize = fixedSize;
649
651
  headerSize2 = 1;
650
652
  } else {
651
653
  const sizeStart = reader.tell();
@@ -662,16 +664,14 @@ function parseFlp(buffer) {
662
664
  payload: Buffer.from(payload)
663
665
  };
664
666
  events.push(event);
665
- if (eventId === EVENT_ID.PROJECT_FL_VERSION && !detectedVersion) {
667
+ if (eventId === EVENT_ID.PROJECT_FL_VERSION && flVersion === "0.0.0") {
666
668
  const candidate = payload.toString("ascii").replace(/\0/g, "").trim();
667
- const isSemverLike = /^\d+(\.\d+)+$/.test(candidate);
668
- if (isSemverLike) {
669
+ if (/^\d+(\.\d+)+$/.test(candidate)) {
669
670
  flVersion = candidate;
670
671
  const parts = flVersion.split(".").map((p) => parseInt(p, 10));
671
672
  const major = parts[0] ?? 0;
672
673
  const minor = parts[1] ?? 0;
673
674
  useUnicode = parts.length >= 2 && (major > 11 || major === 11 && minor >= 5);
674
- detectedVersion = true;
675
675
  }
676
676
  }
677
677
  }
@@ -738,7 +738,7 @@ function createEvent(id, payload) {
738
738
  function patchEvents(parsed, patcher) {
739
739
  const patchedEvents = parsed.events.map((event, index) => {
740
740
  const patched = patcher(event, index);
741
- if (patched.payload !== event.payload && patched.id >= TEXT) {
741
+ if (patched.payload !== event.payload && event.header.length > 1) {
742
742
  const headerWriter = new BinaryWriter();
743
743
  headerWriter.writeU8(patched.id);
744
744
  headerWriter.writeVarInt(patched.payload.length);
@@ -831,62 +831,166 @@ function createNumberPayload(value, kind) {
831
831
  }
832
832
 
833
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
+ }
874
+ function readBpm(parsed) {
875
+ const tempoEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_TEMPO);
876
+ if (tempoEvent) {
877
+ return getEventNumber(tempoEvent) / 1e3;
878
+ }
879
+ const coarseEvent = findFirstEvent(parsed, EVENT_ID.PROJECT__TEMPO_COARSE);
880
+ if (coarseEvent) {
881
+ let bpm = coarseEvent.payload.readUInt16LE(0);
882
+ const fineEvent = findFirstEvent(parsed, EVENT_ID.PROJECT__TEMPO_FINE);
883
+ if (fineEvent) {
884
+ bpm += fineEvent.payload.readUInt16LE(0) / 1e3;
885
+ }
886
+ return bpm;
887
+ }
888
+ return null;
889
+ }
834
890
  function readProjectMeta(parsed) {
835
891
  const titleEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_TITLE);
836
892
  const commentsEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_COMMENTS);
837
893
  const artistsEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_ARTISTS);
838
894
  const genreEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_GENRE);
839
- const tempoEvent = findFirstEvent(parsed, EVENT_ID.PROJECT_TEMPO);
840
895
  return {
841
896
  name: titleEvent ? getEventString(titleEvent, parsed.useUnicode) : null,
842
897
  description: commentsEvent ? getEventString(commentsEvent, parsed.useUnicode) : null,
843
898
  artist: artistsEvent ? getEventString(artistsEvent, parsed.useUnicode) : null,
844
899
  genre: genreEvent ? getEventString(genreEvent, parsed.useUnicode) : null,
845
- bpm: tempoEvent ? getEventNumber(tempoEvent) / 1e3 : null
900
+ bpm: readBpm(parsed)
846
901
  };
847
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
+ }
848
978
  function writeProjectMeta(parsed, meta) {
849
- return patchEvents(parsed, (event) => {
850
- if (meta.name !== void 0 && event.id === EVENT_ID.PROJECT_TITLE) {
851
- return {
852
- ...event,
853
- payload: createTextPayload(meta.name ?? "", parsed.useUnicode)
854
- };
855
- }
856
- if (meta.description !== void 0 && event.id === EVENT_ID.PROJECT_COMMENTS) {
857
- return {
858
- ...event,
859
- payload: createTextPayload(meta.description ?? "", parsed.useUnicode)
860
- };
861
- }
862
- if (meta.artist !== void 0 && event.id === EVENT_ID.PROJECT_ARTISTS) {
863
- return {
864
- ...event,
865
- payload: createTextPayload(meta.artist ?? "", parsed.useUnicode)
866
- };
867
- }
868
- if (meta.genre !== void 0 && event.id === EVENT_ID.PROJECT_GENRE) {
869
- return {
870
- ...event,
871
- payload: createTextPayload(meta.genre ?? "", parsed.useUnicode)
872
- };
873
- }
874
- if (meta.bpm && Math.abs(meta.bpm) > 0 && event.id === EVENT_ID.PROJECT_TEMPO) {
875
- return {
876
- ...event,
877
- payload: createNumberPayload(Math.round(Math.abs(meta.bpm) * 1e3), "u32")
878
- };
879
- }
880
- return event;
881
- });
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;
882
986
  }
883
- var DELPHI_EPOCH = new Date(1899, 11, 30);
987
+ var DELPHI_EPOCH_MS = Date.UTC(1899, 11, 30);
884
988
  function delphiToDate(days) {
885
989
  const ms = days * 24 * 60 * 60 * 1e3;
886
- return new Date(DELPHI_EPOCH.getTime() + ms);
990
+ return new Date(DELPHI_EPOCH_MS + ms);
887
991
  }
888
992
  function dateToDelphiDays(date) {
889
- const ms = date.getTime() - DELPHI_EPOCH.getTime();
993
+ const ms = date.getTime() - DELPHI_EPOCH_MS;
890
994
  return ms / (24 * 60 * 60 * 1e3);
891
995
  }
892
996
  function readProjectTimeInfo(parsed) {
@@ -907,31 +1011,23 @@ function readProjectTimeInfo(parsed) {
907
1011
  };
908
1012
  }
909
1013
  function writeProjectTimeInfo(parsed, info) {
1014
+ if (info.creationDate === void 0 && info.workTimeSeconds === void 0) {
1015
+ return parsed;
1016
+ }
910
1017
  const current = readProjectTimeInfo(parsed);
911
- return patchEvents(parsed, (event) => {
912
- if (event.id !== EVENT_ID.PROJECT_TIMESTAMP) {
913
- return event;
914
- }
915
- const writer = new BinaryWriter();
916
- if (info.creationDate !== void 0 && info.creationDate !== null) {
917
- writer.writeF64LE(dateToDelphiDays(info.creationDate));
918
- } else if (current.creationDate !== null) {
919
- writer.writeF64LE(dateToDelphiDays(current.creationDate));
920
- } else {
921
- writer.writeF64LE(0);
922
- }
923
- if (info.workTimeSeconds !== void 0 && info.workTimeSeconds !== null) {
924
- writer.writeF64LE(info.workTimeSeconds / (24 * 60 * 60));
925
- } else if (current.workTimeSeconds !== null) {
926
- writer.writeF64LE(current.workTimeSeconds / (24 * 60 * 60));
927
- } else {
928
- writer.writeF64LE(0);
929
- }
930
- return {
931
- ...event,
932
- payload: writer.toBuffer()
933
- };
934
- });
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()));
935
1031
  }
936
1032
  function listSamples(parsed) {
937
1033
  const samples = [];