flpkit 0.8.1__tar.gz

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.
Files changed (50) hide show
  1. flpkit-0.8.1/.github/workflows/publish.yml +37 -0
  2. flpkit-0.8.1/.gitignore +6 -0
  3. flpkit-0.8.1/CHANGELOG.md +10 -0
  4. flpkit-0.8.1/LICENSE +21 -0
  5. flpkit-0.8.1/PKG-INFO +124 -0
  6. flpkit-0.8.1/README.md +98 -0
  7. flpkit-0.8.1/RELEASING.md +32 -0
  8. flpkit-0.8.1/TASTE.md +85 -0
  9. flpkit-0.8.1/pyproject.toml +50 -0
  10. flpkit-0.8.1/scripts/check-release.sh +44 -0
  11. flpkit-0.8.1/src/flpkit/__init__.py +512 -0
  12. flpkit-0.8.1/src/flpkit/codec.py +317 -0
  13. flpkit-0.8.1/src/flpkit/data/plugins/fruity-parametric-eq-2.json +8 -0
  14. flpkit-0.8.1/src/flpkit/data/plugins/index.json +3 -0
  15. flpkit-0.8.1/src/flpkit/detect.py +83 -0
  16. flpkit-0.8.1/src/flpkit/formats/__init__.py +17 -0
  17. flpkit-0.8.1/src/flpkit/formats/automation.py +189 -0
  18. flpkit-0.8.1/src/flpkit/formats/effects.py +170 -0
  19. flpkit-0.8.1/src/flpkit/formats/levels.py +97 -0
  20. flpkit-0.8.1/src/flpkit/formats/notes.py +219 -0
  21. flpkit-0.8.1/src/flpkit/formats/playlist.py +173 -0
  22. flpkit-0.8.1/src/flpkit/formats/tempo.py +50 -0
  23. flpkit-0.8.1/src/flpkit/py.typed +0 -0
  24. flpkit-0.8.1/tests/conftest.py +16 -0
  25. flpkit-0.8.1/tests/golden/automation-custom-tails.flp +0 -0
  26. flpkit-0.8.1/tests/golden/automation-empty-replace.flp +0 -0
  27. flpkit-0.8.1/tests/golden/automation-replace-points.flp +0 -0
  28. flpkit-0.8.1/tests/golden/levels-all-three-fields.flp +0 -0
  29. flpkit-0.8.1/tests/golden/levels-implicit-channel-zero.flp +0 -0
  30. flpkit-0.8.1/tests/golden/levels-partial-pan-only.flp +0 -0
  31. flpkit-0.8.1/tests/golden/notes-big-blob-multibyte-varint.flp +0 -0
  32. flpkit-0.8.1/tests/golden/notes-create-after-anchor.flp +0 -0
  33. flpkit-0.8.1/tests/golden/notes-create-pattern.flp +0 -0
  34. flpkit-0.8.1/tests/golden/notes-empty-replace-clears.flp +0 -0
  35. flpkit-0.8.1/tests/golden/notes-insert-after-pattern-event.flp +0 -0
  36. flpkit-0.8.1/tests/golden/notes-merge-keeps-existing.flp +0 -0
  37. flpkit-0.8.1/tests/golden/notes-ppq-192-quantization.flp +0 -0
  38. flpkit-0.8.1/tests/golden/notes-replace-scoped-to-channel.flp +0 -0
  39. flpkit-0.8.1/tests/golden/playlist-merge-sorts-before-existing.flp +0 -0
  40. flpkit-0.8.1/tests/golden/playlist-replace.flp +0 -0
  41. flpkit-0.8.1/tests/golden/playlist-second-arrangement.flp +0 -0
  42. flpkit-0.8.1/tests/golden/playlist-template-tail-inherited.flp +0 -0
  43. flpkit-0.8.1/tests/golden/sequenced-writes-compose.flp +0 -0
  44. flpkit-0.8.1/tests/golden/tempo-append-when-absent.flp +0 -0
  45. flpkit-0.8.1/tests/golden/tempo-patch-in-place.flp +0 -0
  46. flpkit-0.8.1/tests/test_corpus.py +100 -0
  47. flpkit-0.8.1/tests/test_differential.py +248 -0
  48. flpkit-0.8.1/tests/test_reader.py +521 -0
  49. flpkit-0.8.1/tests/test_writers.py +866 -0
  50. flpkit-0.8.1/uv.lock +108 -0
@@ -0,0 +1,37 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v7
17
+ - run: uv build
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: python-dist
21
+ path: dist/
22
+ if-no-files-found: error
23
+
24
+ publish:
25
+ needs: build
26
+ runs-on: ubuntu-latest
27
+ environment:
28
+ name: pypi
29
+ url: https://pypi.org/p/flpkit
30
+ permissions:
31
+ id-token: write
32
+ steps:
33
+ - uses: actions/download-artifact@v4
34
+ with:
35
+ name: python-dist
36
+ path: dist/
37
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.8.0
4
+
5
+ - Effects are captured plugin-reference data and use the common Format patch path.
6
+ - Validation now lives in Formats and raises `FlpError` for invalid mode, note, level, and tempo inputs.
7
+
8
+ ## 0.7.2
9
+
10
+ - Added the captured Parametric EQ 2 mixer-effect reference.
flpkit-0.8.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Origami Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
flpkit-0.8.1/PKG-INFO ADDED
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.5
2
+ Name: flpkit
3
+ Version: 0.8.1
4
+ Summary: Read and write FL Studio .flp project files - a small, dependency-free library for the undocumented FLP format, with self-verifying raw-surgery writers
5
+ Project-URL: Homepage, https://github.com/clarkipeng/flpkit
6
+ Project-URL: Repository, https://github.com/clarkipeng/flpkit
7
+ Project-URL: Issues, https://github.com/clarkipeng/flpkit/issues
8
+ Project-URL: Changelog, https://github.com/clarkipeng/flpkit/blob/main/CHANGELOG.md
9
+ Author: Origami Research
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: daw,fl-studio,flp,midi,music
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Multimedia :: Sound/Audio
24
+ Requires-Python: >=3.11
25
+ Description-Content-Type: text/markdown
26
+
27
+ # flpkit
28
+
29
+ Read and write FL Studio `.flp` project files - without FL Studio, without dependencies.
30
+
31
+ ```python
32
+ import flpkit
33
+
34
+ project = flpkit.read(path) # ppq, tempo, channels (names + levels + automation), notes, playlist
35
+ flpkit.set_tempo(path, 128.5) # returns the tempo the SAVED file contains
36
+ flpkit.write_notes(
37
+ path,
38
+ [flpkit.NoteSpec(key=60, start=0, length=1)], # beats; velocity/pan 0..1
39
+ pattern=1, channel=0, mode="merge",
40
+ ) # returns the notes read back from the saved file
41
+ flpkit.set_channel_levels(path, 0, volume=0.8, pan=-0.25)
42
+ ```
43
+
44
+ ## Why this exists
45
+
46
+ The FLP format is proprietary and undocumented.
47
+ The existing reverse-engineered library (pyflp, GPL) has a broad *parser*, but its *serializer* rewrites bytes it shouldn't - we observed it write a wrong channel count into the file header and mangle a UTF-16 text event, producing files that parsers read back happily and **real FL Studio refuses to open**.
48
+
49
+ flpkit takes the opposite approach for writing: **raw byte surgery**.
50
+ A write patches or appends exactly the bytes that express the change and never reserializes the file, so everything the library does not model survives untouched.
51
+ Every writer then **verifies itself**: it re-reads the saved file and field-matches the result against what was sent, raising `FlpError` instead of returning hope.
52
+
53
+ ## What it reads
54
+
55
+ - PPQ and tempo, including the legacy pre-`156` coarse/fine word pair
56
+ - Channels with display names (user rename → legacy name → plugin internal name) and mix levels across four format generations (`Levels` 219, word events, byte events)
57
+ - Notes per pattern and channel (the 24-byte packed record), with correct attribution for the implicit channel 0 and pre-pattern note blobs that stock FL files contain
58
+ - UTF-16/Latin-1 text switching keyed off the file's `FLVersion`
59
+ - Playlist items per arrangement (pattern/audio clips: position, length, track, group), with the record stride DETECTED per blob - FL grew the record from 32 to 60 to 80 to 88 bytes across eras, and the constant `pattern_base` signature identifies the true size instead of a hardcoded list
60
+ - Automation clips: each type-5 channel's points (position in beats from clip start, value 0..1, tension), decoded from the delta-encoded f64 records and verified point-identical to pyflp across 1,100 real blobs
61
+
62
+ ## What it writes
63
+
64
+ - `set_tempo` - patches the tempo event in place, or appends one when the file omits it (FL expresses default tempo by omission; end-of-stream append is the placement real FL accepts)
65
+ - `write_notes` - splices a pattern's notes blob; `mode="replace"` is scoped to the target channel (a pattern's blob holds *every* channel's notes - naive replacement destroys other channels' work)
66
+ - `set_channel_levels` - patches pan/volume/pitch int32s inside the channel's `Levels` event; refuses legacy files rather than writing guessed units
67
+ - `write_playlist` - splices pattern clips into an arrangement's playlist event; every new record is built from the first EXISTING record as a byte template (so the era-specific tail carries FL's own defaults), kept position-sorted the way FL writes them. Live-verified: FL Studio 2026 loads the written clips (song length grows to match) and its OWN re-save round-trips them byte-identically
68
+ - `write_automation` - replaces the points inside an EXISTING automation channel's blob; the 17-byte header and the opaque era trailer are carried verbatim, absolute positions convert back to FL's stored x-deltas, and each point's opaque 4-byte tail rides along.
69
+ Feeding a channel's decoded points straight back is readback-identical; it is byte-identical for FL-authored blobs in corpus tests.
70
+ Creating a NEW clip is out of scope until the link bytes are decoded - a non-automation channel or a missing blob is an error, not an invitation to fabricate
71
+ - `add_effect` and `effects_at` - splice and read captured mixer-effect references while preserving plugin state as opaque data
72
+
73
+ ## One engine, formats as data (0.6.0)
74
+
75
+ Every writer and element reader above flows through ONE generic engine, `flpkit.codec`:
76
+
77
+ ```python
78
+ from flpkit import codec
79
+ from flpkit.formats import NotesFormat
80
+
81
+ notes = codec.read(path, NotesFormat(), codec.Target(pattern=1, channel=0))
82
+ codec.patch(path, NotesFormat(), codec.Target(pattern=1, channel=0), notes, mode="merge")
83
+ ```
84
+
85
+ A `Format` (see `flpkit/formats/`) states one `.flp` element - `locate`, `encode`, `decode`, `verify` - and the engine does the rest: splice, chunk-length fixing, and verify-by-readback live in `codec.patch`, once.
86
+ Reading `formats/notes.py` plus `codec.py` tells you everything about how notes work; the same goes for playlist, automation, levels, tempo, and effects.
87
+ The public functions (`write_notes`, `set_tempo`, ...) are thin shims over `codec.patch`, and `tests/test_differential.py` proves the codec writes byte-identical output to the pre-0.6.0 hand-rolled writers.
88
+
89
+ ## Detect, don't assume
90
+
91
+ Format constants are DETECTED from the file itself wherever the data carries an invariant, with hardcoded values only as logged fallbacks:
92
+
93
+ - The playlist record stride is detected per blob (the constant `pattern_base` signature), never taken from a version table.
94
+ - The note-flags word is templated from the target file's own notes; the corpus-surveyed `0x4000` covers files with none.
95
+ - New playlist records inherit their cut-window bytes from an existing record; existing era-specific windows remain opaque.
96
+ - Event-size overrides (event 172 is one byte on FL 2026, not the classic four) are an `event_size_overrides` argument on every public function, so a capability profile can supply a measured table for other FL versions; the built-in FL-2026 table is the once-logged fallback.
97
+
98
+ ## How it was verified
99
+
100
+ Every byte-level fact in the source carries its evidence in a comment.
101
+ The facts come from two directions:
102
+
103
+ 1. **Differential reading** against pyflp across 164 FL-authored projects (dev-only oracle; flpkit ships with zero dependencies and no GPL code).
104
+ 2. **Live FL Studio**: files written by flpkit are opened by real FL Studio 2026 (macOS) and read back over a control connection - tempo, note, and level writes are all confirmed by FL itself, not just by our own parser. The live harness lives in the parent project, [fl-studio-mcp](https://github.com/origami-research/fl-studio-mcp).
105
+
106
+ One example of why the live half matters: a note-record flags field of `0` parses fine everywhere, but every note FL itself writes carries `0x4000` (surveyed: 24,435 records across FL's bundled projects, not one with `0`).
107
+
108
+ ## Scope, honestly
109
+
110
+ flpkit models what a composition agent needs: tempo, channels, levels, notes, and captured mixer-effect references.
111
+ It does not decode plugin state or the mixer graph: plugin tuples are captured and spliced as opaque data, while `read()` does not expose a mixer graph. Playlist reading landed in 0.2.0, automation-clip reading in 0.3.0, playlist writing in 0.4.0 (FL-2026 live-verified), automation-point writing in 0.5.0 (readback-identical; byte-identical for FL-authored corpus blobs; live FL round-trip pending).
112
+ Automation clip CREATION (a new curve on a new target) stays out until the target-link bytes are decoded by live minimal-pair experiments.
113
+
114
+ ## Captured plugin references
115
+
116
+ Plugin references live in `src/flpkit/data/plugins/`. The index maps a plugin name to one JSON record. Each record has `name`, `fl_build`, `kind`, `chunk_hex`, `sha256`, and `captured_on`. Capture pipelines should write that record from an FL-authored plugin tuple, hash the decoded `chunk_hex`, and add its name to the index. flpkit validates the hash before it splices the opaque tuple.
117
+
118
+ ## Bring your own note type
119
+
120
+ `write_notes` accepts any object with `key` (MIDI int), `start`/`length` (beats), `velocity`/`pan` (0..1) attributes - `NoteSpec` is provided for convenience, but a pydantic model or your own dataclass works as-is.
121
+
122
+ ## License
123
+
124
+ MIT.
flpkit-0.8.1/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # flpkit
2
+
3
+ Read and write FL Studio `.flp` project files - without FL Studio, without dependencies.
4
+
5
+ ```python
6
+ import flpkit
7
+
8
+ project = flpkit.read(path) # ppq, tempo, channels (names + levels + automation), notes, playlist
9
+ flpkit.set_tempo(path, 128.5) # returns the tempo the SAVED file contains
10
+ flpkit.write_notes(
11
+ path,
12
+ [flpkit.NoteSpec(key=60, start=0, length=1)], # beats; velocity/pan 0..1
13
+ pattern=1, channel=0, mode="merge",
14
+ ) # returns the notes read back from the saved file
15
+ flpkit.set_channel_levels(path, 0, volume=0.8, pan=-0.25)
16
+ ```
17
+
18
+ ## Why this exists
19
+
20
+ The FLP format is proprietary and undocumented.
21
+ The existing reverse-engineered library (pyflp, GPL) has a broad *parser*, but its *serializer* rewrites bytes it shouldn't - we observed it write a wrong channel count into the file header and mangle a UTF-16 text event, producing files that parsers read back happily and **real FL Studio refuses to open**.
22
+
23
+ flpkit takes the opposite approach for writing: **raw byte surgery**.
24
+ A write patches or appends exactly the bytes that express the change and never reserializes the file, so everything the library does not model survives untouched.
25
+ Every writer then **verifies itself**: it re-reads the saved file and field-matches the result against what was sent, raising `FlpError` instead of returning hope.
26
+
27
+ ## What it reads
28
+
29
+ - PPQ and tempo, including the legacy pre-`156` coarse/fine word pair
30
+ - Channels with display names (user rename → legacy name → plugin internal name) and mix levels across four format generations (`Levels` 219, word events, byte events)
31
+ - Notes per pattern and channel (the 24-byte packed record), with correct attribution for the implicit channel 0 and pre-pattern note blobs that stock FL files contain
32
+ - UTF-16/Latin-1 text switching keyed off the file's `FLVersion`
33
+ - Playlist items per arrangement (pattern/audio clips: position, length, track, group), with the record stride DETECTED per blob - FL grew the record from 32 to 60 to 80 to 88 bytes across eras, and the constant `pattern_base` signature identifies the true size instead of a hardcoded list
34
+ - Automation clips: each type-5 channel's points (position in beats from clip start, value 0..1, tension), decoded from the delta-encoded f64 records and verified point-identical to pyflp across 1,100 real blobs
35
+
36
+ ## What it writes
37
+
38
+ - `set_tempo` - patches the tempo event in place, or appends one when the file omits it (FL expresses default tempo by omission; end-of-stream append is the placement real FL accepts)
39
+ - `write_notes` - splices a pattern's notes blob; `mode="replace"` is scoped to the target channel (a pattern's blob holds *every* channel's notes - naive replacement destroys other channels' work)
40
+ - `set_channel_levels` - patches pan/volume/pitch int32s inside the channel's `Levels` event; refuses legacy files rather than writing guessed units
41
+ - `write_playlist` - splices pattern clips into an arrangement's playlist event; every new record is built from the first EXISTING record as a byte template (so the era-specific tail carries FL's own defaults), kept position-sorted the way FL writes them. Live-verified: FL Studio 2026 loads the written clips (song length grows to match) and its OWN re-save round-trips them byte-identically
42
+ - `write_automation` - replaces the points inside an EXISTING automation channel's blob; the 17-byte header and the opaque era trailer are carried verbatim, absolute positions convert back to FL's stored x-deltas, and each point's opaque 4-byte tail rides along.
43
+ Feeding a channel's decoded points straight back is readback-identical; it is byte-identical for FL-authored blobs in corpus tests.
44
+ Creating a NEW clip is out of scope until the link bytes are decoded - a non-automation channel or a missing blob is an error, not an invitation to fabricate
45
+ - `add_effect` and `effects_at` - splice and read captured mixer-effect references while preserving plugin state as opaque data
46
+
47
+ ## One engine, formats as data (0.6.0)
48
+
49
+ Every writer and element reader above flows through ONE generic engine, `flpkit.codec`:
50
+
51
+ ```python
52
+ from flpkit import codec
53
+ from flpkit.formats import NotesFormat
54
+
55
+ notes = codec.read(path, NotesFormat(), codec.Target(pattern=1, channel=0))
56
+ codec.patch(path, NotesFormat(), codec.Target(pattern=1, channel=0), notes, mode="merge")
57
+ ```
58
+
59
+ A `Format` (see `flpkit/formats/`) states one `.flp` element - `locate`, `encode`, `decode`, `verify` - and the engine does the rest: splice, chunk-length fixing, and verify-by-readback live in `codec.patch`, once.
60
+ Reading `formats/notes.py` plus `codec.py` tells you everything about how notes work; the same goes for playlist, automation, levels, tempo, and effects.
61
+ The public functions (`write_notes`, `set_tempo`, ...) are thin shims over `codec.patch`, and `tests/test_differential.py` proves the codec writes byte-identical output to the pre-0.6.0 hand-rolled writers.
62
+
63
+ ## Detect, don't assume
64
+
65
+ Format constants are DETECTED from the file itself wherever the data carries an invariant, with hardcoded values only as logged fallbacks:
66
+
67
+ - The playlist record stride is detected per blob (the constant `pattern_base` signature), never taken from a version table.
68
+ - The note-flags word is templated from the target file's own notes; the corpus-surveyed `0x4000` covers files with none.
69
+ - New playlist records inherit their cut-window bytes from an existing record; existing era-specific windows remain opaque.
70
+ - Event-size overrides (event 172 is one byte on FL 2026, not the classic four) are an `event_size_overrides` argument on every public function, so a capability profile can supply a measured table for other FL versions; the built-in FL-2026 table is the once-logged fallback.
71
+
72
+ ## How it was verified
73
+
74
+ Every byte-level fact in the source carries its evidence in a comment.
75
+ The facts come from two directions:
76
+
77
+ 1. **Differential reading** against pyflp across 164 FL-authored projects (dev-only oracle; flpkit ships with zero dependencies and no GPL code).
78
+ 2. **Live FL Studio**: files written by flpkit are opened by real FL Studio 2026 (macOS) and read back over a control connection - tempo, note, and level writes are all confirmed by FL itself, not just by our own parser. The live harness lives in the parent project, [fl-studio-mcp](https://github.com/origami-research/fl-studio-mcp).
79
+
80
+ One example of why the live half matters: a note-record flags field of `0` parses fine everywhere, but every note FL itself writes carries `0x4000` (surveyed: 24,435 records across FL's bundled projects, not one with `0`).
81
+
82
+ ## Scope, honestly
83
+
84
+ flpkit models what a composition agent needs: tempo, channels, levels, notes, and captured mixer-effect references.
85
+ It does not decode plugin state or the mixer graph: plugin tuples are captured and spliced as opaque data, while `read()` does not expose a mixer graph. Playlist reading landed in 0.2.0, automation-clip reading in 0.3.0, playlist writing in 0.4.0 (FL-2026 live-verified), automation-point writing in 0.5.0 (readback-identical; byte-identical for FL-authored corpus blobs; live FL round-trip pending).
86
+ Automation clip CREATION (a new curve on a new target) stays out until the target-link bytes are decoded by live minimal-pair experiments.
87
+
88
+ ## Captured plugin references
89
+
90
+ Plugin references live in `src/flpkit/data/plugins/`. The index maps a plugin name to one JSON record. Each record has `name`, `fl_build`, `kind`, `chunk_hex`, `sha256`, and `captured_on`. Capture pipelines should write that record from an FL-authored plugin tuple, hash the decoded `chunk_hex`, and add its name to the index. flpkit validates the hash before it splices the opaque tuple.
91
+
92
+ ## Bring your own note type
93
+
94
+ `write_notes` accepts any object with `key` (MIDI int), `start`/`length` (beats), `velocity`/`pan` (0..1) attributes - `NoteSpec` is provided for convenience, but a pydantic model or your own dataclass works as-is.
95
+
96
+ ## License
97
+
98
+ MIT.
@@ -0,0 +1,32 @@
1
+ # Releasing flpkit
2
+
3
+ Releases are published by GitHub Actions only. Do not create or store a PyPI
4
+ token for this repository.
5
+
6
+ ## One-time PyPI setup
7
+
8
+ Before the first release, sign in to PyPI as an owner and create a pending
9
+ trusted publisher at PyPI's Publishing settings. Enter:
10
+
11
+ - PyPI project name: `flpkit`
12
+ - Owner: `clarkipeng`
13
+ - Repository: `flpkit`
14
+ - Workflow filename: `publish.yml`
15
+ - Environment: `pypi`
16
+
17
+ For a project that already exists, add the same GitHub trusted publisher from
18
+ that project's Publishing settings. The pending publisher creates the project
19
+ when the first matching workflow publishes it.
20
+
21
+ ## Release steps
22
+
23
+ 1. Confirm `scripts/check-release.sh` and `uv run pytest -q` pass.
24
+ 2. Update the version and changelog, then merge the release PR.
25
+ 3. Create and push an annotated `vX.Y.Z` tag on the merged commit.
26
+ 4. Watch the `Publish to PyPI` workflow. Its dedicated `pypi` environment
27
+ exchanges GitHub's OIDC identity for PyPI publishing permission and uploads
28
+ the distributions built from that tag.
29
+
30
+ The workflow triggers only for `v*` tag pushes and has no token or manual
31
+ upload fallback. PyPI keeps release versions immutable, so verify the version
32
+ and artifacts before pushing the tag.
flpkit-0.8.1/TASTE.md ADDED
@@ -0,0 +1,85 @@
1
+ # Taste
2
+
3
+ How we build. Every agent reads this before writing code.
4
+ When a judgment call comes up, this doc decides it. Briefs add context, never new taste.
5
+ Built from 12k of my own messages (June to September 2026); every rule traces to quotes in the local taste corpus.
6
+
7
+ ## How to work with me
8
+
9
+ - Proceed. Don't ask for permission on ordinary engineering, cleanup, or merge work. Ask only when the decision is genuinely mine: product direction, spend, irreversible external action, or a scope tradeoff evidence can't settle. Then ask a real directional question with your recommendation and its cost, never "should I continue?".
10
+ - Terse means go. "ok", "go", "yes", "do this", "continue" are approvals, not requests for more detail.
11
+ - An explicit hold is a hold ("don't do anything until I say so"). A request to explain is not approval to change things.
12
+ - Tell me before I have to ask. If I'm typing "updates?", "eta?", "check", "hello?", you went quiet too long. A status says what changed, what is true now, what's running, the next action, and exactly what needs me.
13
+ - Speak like a person. Plain speech, sentence case, short bullets, no agent-speak, no ceremony. Say what's done and what's still needed.
14
+ - "Explain" or "wdym" means the framing failed, and usually the system is over-complicated. Rebuild the mental model from the concrete thing: what it is, its state now, the problem, the proposed change, why each non-obvious piece exists. Simplify the system, not just the sentence.
15
+ - Show me the thing I need to decide in a form I can inspect. Give choices only at a genuine fork, with the recommended one and its cost.
16
+ - Keep a live doc I can glance at (`doing.md`): now, waiting on me, done, known issues. Short and current.
17
+ - Report what actually happened. When evidence overturns an earlier claim, correct every copy of the old story in the same change.
18
+
19
+ ## Architecture
20
+
21
+ - Smallest robust architecture. If we're adding constraints and it feels complicated, that's the signal it's wrong. No framework where a function does the job.
22
+ - One path, not branches. If a function grows if/else arms and edge-case handlers, the fix is one path with an invariant that makes the special cases impossible.
23
+ - Constraints that make bad states impossible (a unique index, a CHECK, one transaction, a counter that only goes up) get built first. Mechanisms that interpret evidence (classifiers, heuristics, taxonomies) wait until a real incident fixes their exact shape.
24
+ - One implementation per capability, one source of truth. When two paths overlap, keep the better one and delete the other in the same change. Replacing a path includes deleting the old one, or the change isn't done.
25
+ - No workarounds, not even temporary. No compatibility alias, arbitrary cap, stale sentinel, or underscored dead value. Fix the invariant. Keep compatibility only when a real supported user or durable data contract requires it.
26
+ - Fix the cause, not the symptom. Trace the real path that produced the failure, make that bad state impossible, add the one narrow proof, and leave a short diagnosis so recurrence is trivial to spot.
27
+ - Generalize after real repetition, or when the next shared use is known and concrete. An abstraction with one caller is waste. Don't invent a framework for an imagined future.
28
+ - Measure real things. The actual process, stored row, browser output, deployed state. Not a stand-in, cached copy, or dashboard flag. Repository text alone proves nothing is dead or working; check the databases and dashboards too.
29
+ - Data lives on the thing it describes. A variant declares its own facts where it's constructed; shared systems read them. Question anything that centralizes unrelated decisions (one manager scoring everything, one writer, one mode switch).
30
+ - Typed outcomes for distinct realities. Success, failure, unavailable, interrupted, and invalidated are not interchangeable empties. Never infer state by scraping text or exit codes.
31
+ - Nothing gets orphaned, silently dropped, or marked terminal before its side effect is confirmed. One owner and one authoritative lifecycle decide the state. Silence is a failure mode.
32
+ - Never write "couldn't run the measurement" down as "the experiment failed". Keep the missing-measurement cause distinct and recoverable.
33
+ - Delete finished one-off jobs. A completed migration script left in the tree is a loaded gun. Net-negative PRs are good.
34
+
35
+ ## Configuration
36
+
37
+ - Dials live next to the rules they tune, in small modules. If a value is definitional (0, 1, "always"), it's code, not config.
38
+ - Defaults make the intended path work. Don't expose knobs that let a caller, provider, or stale environment choose a system invariant.
39
+ - Centralize genuinely shared, user-visible budgets in one named owner. Don't centralize merely equal numbers; app-local policy stays local.
40
+ - No config bureaucracy and no standing style nags. On-demand audits and structural gates that protect a real invariant are good. Lint tripwires about taste are not.
41
+
42
+ ## Process
43
+
44
+ - Fix real problems before building features. Reproduce bugs end to end as a user first, and test the real user path before claiming a cause or a fix.
45
+ - Small, specified fixes get done directly. A worker costs 30-45 minutes for any size change; dispatch only work that is genuinely long, heavy, or parallel.
46
+ - Batch the work: read everything needed once, then write code, tests, and run the suite in one go. Fewer roundtrips, fewer tokens. Prove prerequisites cheaply before an expensive end-to-end run; a long series of dry runs is not discovery.
47
+ - Reviews match blast radius. Kernel, schema, and seam changes get an independent adversarial review. Small fixes get one probe of the real risk plus one test that fails without the fix.
48
+ - Every test says why it exists, especially regression guards. Test the invariant that prevented the incident, including timing and recovery boundaries. Never weaken or skip a test to look green; refactors ship with tests moved, never weakened.
49
+ - Run the local checks the change's risk actually warrants before pushing. CI confirms; it never discovers. Measure before adding process.
50
+ - Squash-merge, never push main directly, review PRs to zero blockers. Keep PRs draft after local gates; the reviewing manager flips ready once the exact head is merge-worthy.
51
+ - Before saying "fixed", check every item raised and state the remaining blocker plainly.
52
+ - Preserve completed work through a coherent checkpoint. After an interruption, continue from it; never blindly regenerate or discard valid state.
53
+ - If something looks off inside the change you're making, fix it. Same for red tests, lint, and flakes in that scope. Outside your scope, file or flag it; never quietly bundle it in.
54
+
55
+ ## Docs and voice
56
+
57
+ - Docs are a short, human-readable judgment surface. Lead with what and when. Changing specifics live in code, tests, issues, and memory.
58
+ - If it reads like slop, rewrite it as what I'd actually say. Sentence case. No em dashes. Judge animation and feel from footage, not adjectives.
59
+
60
+ ## UI
61
+
62
+ - Build around the interaction that carries the rules. A 3D scene is the hero only when the scene is how you read the game; never default to 3D because it looks good.
63
+ - Start from the user's model of the product. Stable things stay stable; move the camera or focus when that is what the user meant.
64
+ - One fact per surface, labeled. No duplicated information anywhere; the logo twice is a bug. Layout is hierarchy and attention direction; if an element doesn't help, cut it.
65
+ - The control lives where the decision happens (the card is the button). Prefer direct visible choices over generic buttons and hidden state.
66
+ - Icons come from the lucide pipeline only, never hand-authored SVG. One glyph per control, colour working inside the glyph with a contrast floor.
67
+ - Latency is product quality. Set and test budgets for user-visible interactions in the existing harness.
68
+ - Inspect the rendered result. A screen that works but looks wrong, overlaps, or hides the interaction is not done.
69
+
70
+ ## Orchestration
71
+
72
+ - Multi-slice work runs through SightMesh: sol manages, terra/luna execute, one program prefix per worker, disjoint file ownership, durable handoffs, the manager reviews and merges. The coordinator writes briefs, gathers evidence, deliberates with me, oversees merges, and deploys dev.
73
+ - Don't coordinate with finished workers or nest orchestration. Read the diff, continue from the checkpoint. Report inherited blockers with the exact evidence.
74
+ - Never invent a release flow, CLI, endpoint, identity, or policy from a plausible name. Verify it in live code and state first.
75
+
76
+ ## Tools and safety
77
+
78
+ - Shell-safe by default: no backticks inside double-quoted bodies; use body files or single-quoted heredocs.
79
+ - Secrets never print, only their shape. Anything that appeared in a transcript is exposed; rotate it.
80
+ - If you're testing on my machine, don't take over my screen. Headless only.
81
+
82
+ ## Models and cost
83
+
84
+ - Route by cognitive risk: Fable plans and reviews kernel-class changes; sol orchestrates; terra implements and audits; luna does bounded mechanical work. GPT (codex) and Claude accounts both exist; fail over on quota or auth errors only, never on test failures.
85
+ - Fewer roundtrips, fewer tokens. A 15-minute direct fix beats a 45-minute worker round trip.
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "flpkit"
3
+ version = "0.8.1"
4
+ description = "Read and write FL Studio .flp project files - a small, dependency-free library for the undocumented FLP format, with self-verifying raw-surgery writers"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = []
9
+ authors = [{ name = "Origami Research" }]
10
+ keywords = ["fl-studio", "flp", "daw", "midi", "music"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Multimedia :: Sound/Audio",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/clarkipeng/flpkit"
27
+ Repository = "https://github.com/clarkipeng/flpkit"
28
+ Issues = "https://github.com/clarkipeng/flpkit/issues"
29
+ Changelog = "https://github.com/clarkipeng/flpkit/blob/main/CHANGELOG.md"
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/flpkit"]
37
+
38
+ [dependency-groups]
39
+ dev = ["pytest>=8.0", "ruff>=0.6"]
40
+
41
+ [tool.ruff]
42
+ line-length = 110
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "I", "BLE", "TRY", "SIM", "PTH", "TID", "RUF"]
46
+ ignore = ["TRY003", "E501"]
47
+
48
+ [tool.pytest.ini_options]
49
+ pythonpath = ["src"]
50
+ testpaths = ["tests"]
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # Validate the exact artifact path users receive from PyPI. No publish occurs.
3
+ set -euo pipefail
4
+
5
+ repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
6
+ dist_dir="$repo_root/dist"
7
+ temp_dir=$(mktemp -d)
8
+ trap 'rm -rf "$temp_dir"' EXIT
9
+
10
+ rm -rf "$dist_dir"
11
+ uv build --out-dir "$dist_dir" "$repo_root"
12
+
13
+ sdist=$(find "$dist_dir" -maxdepth 1 -name 'flpkit-*.tar.gz' -print -quit)
14
+ test -n "$sdist"
15
+ rebuilt_dist="$temp_dir/rebuilt-dist"
16
+ uv build --wheel --out-dir "$rebuilt_dist" "$sdist"
17
+
18
+ wheel=$(find "$rebuilt_dist" -maxdepth 1 -name 'flpkit-*.whl' -print -quit)
19
+ test -n "$wheel"
20
+ venv_dir="$temp_dir/venv"
21
+ uv venv "$venv_dir"
22
+ uv pip install --python "$venv_dir/bin/python" "$wheel"
23
+
24
+ golden="$repo_root/tests/golden/tempo-patch-in-place.flp"
25
+ sample="$temp_dir/roundtrip.flp"
26
+ cp "$golden" "$sample"
27
+ (
28
+ cd "$temp_dir"
29
+ "$venv_dir/bin/python" - "$sample" <<'PY'
30
+ from pathlib import Path
31
+ import sys
32
+
33
+ import flpkit
34
+
35
+ path = Path(sys.argv[1])
36
+ project = flpkit.read(path)
37
+ if project.tempo is None:
38
+ raise RuntimeError("golden fixture must contain an explicit tempo")
39
+ saved_tempo = flpkit.set_tempo(path, project.tempo)
40
+ round_tripped = flpkit.read(path)
41
+ assert round_tripped.tempo == saved_tempo
42
+ print(f"flpkit {flpkit.__version__}: imported and round-tripped {path.name} at {saved_tempo:g} BPM")
43
+ PY
44
+ )