// Source section end
+> // Item section end
+```
+
+## MIDI Item
+
+```text
+One example is below, but more info can be found in the [[StateChunkAndRppMidiFormat]] page.
+
+- // SysEx section end
+ E 540 b0 7b 00 // unselected msg, offset 540, Control Change , all notes off (cc123)
+ GUID {9DAB52C8-6257-4A4E-9E60-323514C0DB9B} // Take id
+ IGNTEMPO 0 120.00000000 4 4 // Ignore project tempo, override with new settings
+ // field 1, int, ignore on/off
+ // field 2, float, tempo used to override project tempo
+ // field 3-4, int, time signature to override project time signature
+ VELLANE 128 97 0 // Velocity/CC lane settings
+ // field 1, int, lane type
+ // -1 = velocity
+ // 0-119 = CC #0 - CC #119
+ // 128 = pitch bend
+ // 129 = program
+ // 130 = channel pressure
+ // 131 = bank/program select
+ // 132 = text events
+ // 133 = sysex
+ // field 2, int, height of the lane in MIDI editor mode
+ // field 3, int, height of the lane in inline editor mode
+ BANKPROGRAMFILE "C:\Path\To\GM.reabank" // Path to ReaBank file used by the current item
+ CFGEDITVIEW 3787.8 0.1 0 48 0 0 0 // Somehow related to H/V zoom levels and scrollbar positions
+ CFGEDIT 1 1 0 1 1 16 1 1 1 1 1 0.06250000 0 0 // MIDI editor window configuration data
+ 1024 768 0 2 0 0 0.00000000 0 0 // field 1, int, ??
+ // field 2, int (bool), sync editor transport to project transport
+ // field 3, int, ??
+ // field 4, int (bool), show velocity handles
+ // field 5, int (bool), show note names
+ // field 6, int, MIDI editor view mode
+ // 0 = piano roll, rectangle
+ // 1 = named notes, rectangle
+ // 5 = event list, rectangle
+ // 8 = piano roll, triangle
+ // 9 = named notes, triangle
+ // 13 = event list, triangle
+ // 16 = piano roll, diamond
+ // 17 = named notes, diamond
+ // 21 = event list, diamond
+ // field 7, int, ??
+ // field 8, int (bool), move CCs with notes
+ // field 9, int, ??
+ // field 10, int (bool), snap to grid
+ // field 11, int (bool), show grid
+ // field 12, float, grid division unit, in beats.ticks
+ // field 13, field 14, int, X and Y coords of upper left corner of ME window
+ // field 15, field 16, int, X and Y coords of bottom right corner of ME window
+ // field 17, int (bool), dock editor window
+ // field 18, int, note row view modes
+ // 0 = show all rows
+ // 1 = hide unused rows
+ // 2 = hide unused and unnamed rows
+ // field 19, int, state of Timebase setting
+ // 0 = project beats
+ // 1 = project sync
+ // 2 = project time
+ // 4 = source beats
+ // field 20, int, state of Color mode setting
+ // 0 = velocity
+ // 1 = channel
+ // 2 = pitch
+ // 4 = source
+ // field 21, float, length of last drawn note in beats.ticks
+ // field 22, int, re-use editor window
+ // 0 = off
+ // 1 = re-use MIDI editor window
+ // 2 = re-use MIDI editor window, keeping current item secondary
+ // field 23, int (bool), show velocity numbers
+ EVTFILTER 0 -1 -1 -1 -1 0 1 // Event filter settings
+ // field 1, int (16-bit mask), state of channel checkboxes
+ // 0000000000000000 (0) - "All" is checked, 1-16 unchecked
+ // 1111111111111111 (65536) - "All" is checked along with 1-16
+ // 1000000000000001 (32769) - 1 (LSB) and 16 (MSB) are selected
+ // etc.
+ // field 2, int, Event type
+ // -1 =
+ // 144 = Note
+ // 160 = Poly Aftertouch
+ // 176 = Control Change (CC)
+ // 192 = Program Change (PC)
+ // 208 = Channel Aftertouch
+ // 224 = Pitch
+ // 240 = Sysex/Meta
+ // field 3, int, parameter field (range: 0-127)
+ // field 4, int, value Low field (range: 0-127)
+ // field 5, int, value High field (range: 0-127)
+ // field 6, int, draw events on channel setting (range: 0-15)
+ // field 7, int (bool), enable filter
+ > // Source section end
+> // Item section end
+```
+
+MIDI Event -> field 2 above... This is the number of ticks (it's ok, I'll tell you in a minute) since the last event or (if this is the first MIDI Event) the start of the Item.
+
+Ticks then. A tick is a small slice of a beat, so the number of Ticks per second varies with the BPM (Beats Per Minute) of the music.
+
+1 beat = 2.6041666666666666666666666666667e-4 \times 240 \times 16
+
+1 beat = 2.6041666666666666666666666666667e-4 * 240 * 16
+
+The MIDI format used in REAPER State Chunks and .rpp files is documented here State Chunk And RPP MIDI Format.
+
+
+
+
+## Envelope
+
+Envelopes appear to share most of their characteristics though there may be some differences in how point values are used. Needs more testing
+
+Envelope types include:
+
+- VOLENV - Track (pre FX)/Take volume (0.0 to 2.0)
+- VOLENV2 - Track (post FX) volume (0.0 to 2.0)
+- PANENV - Track (pre FX)/Take pan (-1.0 to 1.0)
+- PANENV2 - Track (post FX) pan (-1.0 to 1.0)
+- MUTEENV - Track mute (0.0 to 1.0)
+- AUXVOLENV - Receive volume (0.0 to 2.0?)
+- AUXPANENV - Receive pan (-1.0 to 1.0)
+- AUXMUTEENV - Receive mute (0.0 to 1.0)
+- PARMENV - Plugin parameter (value range defined by additional parameters after type tag)
+- TEMPOENVEX - Master tempo
+
+
+```text
+```
+
+PARMENV contains extra data denoting which plugin parameter is controlled and what the minimum, maximum and default values are.
+
+```text
+
+```
+
+
+## Track/Take FX
+
+Plugin chains appear to be the same for both tracks and item takes, with the exception that the chunk name is different.
+
+```text
+ //
+ FLOATPOS 0 0 0 0 // Plugin floating window pos, int (top, left, width, height)
+ FXID {1191A7FE-F583-477E-8416-D9F0F58226CD} // Plugin id, leave it alone
+
+ // --- Start of parameter envelopes for this plugin.
+ // One block for each non-empty envelope.
+ //
+ // --- End of parameter envelopes
+
+ PARM_TCP 4 2 0 // Params visible on TCP/MCP.
+ // List of int indexes separated by spaces.
+ WAK 0 // int, ??
+ //
+
+ // --- Start of second plugin definition
+ BYPASS 0 0 0 // ...
+ //
+ FLOATPOS 0 0 0 0 // ...
+ FXID {BE5D3334-6675-4DAD-AFDD-F4489878D82A} // ...
+ WAK 0 // ...
+ // ...
+
+ > // --- End of FX section
+
+```
+
+## VST(i) Plugin
+
+```text
+ // End of VST plugin data
+```
+
+## Jesusonic Plugin
+
+For the sake of formatting, the plugin data has been truncated.
+
+```text
+ // End of JS plugin data
+```
+
diff --git a/docs/REASAMPLOMATIC.md b/docs/REASAMPLOMATIC.md
new file mode 100644
index 0000000..f41f540
--- /dev/null
+++ b/docs/REASAMPLOMATIC.md
@@ -0,0 +1,330 @@
+State format of REAPER's ReaSamplOmatic 5000
+I used the verson that bundled with REAPER 7.48
+
+// volume
+000 82
+001 66
+002 51
+003 207
+004 185
+005 9
+006 224
+007 63
+
+//
+008 0
+009 0
+010 0
+011 0
+012 0
+013 0
+014 224
+015 63
+
+// max volume
+016 14
+017 71
+018 84
+019 28
+020 213
+021 115
+022 252
+023 63
+
+//
+024 0
+025 0
+026 0
+027 0
+028 0
+029 0
+030 0
+031 0
+
+//
+032 0
+033 0
+034 0
+035 0
+036 0
+037 0
+038 240
+039 63
+
+// pitch start/root note (note mode), C4 = -60, C3 = -48, C-1 = 0
+040 154
+041 153
+042 153
+043 153
+044 153
+045 153
+046 177
+047 63
+
+//
+048 205
+049 204
+050 204
+051 204
+052 204
+053 204
+054 235
+055 63
+
+//
+056 0
+057 0
+058 0
+059 0
+060 0
+061 0
+062 0
+063 0
+
+//
+064 28
+065 199
+066 113
+067 28
+068 199
+069 113
+070 220
+071 63
+
+// attack
+072 252
+073 169
+074 241
+075 210
+076 77
+077 98
+078 64
+079 63
+
+// release
+080 252
+081 169
+082 241
+083 210
+084 77
+085 98
+086 64
+087 63
+
+// obey note off: 0, 1
+088 0
+089 0
+090 0
+091 0
+092 0
+093 0
+094 0
+095 0
+
+//
+096 0
+097 0
+098 0
+099 0
+100 0
+101 0
+102 0
+103 0
+
+// sample start
+104 0
+105 0
+106 0
+107 0
+108 0
+109 0
+110 0
+111 0
+
+// sample end
+112 0
+113 0
+114 0
+115 0
+116 0
+117 0
+118 240
+119 63
+
+// pitch offset in semitones
+120 0
+121 0
+122 0
+123 0
+124 0
+125 0
+126 224
+127 63
+
+//
+128 1 // mode: 0, 1, 2
+129 0
+130 0
+131 0
+132 0
+133 0
+134 0
+135 0
+136 0
+137 0
+138 0
+139 0
+
+//
+140 0
+141 0
+142 0
+143 0
+144 0
+145 0
+146 240
+147 63
+
+148 64
+149 0
+150 0
+151 0
+
+//
+152 85
+153 85
+154 85
+155 85
+156 85
+157 85
+158 197
+159 63
+
+160 255
+161 255
+162 255
+163 255
+164 8
+165 4
+166 2
+167 129
+168 64
+169 32
+170 128
+171 63
+
+//
+172 0
+173 0
+174 0
+175 0
+176 0
+177 0
+178 240
+179 63
+
+//
+180 0
+181 0
+182 0
+183 0
+184 0
+185 0
+186 240
+187 63
+
+
+188 0
+189 0
+190 0
+191 0
+192 0
+193 0
+194 0
+195 0
+196 0
+197 0
+198 0
+199 0
+200 0
+201 0
+202 0
+203 0
+204 0
+205 0
+206 0
+207 0
+208 0
+209 0
+210 0
+211 0
+212 0
+213 0
+214 0
+215 0
+216 0
+217 0
+218 0
+219 0
+
+//
+220 206
+221 164
+222 33
+223 33
+224 26
+225 101
+226 144
+227 63
+
+//
+228 0
+229 0
+230 0
+231 0
+232 0
+233 0
+234 240
+235 63
+
+//
+236 252
+237 169
+238 241
+239 210
+240 77
+241 98
+242 48
+243 63
+
+
+244 0
+245 0
+246 0
+247 0
+248 0
+249 0
+250 0
+251 0
+252 0
+253 0
+254 0
+255 0
+256 0
+257 0
+258 0
+259 0
+260 0
+261 0
+262 0
+263 0
+264 0
+265 0
+266 0
+267 0
+268 0
+269 0
+270 16
+271 0
+272 0
+273 0
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..18f45cd
--- /dev/null
+++ b/index.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+ EP-133: Export To Daw
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..daa9adf
--- /dev/null
+++ b/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "ep133-export-to-daw",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "typecheck": "tsc --noEmit",
+ "lint": "biome check --write src",
+ "format": "biome format --write src"
+ },
+ "dependencies": {
+ "@hookform/resolvers": "^5.4.0",
+ "@sentry/react": "^10.53.1",
+ "@sentry/vite-plugin": "^5.3.0",
+ "@tanstack/react-query": "^5.100.13",
+ "@tonejs/midi": "^2.0.28",
+ "clsx": "^2.1.1",
+ "events": "^3.3.0",
+ "jotai": "^2.20.0",
+ "jstoxml": "^7.1.0",
+ "jszip": "^3.10.1",
+ "ky": "^2.0.2",
+ "lodash": "^4.18.1",
+ "preact": "^10.29.2",
+ "react-dnd": "^16.0.1",
+ "react-dnd-html5-backend": "^16.0.1",
+ "react-hook-form": "^7.76.1",
+ "tailwind-merge": "^3.6.0",
+ "tailwindcss": "^4.3.0",
+ "workbox-window": "^7.4.1",
+ "wouter": "^3.10.0",
+ "wouter-preact": "^3.10.0",
+ "xml2js": "^0.6.2",
+ "xmlbuilder2": "^4.0.3",
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.2.0",
+ "@preact/preset-vite": "^2.10.5",
+ "@tailwindcss/vite": "^4.3.0",
+ "@types/jstoxml": "^5.0.0",
+ "@types/lodash": "^4.17.24",
+ "@types/node": "^24.12.4",
+ "@types/react": "^19.2.15",
+ "@types/react-dom": "^19.2.3",
+ "@types/xml2js": "^0.4.14",
+ "tsx": "^4.22.3",
+ "typescript": "^6.0.3",
+ "vite": "^8.0.14",
+ "vite-plugin-minify": "^2.1.0",
+ "vite-plugin-pwa": "^1.3.0",
+ "vite-plugin-svgr": "^5.2.0"
+ },
+ "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
+}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..3a3e37d
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+onlyBuiltDependencies:
+ - '@sentry/cli'
+ - esbuild
diff --git a/public/ableton.jpg b/public/ableton.jpg
new file mode 100644
index 0000000..bb0bdb9
Binary files /dev/null and b/public/ableton.jpg differ
diff --git a/public/android-icon-144x144.png b/public/android-icon-144x144.png
new file mode 100644
index 0000000..f197f5c
Binary files /dev/null and b/public/android-icon-144x144.png differ
diff --git a/public/android-icon-192x192.png b/public/android-icon-192x192.png
new file mode 100644
index 0000000..c3477a8
Binary files /dev/null and b/public/android-icon-192x192.png differ
diff --git a/public/android-icon-36x36.png b/public/android-icon-36x36.png
new file mode 100644
index 0000000..a8bc6bf
Binary files /dev/null and b/public/android-icon-36x36.png differ
diff --git a/public/android-icon-48x48.png b/public/android-icon-48x48.png
new file mode 100644
index 0000000..8efbc1e
Binary files /dev/null and b/public/android-icon-48x48.png differ
diff --git a/public/android-icon-72x72.png b/public/android-icon-72x72.png
new file mode 100644
index 0000000..8d7c1b9
Binary files /dev/null and b/public/android-icon-72x72.png differ
diff --git a/public/android-icon-96x96.png b/public/android-icon-96x96.png
new file mode 100644
index 0000000..1f0c0d8
Binary files /dev/null and b/public/android-icon-96x96.png differ
diff --git a/public/apple-icon-114x114.png b/public/apple-icon-114x114.png
new file mode 100644
index 0000000..2197b89
Binary files /dev/null and b/public/apple-icon-114x114.png differ
diff --git a/public/apple-icon-120x120.png b/public/apple-icon-120x120.png
new file mode 100644
index 0000000..8596aac
Binary files /dev/null and b/public/apple-icon-120x120.png differ
diff --git a/public/apple-icon-144x144.png b/public/apple-icon-144x144.png
new file mode 100644
index 0000000..f197f5c
Binary files /dev/null and b/public/apple-icon-144x144.png differ
diff --git a/public/apple-icon-152x152.png b/public/apple-icon-152x152.png
new file mode 100644
index 0000000..a40659c
Binary files /dev/null and b/public/apple-icon-152x152.png differ
diff --git a/public/apple-icon-180x180.png b/public/apple-icon-180x180.png
new file mode 100644
index 0000000..0eb7609
Binary files /dev/null and b/public/apple-icon-180x180.png differ
diff --git a/public/apple-icon-57x57.png b/public/apple-icon-57x57.png
new file mode 100644
index 0000000..c539c91
Binary files /dev/null and b/public/apple-icon-57x57.png differ
diff --git a/public/apple-icon-60x60.png b/public/apple-icon-60x60.png
new file mode 100644
index 0000000..06cfd27
Binary files /dev/null and b/public/apple-icon-60x60.png differ
diff --git a/public/apple-icon-72x72.png b/public/apple-icon-72x72.png
new file mode 100644
index 0000000..8d7c1b9
Binary files /dev/null and b/public/apple-icon-72x72.png differ
diff --git a/public/apple-icon-76x76.png b/public/apple-icon-76x76.png
new file mode 100644
index 0000000..ae400b5
Binary files /dev/null and b/public/apple-icon-76x76.png differ
diff --git a/public/apple-icon-precomposed.png b/public/apple-icon-precomposed.png
new file mode 100644
index 0000000..0f61822
Binary files /dev/null and b/public/apple-icon-precomposed.png differ
diff --git a/public/apple-icon.png b/public/apple-icon.png
new file mode 100644
index 0000000..0f61822
Binary files /dev/null and b/public/apple-icon.png differ
diff --git a/public/bg-ep1320.png b/public/bg-ep1320.png
new file mode 100644
index 0000000..812fefa
Binary files /dev/null and b/public/bg-ep1320.png differ
diff --git a/public/bg-ep133.png b/public/bg-ep133.png
new file mode 100644
index 0000000..ed83c2f
Binary files /dev/null and b/public/bg-ep133.png differ
diff --git a/public/bg-ep40.png b/public/bg-ep40.png
new file mode 100644
index 0000000..454d50d
Binary files /dev/null and b/public/bg-ep40.png differ
diff --git a/public/bitwig.jpg b/public/bitwig.jpg
new file mode 100644
index 0000000..7ceadcc
Binary files /dev/null and b/public/bitwig.jpg differ
diff --git a/public/browserconfig.xml b/public/browserconfig.xml
new file mode 100644
index 0000000..c554148
--- /dev/null
+++ b/public/browserconfig.xml
@@ -0,0 +1,2 @@
+
+#ffffff
\ No newline at end of file
diff --git a/public/ep1320-on.png b/public/ep1320-on.png
new file mode 100644
index 0000000..9a874b2
Binary files /dev/null and b/public/ep1320-on.png differ
diff --git a/public/ep133-on.png b/public/ep133-on.png
new file mode 100644
index 0000000..b4eadee
Binary files /dev/null and b/public/ep133-on.png differ
diff --git a/public/ep133-to-daw.png b/public/ep133-to-daw.png
new file mode 100644
index 0000000..2d3ec23
Binary files /dev/null and b/public/ep133-to-daw.png differ
diff --git a/public/ep133.png b/public/ep133.png
new file mode 100644
index 0000000..b3f7255
Binary files /dev/null and b/public/ep133.png differ
diff --git a/public/ep40-on.png b/public/ep40-on.png
new file mode 100644
index 0000000..ba48ae5
Binary files /dev/null and b/public/ep40-on.png differ
diff --git a/public/eptoolkit/eptoolkit-1.png b/public/eptoolkit/eptoolkit-1.png
new file mode 100644
index 0000000..193ef77
Binary files /dev/null and b/public/eptoolkit/eptoolkit-1.png differ
diff --git a/public/eptoolkit/eptoolkit-2.png b/public/eptoolkit/eptoolkit-2.png
new file mode 100644
index 0000000..dcae444
Binary files /dev/null and b/public/eptoolkit/eptoolkit-2.png differ
diff --git a/public/eptoolkit/eptoolkit-3.png b/public/eptoolkit/eptoolkit-3.png
new file mode 100644
index 0000000..b164040
Binary files /dev/null and b/public/eptoolkit/eptoolkit-3.png differ
diff --git a/public/eptoolkit/eptoolkit-logo.png b/public/eptoolkit/eptoolkit-logo.png
new file mode 100644
index 0000000..cd4f2f8
Binary files /dev/null and b/public/eptoolkit/eptoolkit-logo.png differ
diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png
new file mode 100644
index 0000000..eeea885
Binary files /dev/null and b/public/favicon-16x16.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
new file mode 100644
index 0000000..beeb1b4
Binary files /dev/null and b/public/favicon-32x32.png differ
diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png
new file mode 100644
index 0000000..431f9f8
Binary files /dev/null and b/public/favicon-96x96.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..4a3f32f
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/ms-icon-144x144.png b/public/ms-icon-144x144.png
new file mode 100644
index 0000000..66fad2e
Binary files /dev/null and b/public/ms-icon-144x144.png differ
diff --git a/public/ms-icon-150x150.png b/public/ms-icon-150x150.png
new file mode 100644
index 0000000..1e3b025
Binary files /dev/null and b/public/ms-icon-150x150.png differ
diff --git a/public/ms-icon-310x310.png b/public/ms-icon-310x310.png
new file mode 100644
index 0000000..20f6cb9
Binary files /dev/null and b/public/ms-icon-310x310.png differ
diff --git a/public/ms-icon-70x70.png b/public/ms-icon-70x70.png
new file mode 100644
index 0000000..7e80467
Binary files /dev/null and b/public/ms-icon-70x70.png differ
diff --git a/public/reaper.jpg b/public/reaper.jpg
new file mode 100644
index 0000000..dc79cc4
Binary files /dev/null and b/public/reaper.jpg differ
diff --git a/public/riddim.png b/public/riddim.png
new file mode 100644
index 0000000..e6fd791
Binary files /dev/null and b/public/riddim.png differ
diff --git a/public/track-bg.svg b/public/track-bg.svg
new file mode 100644
index 0000000..33d4c12
--- /dev/null
+++ b/public/track-bg.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/reaper_extract_vst_base64.mjs b/scripts/reaper_extract_vst_base64.mjs
new file mode 100755
index 0000000..99919e6
--- /dev/null
+++ b/scripts/reaper_extract_vst_base64.mjs
@@ -0,0 +1,153 @@
+import fs from 'node:fs';
+
+function int32ToLEBytes(value) {
+ const bytes = new Uint8Array(4);
+ bytes[0] = value & 0xff;
+ bytes[1] = (value >> 8) & 0xff;
+ bytes[2] = (value >> 16) & 0xff;
+ bytes[3] = (value >> 24) & 0xff;
+ return bytes;
+}
+function bytesToDouble(bytes) {
+ if (bytes.length !== 8) {
+ throw new Error('Expected 8 bytes');
+ }
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ bytes.forEach((b, i) => view.setUint8(i, b));
+ return view.getFloat64(0, true);
+}
+
+function doubleToBytes(num) {
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ view.setFloat64(0, num, true);
+ const bytes = [];
+ for (let i = 0; i < 8; i++) {
+ bytes.push(view.getUint8(i));
+ }
+ return bytes;
+}
+
+function paramToBytes(paramValue) {
+ const normalizedValue = paramValue / 250.0;
+
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ view.setFloat64(0, normalizedValue, true);
+
+ const bytes = [];
+ for (let i = 0; i < 8; i++) {
+ bytes.push(view.getUint8(i));
+ }
+ return bytes;
+}
+
+function dbToBytes(dbValue) {
+ const linearGain = Math.pow(10, dbValue / 20);
+
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ view.setFloat64(0, linearGain, true);
+
+ const bytes = [];
+ for (let i = 0; i < 8; i++) {
+ bytes.push(view.getUint8(i));
+ }
+ return bytes;
+}
+
+function msToBytes(msValue, sampleLengthMs) {
+ const normalizedValue = msValue / sampleLengthMs;
+
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ view.setFloat64(0, normalizedValue, true);
+
+ const bytes = [];
+ for (let i = 0; i < 8; i++) {
+ bytes.push(view.getUint8(i));
+ }
+ return bytes;
+}
+
+function bytesToString(bytes) {
+ return bytes.reduce((acc, byte, index) => {
+ return `${acc + String(index).padStart(3, '0')} ${byte}\n`;
+ }, '');
+}
+
+function midiNoteToBytes(note) {
+ return doubleToBytes((note - 40) / 160);
+}
+
+const filePath = '/home/yura/Documents/REAPER Media/test1/test1.RPP';
+// const filePath = '/home/yura/dev/MY/ep133-export-to-daw/.tmp/reaper/test2.RPP';
+const txt = fs.readFileSync(filePath, 'utf8');
+
+const lines = txt.split(/\r?\n/);
+let startIdx = -1;
+for (let i = 0; i < lines.length; i++) {
+ if (lines[i].trim().startsWith(' startIdx && t === '>') break;
+ section.push(lines[i]);
+}
+
+const joined = section.slice(1).join('');
+
+const sections = [];
+let buf = '';
+for (let i = 0; i < joined.length; i++) {
+ buf += joined[i];
+
+ if ((joined[i] === '=' && joined[i + 1] === ' ') || i === joined.length - 1) {
+ sections.push(buf.trim().replace(/\s/g, ''));
+ buf = '';
+ }
+}
+
+const bin1 = Buffer.from(sections[0], 'base64');
+const bin2 = Buffer.from(sections[1], 'base64');
+
+const idx = bin2.indexOf(0x00) + 1;
+// console.log(idx);
+// const textData = bin2.slice(0, idx).toString('utf8');
+const cut2 = bin2.slice(idx);
+// console.dir(textData);
+// console.dir(Array.from(cut2), { maxArrayLength: null, compact: false });
+// console.log(cut.length);
+// console.log(bytesToString(cut2));
+
+// console.log('Size:', size);
+// console.log(int32ToLEBytes(size));
+
+// const newHeader = [...VST_HEADER];
+// newHeader.splice(32, 4, ...int32ToLEBytes(347));
+
+// console.dir(newHeader, { maxArrayLength: null, compact: false });
+
+// console.dir(new Uint8Array(bin2), { maxArrayLength: null, compact: false });
+// console.log(bin2.length);
+// console.dir(val.slice(207, 214), { maxArrayLength: null, compact: false });
+
+const ofs = 40;
+console.dir(cut2.slice(ofs, ofs + 8), { compact: false });
+console.log(bytesToDouble(cut2.slice(ofs, ofs + 8)));
+// console.dir(paramToBytes(18.11), { compact: false });
+
+// console.log(bytesToFloat32BE([0x40, 0x94, 0x84, 0xf2]));
+// console.dir(paramToBytes(10), { compact: false });
+// console.dir(dbToBytes(-10), { compact: false });
+
+// console.log(midiNoteToBytes(60));
+// console.log(bytesToDouble(midiNoteToBytes(60)));
+// console.log(doubleToBytes(460.59));
+// console.log(int32ToLEBytes(46875));
diff --git a/scripts/reaper_generate_vst_state.mjs b/scripts/reaper_generate_vst_state.mjs
new file mode 100755
index 0000000..61cded8
--- /dev/null
+++ b/scripts/reaper_generate_vst_state.mjs
@@ -0,0 +1,135 @@
+const VST_HEADER = [
+ 109, 111, 115, 114, 238, 94, 237, 254, 0, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0,
+ 0, 0, 0, 0, 90, 1, 0, 0, 1, 0, 0, 0, 0, 0, 16, 0,
+];
+
+const VST_BODY = [
+ 0, 0, 0, 0, 0, 0, 240, 63, 0, 0, 0, 0, 0, 0, 224, 63, 0, 0, 0, 0, 0, 0, 240, 63, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 240, 63, 154, 153, 153, 153, 153, 153, 177, 63, 205, 204, 204, 204, 204,
+ 204, 235, 63, 0, 0, 0, 0, 0, 0, 0, 0, 28, 199, 113, 28, 199, 113, 220, 63, 252, 169, 241, 210, 77,
+ 98, 64, 63, 252, 169, 241, 210, 77, 98, 64, 63, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 240, 63, 0, 0, 0, 0, 0, 0, 224, 63, 1, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 240, 63, 64, 0, 0, 0, 85, 85, 85, 85, 85, 85, 197, 63, 255, 255,
+ 255, 255, 8, 4, 2, 129, 64, 32, 128, 63, 0, 0, 0, 0, 0, 0, 240, 63, 0, 0, 0, 0, 0, 0, 240, 63, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 206,
+ 164, 33, 33, 26, 101, 144, 63, 0, 0, 0, 0, 0, 0, 240, 63, 252, 169, 241, 210, 77, 98, 48, 63, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0,
+];
+
+function int32ToLEBytes(value) {
+ const bytes = new Uint8Array(4);
+ bytes[0] = value & 0xff;
+ bytes[1] = (value >> 8) & 0xff;
+ bytes[2] = (value >> 16) & 0xff;
+ bytes[3] = (value >> 24) & 0xff;
+ return bytes;
+}
+function bytesToDouble(bytes) {
+ if (bytes.length !== 8) {
+ throw new Error('Expected 8 bytes');
+ }
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ bytes.forEach((b, i) => view.setUint8(i, b));
+ return view.getFloat64(0, true);
+}
+
+function doubleToBytes(num) {
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ view.setFloat64(0, num, true);
+ const bytes = [];
+ for (let i = 0; i < 8; i++) {
+ bytes.push(view.getUint8(i));
+ }
+ return bytes;
+}
+
+function paramToBytes(paramValue) {
+ const normalizedValue = paramValue / 250.0;
+
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ view.setFloat64(0, normalizedValue, true);
+
+ const bytes = [];
+ for (let i = 0; i < 8; i++) {
+ bytes.push(view.getUint8(i));
+ }
+ return bytes;
+}
+
+function dbToBytes(dbValue) {
+ const linearGain = Math.pow(10, dbValue / 20);
+
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ view.setFloat64(0, linearGain, true);
+
+ const bytes = [];
+ for (let i = 0; i < 8; i++) {
+ bytes.push(view.getUint8(i));
+ }
+ return bytes;
+}
+
+function msToBytes(msValue, sampleLengthMs) {
+ const normalizedValue = msValue / sampleLengthMs;
+
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ view.setFloat64(0, normalizedValue, true);
+
+ const bytes = [];
+ for (let i = 0; i < 8; i++) {
+ bytes.push(view.getUint8(i));
+ }
+ return bytes;
+}
+
+function bytesToString(bytes) {
+ return bytes.reduce((acc, byte, index) => {
+ return `${acc + String(index).padStart(3, '0')} ${byte}\n`;
+ }, '');
+}
+
+function buildVstState({ filePath, volume = -6, attack = 10, release = 10 }) {
+ const encoder = new TextEncoder();
+ const bufferHeader = VST_HEADER;
+ const bufferBody = [...VST_BODY];
+
+ // console.log(cut.length);
+
+ // console.dir(bufferHeader, { maxArrayLength: null, compact: false });
+ // console.dir(Buffer.from(bufferBody).toString('utf8'), { maxArrayLength: null, compact: fal 6y5a -O0se });
+
+ // const decoder = new TextDecoder('utf-8'); // Specify the encoding, e.g., 'utf-8'
+ // console.log(decoder.decode(new Uint8Array(VST_BODY)));
+ // console.log(decoder.decode(new Uint8Array(cut)));
+
+ bufferBody.splice(0, 8, ...dbToBytes(volume));
+ // bufferBody.splice(8, 8, ...dbToBytes(5));
+ // bufferBody.splice(16, 8, ...dbToBytes()); // min volume
+ bufferBody.splice(72, 8, ...msToBytes(attack, 920)); // atack
+ bufferBody.splice(80, 8, ...msToBytes(release, 920)); // release
+
+ // console.dir(bufferBody, { maxArrayLength: null, compact: false });
+ console.log(bytesToString(bufferBody));
+
+ const fullBody = [...encoder.encode(filePath), 0, ...bufferBody];
+
+ bufferHeader.splice(32, 4, ...int32ToLEBytes(fullBody.length));
+
+ return {
+ header: Buffer.from(bufferHeader).toString('base64'),
+ body: Buffer.from(fullBody).toString('base64'),
+ };
+}
+
+const result = buildVstState({
+ filePath: 'Media/BD B 808 Decay C 02.wav',
+ // filePath: 'Media/600 veh2 percussive .wav',
+});
+
+console.log(' ' + result.header);
+console.log(' ' + result.body);
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..9e94e32
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,42 @@
+import { QueryClientProvider } from '@tanstack/react-query';
+import { Provider } from 'jotai';
+import { DndProvider } from 'react-dnd/dist/core/DndProvider';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+import { Route, Switch } from 'wouter-preact';
+import Faq from '~/routes/faq/Faq';
+import Home from '~/routes/home/Home';
+import DeviceProvider from './components/DeviceProvider';
+import ErrorBoundary from './components/ErrorBoundary';
+import ErrorFallback from './components/ErrorFallback';
+import Layout from './components/Layout';
+import Page404 from './components/Page404';
+import queryClient from './lib/queryClient';
+import { store } from './lib/store';
+
+function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/src/atoms/deviceSku.ts b/src/atoms/deviceSku.ts
new file mode 100644
index 0000000..c1e0028
--- /dev/null
+++ b/src/atoms/deviceSku.ts
@@ -0,0 +1,4 @@
+import { atom } from 'jotai';
+import { SKU_EP133 } from '~/lib/constants';
+
+export const deviceSkuAtom = atom(SKU_EP133);
diff --git a/src/atoms/droppedProjectFile.ts b/src/atoms/droppedProjectFile.ts
new file mode 100644
index 0000000..9fa8c93
--- /dev/null
+++ b/src/atoms/droppedProjectFile.ts
@@ -0,0 +1,13 @@
+import { atom } from 'jotai';
+import type JSZip from 'jszip';
+
+export type DroppedProjectFile = {
+ name: string;
+ data: Uint8Array;
+};
+
+export const droppedProjectFileAtom = atom(null);
+export const droppedBackupFileAtom = atom(null);
+export const unzippedBackupAtom = atom(null);
+export const backupSkuAtom = atom(null);
+export const backupProjectIdsAtom = atom([]);
diff --git a/src/atoms/feedbackDialog.ts b/src/atoms/feedbackDialog.ts
new file mode 100644
index 0000000..9b8ca20
--- /dev/null
+++ b/src/atoms/feedbackDialog.ts
@@ -0,0 +1,3 @@
+import { atom } from 'jotai';
+
+export const feedbackDialogAtom = atom(false);
diff --git a/src/atoms/project.ts b/src/atoms/project.ts
new file mode 100644
index 0000000..09b0ed0
--- /dev/null
+++ b/src/atoms/project.ts
@@ -0,0 +1,4 @@
+import { atom } from 'jotai';
+
+export const projectIdAtom = atom('');
+export const customSceneNamesByProjectAtom = atom>({});
diff --git a/src/atoms/swUpdate.ts b/src/atoms/swUpdate.ts
new file mode 100644
index 0000000..919b9b5
--- /dev/null
+++ b/src/atoms/swUpdate.ts
@@ -0,0 +1,3 @@
+import { atom } from 'jotai';
+
+export const swUpdateAvailableAtom = atom(false);
diff --git a/src/atoms/toast.ts b/src/atoms/toast.ts
new file mode 100644
index 0000000..e4f474a
--- /dev/null
+++ b/src/atoms/toast.ts
@@ -0,0 +1,19 @@
+import { atom } from 'jotai';
+
+export type ToastSeverity = 'info' | 'error';
+
+export interface ToastMessage {
+ id: string;
+ message: string;
+ severity: ToastSeverity;
+ timestamp: number;
+}
+
+export const toastsAtom = atom([]);
+
+export const removeToastAtom = atom(null, (get, set, id: string) => {
+ set(
+ toastsAtom,
+ get(toastsAtom).filter((toast) => toast.id !== id),
+ );
+});
diff --git a/src/components/DeviceContext.tsx b/src/components/DeviceContext.tsx
new file mode 100644
index 0000000..e7d314d
--- /dev/null
+++ b/src/components/DeviceContext.tsx
@@ -0,0 +1,12 @@
+import { createContext } from 'preact';
+import { TEDevice } from '../lib/midi/types';
+
+const DeviceContext = createContext<{
+ device: TEDevice | null;
+ error: Error | null;
+}>({
+ device: null,
+ error: null,
+});
+
+export default DeviceContext;
diff --git a/src/components/DeviceProvider.tsx b/src/components/DeviceProvider.tsx
new file mode 100644
index 0000000..443199b
--- /dev/null
+++ b/src/components/DeviceProvider.tsx
@@ -0,0 +1,40 @@
+import { useAtom } from 'jotai';
+import { useEffect, useState } from 'preact/hooks';
+import { deviceSkuAtom } from '~/atoms/deviceSku';
+import { SKU_EP133 } from '~/lib/constants';
+import { initDevice } from '../lib/midi';
+import { TEDevice } from '../lib/midi/types';
+import DeviceContext from './DeviceContext';
+
+function DeviceProvider({ children }: { children: preact.ComponentChildren }) {
+ const [device, setDevice] = useState(null);
+ const [deviceError, setDeviceError] = useState(null);
+ const [, setDeviceSku] = useAtom(deviceSkuAtom);
+
+ useEffect(() => {
+ initDevice({
+ onDeviceFound: (deviceInfo) => {
+ console.log('Device found:', deviceInfo);
+ setDevice(deviceInfo);
+ setDeviceSku(deviceInfo.sku);
+ },
+ onDeviceLost: () => {
+ console.log('Device lost');
+ setDevice(null);
+ setDeviceSku(SKU_EP133);
+ },
+ onNoMidiAccess: (error) => {
+ console.error('No MIDI access:', error);
+ setDeviceError(error);
+ },
+ });
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default DeviceProvider;
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..bc7c9fd
--- /dev/null
+++ b/src/components/ErrorBoundary.tsx
@@ -0,0 +1,41 @@
+import * as Sentry from '@sentry/react';
+import { Component } from 'preact';
+
+interface ErrorBoundaryProps {
+ children: any;
+ FallbackComponent: any;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error?: Error;
+}
+
+class ErrorBoundary extends Component {
+ state: ErrorBoundaryState;
+
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: any) {
+ console.error('ErrorBoundary caught an error:', error, errorInfo);
+ Sentry.captureReactException(error, errorInfo);
+ }
+
+ render() {
+ if (this.state.hasError && this.state.error) {
+ const FallbackComponent = this.props.FallbackComponent;
+ return ;
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/src/components/ErrorFallback.tsx b/src/components/ErrorFallback.tsx
new file mode 100644
index 0000000..c8cf514
--- /dev/null
+++ b/src/components/ErrorFallback.tsx
@@ -0,0 +1,22 @@
+import Button from './ui/Button';
+
+function ErrorFallback() {
+ return (
+
+
+
Something went wrong
+
+
+ An unexpected error occurred. Please try refreshing the page. If the problem persists,
+ please submit a bug report. There is a feedback button in the top right corner of the app.
+
+
+
+ window.location.reload()}>Refresh Page
+
+
+
+ );
+}
+
+export default ErrorFallback;
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
new file mode 100644
index 0000000..9afbf6f
--- /dev/null
+++ b/src/components/Layout.tsx
@@ -0,0 +1,12 @@
+import useTheme from '~/hooks/useTheme';
+
+function Layout({ children }: { children: preact.ComponentChildren }) {
+ const theme = useTheme();
+ return (
+
+ {children}
+
+ );
+}
+
+export default Layout;
diff --git a/src/components/OfflineInformer.tsx b/src/components/OfflineInformer.tsx
new file mode 100644
index 0000000..c3b0a6c
--- /dev/null
+++ b/src/components/OfflineInformer.tsx
@@ -0,0 +1,34 @@
+import clsx from 'clsx';
+import { useEffect, useState } from 'preact/hooks';
+
+function OfflineInformer({ className }: { className?: string }) {
+ const [isOnline, setIsOnline] = useState(navigator.onLine);
+
+ useEffect(() => {
+ const handleOnline = () => setIsOnline(true);
+ const handleOffline = () => setIsOnline(false);
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, []);
+
+ if (isOnline) return null;
+
+ return (
+
+ You’re currently offline, but the app is still working just fine!
+
+ );
+}
+
+export default OfflineInformer;
diff --git a/src/components/Page404.tsx b/src/components/Page404.tsx
new file mode 100644
index 0000000..1de0c7a
--- /dev/null
+++ b/src/components/Page404.tsx
@@ -0,0 +1,12 @@
+function Page404() {
+ return (
+
+
+
404: Not Found
+
The page you are looking for does not exist.
+
+
+ );
+}
+
+export default Page404;
diff --git a/src/components/UpdateNotifier.tsx b/src/components/UpdateNotifier.tsx
new file mode 100644
index 0000000..6348179
--- /dev/null
+++ b/src/components/UpdateNotifier.tsx
@@ -0,0 +1,47 @@
+import clsx from 'clsx';
+import { useAtomValue } from 'jotai';
+import { useEffect, useState } from 'preact/hooks';
+import { swUpdateAvailableAtom } from '~/atoms/swUpdate';
+
+const RELOAD_DELAY_SECONDS = 3;
+
+function UpdateNotifier({ className }: { className?: string }) {
+ const updateAvailable = useAtomValue(swUpdateAvailableAtom);
+ const [countdown, setCountdown] = useState(RELOAD_DELAY_SECONDS);
+
+ useEffect(() => {
+ if (!updateAvailable) {
+ return;
+ }
+
+ const interval = setInterval(() => {
+ setCountdown((prev) => {
+ if (prev <= 1) {
+ clearInterval(interval);
+ window.location.reload();
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [updateAvailable]);
+
+ if (!updateAvailable) {
+ return null;
+ }
+
+ return (
+
+ A new version is available. Reloading in {countdown} second{countdown !== 1 ? 's' : ''}...
+
+ );
+}
+
+export default UpdateNotifier;
diff --git a/src/components/form/CheckboxField.tsx b/src/components/form/CheckboxField.tsx
new file mode 100644
index 0000000..80020ea
--- /dev/null
+++ b/src/components/form/CheckboxField.tsx
@@ -0,0 +1,37 @@
+import { Controller, FieldPath, FieldValues, useFormContext } from 'react-hook-form';
+import CheckBox from '../ui/CheckBox';
+
+function CheckboxField({
+ name,
+ title,
+ disabled,
+ helperText,
+ className,
+}: {
+ name: FieldPath;
+ title: string;
+ disabled?: boolean;
+ helperText?: string;
+ className?: string;
+}) {
+ const { control } = useFormContext();
+
+ return (
+ (
+ field.onChange(checked)}
+ title={title}
+ disabled={disabled ?? false}
+ helperText={helperText}
+ className={className}
+ />
+ )}
+ />
+ );
+}
+
+export default CheckboxField;
diff --git a/src/components/form/InputField.tsx b/src/components/form/InputField.tsx
new file mode 100644
index 0000000..47f2638
--- /dev/null
+++ b/src/components/form/InputField.tsx
@@ -0,0 +1,42 @@
+import { Controller, FieldPath, FieldValues, useFormContext } from 'react-hook-form';
+import Input from '../ui/Input';
+
+function InputField({
+ name,
+ label,
+ disabled,
+ className,
+ placeholder,
+ transform,
+}: {
+ name: FieldPath;
+ label?: string;
+ disabled?: boolean;
+ className?: string;
+ placeholder?: string;
+ transform?: (value: string) => string;
+}) {
+ const { control } = useFormContext();
+
+ return (
+ (
+ {
+ const raw = (e.currentTarget as HTMLInputElement).value;
+ field.onChange(transform ? transform(raw) : raw);
+ }}
+ disabled={disabled}
+ className={className}
+ placeholder={placeholder}
+ />
+ )}
+ />
+ );
+}
+
+export default InputField;
diff --git a/src/components/form/SelectField.tsx b/src/components/form/SelectField.tsx
new file mode 100644
index 0000000..86928f8
--- /dev/null
+++ b/src/components/form/SelectField.tsx
@@ -0,0 +1,39 @@
+import { JSX } from 'preact';
+import { Controller, FieldPath, FieldValues, useFormContext } from 'react-hook-form';
+import Select from '../ui/Select';
+
+function SelectField({
+ name,
+ children,
+ disabled,
+ className,
+}: {
+ name: FieldPath;
+ children: any;
+ disabled?: boolean;
+ className?: string;
+}) {
+ const { control } = useFormContext();
+
+ return (
+ (
+ ) =>
+ field.onChange(e.currentTarget.value)
+ }
+ value={field.value as string}
+ name={name}
+ disabled={disabled}
+ className={className}
+ >
+ {children}
+
+ )}
+ />
+ );
+}
+
+export default SelectField;
diff --git a/src/components/icons/arrow-dialog.svg b/src/components/icons/arrow-dialog.svg
new file mode 100644
index 0000000..5002479
--- /dev/null
+++ b/src/components/icons/arrow-dialog.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/components/icons/arrow-left.svg b/src/components/icons/arrow-left.svg
new file mode 100644
index 0000000..d1174dd
--- /dev/null
+++ b/src/components/icons/arrow-left.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/components/icons/check.svg b/src/components/icons/check.svg
new file mode 100644
index 0000000..3be3523
--- /dev/null
+++ b/src/components/icons/check.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/icons/close.svg b/src/components/icons/close.svg
new file mode 100644
index 0000000..af50392
--- /dev/null
+++ b/src/components/icons/close.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/icons/edit.svg b/src/components/icons/edit.svg
new file mode 100644
index 0000000..5af59b6
--- /dev/null
+++ b/src/components/icons/edit.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/icons/file.svg b/src/components/icons/file.svg
new file mode 100644
index 0000000..c8ff095
--- /dev/null
+++ b/src/components/icons/file.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/components/icons/github.svg b/src/components/icons/github.svg
new file mode 100644
index 0000000..8333f09
--- /dev/null
+++ b/src/components/icons/github.svg
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/components/icons/info.svg b/src/components/icons/info.svg
new file mode 100644
index 0000000..f84ed13
--- /dev/null
+++ b/src/components/icons/info.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/components/icons/mail.svg b/src/components/icons/mail.svg
new file mode 100644
index 0000000..62ca6f0
--- /dev/null
+++ b/src/components/icons/mail.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/src/components/icons/music.svg b/src/components/icons/music.svg
new file mode 100644
index 0000000..128efe6
--- /dev/null
+++ b/src/components/icons/music.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/icons/reload.svg b/src/components/icons/reload.svg
new file mode 100644
index 0000000..31cbc8d
--- /dev/null
+++ b/src/components/icons/reload.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/components/icons/reset-settings.svg b/src/components/icons/reset-settings.svg
new file mode 100644
index 0000000..2ff96c1
--- /dev/null
+++ b/src/components/icons/reset-settings.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/src/components/icons/trash.svg b/src/components/icons/trash.svg
new file mode 100644
index 0000000..e89b1ba
--- /dev/null
+++ b/src/components/icons/trash.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
new file mode 100644
index 0000000..b0713a1
--- /dev/null
+++ b/src/components/ui/Button.tsx
@@ -0,0 +1,68 @@
+import clsx from 'clsx';
+import { JSX } from 'preact';
+import { twMerge } from 'tailwind-merge';
+import { Link } from 'wouter';
+
+interface ButtonProps extends JSX.HTMLAttributes {
+ children: any;
+ size?: 'sm' | 'md' | 'xs';
+ disabled?: boolean;
+ variant?: 'primary' | 'secondary' | 'outlined' | 'tertiary' | 'ghost' | 'icon';
+ type?: 'button' | 'submit' | 'reset';
+ to?: string;
+ target?: string;
+ rel?: string;
+}
+
+function Button({
+ children,
+ className = '',
+ variant = 'primary',
+ size = 'md',
+ type = 'button',
+ to,
+ target,
+ rel,
+ disabled,
+ ...props
+}: ButtonProps) {
+ const variantClasses = {
+ primary: 'bg-face hover:bg-[#d0d2d0] active:bg-[#c5c7c5]',
+ secondary: 'bg-gray-100 hover:bg-gray-300 active:bg-gray-400',
+ tertiary: 'bg-[#c6d1d7] hover:bg-gray-100 active:bg-gray-200',
+ outlined: 'bg-transparent hover:bg-black/10 active:bg-black/20 border-1 border-black',
+ ghost: 'border-0 bg-transparent hover:bg-black/10 active:bg-black/20',
+ icon: 'flex-shrink-0 !py-1 !px-1 border-0 !h-auto',
+ } as const;
+
+ const sizeClasses = {
+ xs: 'text-xs py-1 px-2 h-[24px]',
+ sm: 'text-sm py-1 px-2 h-[28px]',
+ md: 'text-sm py-2 px-4 h-[42px]',
+ } as const;
+
+ const baseClasses =
+ 'text-black cursor-pointer border-1 border-black disabled:opacity-80 disabled:cursor-not-allowed disabled:text-gray-400 outline-none transition-colors duration-200';
+
+ const linkDisabledClasses = disabled ? 'pointer-events-none opacity-80 text-gray-400' : '';
+
+ const composed = twMerge(
+ clsx(baseClasses, variantClasses[variant], sizeClasses[size], linkDisabledClasses, className),
+ );
+
+ if (to) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default Button;
diff --git a/src/components/ui/CheckBox.tsx b/src/components/ui/CheckBox.tsx
new file mode 100644
index 0000000..73bd3a6
--- /dev/null
+++ b/src/components/ui/CheckBox.tsx
@@ -0,0 +1,56 @@
+import clsx from 'clsx';
+import { JSX } from 'preact';
+import { useId } from 'preact/hooks';
+import IconInfo from '../icons/info.svg?react';
+
+function CheckBox({
+ checked,
+ onChange,
+ title,
+ helperText,
+ disabled,
+ className,
+}: {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ title: string;
+ helperText?: string;
+ disabled: boolean;
+ className?: string;
+}) {
+ const _id = useId();
+
+ return (
+
+ ) => onChange(e.currentTarget.checked)}
+ disabled={disabled}
+ className={clsx('w-4 h-4', disabled && 'opacity-50')}
+ />
+
+ {title}
+
+
+ {helperText && (
+
+
+
+
+
+ {helperText}
+
+
+ )}
+
+ );
+}
+
+export default CheckBox;
diff --git a/src/components/ui/Dialog.tsx b/src/components/ui/Dialog.tsx
new file mode 100644
index 0000000..cd38e0c
--- /dev/null
+++ b/src/components/ui/Dialog.tsx
@@ -0,0 +1,51 @@
+import clsx from 'clsx';
+import { useEffect, useRef } from 'preact/hooks';
+
+interface DialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ children: any;
+ className?: string;
+ containerClassName?: string;
+}
+
+function Dialog({
+ isOpen,
+ onClose,
+ children,
+ className = '',
+ containerClassName = '',
+}: DialogProps) {
+ const dialogRef = useRef(null);
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+
+ if (!dialog) {
+ return;
+ }
+
+ if (isOpen) {
+ dialog.showModal();
+ } else {
+ dialog.close();
+ }
+ }, [isOpen]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export default Dialog;
diff --git a/src/components/ui/FileInput.tsx b/src/components/ui/FileInput.tsx
new file mode 100644
index 0000000..11893b1
--- /dev/null
+++ b/src/components/ui/FileInput.tsx
@@ -0,0 +1,84 @@
+import { useRef } from 'preact/hooks';
+import IconFile from '../icons/file.svg?react';
+import IconTrash from '../icons/trash.svg?react';
+import Button from './Button';
+
+interface FileInputProps {
+ files: File[];
+ onFilesChange: (files: File[]) => void;
+ maxFiles?: number;
+ disabled?: boolean;
+ label?: string;
+}
+
+function FileInput({
+ files,
+ onFilesChange,
+ maxFiles = 3,
+ disabled = false,
+ label = 'Attach files',
+}: FileInputProps) {
+ const fileInputRef = useRef(null);
+
+ const handleFileSelect = (e: Event) => {
+ const target = e.target as HTMLInputElement;
+ if (target.files) {
+ const newFiles = Array.from(target.files);
+ onFilesChange([...files, ...newFiles].slice(0, maxFiles));
+ }
+ };
+
+ const removeFile = (index: number) => {
+ onFilesChange(files.filter((_, i) => i !== index));
+ };
+
+ const openFileDialog = () => {
+ fileInputRef.current?.click();
+ };
+
+ return (
+
+
+ {label} (optional, max {maxFiles})
+
+
+ {files.map((file, index) => (
+
+
+ {file.name}
+
+ {(file.size / 1024).toFixed(2)}KB
+
+ removeFile(index)} disabled={disabled}>
+
+
+
+ ))}
+ {files.length < maxFiles && (
+
+ +
+ Add file
+
+ )}
+
+
+
+ );
+}
+
+export default FileInput;
diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx
new file mode 100644
index 0000000..03bce4a
--- /dev/null
+++ b/src/components/ui/Input.tsx
@@ -0,0 +1,66 @@
+import clsx from 'clsx';
+import { JSX } from 'preact';
+import { forwardRef } from 'preact/compat';
+import { twMerge } from 'tailwind-merge';
+
+interface BaseInputProps {
+ label?: string;
+ type?: 'text' | 'email' | 'textarea' | 'file';
+ error?: string;
+ className?: string;
+ value?: string;
+ disabled?: boolean;
+ onChange?: (e: Event) => void;
+ required?: boolean;
+ multiple?: boolean;
+ accept?: string;
+ placeholder?: string;
+ rows?: number;
+}
+
+type InputProps = BaseInputProps &
+ (JSX.HTMLAttributes | JSX.HTMLAttributes);
+
+const Input = forwardRef(
+ ({ label, type = 'text', className = '', disabled = false, ...props }, ref) => {
+ const id = props.id || label?.toLowerCase().replace(/\s+/g, '-');
+ const inputClasses = twMerge(
+ clsx(
+ 'w-full px-3 py-2 border-1 border-black focus:outline-none focus:ring-0 bg-gray-50 placeholder:text-gray-400',
+ disabled && 'opacity-50',
+ className,
+ ),
+ );
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+ {type === 'textarea' ? (
+
+ );
+ },
+);
+
+export default Input;
diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx
new file mode 100644
index 0000000..24cff7b
--- /dev/null
+++ b/src/components/ui/Select.tsx
@@ -0,0 +1,60 @@
+import clsx from 'clsx';
+import { JSX } from 'preact';
+
+interface SelectProps extends Omit, 'size'> {
+ children: any;
+ className?: string;
+ size?: 'sm' | 'md';
+ variant?: 'primary' | 'secondary' | 'outlined' | 'tertiary' | 'ghost' | 'icon';
+}
+
+function Select({
+ children,
+ className = '',
+ size = 'md',
+ variant = 'primary',
+ ...props
+}: SelectProps) {
+ const variantClasses = {
+ primary: 'bg-gray-100 hover:bg-[#d0d2d0] active:bg-[#c5c7c5] border-1 border-black',
+ secondary: 'bg-gray-100 hover:bg-gray-300 active:bg-gray-400 border-1 border-black',
+ tertiary: 'bg-[#c6d1d7] hover:bg-gray-100 active:bg-gray-200 border-1 border-black',
+ outlined: 'bg-transparent hover:bg-black/10 active:bg-black/20 border-1 border-black',
+ ghost: 'border-0 bg-transparent hover:bg-black/10 active:bg-black/20',
+ icon: 'border-0 bg-transparent',
+ } as const;
+
+ const sizeClasses = {
+ sm: {
+ wrapper: 'h-[28px]',
+ select: 'relative h-[28px] px-1 py-0 text-sm -top-[1px]',
+ },
+ md: {
+ wrapper: 'h-[42px]',
+ select: 'h-[42px] p-2 text-sm',
+ },
+ } as const;
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export default Select;
diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx
new file mode 100644
index 0000000..084c855
--- /dev/null
+++ b/src/components/ui/Toast.tsx
@@ -0,0 +1,64 @@
+import clsx from 'clsx';
+import { useAtom } from 'jotai';
+import { useEffect, useRef } from 'preact/hooks';
+import { removeToastAtom, type ToastMessage, toastsAtom } from '../../atoms/toast';
+import Button from './Button';
+
+const TOAST_DURATION = 5000;
+
+function ToastItem({ toast, onRemove }: { toast: ToastMessage; onRemove: (id: string) => void }) {
+ const toastRef = useRef(null);
+
+ useEffect(() => {
+ if (toastRef.current) {
+ toastRef.current.showPopover();
+ }
+ }, [toast.id]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ onRemove(toast.id);
+ }, TOAST_DURATION);
+
+ return () => clearTimeout(timer);
+ }, [toast.id, onRemove]);
+
+ return (
+
+
+
{toast.message}
+
onRemove(toast.id)} className="ml-auto">
+
+
+
+
+
+
+ );
+}
+
+function Toast() {
+ const [toasts] = useAtom(toastsAtom);
+ const [, removeToast] = useAtom(removeToastAtom);
+
+ return (
+ <>
+ {toasts.map((toast) => (
+
+ ))}
+ >
+ );
+}
+
+export default Toast;
diff --git a/src/hooks/useAppState.ts b/src/hooks/useAppState.ts
new file mode 100644
index 0000000..bc9bd46
--- /dev/null
+++ b/src/hooks/useAppState.ts
@@ -0,0 +1,75 @@
+import { useAtomValue } from 'jotai';
+import { useMemo } from 'preact/hooks';
+import { droppedBackupFileAtom } from '~/atoms/droppedProjectFile';
+import { projectIdAtom } from '../atoms/project';
+import useDevice from './useDevice';
+import useProject from './useProject';
+
+export const APP_STATES = {
+ CAN_DISPLAY_PROJECT: 'CAN_DISPLAY_PROJECT',
+ NO_DEVICE_CONNECTED: 'NO_DEVICE_CONNECTED',
+ ERROR: 'ERROR',
+ LOADING: 'LOADING',
+ NO_PROJECT_SELECTED: 'NO_PROJECT_SELECTED',
+ CAN_SELECT_PROJECT: 'CAN_SELECT_PROJECT',
+ CAN_RELOAD_PROJECT: 'CAN_RELOAD_PROJECT',
+ DEVICE_READY: 'DEVICE_READY',
+ CAN_EXPORT_PROJECT: 'CAN_EXPORT_PROJECT',
+ HAS_BACKUP_FILE: 'HAS_BACKUP_FILE',
+};
+
+export function useAppState() {
+ const projectId = useAtomValue(projectIdAtom);
+ const { isLoading: isLoadingProject, isRefetching } = useProject(projectId);
+ const { device, error } = useDevice();
+ const droppedBackupFile = useAtomValue(droppedBackupFileAtom);
+
+ const isLoading = isLoadingProject || isRefetching;
+ const noDevice = !device;
+ const hasBackupFile = !!droppedBackupFile;
+
+ return useMemo(() => {
+ const finalState = [];
+
+ if (hasBackupFile) {
+ finalState.push(APP_STATES.HAS_BACKUP_FILE);
+ }
+
+ if (projectId && !isLoading && (!noDevice || hasBackupFile)) {
+ finalState.push(APP_STATES.CAN_DISPLAY_PROJECT);
+ }
+
+ if (noDevice && !error && !hasBackupFile) {
+ finalState.push(APP_STATES.NO_DEVICE_CONNECTED);
+ }
+
+ if (error) {
+ finalState.push(APP_STATES.ERROR);
+ }
+
+ if (isLoading) {
+ finalState.push(APP_STATES.LOADING);
+ }
+
+ if (!projectId && !isLoading && (!noDevice || hasBackupFile)) {
+ finalState.push(APP_STATES.NO_PROJECT_SELECTED);
+ }
+
+ if (!noDevice && !isLoading) {
+ finalState.push(APP_STATES.CAN_SELECT_PROJECT);
+ }
+
+ if (hasBackupFile && !isLoading) {
+ finalState.push(APP_STATES.CAN_SELECT_PROJECT);
+ }
+
+ if (projectId && (!noDevice || hasBackupFile) && !isLoading) {
+ if (!hasBackupFile) {
+ finalState.push(APP_STATES.CAN_RELOAD_PROJECT);
+ }
+ finalState.push(APP_STATES.CAN_EXPORT_PROJECT);
+ }
+
+ return finalState;
+ }, [isLoading, noDevice, projectId, error, hasBackupFile]);
+}
diff --git a/src/hooks/useCustomSceneNames.ts b/src/hooks/useCustomSceneNames.ts
new file mode 100644
index 0000000..fa2c4d9
--- /dev/null
+++ b/src/hooks/useCustomSceneNames.ts
@@ -0,0 +1,46 @@
+import { useAtom } from 'jotai';
+import { useCallback } from 'preact/hooks';
+import { customSceneNamesByProjectAtom } from '~/atoms/project';
+
+function checkHasCustomSceneNames(projectPrefix: string): boolean {
+ if (projectPrefix === 'p') {
+ return false;
+ }
+
+ try {
+ return Object.keys(localStorage).some((key) => key.startsWith(projectPrefix));
+ } catch (error) {
+ console.warn(`Error checking custom scene names:`, error);
+ return false;
+ }
+}
+
+function useCustomSceneNames(projectId: string) {
+ const [customSceneNamesByProject, setCustomSceneNamesByProject] = useAtom(
+ customSceneNamesByProjectAtom,
+ );
+ const projectPrefix = `p${projectId}`;
+ const hasCustomNames =
+ customSceneNamesByProject[projectId] ?? checkHasCustomSceneNames(projectPrefix);
+
+ const syncCustomSceneNames = useCallback(() => {
+ setCustomSceneNamesByProject((prev) => ({
+ ...prev,
+ [projectId]: checkHasCustomSceneNames(projectPrefix),
+ }));
+ }, [projectId, projectPrefix, setCustomSceneNamesByProject]);
+
+ const setHasCustomNames = useCallback(
+ (value: boolean) => {
+ setCustomSceneNamesByProject((prev) => ({
+ ...prev,
+ [projectId]: value,
+ }));
+ },
+ [projectId, setCustomSceneNamesByProject],
+ );
+
+ return { hasCustomNames, setHasCustomNames, syncCustomSceneNames };
+}
+
+export default useCustomSceneNames;
diff --git a/src/hooks/useDevice.tsx b/src/hooks/useDevice.tsx
new file mode 100644
index 0000000..05465f6
--- /dev/null
+++ b/src/hooks/useDevice.tsx
@@ -0,0 +1,14 @@
+import { useContext } from 'preact/hooks';
+import DeviceContext from '../components/DeviceContext';
+
+function useDevice() {
+ const context = useContext(DeviceContext);
+
+ if (context === undefined) {
+ throw new Error('useDevice must be used within a DeviceProvider');
+ }
+
+ return context;
+}
+
+export default useDevice;
diff --git a/src/hooks/useDroppedFile.ts b/src/hooks/useDroppedFile.ts
new file mode 100644
index 0000000..dd48ad4
--- /dev/null
+++ b/src/hooks/useDroppedFile.ts
@@ -0,0 +1,123 @@
+import { useAtom } from 'jotai';
+import JSZip from 'jszip';
+import type { DropTargetMonitor } from 'react-dnd';
+import { useDrop } from 'react-dnd';
+import { NativeTypes } from 'react-dnd-html5-backend';
+import {
+ backupProjectIdsAtom,
+ backupSkuAtom,
+ droppedBackupFileAtom,
+ droppedProjectFileAtom,
+ unzippedBackupAtom,
+} from '~/atoms/droppedProjectFile';
+import { projectIdAtom } from '~/atoms/project';
+import { store } from '~/lib/store';
+import { showToast } from '~/lib/toast';
+import useDevice from './useDevice';
+
+export const DROPPED_FILE_ID = '10';
+
+function useDroppedFile() {
+ const { device } = useDevice();
+ const [, setProjectId] = useAtom(projectIdAtom);
+ const [, setDroppedProjectFile] = useAtom(droppedProjectFileAtom);
+ const [, setDroppedBackupFile] = useAtom(droppedBackupFileAtom);
+ const [, setBackupSku] = useAtom(backupSkuAtom);
+ const [, setBackupProjectIds] = useAtom(backupProjectIdsAtom);
+
+ const [{ isOver }, dropRef] = useDrop(
+ () => ({
+ accept: [NativeTypes.FILE],
+ drop: async (_item: unknown, monitor: DropTargetMonitor) => {
+ const item = monitor.getItem() as { files?: File[] };
+ const file = item?.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ const fileNameLower = file.name.toLowerCase();
+ if (
+ !(
+ fileNameLower.endsWith('.tar') ||
+ fileNameLower.endsWith('.ppak') ||
+ fileNameLower.endsWith('.pak')
+ )
+ ) {
+ showToast('Unsupported file. Use a .tar or .ppak. or .pak', 'error');
+ return;
+ }
+
+ if (!device && fileNameLower.endsWith('.tar')) {
+ showToast(
+ 'Connect a device to load .tar files, or use a .pak/.ppak backup file',
+ 'error',
+ );
+ return;
+ }
+
+ if (device && (fileNameLower.endsWith('.pak') || fileNameLower.endsWith('.ppak'))) {
+ showToast('Disconnect the device to use a .pak/.ppak backup file', 'error');
+ return;
+ }
+
+ try {
+ const buf = await file.arrayBuffer();
+
+ if (fileNameLower.endsWith('.tar')) {
+ setDroppedProjectFile({ name: file.name, data: new Uint8Array(buf) });
+ setDroppedBackupFile(null);
+ store.set(unzippedBackupAtom, null);
+ setBackupSku(null);
+ setBackupProjectIds([]);
+ setProjectId(DROPPED_FILE_ID);
+ showToast(`Added "${file.name}" to projects`, 'info');
+ } else if (fileNameLower.endsWith('.pak') || fileNameLower.endsWith('.ppak')) {
+ const backupData = new Uint8Array(buf);
+ setDroppedBackupFile(backupData);
+ setDroppedProjectFile(null);
+
+ const unzipped = await JSZip.loadAsync(backupData);
+ store.set(unzippedBackupAtom, unzipped);
+
+ // Read backup metadata
+ const metaFile = unzipped.file('meta.json');
+ if (metaFile) {
+ try {
+ const metaText = await metaFile.async('text');
+ const meta = JSON.parse(metaText);
+ const sku = meta.device_sku || meta.base_sku || null;
+ setBackupSku(sku);
+ } catch {
+ setBackupSku(null);
+ }
+ } else {
+ setBackupSku(null);
+ }
+
+ // Scan for available projects
+ const projectIds: number[] = [];
+ for (const zipFile of Object.values(unzipped.files)) {
+ const match = zipFile.name.match(/\/projects\/P(\d{2})\.tar$/);
+ if (match) {
+ projectIds.push(Number(match[1]));
+ }
+ }
+ setBackupProjectIds(projectIds.sort((a, b) => a - b));
+
+ showToast(`Loaded backup file "${file.name}"`, 'info');
+ }
+ } catch {
+ showToast('Failed to read dropped file', 'error');
+ }
+ },
+ collect: (monitor: DropTargetMonitor) => ({
+ isOver: monitor.isOver(),
+ }),
+ }),
+ [device, setDroppedProjectFile, setProjectId, setBackupSku, setBackupProjectIds],
+ );
+
+ return { dropRef, isOver };
+}
+
+export default useDroppedFile;
diff --git a/src/hooks/useExportProject.ts b/src/hooks/useExportProject.ts
new file mode 100644
index 0000000..6487b55
--- /dev/null
+++ b/src/hooks/useExportProject.ts
@@ -0,0 +1,195 @@
+import * as Sentry from '@sentry/react';
+import { useAtomValue } from 'jotai';
+import { useState } from 'preact/hooks';
+import { projectIdAtom } from '../atoms/project';
+import { trackEvent } from '../lib/ga';
+import { AbortError } from '../lib/utils';
+import {
+ ExporterParams,
+ ExportFormat,
+ ExportFormatId,
+ ExportResult,
+ ExportStatus,
+ ProjectRawData,
+ SampleReport,
+} from '../types/types';
+import useProject from './useProject';
+import { getCustomSceneNames } from './useSceneName';
+
+export const EXPORT_FORMATS: ExportFormat[] = [
+ {
+ name: 'Ableton 11+',
+ value: 'ableton',
+ },
+ {
+ name: 'DAWproject',
+ value: 'dawproject',
+ },
+ {
+ name: 'MIDI',
+ value: 'midi',
+ },
+ {
+ name: 'REAPER',
+ value: 'reaper',
+ },
+];
+
+async function getExporterFn(
+ format: ExportFormatId,
+): Promise<
+ (
+ projectId: string,
+ data: any,
+ progressCallback: any,
+ exporterParams: ExporterParams,
+ abortSignal: AbortSignal,
+ ) => Promise
+> {
+ switch (format) {
+ case 'ableton':
+ return (await import('../lib/exporters/ableton')).default;
+ case 'dawproject':
+ return (await import('../lib/exporters/dawProject')).default;
+ case 'midi':
+ return (await import('../lib/exporters/midi')).default;
+ case 'reaper':
+ return (await import('../lib/exporters/reaper')).default;
+ default:
+ throw new Error(`Unknown export format: ${format}`);
+ }
+}
+
+function filterScenes(data: ProjectRawData, params: ExporterParams): ProjectRawData {
+ if (params.allScenes) {
+ return data;
+ }
+ return {
+ ...data,
+ scenes: data.scenes.filter((scene) => params.selectedScenes?.includes(scene.name)),
+ };
+}
+
+function useExportProject() {
+ const projectId = useAtomValue(projectIdAtom);
+ const { data: projectRawData } = useProject(projectId);
+ const [isPending, setIsPending] = useState(false);
+ const [pendingStatus, setPendingStatus] = useState('');
+ const [percentage, setPercentage] = useState(0);
+ const [error, setError] = useState(null);
+ const [result, setResult] = useState(null);
+ const [sampleReport, setSampleReport] = useState(null);
+ const [abortController, setAbortController] = useState(null);
+
+ const startExport = async (params: ExporterParams & { format: ExportFormatId }) => {
+ const { format, ...exporterParams } = params;
+
+ trackEvent('export_start', {
+ format,
+ });
+
+ try {
+ const formatData = EXPORT_FORMATS.find((f) => f.value === format);
+
+ if (!formatData || !projectRawData) {
+ return;
+ }
+
+ setPendingStatus('Starting export...');
+ setError(null);
+ setIsPending(true);
+ setPercentage(1);
+
+ const controller = new AbortController();
+ setAbortController(controller);
+
+ const exportFn = await getExporterFn(format);
+
+ const filteredData = filterScenes(projectRawData, exporterParams);
+
+ const customSceneNames = getCustomSceneNames(
+ projectId,
+ filteredData.scenes.map((s) => s.name),
+ );
+
+ const result = await exportFn(
+ projectId,
+ filteredData,
+ (stat: ExportStatus) => {
+ setPercentage(stat.progress);
+ setPendingStatus(stat.status);
+ },
+ { ...exporterParams, customSceneNames },
+ controller.signal,
+ );
+
+ setResult(result);
+ setSampleReport(result.sampleReport || null);
+ setIsPending(false);
+ setAbortController(null);
+
+ trackEvent('export_end');
+ } catch (err) {
+ if (err instanceof AbortError) {
+ setPendingStatus('Export cancelled');
+ setIsPending(false);
+ setAbortController(null);
+ return;
+ }
+
+ console.error(err);
+
+ Sentry.captureException(err);
+
+ setError(err);
+ setIsPending(false);
+ setAbortController(null);
+
+ trackEvent('export_error');
+ }
+ };
+
+ const cancelExport = () => {
+ if (abortController) {
+ abortController.abort();
+ setAbortController(null);
+ }
+ };
+
+ const reset = () => {
+ setError(null);
+ setIsPending(false);
+ setPercentage(0);
+ setPendingStatus('');
+ setSampleReport(null);
+
+ if (abortController) {
+ abortController.abort();
+ setAbortController(null);
+ }
+
+ if (!result) {
+ return;
+ }
+
+ for (const file of result.files) {
+ URL.revokeObjectURL(file.url);
+ }
+
+ setResult(null);
+ };
+
+ return {
+ startExport,
+ cancelExport,
+ reset,
+ isPending,
+ pendingStatus,
+ percentage,
+ result,
+ error,
+ sampleReport,
+ };
+}
+
+export default useExportProject;
diff --git a/src/hooks/usePersistedFormValues.ts b/src/hooks/usePersistedFormValues.ts
new file mode 100644
index 0000000..00f52d6
--- /dev/null
+++ b/src/hooks/usePersistedFormValues.ts
@@ -0,0 +1,54 @@
+import { useEffect } from 'preact/hooks';
+import { UseFormReturn, useWatch } from 'react-hook-form';
+import { ZodType } from 'zod';
+
+const STORAGE_KEY = 'export_form';
+
+function pick>(obj: T, keys: (keyof T)[]): Partial {
+ const result: Partial = {};
+ for (const key of keys) {
+ if (key in obj) {
+ result[key] = obj[key];
+ }
+ }
+ return result;
+}
+
+export function usePersistedFormValues>(
+ form: UseFormReturn,
+ persistedFields: (keyof T)[],
+ schema?: ZodType,
+) {
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ const picked = pick(parsed, persistedFields);
+ if (schema) {
+ const result = schema.safeParse({ ...form.getValues(), ...picked });
+ if (result.success) {
+ form.reset(result.data as T);
+ } else {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ } else {
+ form.reset({ ...form.getValues(), ...picked } as T);
+ }
+ }
+ } catch (e) {
+ console.warn('Failed to load persisted form values:', e);
+ }
+ }, []);
+
+ const values = useWatch({ control: form.control });
+
+ useEffect(() => {
+ try {
+ const toSave = pick(values as T, persistedFields);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
+ } catch (e) {
+ console.warn('Failed to persist form values:', e);
+ }
+ }, [values]);
+}
diff --git a/src/hooks/usePersistedState.ts b/src/hooks/usePersistedState.ts
new file mode 100644
index 0000000..93942ee
--- /dev/null
+++ b/src/hooks/usePersistedState.ts
@@ -0,0 +1,27 @@
+import { useState } from 'preact/hooks';
+
+function usePersistedState(key: string, defaultValue: T): [T, (value: T) => void] {
+ const [state, setState] = useState(() => {
+ try {
+ const item = localStorage.getItem(key);
+ return item ? JSON.parse(item) : defaultValue;
+ } catch (error) {
+ console.warn(`Error reading localStorage key "${key}":`, error);
+ return defaultValue;
+ }
+ });
+
+ const setPersistedState = (value: T) => {
+ try {
+ setState(value);
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ console.warn(`Error writing to localStorage key "${key}":`, error);
+ setState(value);
+ }
+ };
+
+ return [state, setPersistedState];
+}
+
+export default usePersistedState;
diff --git a/src/hooks/useProject.ts b/src/hooks/useProject.ts
new file mode 100644
index 0000000..f02a217
--- /dev/null
+++ b/src/hooks/useProject.ts
@@ -0,0 +1,111 @@
+import { useQuery } from '@tanstack/react-query';
+import { useAtomValue } from 'jotai';
+import JSZip from 'jszip';
+import { deviceSkuAtom } from '~/atoms/deviceSku';
+import {
+ backupSkuAtom,
+ droppedBackupFileAtom,
+ droppedProjectFileAtom,
+ unzippedBackupAtom,
+} from '~/atoms/droppedProjectFile';
+import { getProjectFile } from '../lib/midi';
+import {
+ collectEffects,
+ collectPads,
+ collectScenesAndPatterns,
+ collectScenesSettings,
+ collectSettings,
+ collectSounds,
+ loadSoundsFromBackup,
+} from '../lib/parsers';
+import { store } from '../lib/store';
+import { showToast } from '../lib/toast';
+import { untar } from '../lib/untar';
+import { ProjectRawData } from '../types/types';
+import useDevice from './useDevice';
+import { DROPPED_FILE_ID } from './useDroppedFile';
+
+function useProject(id?: number | string) {
+ const { device } = useDevice();
+ const deviceSku = useAtomValue(deviceSkuAtom);
+ const droppedProjectFile = useAtomValue(droppedProjectFileAtom);
+ const droppedBackupFile = useAtomValue(droppedBackupFileAtom);
+ const backupSku = useAtomValue(backupSkuAtom);
+
+ const result = useQuery({
+ queryKey: ['project', id],
+ queryFn: async () => {
+ if (!id) {
+ return null;
+ }
+
+ if (!droppedBackupFile && !device) {
+ return null;
+ }
+
+ let archiveData: Uint8Array | undefined;
+ let unzippedBackup: JSZip | undefined;
+
+ try {
+ if (droppedBackupFile) {
+ unzippedBackup = store.get(unzippedBackupAtom) ?? undefined;
+ if (!unzippedBackup) {
+ unzippedBackup = await JSZip.loadAsync(droppedBackupFile);
+ }
+ const projectFile = unzippedBackup.file(`/projects/P${String(id).padStart(2, '0')}.tar`);
+ if (!projectFile) {
+ throw new Error('No project file in backup');
+ }
+ const projectFileData = await projectFile.async('uint8array');
+ archiveData = projectFileData;
+ } else if (id === DROPPED_FILE_ID && droppedProjectFile) {
+ archiveData = droppedProjectFile.data;
+ } else {
+ const archive = await getProjectFile(Number(id));
+ archiveData = archive.data;
+ }
+
+ const files = await untar(archiveData);
+ const sounds = unzippedBackup
+ ? await loadSoundsFromBackup(unzippedBackup, files)
+ : await collectSounds(files);
+ const settings = collectSettings(files);
+ const pads = collectPads(files, sounds);
+ const scenes = collectScenesAndPatterns(files, device?.sku || backupSku || deviceSku);
+ const scenesSettings = collectScenesSettings(files);
+ const effects = collectEffects(files);
+
+ // @ts-expect-error wrong typing?
+ const projectFileBlob = new Blob([archiveData]);
+ const projectFile = new File(
+ [projectFileBlob],
+ `project-${String(id).padStart(2, '0')}.tar`,
+ {
+ type: 'application/x-tar',
+ lastModified: Date.now(),
+ },
+ );
+
+ return {
+ pads,
+ scenes,
+ settings,
+ sounds,
+ effects,
+ projectFile,
+ scenesSettings,
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to load project';
+ showToast(message, 'error');
+ return null;
+ }
+ },
+ retry: false,
+ enabled: !!id && (!!device || !!droppedBackupFile),
+ });
+
+ return result;
+}
+
+export default useProject;
diff --git a/src/hooks/useProjectsList.ts b/src/hooks/useProjectsList.ts
new file mode 100644
index 0000000..0f9c330
--- /dev/null
+++ b/src/hooks/useProjectsList.ts
@@ -0,0 +1,42 @@
+import { useAtomValue } from 'jotai';
+import {
+ backupProjectIdsAtom,
+ droppedBackupFileAtom,
+ droppedProjectFileAtom,
+} from '~/atoms/droppedProjectFile';
+import useDevice from './useDevice';
+import { DROPPED_FILE_ID } from './useDroppedFile';
+
+export type ProjectOption = {
+ value: string;
+ label: string;
+};
+
+function useProjectsList(): ProjectOption[] {
+ const { device } = useDevice();
+ const droppedProjectFile = useAtomValue(droppedProjectFileAtom);
+ const droppedBackupFile = useAtomValue(droppedBackupFileAtom);
+ const backupProjectIds = useAtomValue(backupProjectIdsAtom);
+
+ const base: ProjectOption[] = [];
+
+ if (device) {
+ for (let i = 1; i <= 9; i++) {
+ base.push({ value: String(i), label: `Project ${i}` });
+ }
+ } else if (droppedBackupFile) {
+ if (backupProjectIds.length > 0) {
+ for (const id of backupProjectIds) {
+ base.push({ value: String(id), label: `Project ${id}` });
+ }
+ }
+ }
+
+ if (droppedProjectFile) {
+ base.push({ value: DROPPED_FILE_ID, label: droppedProjectFile.name });
+ }
+
+ return base;
+}
+
+export default useProjectsList;
diff --git a/src/hooks/useSceneName.ts b/src/hooks/useSceneName.ts
new file mode 100644
index 0000000..8a9b317
--- /dev/null
+++ b/src/hooks/useSceneName.ts
@@ -0,0 +1,122 @@
+import { useCallback, useEffect } from 'preact/hooks';
+import useCustomSceneNames from '~/hooks/useCustomSceneNames';
+import usePersistedState from '~/hooks/usePersistedState.ts';
+
+function getSceneNameKey(name: string) {
+ return `_s${name}_scene_name`;
+}
+
+export function getDefaultSceneName(name: string) {
+ return `SCENE ${name}`;
+}
+
+export function getCustomSceneNames(
+ projectId: string,
+ sceneNames: string[],
+): Record {
+ const projectPrefix = `p${projectId}`;
+ const result: Record = {};
+
+ for (const sceneName of sceneNames) {
+ const defaultName = getDefaultSceneName(sceneName);
+ const key = projectPrefix + getSceneNameKey(sceneName);
+ try {
+ const storedValue = localStorage.getItem(key);
+ if (storedValue) {
+ const parsed = JSON.parse(storedValue);
+ if (parsed) {
+ result[sceneName] = parsed;
+ continue;
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ result[sceneName] = defaultName;
+ }
+
+ return result;
+}
+
+function useSceneName(projectId: string, originalName: string = '') {
+ const projectPrefix = `p${projectId}`;
+ const key = projectPrefix + getSceneNameKey(originalName);
+ const defaultName = getDefaultSceneName(originalName);
+ const [sceneName, setSceneNameState] = usePersistedState(key, defaultName);
+ const { hasCustomNames, setHasCustomNames, syncCustomSceneNames } =
+ useCustomSceneNames(projectId);
+
+ useEffect(() => {
+ if (originalName === '') {
+ return;
+ }
+
+ if (!hasCustomNames) {
+ setSceneNameState(defaultName);
+
+ try {
+ localStorage.removeItem(key);
+ } catch (error) {
+ console.warn(`Error removing localStorage key "${key}":`, error);
+ }
+ }
+ }, [defaultName, hasCustomNames, key, originalName, setSceneNameState]);
+
+ const setSceneName = useCallback(
+ (value: string) => {
+ const normalizedValue = value.trim() === '' ? defaultName : value.trim();
+ setSceneNameState(normalizedValue);
+
+ if (normalizedValue === defaultName) {
+ try {
+ localStorage.removeItem(key);
+ } catch (error) {
+ console.warn(`Error removing localStorage key "${key}":`, error);
+ }
+ }
+
+ syncCustomSceneNames();
+ },
+ [defaultName, key, setSceneNameState, syncCustomSceneNames],
+ );
+
+ const resetSceneNames = useCallback(() => {
+ try {
+ const keysToRemove = Object.keys(localStorage).filter((key) => key.startsWith(projectPrefix));
+
+ keysToRemove.forEach((key) => {
+ localStorage.removeItem(key);
+ });
+ setHasCustomNames(false);
+ } catch (error) {
+ console.warn(`Error removing localStorage keys for "${projectPrefix}":`, error);
+ }
+ }, [projectPrefix, setHasCustomNames]);
+
+ function getSceneName(name: string) {
+ const defaultName = getDefaultSceneName(name);
+
+ try {
+ const key = projectPrefix + getSceneNameKey(name);
+ const storedValue = localStorage.getItem(key);
+
+ if (!storedValue) {
+ return defaultName;
+ }
+
+ try {
+ return JSON.parse(storedValue);
+ } catch {
+ return storedValue;
+ }
+ } catch (error) {
+ console.warn(`Error getSceneName "${key}":`, error);
+ return defaultName;
+ }
+ }
+
+ return { sceneName, setSceneName, getSceneName, resetSceneNames };
+}
+
+export default useSceneName;
diff --git a/src/hooks/useSubmitFeedback.ts b/src/hooks/useSubmitFeedback.ts
new file mode 100644
index 0000000..fa69fee
--- /dev/null
+++ b/src/hooks/useSubmitFeedback.ts
@@ -0,0 +1,53 @@
+import { useMutation } from '@tanstack/react-query';
+import { useAtomValue } from 'jotai';
+import ky from 'ky';
+import { projectIdAtom } from '../atoms/project';
+import useDevice from './useDevice';
+import useProject from './useProject';
+
+interface SubmitFeedbackParams {
+ description: string;
+ email: string;
+ files: File[] | null;
+ attachProject: boolean;
+}
+
+function useSubmitFeedback() {
+ const projectId = useAtomValue(projectIdAtom);
+ const { device } = useDevice();
+ const { data } = useProject(projectId);
+
+ return useMutation({
+ mutationFn: async (params: SubmitFeedbackParams) => {
+ const formData = new FormData();
+
+ formData.append('description', params.description);
+ formData.append('metadata', JSON.stringify(device?.metadata || {}));
+
+ if (params.email) {
+ formData.append('email', params.email);
+ }
+
+ if (params.files) {
+ for (let i = 0; i < params.files.length; i++) {
+ formData.append('files', params.files[i]);
+ }
+ }
+
+ if (params.attachProject && data?.projectFile) {
+ formData.append('files', data.projectFile);
+ }
+
+ return ky
+ .post(`${import.meta.env.VITE_API_URL}/support/submit`, {
+ body: formData,
+ timeout: 100_000,
+ })
+ .json();
+ },
+ retry: 10,
+ retryDelay: 2000,
+ });
+}
+
+export default useSubmitFeedback;
diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts
new file mode 100644
index 0000000..b20bc33
--- /dev/null
+++ b/src/hooks/useTheme.ts
@@ -0,0 +1,28 @@
+import { useAtomValue } from 'jotai';
+import { useMemo } from 'preact/hooks';
+import { useSearch } from 'wouter-preact';
+import { deviceSkuAtom } from '~/atoms/deviceSku';
+import { SKU_EP133, SKU_TO_NAME } from '~/lib/constants';
+
+function useTheme(): { id: string; name: string } {
+ const deviceSku = useAtomValue(deviceSkuAtom);
+ const search = useSearch();
+
+ const theme = useMemo(() => {
+ const urlParams = new URLSearchParams(search);
+ const themeParam = urlParams.get('theme');
+
+ if (themeParam) {
+ const themeEntry = Object.entries(SKU_TO_NAME).find(([, value]) => value.id === themeParam);
+ if (themeEntry) {
+ return themeEntry[1];
+ }
+ }
+
+ return SKU_TO_NAME[deviceSku] || SKU_TO_NAME[SKU_EP133];
+ }, [deviceSku, search]);
+
+ return theme;
+}
+
+export default useTheme;
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644
index 0000000..30d4666
--- /dev/null
+++ b/src/index.tsx
@@ -0,0 +1,23 @@
+import * as Sentry from '@sentry/react';
+import { render } from 'preact';
+import App from './App';
+import { swUpdateAvailableAtom } from './atoms/swUpdate';
+import { store } from './lib/store';
+import './styles.css';
+import { registerSW } from 'virtual:pwa-register';
+
+if (import.meta.env.VITE_SENTRY_DSN) {
+ Sentry.init({
+ dsn: import.meta.env.VITE_SENTRY_DSN,
+ sendDefaultPii: true,
+ });
+}
+
+registerSW({
+ immediate: true,
+ onNeedRefresh() {
+ store.set(swUpdateAvailableAtom, true);
+ },
+});
+
+render( , document.getElementById('app') || document.body);
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
new file mode 100644
index 0000000..ff78dcb
--- /dev/null
+++ b/src/lib/constants.ts
@@ -0,0 +1,83 @@
+export const SKU_EP133 = 'TE032AS001';
+export const SKU_EP1320 = 'TE032AS005';
+export const SKU_EP40 = 'TE032AS006';
+
+export const SKU_TO_NAME: Record = {
+ [SKU_EP133]: { id: 'ep133', name: 'EP-133' },
+ [SKU_EP1320]: { id: 'ep1320', name: 'EP-1320' },
+ [SKU_EP40]: { id: 'ep40', name: 'EP-40' },
+};
+
+export const GROUPS = [
+ {
+ name: 'A',
+ id: 'a',
+ },
+ {
+ name: 'B',
+ id: 'b',
+ },
+ {
+ name: 'C',
+ id: 'c',
+ },
+ {
+ name: 'D',
+ id: 'd',
+ },
+] as const;
+
+export const PADS = ['9', '8', '7', '6', '5', '4', '3', '2', '1', '.', '0', 'E'];
+
+export const PAD_DISPLAY_FROM_INDEX: Record = {
+ 0: '7',
+ 1: '8',
+ 2: '9',
+ 3: '4',
+ 4: '5',
+ 5: '6',
+ 6: '1',
+ 7: '2',
+ 8: '3',
+ 9: '.',
+ 10: '0',
+ 11: 'E',
+};
+
+export const EFFECTS = {
+ 0: 'None',
+ 1: 'Delay',
+ 2: 'Reverb',
+ 3: 'Distortion',
+ 4: 'Chorus',
+ 5: 'Filter',
+ 6: 'Compressor',
+};
+
+export const EFFECTS_SHORT = {
+ 0: 'OFF',
+ 1: 'DLY',
+ 2: 'RVB',
+ 3: 'DST',
+ 4: 'CHO',
+ 5: 'FLT',
+ 6: 'CMP',
+};
+
+export const SCALES_SHORT: Record = {
+ 0: '2T',
+ 1: 'MAJ',
+ 2: 'MIN',
+ 3: 'DOR',
+ 4: 'PHR',
+ 5: 'LYD',
+ 6: 'MIX',
+ 7: 'LOC',
+ 8: 'MA.P',
+ 9: 'MI.P',
+ 10: 'BLU',
+ 11: 'H.MI',
+ 12: 'M.MI',
+};
+
+export const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
diff --git a/src/lib/exporters/ableton/builders.ts b/src/lib/exporters/ableton/builders.ts
new file mode 100644
index 0000000..f85715c
--- /dev/null
+++ b/src/lib/exporters/ableton/builders.ts
@@ -0,0 +1,687 @@
+import { create } from 'xmlbuilder2';
+import {
+ EffectType,
+ ExporterParams,
+ FaderParam,
+ ProjectRawData,
+ ProjectSettings,
+} from '../../../types/types';
+import { EFFECTS } from '../../constants';
+import abletonTransformer, { AblClip, AblScene, AblTrack } from '../../transformers/ableton';
+import { getQuarterNotesPerBar } from '../utils';
+import { ALSDrumBranch } from './types/drumBranch';
+import { ALSDrumRack } from './types/drumRack';
+import { ALSChorus } from './types/effectChorus';
+import { ALSCompressor } from './types/effectCompressor';
+import { ALSDelay } from './types/effectDelay';
+import { ALSDistortion } from './types/effectDistortion';
+import { ALSFilter } from './types/effectFilter';
+import { ALSReverb } from './types/effectReverb';
+import { ALSGroupTrack } from './types/groupTrack';
+import { ALSMidiClip, ALSMidiClipContent } from './types/midiClip';
+import { ALSMidiTrack } from './types/midiTrack';
+import { ALSProject } from './types/project';
+import { ALSReturnTrack } from './types/returnTrack';
+import { ALSScene, ALSSceneContent } from './types/scene';
+import { ALSSimpler } from './types/simpler';
+import { ALSTrackSendHolder } from './types/trackSendHolder';
+import {
+ filterFreqFromNormalized,
+ fixIds,
+ getId,
+ gzipString,
+ koEnvRangeToSeconds,
+ loadTemplate,
+ TIME_SIGNATURES,
+ toNativePath,
+} from './utils';
+
+let _localId = -1;
+let _localGroupId = -1;
+let _localTrackColor = -1;
+let _localGroupTrackColor = -1;
+
+const MIN_CLIP_LAUNCHER_SCENES = 8;
+
+async function buildMidiClip(
+ koClip: AblClip,
+ clipIdx: number,
+ color: number,
+ clipForLauncher: boolean = false,
+): Promise {
+ const midiClipTemplate = await loadTemplate('midiClip');
+ const midiClip = structuredClone(midiClipTemplate.MidiClip);
+
+ const beats = getQuarterNotesPerBar(
+ koClip.timeSignature.numerator,
+ koClip.timeSignature.denominator,
+ );
+ const time = koClip.offset * beats;
+ const start = time;
+ const end = time + koClip.sceneBars * beats;
+
+ midiClip['@Id'] = clipIdx;
+ midiClip['@Time'] = time;
+ midiClip.CurrentStart['@Value'] = start;
+ midiClip.CurrentEnd['@Value'] = end;
+ midiClip.Loop.LoopOn['@Value'] = 'true';
+ midiClip.Loop.LoopStart['@Value'] = 0;
+ midiClip.Loop.LoopEnd['@Value'] = koClip.bars * beats;
+ midiClip.Loop.HiddenLoopStart['@Value'] = 0;
+ midiClip.Loop.HiddenLoopEnd['@Value'] = koClip.bars * beats;
+ midiClip.Color['@Value'] = color;
+ midiClip.Name['@Value'] = koClip.sceneName;
+ midiClip.TimeSignature.TimeSignatures.RemoteableTimeSignature.Numerator['@Value'] =
+ koClip.timeSignature.numerator;
+ midiClip.TimeSignature.TimeSignatures.RemoteableTimeSignature.Denominator['@Value'] =
+ koClip.timeSignature.denominator;
+
+ if (clipForLauncher) {
+ midiClip['@Time'] = 0;
+ midiClip.CurrentStart['@Value'] = 0;
+ midiClip.CurrentEnd['@Value'] = koClip.bars * beats;
+ }
+
+ // ableton group notes
+ const grouppedNotes: Record = koClip.notes.reduce(
+ (acc, note) => {
+ const group = note.note.toString();
+ acc[group] = acc[group] || [];
+ acc[group].push(note);
+
+ return acc;
+ },
+ {} as Record,
+ );
+
+ midiClip.Notes.KeyTracks.KeyTrack = [];
+ let _noteId = 0;
+
+ Object.entries(grouppedNotes).forEach(([noteValueStr, notes], groupIndex) => {
+ const noteValue = parseInt(noteValueStr, 10);
+
+ midiClip.Notes.KeyTracks.KeyTrack.push({
+ '@Id': groupIndex,
+ Notes: {
+ MidiNoteEvent: notes.map((noteData, noteIndex) => {
+ let dur = noteData.duration / 96;
+ const nextNote = notes[noteIndex + 1];
+
+ // making sure same notes are not overlapping
+ if (nextNote && noteData.position / 96 + dur > nextNote.position / 96) {
+ dur = nextNote.position / 96 - noteData.position / 96;
+ }
+
+ let velocity = noteData.velocity;
+
+ if (koClip.faderParams[FaderParam.VEL] !== -1) {
+ velocity = koClip.faderParams[FaderParam.VEL] * 127;
+ }
+
+ return {
+ '@Time': noteData.position / 96,
+ '@Duration': dur,
+ '@Velocity': velocity,
+ '@OffVelocity': 64,
+ '@NoteId': _noteId++,
+ };
+ }),
+ },
+ MidiKey: {
+ '@Value': noteValue,
+ },
+ });
+ });
+
+ return midiClip;
+}
+
+async function buildSimplerDevice(koTrack: AblTrack) {
+ const simplerTemplate = await loadTemplate('simpler');
+ const device = structuredClone(simplerTemplate.OriginalSimpler);
+
+ device.LastPresetRef.Value = {};
+ switch (koTrack.playMode) {
+ case 'legato':
+ case 'oneshot':
+ device.Globals.PlaybackMode['@Value'] = 1;
+ break;
+ case 'key':
+ device.Globals.PlaybackMode['@Value'] = 0;
+ break;
+ }
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleRef.FileRef.RelativePath[
+ '@Value'
+ ] = toNativePath(`Samples/Imported/${koTrack.sampleName}`);
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.Name['@Value'] = koTrack.name;
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.RootKey['@Value'] = koTrack.rootNote;
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleStart['@Value'] = koTrack.trimLeft;
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleEnd['@Value'] = koTrack.trimRight;
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.TimeSignature.TimeSignatures.RemoteableTimeSignature.Numerator[
+ '@Value'
+ ] = koTrack.timeSignature.numerator;
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.TimeSignature.TimeSignatures.RemoteableTimeSignature.Denominator[
+ '@Value'
+ ] = koTrack.timeSignature.denominator;
+
+ // setting velocity scaling to 20% to match KO behavior
+ device.VolumeAndPan.VolumeVelScale.Manual['@Value'] = 0.2;
+
+ device.VolumeAndPan.Envelope.ReleaseTime.Manual['@Value'] =
+ koEnvRangeToSeconds(koTrack.release, koTrack.soundLength) * 1000;
+ device.VolumeAndPan.Envelope.AttackTime.Manual['@Value'] =
+ koEnvRangeToSeconds(koTrack.attack, koTrack.soundLength) * 1000;
+ device.VolumeAndPan.Panorama.Manual['@Value'] = koTrack.pan;
+
+ // overriding attack time if ATK fader param is defined
+ if (koTrack.faderParams[FaderParam.ATK] !== -1) {
+ device.VolumeAndPan.Envelope.AttackTime.Manual['@Value'] =
+ koEnvRangeToSeconds(koTrack.faderParams[FaderParam.ATK] * 255, koTrack.soundLength) * 1000;
+ }
+
+ // overriding release time if REL fader param is defined
+ if (koTrack.faderParams[FaderParam.REL] !== -1) {
+ device.VolumeAndPan.Envelope.ReleaseTime.Manual['@Value'] =
+ koEnvRangeToSeconds(koTrack.faderParams[FaderParam.REL] * 255, koTrack.soundLength) * 1000;
+ }
+
+ let pitch = koTrack.pitch || 0;
+ if (koTrack.samplePitch) {
+ pitch += koTrack.samplePitch;
+ }
+
+ // adding PITCH fader param if defined
+ if (koTrack.faderParams[FaderParam.PTC] !== -1) {
+ const normalized = (koTrack.faderParams[FaderParam.PTC] - 0.5) * 2; // from 0-1 to -1 to +1
+ pitch += Math.round(normalized * 5);
+ }
+
+ // adding TUNE fader param if defined
+ if (koTrack.faderParams[FaderParam.TUNE] !== -1) {
+ pitch += Math.round(koTrack.faderParams[FaderParam.TUNE] * 23.5 - 12); // the range is slightly less than +/-12 cause that's how KO does it
+ }
+
+ device.Pitch.TransposeKey.Manual['@Value'] = pitch;
+
+ // adding vibrato from MOD fader param if defined
+ if (koTrack.faderParams[FaderParam.MOD] !== -1) {
+ device.Lfo.IsOn.Manual['@Value'] = 'true';
+ // KO's MOD wheel on 100% is roughly equal to 5% LFO pitch modulation
+ device.Pitch.PitchLfoAmount.Manual['@Value'] = koTrack.faderParams[FaderParam.MOD] / 20;
+ device.Lfo.Slot.Value.SimplerLfo.RateType.Manual['@Value'] = 1; // tempo synced 1/16
+ }
+
+ if (koTrack.faderParams[FaderParam.LPF] !== -1 && koTrack.faderParams[FaderParam.LPF] !== 1) {
+ device.Filter.IsOn.Manual['@Value'] = 'true';
+ device.Filter.Slot.Value.SimplerFilter.Type.Manual['@Value'] = 0; // Low Pass
+ device.Filter.Slot.Value.SimplerFilter.Slope.Manual['@Value'] = 'true'; // 24db/oct
+ device.Filter.Slot.Value.SimplerFilter.Freq.Manual['@Value'] = filterFreqFromNormalized(
+ koTrack.faderParams[FaderParam.LPF] * 0.9, // lowering max freq to match KO behavior
+ );
+ }
+
+ if (koTrack.faderParams[FaderParam.HPF] !== -1 && koTrack.faderParams[FaderParam.HPF] !== 0) {
+ device.Filter.IsOn.Manual['@Value'] = 'true';
+ device.Filter.Slot.Value.SimplerFilter.Type.Manual['@Value'] = 1; // High Pass
+ device.Filter.Slot.Value.SimplerFilter.Slope.Manual['@Value'] = 'false'; // 12db/oct
+ device.Filter.Slot.Value.SimplerFilter.Freq.Manual['@Value'] = filterFreqFromNormalized(
+ koTrack.faderParams[FaderParam.HPF] * 0.7, // lowering max freq to match KO behavior
+ );
+ }
+
+ // TODO: add bandpass filter
+
+ if (koTrack.timeStretch === 'bars') {
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.IsWarped[
+ '@Value'
+ ] = 'true';
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.WarpMode[
+ '@Value'
+ ] = 0; // warp in 'Beats' mode
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.WarpMarkers.WarpMarker =
+ [
+ {
+ '@Id': 0,
+ '@SecTime': 0,
+ '@BeatTime': 0,
+ },
+ {
+ '@Id': 1,
+ '@SecTime': koTrack.soundLength,
+ '@BeatTime': koTrack.timeStretchBars * koTrack.timeSignature.numerator, // convert bars to beats
+ },
+ ];
+ }
+
+ if (koTrack.timeStretch === 'bpm') {
+ // calculating beat length using koTrack.timeStretchBpm
+ const beatTime = koTrack.soundLength / (60 / koTrack.timeStretchBpm);
+
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.IsWarped[
+ '@Value'
+ ] = 'true';
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.WarpMode[
+ '@Value'
+ ] = 0; // warp in 'Beats' mode
+ device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.WarpMarkers.WarpMarker =
+ [
+ {
+ '@Id': 0,
+ '@SecTime': 0,
+ '@BeatTime': 0,
+ },
+ {
+ '@Id': 1,
+ '@SecTime': koTrack.soundLength,
+ '@BeatTime': beatTime,
+ },
+ ];
+ }
+
+ if (koTrack.timeStretch === 'rev') {
+ device.Player.Reverse.Manual['@Value'] = 'true';
+ }
+
+ return {
+ OriginalSimpler: device,
+ };
+}
+
+async function buildDrumRackDevice(koTrack: AblTrack) {
+ const [drumRackTemplate, drumBranchTemplate] = await Promise.all([
+ loadTemplate('drumRack'),
+ loadTemplate('drumBranch'),
+ ]);
+
+ const device = structuredClone(drumRackTemplate.DrumGroupDevice);
+
+ device.Branches.DrumBranch = [];
+
+ for (const [idx, subtrack] of koTrack.tracks.entries()) {
+ if (!subtrack.sampleName) {
+ continue;
+ }
+
+ const padNumber = parseInt(subtrack.padCode.slice(1), 10);
+ // EP133 is 3x4 (top→bottom), Ableton Drum Rack is 4x4 (bottom→top)
+ // flip vertically: row 3 → row 0, row 0 → row 3
+ const row = 3 - Math.floor(padNumber / 3);
+ const col = padNumber % 3;
+ const drumPad = row * 4 + col;
+
+ const drumBranch = structuredClone(drumBranchTemplate.DrumBranch);
+ const note = 92 - drumPad; // 92 is the number of slot for C1 in the drum rack and it goes down
+
+ drumBranch['@Id'] = idx;
+ drumBranch.Name.EffectiveName['@Value'] = subtrack.name;
+ drumBranch.Name.UserName['@Value'] = subtrack.name;
+ drumBranch.BranchInfo.ReceivingNote['@Value'] = note;
+ drumBranch.BranchInfo.SendingNote['@Value'] = 60; // not sure if this matters
+ drumBranch.BranchInfo.ChokeGroup['@Value'] = subtrack.inChokeGroup ? 1 : 0;
+ drumBranch.DeviceChain.MidiToAudioDeviceChain.Devices = await buildSimplerDevice(subtrack);
+
+ device.Branches.DrumBranch.push(drumBranch);
+ }
+
+ return {
+ DrumGroupDevice: device,
+ };
+}
+
+async function buildTrack(
+ koTrack: AblTrack,
+ maxScenes: number,
+ trackGroupId: number,
+ exporterParams: ExporterParams,
+): Promise {
+ const midiTrackTemplate = await loadTemplate('midiTrack');
+ const midiTrack = structuredClone(midiTrackTemplate.MidiTrack);
+
+ midiTrack['@Id'] = _localId++;
+ midiTrack.Name.EffectiveName['@Value'] = koTrack.soundId
+ ? `${String(koTrack.soundId).padStart(3, '0')} ${koTrack.name}`
+ : koTrack.name;
+ midiTrack.Name.UserName['@Value'] = midiTrack.Name.EffectiveName['@Value'];
+ midiTrack.Color['@Value'] = 8 + _localTrackColor++; // started with 8th color in the Ableton palette, why not
+ midiTrack.DeviceChain.MainSequencer.ClipTimeable.ArrangerAutomation.Events.MidiClip = [];
+ midiTrack.TrackGroupId['@Value'] = trackGroupId;
+ midiTrack.DeviceChain.DeviceChain.Devices['#'] = [];
+ midiTrack.DeviceChain.Mixer.Volume.Manual['@Value'] = koTrack.volume;
+
+ if (koTrack.faderParams[FaderParam.PAN] !== -1) {
+ midiTrack.DeviceChain.Mixer.Pan.Manual['@Value'] =
+ (koTrack.faderParams[FaderParam.PAN] - 0.5) * 2;
+ }
+
+ if (koTrack.faderParams[FaderParam.LVL] !== -1) {
+ // setting volume to the minimum of track volume and fader param
+ midiTrack.DeviceChain.Mixer.Volume.Manual['@Value'] = Math.min(
+ koTrack.volume,
+ koTrack.faderParams[FaderParam.LVL],
+ );
+ }
+
+ if (koTrack.drumRack && exporterParams.includeArchivedSamples) {
+ midiTrack.DeviceChain.DeviceChain.Devices['#'].push(await buildDrumRackDevice(koTrack));
+ }
+
+ if (!koTrack.drumRack && koTrack.sampleName && exporterParams.includeArchivedSamples) {
+ midiTrack.DeviceChain.DeviceChain.Devices['#'].push(await buildSimplerDevice(koTrack));
+ }
+
+ // make sure id's are sequential in device chain
+ midiTrack.DeviceChain.DeviceChain.Devices['#'].forEach((device, deviceIdx) => {
+ Object.values(device).forEach((devContent: any) => {
+ devContent['@Id'] = deviceIdx;
+ });
+ });
+
+ // make sure each tracks has the same empty slots for clips
+ // must be equeal to GroupTrackSlot if this track is in a group
+ // must be at least 8 slots to avoid Ableton crashes
+ for (let sc = 0; sc < Math.max(MIN_CLIP_LAUNCHER_SCENES, maxScenes); sc++) {
+ midiTrack.DeviceChain.MainSequencer.ClipSlotList.ClipSlot[sc] = {
+ '@Id': sc,
+ LomId: {
+ '@Value': 0,
+ },
+ ClipSlot: {
+ Value: {},
+ },
+ };
+ midiTrack.DeviceChain.FreezeSequencer.ClipSlotList.ClipSlot[sc] = {
+ '@Id': sc,
+ LomId: {
+ '@Value': 0,
+ },
+ ClipSlot: {
+ Value: {},
+ },
+ HasStop: {
+ '@Value': 'true',
+ },
+ NeedRefreeze: {
+ '@Value': 'true',
+ },
+ };
+ }
+
+ if (koTrack.lane) {
+ if (!exporterParams.clips) {
+ for (const [clipIdx, koClip] of koTrack.lane.clips.entries()) {
+ const midiClip = await buildMidiClip(koClip, clipIdx, midiTrack.Color['@Value']);
+
+ midiTrack.DeviceChain.MainSequencer.ClipTimeable.ArrangerAutomation.Events.MidiClip.push(
+ midiClip,
+ );
+ }
+ } else {
+ for (const koClip of koTrack.lane.clips.values()) {
+ const midiClip = await buildMidiClip(koClip, 0, midiTrack.Color['@Value'], true);
+
+ midiTrack.DeviceChain.MainSequencer.ClipSlotList.ClipSlot[koClip.sceneIndex] = {
+ ...midiTrack.DeviceChain.MainSequencer.ClipSlotList.ClipSlot[koClip.sceneIndex],
+ ClipSlot: {
+ Value: {
+ MidiClip: midiClip,
+ },
+ },
+ };
+ }
+ }
+ }
+
+ if (exporterParams.groupTracks) {
+ midiTrack.DeviceChain.AudioOutputRouting.Target['@Value'] = 'AudioOut/GroupTrack';
+ midiTrack.DeviceChain.AudioOutputRouting.UpperDisplayString['@Value'] = 'Group';
+ }
+
+ if (exporterParams.sendEffects && !exporterParams.groupTracks) {
+ const trackSendHolderTemplate = await loadTemplate('trackSendHolder');
+ const trackSendHolder = structuredClone(trackSendHolderTemplate.TrackSendHolder);
+
+ trackSendHolder.Send.Manual['@Value'] = koTrack.faderParams[FaderParam.FX];
+
+ midiTrack.DeviceChain.Mixer.Sends.TrackSendHolder = trackSendHolder;
+ }
+
+ return { MidiTrack: midiTrack };
+}
+
+async function buildGroupTrack(
+ koTrack: AblTrack,
+ exporterParams: ExporterParams,
+ maxScenes: number,
+) {
+ const groupTrackTemplate = await loadTemplate('groupTrack');
+ const groupTrack = structuredClone(groupTrackTemplate.GroupTrack);
+
+ groupTrack['@Id'] = _localGroupId++;
+ groupTrack.Name.EffectiveName['@Value'] = koTrack.group.toLocaleUpperCase();
+ groupTrack.Name.UserName['@Value'] = koTrack.group.toLocaleUpperCase();
+ groupTrack.Color['@Value'] = 5 + _localGroupTrackColor++;
+ groupTrack.Slots.GroupTrackSlot = [];
+
+ // adding empty slots for clips (or Ableton will crash)
+ // must be at least 8 slots to avoid Ableton crashes
+ for (let sc = 0; sc < Math.max(MIN_CLIP_LAUNCHER_SCENES, maxScenes); sc++) {
+ groupTrack.Slots.GroupTrackSlot.push({
+ '@Id': sc,
+ LomId: {
+ '@Value': 0,
+ },
+ });
+ }
+
+ if (exporterParams.sendEffects && koTrack.faderParams[FaderParam.FX] !== -1) {
+ const trackSendHolderTemplate = await loadTemplate('trackSendHolder');
+ const trackSendHolder = structuredClone(trackSendHolderTemplate.TrackSendHolder);
+
+ trackSendHolder.Send.Manual['@Value'] = koTrack.faderParams[FaderParam.FX];
+
+ groupTrack.DeviceChain.Mixer.Sends.TrackSendHolder = trackSendHolder;
+ }
+
+ return { GroupTrack: groupTrack };
+}
+
+async function buildScenes(scenes: AblScene[], settings: ProjectSettings, maxScenes: number) {
+ const sceneTemplate = await loadTemplate('scene');
+ const result: ALSSceneContent[] = [];
+ const sceneCount = Math.max(MIN_CLIP_LAUNCHER_SCENES, maxScenes);
+
+ for (let index = 0; index < sceneCount; index++) {
+ const scene = scenes[index];
+ const sceneContent = structuredClone(sceneTemplate.Scene);
+
+ sceneContent['@Id'] = _localId++;
+ sceneContent.Name['@Value'] = scene?.name || '';
+ sceneContent.Tempo['@Value'] = settings.bpm;
+
+ result.push(sceneContent);
+ }
+
+ return result;
+}
+
+async function buildTracks(tracks: AblTrack[], maxScenes: number, exporterParams: ExporterParams) {
+ const result = [];
+ let trackGroupId = -1;
+ let currentGroup = '';
+
+ // if the tracks are grouped, we need to create a group track for each group BEFORE the children midi tracks
+ // this took me around 3 hours to figure out
+ for (const koTrack of tracks.sort((a, b) => a.group.localeCompare(b.group))) {
+ if (exporterParams.groupTracks && koTrack.group !== currentGroup[0]) {
+ currentGroup = koTrack.group;
+ const groupTrack = await buildGroupTrack(koTrack, exporterParams, maxScenes);
+ trackGroupId = groupTrack.GroupTrack['@Id'];
+ result.push(groupTrack);
+ }
+
+ const midiTrack = await buildTrack(koTrack, maxScenes, trackGroupId, exporterParams);
+
+ result.push(midiTrack);
+ }
+
+ return result;
+}
+
+async function buildReturnTrack(projectData: ProjectRawData, trackId: number = 0) {
+ const returnTrackTemplate = await loadTemplate('returnTrack');
+ const returnTrack = structuredClone(returnTrackTemplate.ReturnTrack);
+
+ returnTrack['@Id'] = trackId;
+ returnTrack.Name.EffectiveName['@Value'] = EFFECTS[projectData.effects.effectType] || '';
+ returnTrack.Name.UserName['@Value'] = EFFECTS[projectData.effects.effectType] || '';
+ returnTrack.Color['@Value'] = 28;
+
+ if (projectData.effects.effectType === EffectType.Reverb) {
+ const effectReverbTemplate = await loadTemplate('effectReverb');
+ const effectReverb = structuredClone(effectReverbTemplate.Reverb);
+
+ // these params are very rough approximation of the KO's reverb
+ // it's not exact, but close enough
+ effectReverb.PreDelay.Manual['@Value'] = 0.5;
+ effectReverb.RoomSize.Manual['@Value'] = 300;
+ effectReverb.ShelfHiFreq.Manual['@Value'] = 1000;
+ effectReverb.DecayTime.Manual['@Value'] = projectData.effects.param1 * 8000; // max 8 seconds which is kinda close to how it sounds in KO
+ effectReverb.ShelfHiGain.Manual['@Value'] = projectData.effects.param2; // param2 is "color" which roughly matches the high shelf gain cut amount
+
+ returnTrack.DeviceChain.DeviceChain.Devices = { Reverb: effectReverb };
+ }
+
+ if (projectData.effects.effectType === EffectType.Delay) {
+ const effectDelayTemplate = await loadTemplate('effectDelay');
+ const effectDelay = structuredClone(effectDelayTemplate.Delay);
+
+ effectDelay.DelayLine_SyncL.Manual['@Value'] = 'false';
+ effectDelay.DelayLine_SyncR.Manual['@Value'] = 'false';
+ // don't even ask how I came up with this formula
+ effectDelay.DelayLine_TimeL.Manual['@Value'] =
+ 0.017397 * (287.725 ** projectData.effects.param1 - 1);
+ effectDelay.DelayLine_TimeR.Manual['@Value'] =
+ 0.017397 * (287.725 ** projectData.effects.param1 - 1);
+ effectDelay.Feedback.Manual['@Value'] = projectData.effects.param2;
+
+ returnTrack.DeviceChain.DeviceChain.Devices = { Delay: effectDelay };
+ }
+
+ if (projectData.effects.effectType === EffectType.Distortion) {
+ const effectDistortionTemplate = await loadTemplate('effectDistortion');
+ const effectDistortion = structuredClone(effectDistortionTemplate.Overdrive);
+
+ effectDistortion.Drive.Manual['@Value'] = projectData.effects.param1 * 50;
+ effectDistortion.Tone.Manual['@Value'] = projectData.effects.param2 * 100;
+
+ returnTrack.DeviceChain.DeviceChain.Devices = { Overdrive: effectDistortion };
+ }
+
+ if (projectData.effects.effectType === EffectType.Chorus) {
+ const effectChorusTemplate = await loadTemplate('effectChorus');
+ const effectChorus = structuredClone(effectChorusTemplate.Chorus2);
+
+ effectChorus.Amount.Manual['@Value'] = projectData.effects.param1;
+ effectChorus.Feedback.Manual['@Value'] = projectData.effects.param2 * 0.8;
+
+ returnTrack.DeviceChain.DeviceChain.Devices = { Chorus2: effectChorus };
+ }
+
+ // putting a filter in return track is absolutely pointless, but hey, why not
+ if (projectData.effects.effectType === EffectType.Filter) {
+ const effectFilterTemplate = await loadTemplate('effectFilter');
+ const effectFilter = structuredClone(effectFilterTemplate.AutoFilter);
+
+ effectFilter.Cutoff.Manual['@Value'] = 20 + projectData.effects.param1 * (135 - 20);
+ effectFilter.Resonance.Manual['@Value'] = projectData.effects.param2 * 1.25;
+
+ returnTrack.DeviceChain.DeviceChain.Devices = { AutoFilter: effectFilter };
+ }
+
+ if (projectData.effects.effectType === EffectType.Compressor) {
+ const effectCompressorTemplate = await loadTemplate('effectCompressor');
+ const effectCompressor = structuredClone(effectCompressorTemplate.Compressor2);
+
+ // params are ignored for now, just putting a basic compressor
+
+ returnTrack.DeviceChain.DeviceChain.Devices = { Compressor2: effectCompressor };
+ }
+
+ return { ReturnTrack: returnTrack };
+}
+
+export async function buildProject(projectData: ProjectRawData, exporterParams: ExporterParams) {
+ const transformedData = abletonTransformer(projectData, exporterParams);
+
+ if (import.meta.env.DEV) {
+ console.log('projectData', projectData);
+ console.log('transformedData', transformedData);
+ }
+
+ // reset local ids
+ _localId = 1;
+ _localGroupId = 50;
+ _localTrackColor = 1;
+ _localGroupTrackColor = 1;
+
+ const projectTemplate = await loadTemplate('project');
+ const project = structuredClone(projectTemplate);
+
+ // setting up tempo
+ project.Ableton.LiveSet.MasterTrack.DeviceChain.Mixer.Tempo.Manual['@Value'] =
+ projectData.settings.bpm;
+ project.Ableton.LiveSet.MasterTrack.AutomationEnvelopes.Envelopes.AutomationEnvelope[1].Automation.Events.FloatEvent[
+ '@Value'
+ ] = projectData.settings.bpm;
+ // setting up time signature
+ project.Ableton.LiveSet.MasterTrack.AutomationEnvelopes.Envelopes.AutomationEnvelope[0].Automation.Events.EnumEvent[
+ '@Value'
+ ] =
+ TIME_SIGNATURES[
+ `${projectData.scenesSettings.timeSignature.numerator}/${projectData.scenesSettings.timeSignature.denominator}`
+ ] || TIME_SIGNATURES['4/4'];
+
+ project.Ableton.LiveSet.Tracks['#'] = await buildTracks(
+ transformedData.tracks,
+ transformedData.scenes.length,
+ exporterParams,
+ );
+
+ if (exporterParams.sendEffects) {
+ const returnTrack = await buildReturnTrack(
+ projectData,
+ project.Ableton.LiveSet.Tracks['#'].length + 1,
+ );
+
+ project.Ableton.LiveSet.Tracks['#'].push(returnTrack);
+ }
+
+ if (exporterParams.clips) {
+ project.Ableton.LiveSet.Scenes.Scene = await buildScenes(
+ transformedData.scenes,
+ projectData.settings,
+ transformedData.scenes.length,
+ );
+ }
+
+ if (exporterParams.sendEffects) {
+ project.Ableton.LiveSet.SendsPre = {
+ SendPreBool: {
+ '@Id': 1,
+ '@Value': 'false',
+ },
+ };
+ }
+
+ const fixedRoot = fixIds(project);
+ fixedRoot.Ableton.LiveSet.NextPointeeId['@Value'] = getId() + 1;
+
+ if (import.meta.env.DEV) {
+ console.log('ROOT', fixedRoot);
+ }
+
+ const newXml = create(fixedRoot).end({ prettyPrint: true, indent: '\t' });
+ const gzipped = gzipString(newXml);
+
+ return gzipped;
+}
diff --git a/src/lib/exporters/ableton/index.ts b/src/lib/exporters/ableton/index.ts
new file mode 100644
index 0000000..51dc379
--- /dev/null
+++ b/src/lib/exporters/ableton/index.ts
@@ -0,0 +1,73 @@
+import JSZip from 'jszip';
+import {
+ ExporterParams,
+ ExportResult,
+ ExportResultFile,
+ ExportStatus,
+ ProjectRawData,
+ SampleReport,
+} from '../../../types/types';
+import { AbortError } from '../../utils';
+import { collectSamples } from '../utils';
+import { buildProject } from './builders';
+
+async function exportAbleton(
+ projectId: string,
+ data: ProjectRawData,
+ progressCallback: ({ progress, status }: ExportStatus) => void,
+ exporterParams: ExporterParams,
+ abortSignal: AbortSignal,
+): Promise {
+ const files: ExportResultFile[] = [];
+ const projectName = exporterParams.projectName || `Project${projectId}`;
+ const zippedProject = new JSZip();
+
+ progressCallback({ progress: 1, status: 'Building project...' });
+
+ if (abortSignal.aborted) {
+ throw new AbortError();
+ }
+
+ const alsFile = await buildProject(data, exporterParams);
+
+ zippedProject.file(`${projectName} Project/${projectName}.als`, alsFile);
+ zippedProject.file(`${projectName} Project/Ableton Project Info/.dummy`, '');
+
+ let sampleReport: SampleReport | undefined;
+
+ if (exporterParams.includeArchivedSamples) {
+ const { samples, sampleReport: report } = await collectSamples(
+ data,
+ progressCallback,
+ abortSignal,
+ exporterParams.exportAllPadsWithSamples,
+ );
+ samples.forEach((s) => {
+ zippedProject.file(`${projectName} Project/Samples/Imported/${s.name}`, s.data);
+ });
+ sampleReport = report;
+ }
+
+ progressCallback({ progress: 90, status: 'Bundle everything...' });
+
+ const zippedProjectFile = await zippedProject.generateAsync({
+ type: 'blob',
+ compression: 'DEFLATE',
+ });
+
+ files.push({
+ name: `${projectName}.zip`,
+ url: URL.createObjectURL(zippedProjectFile),
+ type: 'archive',
+ size: zippedProjectFile.size,
+ });
+
+ progressCallback({ progress: 100, status: 'Done' });
+
+ return {
+ files,
+ sampleReport,
+ };
+}
+
+export default exportAbleton;
diff --git a/src/lib/exporters/ableton/templates/drumBranch.xml b/src/lib/exporters/ableton/templates/drumBranch.xml
new file mode 100644
index 0000000..d07882c
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/drumBranch.xml
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/drumRack.xml b/src/lib/exporters/ableton/templates/drumRack.xml
new file mode 100644
index 0000000..1eae81c
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/drumRack.xml
@@ -0,0 +1,460 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/effectChorus.xml b/src/lib/exporters/ableton/templates/effectChorus.xml
new file mode 100644
index 0000000..011cab9
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/effectChorus.xml
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/effectCompressor.xml b/src/lib/exporters/ableton/templates/effectCompressor.xml
new file mode 100644
index 0000000..e9ba161
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/effectCompressor.xml
@@ -0,0 +1,386 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/effectDelay.xml b/src/lib/exporters/ableton/templates/effectDelay.xml
new file mode 100644
index 0000000..1d62463
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/effectDelay.xml
@@ -0,0 +1,379 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/effectDistortion.xml b/src/lib/exporters/ableton/templates/effectDistortion.xml
new file mode 100644
index 0000000..ba7247d
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/effectDistortion.xml
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/effectFilter.xml b/src/lib/exporters/ableton/templates/effectFilter.xml
new file mode 100644
index 0000000..f01540e
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/effectFilter.xml
@@ -0,0 +1,435 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/effectReverb.xml b/src/lib/exporters/ableton/templates/effectReverb.xml
new file mode 100644
index 0000000..6c318f0
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/effectReverb.xml
@@ -0,0 +1,480 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/groupTrack.xml b/src/lib/exporters/ableton/templates/groupTrack.xml
new file mode 100644
index 0000000..946c11a
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/groupTrack.xml
@@ -0,0 +1,269 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/midiClip.xml b/src/lib/exporters/ableton/templates/midiClip.xml
new file mode 100644
index 0000000..745b00a
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/midiClip.xml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/midiTrack.xml b/src/lib/exporters/ableton/templates/midiTrack.xml
new file mode 100644
index 0000000..3a9b954
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/midiTrack.xml
@@ -0,0 +1,850 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/project.xml b/src/lib/exporters/ableton/templates/project.xml
new file mode 100644
index 0000000..b3ade3d
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/project.xml
@@ -0,0 +1,1049 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/returnTrack.xml b/src/lib/exporters/ableton/templates/returnTrack.xml
new file mode 100644
index 0000000..e28d216
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/returnTrack.xml
@@ -0,0 +1,285 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/scene.xml b/src/lib/exporters/ableton/templates/scene.xml
new file mode 100644
index 0000000..f0cef84
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/scene.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/exporters/ableton/templates/simpler.xml b/src/lib/exporters/ableton/templates/simpler.xml
new file mode 100644
index 0000000..df9c793
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/simpler.xml
@@ -0,0 +1,1815 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/templates/trackSendHolder.xml b/src/lib/exporters/ableton/templates/trackSendHolder.xml
new file mode 100644
index 0000000..649d8fb
--- /dev/null
+++ b/src/lib/exporters/ableton/templates/trackSendHolder.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/exporters/ableton/types/common.ts b/src/lib/exporters/ableton/types/common.ts
new file mode 100644
index 0000000..bee8b7e
--- /dev/null
+++ b/src/lib/exporters/ableton/types/common.ts
@@ -0,0 +1,77 @@
+export interface ValueElement {
+ '@Value': any;
+}
+
+export interface LomIdElement {
+ LomId: ValueElement;
+}
+
+export interface AutomationTarget {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+}
+
+export interface MidiCCOnOffThresholds {
+ Min: ValueElement;
+ Max: ValueElement;
+}
+
+export interface On {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+export interface MidiControllerRange {
+ Min: ValueElement;
+ Max: ValueElement;
+}
+
+export interface ModulationTarget {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+}
+
+export interface FileRef {
+ RelativePathType: ValueElement;
+ RelativePath: ValueElement;
+ Path: ValueElement;
+ Type: ValueElement;
+ LivePackName: ValueElement;
+ LivePackId: ValueElement;
+ OriginalFileSize: ValueElement;
+ OriginalCrc: ValueElement;
+}
+
+export interface AbletonDefaultPresetRef {
+ '@Id': number;
+ FileRef: FileRef;
+ DeviceId: {
+ '@Name': string;
+ };
+}
+
+export interface LastPresetRef {
+ Value: AbletonDefaultPresetRef;
+}
+
+export interface OriginalFileRef {
+ FileRef: FileRef;
+}
+
+export interface PresetRef {
+ AbletonDefaultPresetRef: AbletonDefaultPresetRef;
+}
+
+export interface BranchSourceContext {
+ '@Id': number;
+ OriginalFileRef: OriginalFileRef;
+ BrowserContentPath: ValueElement;
+ PresetRef: PresetRef;
+ BranchDeviceId: ValueElement;
+}
+
+export interface SourceContext {
+ Value: BranchSourceContext;
+}
diff --git a/src/lib/exporters/ableton/types/drumBranch.ts b/src/lib/exporters/ableton/types/drumBranch.ts
new file mode 100644
index 0000000..4f1e3d9
--- /dev/null
+++ b/src/lib/exporters/ableton/types/drumBranch.ts
@@ -0,0 +1,127 @@
+import {
+ AutomationTarget,
+ LomIdElement,
+ MidiControllerRange,
+ ModulationTarget,
+ On,
+ SourceContext,
+ ValueElement,
+} from './common';
+
+interface Name {
+ EffectiveName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ MemorizedFirstClipName: ValueElement;
+}
+
+interface MidiToAudioDeviceChain {
+ '@Id': number;
+ Devices: {};
+ SignalModulations: {};
+}
+
+interface DeviceChain {
+ MidiToAudioDeviceChain: MidiToAudioDeviceChain;
+}
+
+interface BranchSelectorRange {
+ Min: ValueElement;
+ Max: ValueElement;
+ CrossfadeMin: ValueElement;
+ CrossfadeMax: ValueElement;
+}
+
+interface Volume {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Panorama {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface MpeSettings {
+ ZoneType: ValueElement;
+ FirstNoteChannel: ValueElement;
+ LastNoteChannel: ValueElement;
+}
+
+interface Routable {
+ Target: ValueElement;
+ UpperDisplayString: ValueElement;
+ LowerDisplayString: ValueElement;
+ MpeSettings: MpeSettings;
+}
+
+interface RoutingHelper {
+ Routable: Routable;
+ TargetEnum: ValueElement;
+}
+
+interface MixerDevice {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: On;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: {
+ Value: {};
+ };
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: SourceContext;
+ OverwriteProtectionNumber: ValueElement;
+ Speaker: On;
+ Volume: Volume;
+ Panorama: Panorama;
+ SendInfos: {};
+ RoutingHelper: RoutingHelper;
+ SendsListWrapper: LomIdElement;
+}
+
+interface BranchInfo {
+ ReceivingNote: ValueElement;
+ SendingNote: ValueElement;
+ ChokeGroup: ValueElement;
+}
+
+export interface ALSDrumBranchContent {
+ '@Id': number;
+ LomId: ValueElement;
+ Name: Name;
+ IsSelected: ValueElement;
+ DeviceChain: DeviceChain;
+ BranchSelectorRange: BranchSelectorRange;
+ IsSoloed: ValueElement;
+ SessionViewBranchWidth: ValueElement;
+ IsHighlightedInSessionView: ValueElement;
+ SourceContext: SourceContext;
+ Color: ValueElement;
+ AutoColored: ValueElement;
+ AutoColorScheme: ValueElement;
+ SoloActivatedInSessionMixer: ValueElement;
+ DevicesListWrapper: LomIdElement;
+ MixerDevice: MixerDevice;
+ BranchInfo: BranchInfo;
+}
+
+export interface ALSDrumBranch {
+ DrumBranch: ALSDrumBranchContent;
+}
diff --git a/src/lib/exporters/ableton/types/drumRack.ts b/src/lib/exporters/ableton/types/drumRack.ts
new file mode 100644
index 0000000..2edf558
--- /dev/null
+++ b/src/lib/exporters/ableton/types/drumRack.ts
@@ -0,0 +1,97 @@
+import {
+ AutomationTarget,
+ LastPresetRef,
+ LomIdElement,
+ MidiControllerRange,
+ ModulationTarget,
+ On,
+ SourceContext,
+ ValueElement,
+} from './common';
+import { ALSDrumBranchContent } from './drumBranch';
+
+interface MacroControl {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface ChainSelector {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface MacroVariations {
+ MacroSnapshots: {};
+}
+
+export interface ALSDrumRackContent {
+ '@Id': number;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: On;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: LastPresetRef;
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: SourceContext;
+ OverwriteProtectionNumber: ValueElement;
+ Branches: {
+ DrumBranch: ALSDrumBranchContent[];
+ };
+ IsBranchesListVisible: ValueElement;
+ IsReturnBranchesListVisible: ValueElement;
+ IsRangesEditorVisible: ValueElement;
+ AreDevicesVisible: ValueElement;
+ NumVisibleMacroControls: ValueElement;
+ MacroControls: { [key: string]: MacroControl };
+ MacroDisplayNames: { [key: string]: ValueElement };
+ MacroDefaults: { [key: string]: ValueElement };
+ MacroAnnotations: { [key: string]: ValueElement };
+ ForceDisplayGenericValue: { [key: string]: ValueElement };
+ AreMacroControlsVisible: ValueElement;
+ IsAutoSelectEnabled: ValueElement;
+ ChainSelector: ChainSelector;
+ ChainSelectorRelativePosition: ValueElement;
+ ViewsToRestoreWhenUnfolding: ValueElement;
+ ReturnBranches: {};
+ BranchesSplitterProportion: ValueElement;
+ ShowBranchesInSessionMixer: ValueElement;
+ MacroColor: { [key: string]: ValueElement };
+ LockId: ValueElement;
+ LockSeal: ValueElement;
+ ChainsListWrapper: LomIdElement;
+ ReturnChainsListWrapper: LomIdElement;
+ MacroVariations: MacroVariations;
+ ExcludeMacroFromRandomization: { [key: string]: ValueElement };
+ ExcludeMacroFromSnapshots: { [key: string]: ValueElement };
+ AreMacroVariationsControlsVisible: ValueElement;
+ ChainSelectorFilterMidiCtrl: ValueElement;
+ RangeTypeIndex: ValueElement;
+ ShowsZonesInsteadOfNoteNames: ValueElement;
+ IsMidiSectionVisible: ValueElement;
+ AreSendsVisible: ValueElement;
+ ArePadsVisible: ValueElement;
+ PadScrollPosition: ValueElement;
+ DrumPadsListWrapper: LomIdElement;
+ VisibleDrumPadsListWrapper: LomIdElement;
+}
+
+export interface ALSDrumRack {
+ DrumGroupDevice: ALSDrumRackContent;
+}
diff --git a/src/lib/exporters/ableton/types/effectChorus.ts b/src/lib/exporters/ableton/types/effectChorus.ts
new file mode 100644
index 0000000..9a97edd
--- /dev/null
+++ b/src/lib/exporters/ableton/types/effectChorus.ts
@@ -0,0 +1,151 @@
+import {
+ AutomationTarget,
+ LastPresetRef,
+ LomIdElement,
+ MidiCCOnOffThresholds,
+ MidiControllerRange,
+ ModulationTarget,
+ On,
+ SourceContext,
+ ValueElement,
+} from './common';
+
+interface Mode {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface Shaping {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Rate {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Amount {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Feedback {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface InvertFeedback {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface VibratoOffset {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface HighpassEnabled {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface HighpassFrequency {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Width {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Warmth {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface OutputGain {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DryWet {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+export interface ALSChorusContent {
+ '@Id': number;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: On;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: LastPresetRef;
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: SourceContext;
+ OverwriteProtectionNumber: ValueElement;
+ Mode: Mode;
+ Shaping: Shaping;
+ Rate: Rate;
+ Amount: Amount;
+ Feedback: Feedback;
+ InvertFeedback: InvertFeedback;
+ VibratoOffset: VibratoOffset;
+ HighpassEnabled: HighpassEnabled;
+ HighpassFrequency: HighpassFrequency;
+ Width: Width;
+ Warmth: Warmth;
+ OutputGain: OutputGain;
+ DryWet: DryWet;
+}
+
+export interface ALSChorus {
+ Chorus2: ALSChorusContent;
+}
diff --git a/src/lib/exporters/ableton/types/effectCompressor.ts b/src/lib/exporters/ableton/types/effectCompressor.ts
new file mode 100644
index 0000000..2229f26
--- /dev/null
+++ b/src/lib/exporters/ableton/types/effectCompressor.ts
@@ -0,0 +1,249 @@
+import {
+ AutomationTarget,
+ LastPresetRef,
+ LomIdElement,
+ MidiCCOnOffThresholds,
+ MidiControllerRange,
+ ModulationTarget,
+ On,
+ SourceContext,
+ ValueElement,
+} from './common';
+
+interface Threshold {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Ratio {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface ExpansionRatio {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Attack {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Release {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface AutoReleaseControlOnOff {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface Gain {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface GainCompensation {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface DryWet {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Model {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface LegacyModel {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface LogEnvelope {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface LegacyEnvFollowerMode {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface Knee {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface LookAhead {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface SideListen {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface MpeSettings {
+ ZoneType: ValueElement;
+ FirstNoteChannel: ValueElement;
+ LastNoteChannel: ValueElement;
+}
+
+interface Routable {
+ Target: ValueElement;
+ UpperDisplayString: ValueElement;
+ LowerDisplayString: ValueElement;
+ MpeSettings: MpeSettings;
+}
+
+interface Volume {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface RoutedInput {
+ Routable: Routable;
+ Volume: Volume;
+}
+
+interface OnOff {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface SideChain {
+ OnOff: OnOff;
+ RoutedInput: RoutedInput;
+ DryWet: DryWet;
+}
+
+interface Mode {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface Freq {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Q {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface SideChainEq {
+ On: On;
+ Mode: Mode;
+ Freq: Freq;
+ Q: Q;
+ Gain: Gain;
+}
+
+export interface ALSCompressorContent {
+ '@Id': number;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: On;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: LastPresetRef;
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: SourceContext;
+ OverwriteProtectionNumber: ValueElement;
+ Threshold: Threshold;
+ Ratio: Ratio;
+ ExpansionRatio: ExpansionRatio;
+ Attack: Attack;
+ Release: Release;
+ AutoReleaseControlOnOff: AutoReleaseControlOnOff;
+ Gain: Gain;
+ GainCompensation: GainCompensation;
+ DryWet: DryWet;
+ Model: Model;
+ LegacyModel: LegacyModel;
+ LogEnvelope: LogEnvelope;
+ LegacyEnvFollowerMode: LegacyEnvFollowerMode;
+ Knee: Knee;
+ LookAhead: LookAhead;
+ SideListen: SideListen;
+ SideChain: SideChain;
+ SideChainEq: SideChainEq;
+ Live8LegacyMode: ValueElement;
+ ViewMode: ValueElement;
+ IsOutputCurveVisible: ValueElement;
+ RmsTimeShort: ValueElement;
+ RmsTimeLong: ValueElement;
+ ReleaseTimeShort: ValueElement;
+ ReleaseTimeLong: ValueElement;
+ CrossfaderSmoothingTime: ValueElement;
+}
+
+export interface ALSCompressor {
+ Compressor2: ALSCompressorContent;
+}
diff --git a/src/lib/exporters/ableton/types/effectDelay.ts b/src/lib/exporters/ableton/types/effectDelay.ts
new file mode 100644
index 0000000..b79d63b
--- /dev/null
+++ b/src/lib/exporters/ableton/types/effectDelay.ts
@@ -0,0 +1,245 @@
+import {
+ AutomationTarget,
+ LastPresetRef,
+ LomIdElement,
+ MidiCCOnOffThresholds,
+ MidiControllerRange,
+ ModulationTarget,
+ On,
+ SourceContext,
+ ValueElement,
+} from './common';
+
+interface DelayLine_SmoothingMode {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface DelayLine_Link {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface DelayLine_PingPong {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface DelayLine_SyncL {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface DelayLine_SyncR {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface DelayLine_TimeL {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DelayLine_TimeR {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DelayLine_SimpleDelayTimeL {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DelayLine_SimpleDelayTimeR {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DelayLine_PingPongDelayTimeL {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DelayLine_PingPongDelayTimeR {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DelayLine_SyncedSixteenthL {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface DelayLine_SyncedSixteenthR {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface DelayLine_OffsetL {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DelayLine_OffsetR {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Feedback {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Freeze {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface Filter_On {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface Filter_Frequency {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Filter_Bandwidth {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Modulation_Frequency {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Modulation_AmountTime {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Modulation_AmountFilter {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DryWet {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+export interface ALSDelayContent {
+ '@Id': number;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: On;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: LastPresetRef;
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: SourceContext;
+ OverwriteProtectionNumber: ValueElement;
+ DelayLine_SmoothingMode: DelayLine_SmoothingMode;
+ DelayLine_Link: DelayLine_Link;
+ DelayLine_PingPong: DelayLine_PingPong;
+ DelayLine_SyncL: DelayLine_SyncL;
+ DelayLine_SyncR: DelayLine_SyncR;
+ DelayLine_TimeL: DelayLine_TimeL;
+ DelayLine_TimeR: DelayLine_TimeR;
+ DelayLine_SimpleDelayTimeL: DelayLine_SimpleDelayTimeL;
+ DelayLine_SimpleDelayTimeR: DelayLine_SimpleDelayTimeR;
+ DelayLine_PingPongDelayTimeL: DelayLine_PingPongDelayTimeL;
+ DelayLine_PingPongDelayTimeR: DelayLine_PingPongDelayTimeR;
+ DelayLine_SyncedSixteenthL: DelayLine_SyncedSixteenthL;
+ DelayLine_SyncedSixteenthR: DelayLine_SyncedSixteenthR;
+ DelayLine_OffsetL: DelayLine_OffsetL;
+ DelayLine_OffsetR: DelayLine_OffsetR;
+ DelayLine_CompatibilityMode: ValueElement;
+ Feedback: Feedback;
+ Freeze: Freeze;
+ Filter_On: Filter_On;
+ Filter_Frequency: Filter_Frequency;
+ Filter_Bandwidth: Filter_Bandwidth;
+ Modulation_Frequency: Modulation_Frequency;
+ Modulation_AmountTime: Modulation_AmountTime;
+ Modulation_AmountFilter: Modulation_AmountFilter;
+ DryWet: DryWet;
+ DryWetMode: ValueElement;
+ EcoProcessing: ValueElement;
+}
+
+export interface ALSDelay {
+ Delay: ALSDelayContent;
+}
diff --git a/src/lib/exporters/ableton/types/effectDistortion.ts b/src/lib/exporters/ableton/types/effectDistortion.ts
new file mode 100644
index 0000000..3e3e8ef
--- /dev/null
+++ b/src/lib/exporters/ableton/types/effectDistortion.ts
@@ -0,0 +1,91 @@
+import {
+ AutomationTarget,
+ LastPresetRef,
+ LomIdElement,
+ MidiControllerRange,
+ ModulationTarget,
+ On,
+ SourceContext,
+ ValueElement,
+} from './common';
+
+interface MidFreq {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface BandWidth {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Drive {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DryWet {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Tone {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface PreserveDynamics {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+export interface ALSDistortionContent {
+ '@Id': number;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: On;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: LastPresetRef;
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: SourceContext;
+ OverwriteProtectionNumber: ValueElement;
+ MidFreq: MidFreq;
+ BandWidth: BandWidth;
+ Drive: Drive;
+ DryWet: DryWet;
+ Tone: Tone;
+ PreserveDynamics: PreserveDynamics;
+}
+
+export interface ALSDistortion {
+ Overdrive: ALSDistortionContent;
+}
diff --git a/src/lib/exporters/ableton/types/effectFilter.ts b/src/lib/exporters/ableton/types/effectFilter.ts
new file mode 100644
index 0000000..cf68d4f
--- /dev/null
+++ b/src/lib/exporters/ableton/types/effectFilter.ts
@@ -0,0 +1,307 @@
+import {
+ AutomationTarget,
+ LastPresetRef,
+ LomIdElement,
+ MidiCCOnOffThresholds,
+ MidiControllerRange,
+ ModulationTarget,
+ On,
+ SourceContext,
+ ValueElement,
+} from './common';
+
+interface LegacyFilterType {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface FilterType {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface CircuitLpHp {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface CircuitBpNoMo {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface Slope {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface Cutoff {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface LegacyQ {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Resonance {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Morph {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Drive {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface ModHub {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Attack {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Release {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface LfoAmount {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Type {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface Frequency {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface RateType {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface BeatRate {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface StereoMode {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface Spin {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Phase {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Offset {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface IsOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface Quantize {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface BeatQuantize {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface NoiseWidth {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface Lfo {
+ Type: Type;
+ Frequency: Frequency;
+ RateType: RateType;
+ BeatRate: BeatRate;
+ StereoMode: StereoMode;
+ Spin: Spin;
+ Phase: Phase;
+ Offset: Offset;
+ IsOn: IsOn;
+ Quantize: Quantize;
+ BeatQuantize: BeatQuantize;
+ NoiseWidth: NoiseWidth;
+}
+
+interface MpeSettings {
+ ZoneType: ValueElement;
+ FirstNoteChannel: ValueElement;
+ LastNoteChannel: ValueElement;
+}
+
+interface Routable {
+ Target: ValueElement;
+ UpperDisplayString: ValueElement;
+ LowerDisplayString: ValueElement;
+ MpeSettings: MpeSettings;
+}
+
+interface Volume {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface RoutedInput {
+ Routable: Routable;
+ Volume: Volume;
+}
+
+interface DryWet {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface OnOff {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface SideChain {
+ OnOff: OnOff;
+ RoutedInput: RoutedInput;
+ DryWet: DryWet;
+}
+
+export interface ALSFilterContent {
+ '@Id': number;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: On;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: LastPresetRef;
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: SourceContext;
+ OverwriteProtectionNumber: ValueElement;
+ LegacyMode: ValueElement;
+ LegacyFilterType: LegacyFilterType;
+ FilterType: FilterType;
+ CircuitLpHp: CircuitLpHp;
+ CircuitBpNoMo: CircuitBpNoMo;
+ Slope: Slope;
+ Cutoff: Cutoff;
+ CutoffLimit: ValueElement;
+ LegacyQ: LegacyQ;
+ Resonance: Resonance;
+ Morph: Morph;
+ Drive: Drive;
+ ModHub: ModHub;
+ Attack: Attack;
+ Release: Release;
+ LfoAmount: LfoAmount;
+ Lfo: Lfo;
+ SideChain: SideChain;
+}
+
+export interface ALSFilter {
+ AutoFilter: ALSFilterContent;
+}
diff --git a/src/lib/exporters/ableton/types/effectReverb.ts b/src/lib/exporters/ableton/types/effectReverb.ts
new file mode 100644
index 0000000..bf3d0e9
--- /dev/null
+++ b/src/lib/exporters/ableton/types/effectReverb.ts
@@ -0,0 +1,312 @@
+import {
+ AutomationTarget,
+ LastPresetRef,
+ LomIdElement,
+ MidiCCOnOffThresholds,
+ MidiControllerRange,
+ ModulationTarget,
+ On,
+ SourceContext,
+ ValueElement,
+} from './common';
+
+interface PreDelay {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface BandHighOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface BandLowOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface BandFreq {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface BandWidth {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface SpinOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface EarlyReflectModFreq {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface EarlyReflectModDepth {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DiffuseDelay {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface ShelfHighOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface HighFilterType {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface ShelfHiFreq {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface ShelfHiGain {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface ShelfLowOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface ShelfLoFreq {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface ShelfLoGain {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface ChorusOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface SizeModFreq {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface SizeModDepth {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface DecayTime {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface AllPassGain {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface AllPassSize {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface FreezeOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface FlatOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface CutOn {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+ MidiCCOnOffThresholds: MidiCCOnOffThresholds;
+}
+
+interface RoomSize {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface SizeSmoothing {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface StereoSeparation {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface RoomType {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ AutomationTarget: AutomationTarget;
+}
+
+interface MixReflect {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface MixDiffuse {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+interface MixDirect {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+export interface ALSReverbContent {
+ '@Id': number;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: On;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: LastPresetRef;
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: SourceContext;
+ OverwriteProtectionNumber: ValueElement;
+ PreDelay: PreDelay;
+ BandHighOn: BandHighOn;
+ BandLowOn: BandLowOn;
+ BandFreq: BandFreq;
+ BandWidth: BandWidth;
+ SpinOn: SpinOn;
+ EarlyReflectModFreq: EarlyReflectModFreq;
+ EarlyReflectModDepth: EarlyReflectModDepth;
+ DiffuseDelay: DiffuseDelay;
+ ShelfHighOn: ShelfHighOn;
+ HighFilterType: HighFilterType;
+ ShelfHiFreq: ShelfHiFreq;
+ ShelfHiGain: ShelfHiGain;
+ ShelfLowOn: ShelfLowOn;
+ ShelfLoFreq: ShelfLoFreq;
+ ShelfLoGain: ShelfLoGain;
+ ChorusOn: ChorusOn;
+ SizeModFreq: SizeModFreq;
+ SizeModDepth: SizeModDepth;
+ DecayTime: DecayTime;
+ AllPassGain: AllPassGain;
+ AllPassSize: AllPassSize;
+ FreezeOn: FreezeOn;
+ FlatOn: FlatOn;
+ CutOn: CutOn;
+ RoomSize: RoomSize;
+ SizeSmoothing: SizeSmoothing;
+ StereoSeparation: StereoSeparation;
+ RoomType: RoomType;
+ MixReflect: MixReflect;
+ MixDiffuse: MixDiffuse;
+ MixDirect: MixDirect;
+ StereoSeparationOnDrySignal: ValueElement;
+}
+
+export interface ALSReverb {
+ Reverb: ALSReverbContent;
+}
diff --git a/src/lib/exporters/ableton/types/groupTrack.ts b/src/lib/exporters/ableton/types/groupTrack.ts
new file mode 100644
index 0000000..1cb17f9
--- /dev/null
+++ b/src/lib/exporters/ableton/types/groupTrack.ts
@@ -0,0 +1,65 @@
+import { LomIdElement, ValueElement } from './common';
+import { Mixer } from './midiTrack';
+
+interface Name {
+ EffectiveName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ MemorizedFirstClipName: ValueElement;
+}
+
+interface TrackDelay {
+ Value: ValueElement;
+ IsValueSampleBased: ValueElement;
+}
+
+interface TakeLanes {
+ TakeLanes: {};
+ AreTakeLanesFolded: ValueElement;
+}
+
+interface GroupTrackSlot {
+ '@Id': number;
+ LomId: ValueElement;
+}
+
+interface Slots {
+ GroupTrackSlot: GroupTrackSlot[];
+}
+
+interface DeviceChain {
+ Mixer: Mixer;
+ DeviceChain: {
+ Devices: {
+ '#': Array;
+ };
+ };
+}
+
+export interface ALSGroupTrackContent {
+ '@Id': number;
+ '@_internalId'?: string;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsContentSelectedInDocument: ValueElement;
+ PreferredContentViewMode: ValueElement;
+ TrackDelay: TrackDelay;
+ Name: Name;
+ Color: ValueElement;
+ AutomationEnvelopes: {
+ Envelopes: {};
+ };
+ TrackGroupId: ValueElement;
+ TrackUnfolded: ValueElement;
+ DevicesListWrapper: LomIdElement;
+ ClipSlotsListWrapper: LomIdElement;
+ ViewData: ValueElement;
+ TakeLanes: TakeLanes;
+ LinkedTrackGroupId: ValueElement;
+ Slots: Slots;
+ DeviceChain: DeviceChain;
+}
+
+export interface ALSGroupTrack {
+ GroupTrack: ALSGroupTrackContent;
+}
diff --git a/src/lib/exporters/ableton/types/midiClip.ts b/src/lib/exporters/ableton/types/midiClip.ts
new file mode 100644
index 0000000..aef9faa
--- /dev/null
+++ b/src/lib/exporters/ableton/types/midiClip.ts
@@ -0,0 +1,136 @@
+interface ValueElement {
+ '@Value': any;
+}
+
+interface MidiNoteEvent {
+ '@Time': number;
+ '@Duration': number;
+ '@Velocity': number;
+ '@OffVelocity': number;
+ '@NoteId': number;
+}
+
+interface KeyTrack {
+ '@Id': number;
+ Notes: {
+ MidiNoteEvent: MidiNoteEvent[];
+ };
+ MidiKey: ValueElement;
+}
+
+export interface ALSMidiClipContent {
+ '@Id': number;
+ '@Time': number;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ CurrentStart: ValueElement;
+ CurrentEnd: ValueElement;
+ Loop: {
+ LoopStart: ValueElement;
+ LoopEnd: ValueElement;
+ StartRelative: ValueElement;
+ LoopOn: ValueElement;
+ OutMarker: ValueElement;
+ HiddenLoopStart: ValueElement;
+ HiddenLoopEnd: ValueElement;
+ };
+ Name: ValueElement;
+ Annotation: ValueElement;
+ Color: ValueElement;
+ LaunchMode: ValueElement;
+ LaunchQuantisation: ValueElement;
+ TimeSignature: {
+ TimeSignatures: {
+ RemoteableTimeSignature: {
+ '@Id': number;
+ Numerator: ValueElement;
+ Denominator: ValueElement;
+ Time: ValueElement;
+ };
+ };
+ };
+ Envelopes: {
+ Envelopes: {};
+ };
+ ScrollerTimePreserver: {
+ LeftTime: ValueElement;
+ RightTime: ValueElement;
+ };
+ TimeSelection: {
+ AnchorTime: ValueElement;
+ OtherTime: ValueElement;
+ };
+ Legato: ValueElement;
+ Ram: ValueElement;
+ GrooveSettings: {
+ GrooveId: ValueElement;
+ };
+ Disabled: ValueElement;
+ VelocityAmount: ValueElement;
+ FollowAction: {
+ FollowTime: ValueElement;
+ IsLinked: ValueElement;
+ LoopIterations: ValueElement;
+ FollowActionA: ValueElement;
+ FollowActionB: ValueElement;
+ FollowChanceA: ValueElement;
+ FollowChanceB: ValueElement;
+ JumpIndexA: ValueElement;
+ JumpIndexB: ValueElement;
+ FollowActionEnabled: ValueElement;
+ };
+ Grid: {
+ FixedNumerator: ValueElement;
+ FixedDenominator: ValueElement;
+ GridIntervalPixel: ValueElement;
+ Ntoles: ValueElement;
+ SnapToGrid: ValueElement;
+ Fixed: ValueElement;
+ };
+ FreezeStart: ValueElement;
+ FreezeEnd: ValueElement;
+ IsWarped: ValueElement;
+ TakeId: ValueElement;
+ IsInKey: ValueElement;
+ ScaleInformation: {
+ Root: ValueElement;
+ Name: ValueElement;
+ };
+ Notes: {
+ KeyTracks: {
+ KeyTrack: KeyTrack[];
+ };
+ PerNoteEventStore: {
+ EventLists: {};
+ };
+ NoteProbabilityGroups: {};
+ ProbabilityGroupIdGenerator: {
+ NextId: ValueElement;
+ };
+ NoteIdGenerator: {
+ NextId: ValueElement;
+ };
+ };
+ BankSelectCoarse: ValueElement;
+ BankSelectFine: ValueElement;
+ ProgramChange: ValueElement;
+ NoteEditorFoldInZoom: ValueElement;
+ NoteEditorFoldInScroll: ValueElement;
+ NoteEditorFoldOutZoom: ValueElement;
+ NoteEditorFoldOutScroll: ValueElement;
+ NoteSpellingPreference: ValueElement;
+ AccidentalSpellingPreference: ValueElement;
+ PreferFlatRootNote: ValueElement;
+ ExpressionGrid: {
+ FixedNumerator: ValueElement;
+ FixedDenominator: ValueElement;
+ GridIntervalPixel: ValueElement;
+ Ntoles: ValueElement;
+ SnapToGrid: ValueElement;
+ Fixed: ValueElement;
+ };
+}
+
+export interface ALSMidiClip {
+ MidiClip: ALSMidiClipContent;
+}
diff --git a/src/lib/exporters/ableton/types/midiTrack.ts b/src/lib/exporters/ableton/types/midiTrack.ts
new file mode 100644
index 0000000..0a79186
--- /dev/null
+++ b/src/lib/exporters/ableton/types/midiTrack.ts
@@ -0,0 +1,340 @@
+import {
+ AutomationTarget,
+ LomIdElement,
+ MidiCCOnOffThresholds,
+ MidiControllerRange,
+ ModulationTarget,
+ ValueElement,
+} from './common';
+import type { ALSTrackSendHolder } from './trackSendHolder';
+
+interface ManualElement extends LomIdElement {
+ Manual: ValueElement;
+ MidiControllerRange?: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget?: ModulationTarget;
+ MidiCCOnOffThresholds?: MidiCCOnOffThresholds;
+}
+
+interface Routing {
+ Target: ValueElement;
+ UpperDisplayString: ValueElement;
+ LowerDisplayString: ValueElement;
+ MpeSettings: {
+ ZoneType: ValueElement;
+ FirstNoteChannel: ValueElement;
+ LastNoteChannel: ValueElement;
+ };
+ MpePitchBendUsesTuning: ValueElement;
+}
+
+interface AutomationLane {
+ '@Id': number;
+ SelectedDevice: ValueElement;
+ SelectedEnvelope: ValueElement;
+ IsContentSelectedInDocument: ValueElement;
+ LaneHeight: ValueElement;
+}
+
+interface AutomationLanes {
+ AutomationLane: AutomationLane[];
+ AreAdditionalAutomationLanesFolded: ValueElement;
+}
+
+interface ClipEnvelopeChooserViewState {
+ SelectedDevice: ValueElement;
+ SelectedEnvelope: ValueElement;
+ PreferModulationVisible: ValueElement;
+}
+
+export interface Mixer {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ BreakoutIsExpanded: ValueElement;
+ On: ManualElement;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: {
+ LomId: ValueElement;
+ };
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: {
+ Value: {};
+ };
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: {
+ Value: {};
+ };
+ MpePitchBendUsesTuning: ValueElement;
+ Sends: ALSTrackSendHolder;
+ Speaker: ManualElement;
+ SoloSink: ValueElement;
+ PanMode: ValueElement;
+ Pan: ManualElement;
+ SplitStereoPanL: ManualElement;
+ SplitStereoPanR: ManualElement;
+ Volume: ManualElement;
+ ViewStateSessionTrackWidth: ValueElement;
+ CrossFadeState: ManualElement;
+ SendsListWrapper: {
+ LomId: ValueElement;
+ };
+}
+
+interface ClipSlot {
+ '@Id': number;
+ LomId: ValueElement;
+ ClipSlot: {
+ Value?: MidiClip;
+ };
+ HasStop?: ValueElement;
+ NeedRefreeze?: ValueElement;
+}
+
+interface ClipSlotList {
+ ClipSlot: ClipSlot[];
+}
+
+interface MidiClip {
+ [key: string]: any;
+}
+
+interface ArrangerAutomation {
+ Events: {
+ MidiClip: MidiClip[];
+ };
+ AutomationTransformViewState: {
+ IsTransformPending: ValueElement;
+ TimeAndValueTransforms: {};
+ };
+}
+
+interface ClipTimeable {
+ ArrangerAutomation: ArrangerAutomation;
+}
+
+interface Recorder {
+ IsArmed: ValueElement;
+ TakeCounter: ValueElement;
+}
+
+interface ControllerTarget {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+}
+
+interface MidiControllers {
+ [key: string]: ControllerTarget;
+}
+
+interface MainSequencer {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ BreakoutIsExpanded: ValueElement;
+ On: ManualElement;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: {
+ LomId: ValueElement;
+ };
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: {
+ Value: {};
+ };
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: {
+ Value: {};
+ };
+ MpePitchBendUsesTuning: ValueElement;
+ ClipSlotList: ClipSlotList;
+ MonitoringEnum: ValueElement;
+ KeepRecordMonitoringLatency: ValueElement;
+ ClipTimeable: ClipTimeable;
+ Recorder: Recorder;
+ MidiControllers: MidiControllers;
+}
+
+interface FreezeSequencer {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ BreakoutIsExpanded: ValueElement;
+ On: ManualElement;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: {
+ LomId: ValueElement;
+ };
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: {
+ Value: {};
+ };
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: {
+ Value: {};
+ };
+ MpePitchBendUsesTuning: ValueElement;
+ ClipSlotList: ClipSlotList;
+ MonitoringEnum: ValueElement;
+ KeepRecordMonitoringLatency: ValueElement;
+ Sample: {
+ ArrangerAutomation: {
+ Events: {};
+ AutomationTransformViewState: {
+ IsTransformPending: ValueElement;
+ TimeAndValueTransforms: {};
+ };
+ };
+ };
+ VolumeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ TranspositionModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ TransientEnvelopeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ GrainSizeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ FluxModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ SampleOffsetModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ ComplexProFormantsModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ ComplexProEnvelopeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ PitchViewScrollPosition: ValueElement;
+ SampleOffsetModulationScrollPosition: ValueElement;
+ Recorder: Recorder;
+}
+
+interface DeviceChain {
+ AutomationLanes: AutomationLanes;
+ ClipEnvelopeChooserViewState: ClipEnvelopeChooserViewState;
+ AudioInputRouting: Routing;
+ MidiInputRouting: Routing;
+ AudioOutputRouting: Routing;
+ MidiOutputRouting: Routing;
+ Mixer: Mixer;
+ MainSequencer: MainSequencer;
+ FreezeSequencer: FreezeSequencer;
+ DeviceChain: {
+ Devices: {
+ '#': Array;
+ };
+ SignalModulations: {};
+ };
+}
+
+interface Name {
+ EffectiveName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ MemorizedFirstClipName: ValueElement;
+}
+
+interface TakeLanes {
+ TakeLanes: {};
+ AreTakeLanesFolded: ValueElement;
+}
+
+interface ControllerLayoutCustomization {
+ PitchClassSource: ValueElement;
+ OctaveSource: ValueElement;
+ KeyNoteTarget: ValueElement;
+ StepSize: ValueElement;
+ OctaveEvery: ValueElement;
+ AllowedKeys: ValueElement;
+ FillerKeysMapTo: ValueElement;
+}
+
+export interface ALSMidiTrackContent {
+ '@Id': number;
+ '@SelectedToolPanel': number;
+ '@SelectedTransformationName': string;
+ '@SelectedGeneratorName': string;
+ '@_internalGroupId'?: string;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsContentSelectedInDocument: ValueElement;
+ PreferredContentViewMode: ValueElement;
+ TrackDelay: {
+ Value: ValueElement;
+ IsValueSampleBased: ValueElement;
+ };
+ Name: Name;
+ Color: ValueElement;
+ AutomationEnvelopes: {
+ Envelopes: {};
+ };
+ TrackGroupId: ValueElement;
+ TrackUnfolded: ValueElement;
+ DevicesListWrapper: {
+ LomId: ValueElement;
+ };
+ ClipSlotsListWrapper: {
+ LomId: ValueElement;
+ };
+ ArrangementClipsListWrapper: {
+ LomId: ValueElement;
+ };
+ TakeLanesListWrapper: {
+ LomId: ValueElement;
+ };
+ ViewData: ValueElement;
+ TakeLanes: TakeLanes;
+ LinkedTrackGroupId: ValueElement;
+ SavedPlayingSlot: ValueElement;
+ SavedPlayingOffset: ValueElement;
+ Freeze: ValueElement;
+ NeedArrangerRefreeze: ValueElement;
+ PostProcessFreezeClips: ValueElement;
+ DeviceChain: DeviceChain;
+ ReWireDeviceMidiTargetId: ValueElement;
+ PitchbendRange: ValueElement;
+ IsTuned: ValueElement;
+ ControllerLayoutRemoteable: ValueElement;
+ ControllerLayoutCustomization: ControllerLayoutCustomization;
+}
+
+export interface ALSMidiTrack {
+ MidiTrack: ALSMidiTrackContent;
+}
diff --git a/src/lib/exporters/ableton/types/project.ts b/src/lib/exporters/ableton/types/project.ts
new file mode 100644
index 0000000..ee7c54c
--- /dev/null
+++ b/src/lib/exporters/ableton/types/project.ts
@@ -0,0 +1,372 @@
+import {
+ AutomationTarget,
+ LomIdElement,
+ MidiCCOnOffThresholds,
+ MidiControllerRange,
+ ModulationTarget,
+ ValueElement,
+} from './common';
+import type { ALSGroupTrack } from './groupTrack';
+import type { ALSMidiTrack } from './midiTrack';
+import type { ALSReturnTrack } from './returnTrack';
+import { ALSSceneContent } from './scene';
+
+interface ManualElement extends LomIdElement {
+ Manual: ValueElement;
+ MidiControllerRange?: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget?: ModulationTarget;
+ MidiCCOnOffThresholds?: MidiCCOnOffThresholds;
+}
+
+interface Routing {
+ Target: ValueElement;
+ UpperDisplayString: ValueElement;
+ LowerDisplayString: ValueElement;
+ MpeSettings: {
+ ZoneType: ValueElement;
+ FirstNoteChannel: ValueElement;
+ LastNoteChannel: ValueElement;
+ };
+ MpePitchBendUsesTuning: ValueElement;
+}
+
+interface AutomationLane {
+ '@Id': number;
+ SelectedDevice: ValueElement;
+ SelectedEnvelope: ValueElement;
+ IsContentSelectedInDocument: ValueElement;
+ LaneHeight: ValueElement;
+}
+
+interface AutomationLanes {
+ AutomationLanes: {
+ AutomationLane: AutomationLane[];
+ };
+ AreAdditionalAutomationLanesFolded: ValueElement;
+}
+
+interface ClipEnvelopeChooserViewState {
+ SelectedDevice: ValueElement;
+ SelectedEnvelope: ValueElement;
+ PreferModulationVisible: ValueElement;
+}
+
+interface Mixer {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ BreakoutIsExpanded: ValueElement;
+ On: ManualElement;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: {
+ Value: {};
+ };
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: {
+ Value: {};
+ };
+ MpePitchBendUsesTuning: ValueElement;
+ Sends: {};
+ Speaker: ManualElement;
+ SoloSink: ValueElement;
+ PanMode: ValueElement;
+ Pan: ManualElement;
+ SplitStereoPanL: ManualElement;
+ SplitStereoPanR: ManualElement;
+ Volume: ManualElement;
+ ViewStateSessionTrackWidth: ValueElement;
+ CrossFadeState: ManualElement;
+ SendsListWrapper: LomIdElement;
+ Tempo: ManualElement;
+}
+
+interface DeviceChain {
+ AutomationLanes: AutomationLanes;
+ ClipEnvelopeChooserViewState: ClipEnvelopeChooserViewState;
+ AudioInputRouting: Routing;
+ MidiInputRouting: Routing;
+ AudioOutputRouting: Routing;
+ MidiOutputRouting: Routing;
+ Mixer: Mixer;
+ MainSequencer: {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ BreakoutIsExpanded: ValueElement;
+ On: ManualElement;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: {
+ Value: {};
+ };
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: {
+ Value: {};
+ };
+ MpePitchBendUsesTuning: ValueElement;
+ ClipSlotList: {
+ ClipSlot: any[]; // Empty in template
+ };
+ MonitoringEnum: ValueElement;
+ KeepRecordMonitoringLatency: ValueElement;
+ ClipTimeable: {
+ ArrangerAutomation: {
+ Events: {
+ MidiClip: any[]; // Empty in template
+ };
+ AutomationTransformViewState: {
+ IsTransformPending: ValueElement;
+ TimeAndValueTransforms: {};
+ };
+ };
+ };
+ Recorder: {
+ IsArmed: ValueElement;
+ TakeCounter: ValueElement;
+ };
+ MidiControllers: {};
+ };
+ FreezeSequencer: {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ BreakoutIsExpanded: ValueElement;
+ On: ManualElement;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: {
+ Value: {};
+ };
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: {
+ Value: {};
+ };
+ MpePitchBendUsesTuning: ValueElement;
+ ClipSlotList: {
+ ClipSlot: any[]; // Empty in template
+ };
+ MonitoringEnum: ValueElement;
+ KeepRecordMonitoringLatency: ValueElement;
+ Sample: {
+ ArrangerAutomation: {
+ Events: {};
+ AutomationTransformViewState: {
+ IsTransformPending: ValueElement;
+ TimeAndValueTransforms: {};
+ };
+ };
+ };
+ VolumeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ TranspositionModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ TransientEnvelopeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ GrainSizeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ FluxModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ SampleOffsetModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ ComplexProFormantsModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ ComplexProEnvelopeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ PitchViewScrollPosition: ValueElement;
+ SampleOffsetModulationScrollPosition: ValueElement;
+ Recorder: {
+ IsArmed: ValueElement;
+ TakeCounter: ValueElement;
+ };
+ };
+ DeviceChain: {
+ Devices: {};
+ SignalModulations: {};
+ };
+}
+
+interface Name {
+ EffectiveName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ MemorizedFirstClipName: ValueElement;
+}
+
+interface TrackDelay {
+ Value: ValueElement;
+ IsValueSampleBased: ValueElement;
+}
+
+interface TakeLanes {
+ TakeLanes: {};
+ AreTakeLanesFolded: ValueElement;
+}
+
+interface AutomationEnvelope {
+ '@Id': number;
+ EnvelopeTarget: {
+ PointeeId: ValueElement;
+ };
+ Automation: {
+ Events: {
+ EnumEvent: {
+ '@Id': number;
+ '@Time': number;
+ '@Value': number;
+ };
+ FloatEvent: {
+ '@Id': number;
+ '@Time': number;
+ '@Value': number;
+ };
+ };
+ AutomationTransformViewState: {
+ IsTransformPending: ValueElement;
+ TimeAndValueTransforms: {};
+ };
+ };
+}
+
+interface AutomationEnvelopes {
+ Envelopes: {
+ AutomationEnvelope: AutomationEnvelope[];
+ };
+}
+
+interface MasterTrack {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsContentSelectedInDocument: ValueElement;
+ PreferredContentViewMode: ValueElement;
+ TrackDelay: TrackDelay;
+ Name: Name;
+ Color: ValueElement;
+ AutomationEnvelopes: AutomationEnvelopes;
+ TrackGroupId: ValueElement;
+ TrackUnfolded: ValueElement;
+ DevicesListWrapper: LomIdElement;
+ ClipSlotsListWrapper: LomIdElement;
+ ArrangementClipsListWrapper: LomIdElement;
+ TakeLanesListWrapper: LomIdElement;
+ ViewData: ValueElement;
+ TakeLanes: TakeLanes;
+ LinkedTrackGroupId: ValueElement;
+ DeviceChain: DeviceChain;
+}
+
+interface GroovePool {
+ Grooves: {
+ Groove: any[];
+ };
+}
+
+interface AutoColorPicker {
+ NextColorIndex: ValueElement;
+}
+
+interface VideoWindowRect {
+ '@Top': number;
+ '@Left': number;
+ '@Bottom': number;
+ '@Right': number;
+}
+
+interface LiveSet {
+ NextPointeeId: ValueElement;
+ OverwriteProtectionNumber: ValueElement;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ Tracks: {
+ '#': (ALSMidiTrack | ALSGroupTrack | ALSReturnTrack)[];
+ };
+ MasterTrack: MasterTrack;
+ GroovePool: GroovePool;
+ AutomationMode: ValueElement;
+ SnapAutomationToGrid: ValueElement;
+ ArrangementOverdub: ValueElement;
+ ColorSequenceIndex: ValueElement;
+ AutoColorPickerForPlayerAndGroupTracks: AutoColorPicker;
+ AutoColorPickerForReturnAndMasterTracks: AutoColorPicker;
+ ViewData: ValueElement;
+ ResetNonautomatedMidiControllersOnClipStarts: ValueElement;
+ MidiFoldIn: ValueElement;
+ MidiFoldMode: ValueElement;
+ MultiClipFocusMode: ValueElement;
+ MultiClipLoopBarHeight: ValueElement;
+ MidiPrelisten: ValueElement;
+ LinkedTrackGroups: {};
+ AccidentalSpellingPreference: ValueElement;
+ PreferFlatRootNote: ValueElement;
+ UseWarperLegacyHiQMode: ValueElement;
+ VideoWindowRect: VideoWindowRect;
+ ShowVideoWindow: ValueElement;
+ TrackHeaderWidth: ValueElement;
+ ViewStates: {};
+ Scenes: {
+ Scene: ALSSceneContent[];
+ };
+ SendsPre: {
+ SendPreBool: {
+ '@Id': number;
+ '@Value': string;
+ };
+ };
+}
+
+interface Ableton {
+ '@MajorVersion': string;
+ '@MinorVersion': string;
+ '@SchemaChangeCount': string;
+ '@Creator': string;
+ '@Revision': string;
+ LiveSet: LiveSet;
+}
+
+export interface ALSProject {
+ Ableton: Ableton;
+}
diff --git a/src/lib/exporters/ableton/types/returnTrack.ts b/src/lib/exporters/ableton/types/returnTrack.ts
new file mode 100644
index 0000000..802a853
--- /dev/null
+++ b/src/lib/exporters/ableton/types/returnTrack.ts
@@ -0,0 +1,224 @@
+import {
+ AutomationTarget,
+ LomIdElement,
+ MidiCCOnOffThresholds,
+ MidiControllerRange,
+ ModulationTarget,
+ ValueElement,
+} from './common';
+import { ALSChorus } from './effectChorus';
+import { ALSCompressor } from './effectCompressor';
+import { ALSDelay } from './effectDelay';
+import { ALSDistortion } from './effectDistortion';
+import { ALSFilter } from './effectFilter';
+import { ALSReverb } from './effectReverb';
+
+interface ManualElement extends LomIdElement {
+ Manual: ValueElement;
+ MidiControllerRange?: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget?: ModulationTarget;
+ MidiCCOnOffThresholds?: MidiCCOnOffThresholds;
+}
+
+interface Routing {
+ Target: ValueElement;
+ UpperDisplayString: ValueElement;
+ LowerDisplayString: ValueElement;
+ MpeSettings: {
+ ZoneType: ValueElement;
+ FirstNoteChannel: ValueElement;
+ LastNoteChannel: ValueElement;
+ };
+}
+
+interface AutomationLane {
+ '@Id': number;
+ SelectedDevice: ValueElement;
+ SelectedEnvelope: ValueElement;
+ IsContentSelectedInDocument: ValueElement;
+ LaneHeight: ValueElement;
+}
+
+interface AutomationLanes {
+ AutomationLane: AutomationLane[];
+ AreAdditionalAutomationLanesFolded: ValueElement;
+}
+
+interface ClipEnvelopeChooserViewState {
+ SelectedDevice: ValueElement;
+ SelectedEnvelope: ValueElement;
+ PreferModulationVisible: ValueElement;
+}
+
+interface TrackSendHolder {
+ '@Id': number;
+ Send: ManualElement;
+ Active: ValueElement;
+}
+
+interface Sends {
+ TrackSendHolder: TrackSendHolder[];
+}
+
+interface Mixer {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: ManualElement;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: {
+ Value: {};
+ };
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: {
+ Value: {};
+ };
+ Sends: Sends;
+ Speaker: ManualElement;
+ SoloSink: ValueElement;
+ PanMode: ValueElement;
+ Pan: ManualElement;
+ SplitStereoPanL: ManualElement;
+ SplitStereoPanR: ManualElement;
+ Volume: ManualElement;
+ ViewStateSesstionTrackWidth: ValueElement;
+ CrossFadeState: ManualElement;
+ SendsListWrapper: LomIdElement;
+}
+
+interface Sample {
+ ArrangerAutomation: {
+ Events: {};
+ AutomationTransformViewState: {
+ IsTransformPending: ValueElement;
+ TimeAndValueTransforms: {};
+ };
+ };
+}
+
+interface FreezeSequencer {
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ On: ManualElement;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: LomIdElement;
+ Pointee: {
+ '@Id': number;
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: {
+ Value: {};
+ };
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: {
+ Value: {};
+ };
+ ClipSlotList: {};
+ MonitoringEnum: ValueElement;
+ Sample: Sample;
+ VolumeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ TranspositionModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ GrainSizeModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ FluxModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ SampleOffsetModulationTarget: {
+ '@Id': number;
+ LockEnvelope: ValueElement;
+ };
+ PitchViewScrollPosition: ValueElement;
+ SampleOffsetModulationScrollPosition: ValueElement;
+ Recorder: {
+ IsArmed: ValueElement;
+ TakeCounter: ValueElement;
+ };
+}
+
+interface Name {
+ EffectiveName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ MemorizedFirstClipName: ValueElement;
+}
+
+interface TrackDelay {
+ Value: ValueElement;
+ IsValueSampleBased: ValueElement;
+}
+
+interface AutomationEnvelopes {
+ Envelopes: {};
+}
+
+interface TakeLanes {
+ TakeLanes: {};
+ AreTakeLanesFolded: ValueElement;
+}
+
+type EffectDevice = ALSChorus | ALSCompressor | ALSDelay | ALSDistortion | ALSFilter | ALSReverb;
+
+interface DeviceChain {
+ AutomationLanes: AutomationLanes;
+ ClipEnvelopeChooserViewState: ClipEnvelopeChooserViewState;
+ AudioInputRouting: Routing;
+ MidiInputRouting: Routing;
+ AudioOutputRouting: Routing;
+ MidiOutputRouting: Routing;
+ Mixer: Mixer;
+ DeviceChain: {
+ Devices: EffectDevice;
+ SignalModulations: {};
+ };
+ FreezeSequencer: FreezeSequencer;
+}
+
+export interface ALSReturnTrackContent {
+ '@Id': number;
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsContentSelectedInDocument: ValueElement;
+ PreferredContentViewMode: ValueElement;
+ TrackDelay: TrackDelay;
+ Name: Name;
+ Color: ValueElement;
+ AutomationEnvelopes: AutomationEnvelopes;
+ TrackGroupId: ValueElement;
+ TrackUnfolded: ValueElement;
+ DevicesListWrapper: LomIdElement;
+ ClipSlotsListWrapper: LomIdElement;
+ ViewData: ValueElement;
+ TakeLanes: TakeLanes;
+ LinkedTrackGroupId: ValueElement;
+ DeviceChain: DeviceChain;
+}
+
+export interface ALSReturnTrack {
+ ReturnTrack: ALSReturnTrackContent;
+}
diff --git a/src/lib/exporters/ableton/types/scene.ts b/src/lib/exporters/ableton/types/scene.ts
new file mode 100644
index 0000000..84326c0
--- /dev/null
+++ b/src/lib/exporters/ableton/types/scene.ts
@@ -0,0 +1,34 @@
+import { ValueElement } from './common';
+
+interface FollowAction {
+ FollowTime: ValueElement;
+ IsLinked: ValueElement;
+ LoopIterations: ValueElement;
+ FollowActionA: ValueElement;
+ FollowActionB: ValueElement;
+ FollowChanceA: ValueElement;
+ FollowChanceB: ValueElement;
+ JumpIndexA: ValueElement;
+ JumpIndexB: ValueElement;
+ FollowActionEnabled: ValueElement;
+}
+
+export interface ALSSceneContent {
+ '@Id': number;
+ FollowAction: FollowAction;
+ Name: ValueElement;
+ Annotation: ValueElement;
+ Color: ValueElement;
+ Tempo: ValueElement;
+ IsTempoEnabled: ValueElement;
+ TimeSignatureId: ValueElement;
+ IsTimeSignatureEnabled: ValueElement;
+ LomId: ValueElement;
+ ClipSlotsListWrapper: {
+ '@LomId': number;
+ };
+}
+
+export interface ALSScene {
+ Scene: ALSSceneContent;
+}
diff --git a/src/lib/exporters/ableton/types/simpler.ts b/src/lib/exporters/ableton/types/simpler.ts
new file mode 100644
index 0000000..f4526aa
--- /dev/null
+++ b/src/lib/exporters/ableton/types/simpler.ts
@@ -0,0 +1,545 @@
+import {
+ AutomationTarget,
+ FileRef,
+ LomIdElement,
+ MidiCCOnOffThresholds,
+ MidiControllerRange,
+ ModulationTarget,
+ PresetRef,
+ ValueElement,
+} from './common';
+
+interface ManualElement extends LomIdElement {
+ Manual: ValueElement;
+ MidiControllerRange?: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget?: ModulationTarget;
+ MidiCCOnOffThresholds?: MidiCCOnOffThresholds;
+}
+
+interface BranchSourceContext {
+ _attrs: { Id: number };
+ OriginalFileRef: {};
+ BrowserContentPath: ValueElement;
+ LocalFiltersJson: ValueElement;
+ PresetRef: PresetRef;
+ BranchDeviceId: ValueElement;
+}
+
+interface SourceContext {
+ Value: BranchSourceContext;
+}
+
+interface LastPresetRef {
+ Value: any;
+}
+
+interface WarpMarker {
+ '@Id': number;
+ '@SecTime': number;
+ '@BeatTime': number;
+}
+
+interface WarpMarkers {
+ WarpMarker: WarpMarker[];
+}
+
+interface TimeSignature {
+ TimeSignatures: {
+ RemoteableTimeSignature: {
+ _attrs: { Id: number };
+ Numerator: ValueElement;
+ Denominator: ValueElement;
+ Time: ValueElement;
+ };
+ };
+}
+
+interface BeatGrid {
+ FixedNumerator: ValueElement;
+ FixedDenominator: ValueElement;
+ GridIntervalPixel: ValueElement;
+ Ntoles: ValueElement;
+ SnapToGrid: ValueElement;
+ Fixed: ValueElement;
+}
+
+interface SampleWarpProperties {
+ WarpMarkers: WarpMarkers;
+ WarpMode: ValueElement;
+ GranularityTones: ValueElement;
+ GranularityTexture: ValueElement;
+ FluctuationTexture: ValueElement;
+ ComplexProFormants: ValueElement;
+ ComplexProEnvelope: ValueElement;
+ TransientResolution: ValueElement;
+ TransientLoopMode: ValueElement;
+ TransientEnvelope: ValueElement;
+ IsWarped: ValueElement;
+ Onsets: {
+ UserOnsets: {};
+ HasUserOnsets: ValueElement;
+ };
+ TimeSignature: TimeSignature;
+ BeatGrid: BeatGrid;
+}
+
+interface SampleRef {
+ FileRef: FileRef;
+ LastModDate: ValueElement;
+ SourceContext: {};
+ SampleUsageHint: ValueElement;
+ DefaultDuration: ValueElement;
+ DefaultSampleRate: ValueElement;
+ SamplesToAutoWarp: ValueElement;
+}
+
+interface SustainLoop {
+ Start: ValueElement;
+ End: ValueElement;
+ Mode: ValueElement;
+ Crossfade: ValueElement;
+ Detune: ValueElement;
+}
+
+interface ReleaseLoop {
+ Start: ValueElement;
+ End: ValueElement;
+ Mode: ValueElement;
+ Crossfade: ValueElement;
+ Detune: ValueElement;
+}
+
+interface MultiSamplePart {
+ _attrs: {
+ Id: number;
+ InitUpdateAreSlicesFromOnsetsEditableAfterRead: boolean;
+ HasImportedSlicePoints: boolean;
+ NeedsAnalysisData: boolean;
+ };
+ LomId: ValueElement;
+ Name: ValueElement;
+ Selection: ValueElement;
+ IsActive: ValueElement;
+ Solo: ValueElement;
+ KeyRange: {
+ Min: ValueElement;
+ Max: ValueElement;
+ CrossfadeMin: ValueElement;
+ CrossfadeMax: ValueElement;
+ };
+ VelocityRange: {
+ Min: ValueElement;
+ Max: ValueElement;
+ CrossfadeMin: ValueElement;
+ CrossfadeMax: ValueElement;
+ };
+ SelectorRange: {
+ Min: ValueElement;
+ Max: ValueElement;
+ CrossfadeMin: ValueElement;
+ CrossfadeMax: ValueElement;
+ };
+ RootKey: ValueElement;
+ Detune: ValueElement;
+ TuneScale: ValueElement;
+ Panorama: ValueElement;
+ Volume: ValueElement;
+ Link: ValueElement;
+ SampleStart: ValueElement;
+ SampleEnd: ValueElement;
+ SustainLoop: SustainLoop;
+ ReleaseLoop: ReleaseLoop;
+ SampleRef: SampleRef;
+ SlicingThreshold: ValueElement;
+ SlicingBeatGrid: ValueElement;
+ SlicingRegions: ValueElement;
+ SlicingStyle: ValueElement;
+ SampleWarpProperties: SampleWarpProperties;
+ InitialSlicePointsFromOnsets: {
+ SlicePoint: {
+ TimeInSeconds: number;
+ Rank: number;
+ NormalizedEnergy: number;
+ };
+ };
+ SlicePoints: {};
+ ManualSlicePoints: {};
+ BeatSlicePoints: {};
+ RegionSlicePoints: {};
+ UseDynamicBeatSlices: ValueElement;
+ UseDynamicRegionSlices: ValueElement;
+ AreSlicesFromOnsetsEditable: ValueElement;
+}
+
+interface SampleParts {
+ MultiSamplePart: MultiSamplePart;
+}
+
+interface MultiSampleMap {
+ SampleParts: SampleParts;
+ LoadInRam: ValueElement;
+ LayerCrossfade: ValueElement;
+ SourceContext: {};
+ RoundRobin: ValueElement;
+ RoundRobinMode: ValueElement;
+ RoundRobinResetPeriod: ValueElement;
+ RoundRobinRandomSeed: ValueElement;
+}
+
+interface LoopModulator {
+ IsModulated: ValueElement;
+ SampleStart: ManualElement;
+ SampleLength: ManualElement;
+ LoopOn: ManualElement;
+ LoopLength: ManualElement;
+ LoopFade: ManualElement;
+}
+
+interface LoopModulators {
+ LoopModulator: LoopModulator;
+}
+
+interface Player {
+ MultiSampleMap: MultiSampleMap;
+ LoopModulators: LoopModulators;
+ Reverse: ManualElement;
+ Snap: ManualElement;
+ SampleSelector: ManualElement;
+ SubOsc: {
+ IsOn: ManualElement;
+ Slot: {
+ Value: {};
+ };
+ };
+ InterpolationMode: ValueElement;
+ UseConstPowCrossfade: ValueElement;
+}
+
+interface SimplerPitchEnvelope {
+ _attrs: { Id: number };
+ AttackTime: ManualElement;
+ AttackLevel: ManualElement;
+ AttackSlope: ManualElement;
+ DecayTime: ManualElement;
+ DecayLevel: ManualElement;
+ DecaySlope: ManualElement;
+ SustainLevel: ManualElement;
+ ReleaseTime: ManualElement;
+ ReleaseLevel: ManualElement;
+ ReleaseSlope: ManualElement;
+ LoopMode: ManualElement;
+ LoopTime: ManualElement;
+ RepeatTime: ManualElement;
+ TimeVelScale: ManualElement;
+ CurrentOverlay: ValueElement;
+ Amount: ManualElement;
+ ScrollPosition: ValueElement;
+}
+
+interface Pitch {
+ TransposeKey: ManualElement;
+ TransposeFine: ManualElement;
+ PitchLfoAmount: ManualElement;
+ Envelope: {
+ IsOn: ManualElement;
+ Slot: {
+ Value: SimplerPitchEnvelope;
+ };
+ };
+ ScrollPosition: ValueElement;
+}
+
+interface SimplerFilter {
+ _attrs: { Id: number };
+ LegacyType: ManualElement;
+ Type: ManualElement;
+ CircuitLpHp: ManualElement;
+ CircuitBpNoMo: ManualElement;
+ Slope: ManualElement;
+ Freq: ManualElement;
+ LegacyQ: ManualElement;
+ Res: ManualElement;
+ X: ManualElement;
+ Drive: ManualElement;
+ Envelope: {
+ AttackTime: ManualElement;
+ AttackLevel: ManualElement;
+ AttackSlope: ManualElement;
+ DecayTime: ManualElement;
+ DecayLevel: ManualElement;
+ DecaySlope: ManualElement;
+ SustainLevel: ManualElement;
+ ReleaseTime: ManualElement;
+ ReleaseLevel: ManualElement;
+ ReleaseSlope: ManualElement;
+ LoopMode: ManualElement;
+ LoopTime: ManualElement;
+ RepeatTime: ManualElement;
+ TimeVelScale: ManualElement;
+ CurrentOverlay: ValueElement;
+ IsOn: ManualElement;
+ Amount: ManualElement;
+ ScrollPosition: ValueElement;
+ };
+ ModByPitch: ManualElement;
+ ModByVelocity: ManualElement;
+ ModByLfo: ManualElement;
+}
+
+interface Filter {
+ IsOn: ManualElement;
+ Slot: {
+ Value: {
+ SimplerFilter: SimplerFilter;
+ };
+ };
+}
+
+interface VolumeAndPan {
+ Volume: ManualElement;
+ VolumeVelScale: ManualElement;
+ VolumeKeyScale: ManualElement;
+ VolumeLfoAmount: ManualElement;
+ Panorama: ManualElement;
+ PanoramaKeyScale: ManualElement;
+ PanoramaRnd: ManualElement;
+ PanoramaLfoAmount: ManualElement;
+ Envelope: {
+ AttackTime: ManualElement;
+ AttackLevel: ManualElement;
+ AttackSlope: ManualElement;
+ DecayTime: ManualElement;
+ DecayLevel: ManualElement;
+ DecaySlope: ManualElement;
+ SustainLevel: ManualElement;
+ ReleaseTime: ManualElement;
+ ReleaseLevel: ManualElement;
+ ReleaseSlope: ManualElement;
+ LoopMode: ManualElement;
+ LoopTime: ManualElement;
+ RepeatTime: ManualElement;
+ TimeVelScale: ManualElement;
+ CurrentOverlay: ValueElement;
+ };
+ OneShotEnvelope: {
+ FadeInTime: ManualElement;
+ SustainMode: ManualElement;
+ FadeOutTime: ManualElement;
+ };
+}
+
+export interface ALSOriginalSimplerContent {
+ _attrs: { Id: number };
+ LomId: ValueElement;
+ LomIdView: ValueElement;
+ IsExpanded: ValueElement;
+ BreakoutIsExpanded: ValueElement;
+ On: ManualElement;
+ ModulationSourceCount: ValueElement;
+ ParametersListWrapper: {
+ LomId: ValueElement;
+ };
+ Pointee: {
+ _attrs: { Id: number };
+ };
+ LastSelectedTimeableIndex: ValueElement;
+ LastSelectedClipEnvelopeIndex: ValueElement;
+ LastPresetRef: LastPresetRef;
+ LockedScripts: {};
+ IsFolded: ValueElement;
+ ShouldShowPresetName: ValueElement;
+ UserName: ValueElement;
+ Annotation: ValueElement;
+ SourceContext: SourceContext;
+ MpePitchBendUsesTuning: ValueElement;
+ OverwriteProtectionNumber: ValueElement;
+ Player: Player;
+ Pitch: Pitch;
+ Filter: Filter;
+ Shaper: {
+ IsOn: ManualElement;
+ Slot: {
+ Value: {};
+ };
+ };
+ VolumeAndPan: VolumeAndPan;
+ AuxEnv: {
+ IsOn: ManualElement;
+ Slot: {
+ Value: {};
+ };
+ };
+ Lfo: {
+ IsOn: ManualElement;
+ Slot: {
+ Value: {
+ SimplerLfo: {
+ _attrs: { Id: number };
+ Type: ManualElement;
+ Frequency: ManualElement;
+ RateType: ManualElement;
+ BeatRate: ManualElement;
+ StereoMode: ManualElement;
+ Spin: ManualElement;
+ Phase: ManualElement;
+ Offset: ManualElement;
+ FrequencyKeyScale: ManualElement;
+ Smooth: ManualElement;
+ Attack: ManualElement;
+ Retrigger: ManualElement;
+ Width: ManualElement;
+ };
+ };
+ };
+ };
+ AuxLfos: {
+ '0': {
+ IsOn: ManualElement;
+ Slot: {
+ Value: {};
+ };
+ };
+ '1': {
+ IsOn: ManualElement;
+ Slot: {
+ Value: {};
+ };
+ };
+ };
+ KeyDst: {
+ 'ModConnections.0': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ 'ModConnections.1': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ };
+ VelDst: {
+ 'ModConnections.0': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ 'ModConnections.1': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ };
+ RelVelDst: {
+ 'ModConnections.0': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ 'ModConnections.1': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ };
+ MidiCtrl: {
+ '0': {
+ 'ModConnections.0': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ 'ModConnections.1': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ Feedback: ValueElement;
+ };
+ '1': {
+ 'ModConnections.0': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ 'ModConnections.1': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ Feedback: ValueElement;
+ };
+ '2': {
+ 'ModConnections.0': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ 'ModConnections.1': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ Feedback: ValueElement;
+ };
+ '3': {
+ 'ModConnections.0': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ 'ModConnections.1': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ Feedback: ValueElement;
+ };
+ '4': {
+ 'ModConnections.0': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ 'ModConnections.1': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ Feedback: ValueElement;
+ };
+ '5': {
+ 'ModConnections.0': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ 'ModConnections.1': {
+ Amount: ValueElement;
+ Connection: ValueElement;
+ };
+ Feedback: ValueElement;
+ };
+ };
+ Globals: {
+ NumVoices: ValueElement;
+ NumVoicesEnvTimeControl: ValueElement;
+ RetriggerMode: ValueElement;
+ ModulationResolution: ValueElement;
+ SpreadAmount: ManualElement;
+ KeyZoneShift: ManualElement;
+ PortamentoMode: ManualElement;
+ PortamentoTime: ManualElement;
+ PitchBendRange: ValueElement;
+ MpePitchBendRange: ValueElement;
+ ScrollPosition: ValueElement;
+ EnvScale: {
+ EnvTime: ManualElement;
+ EnvTimeKeyScale: ManualElement;
+ EnvTimeIncludeAttack: ManualElement;
+ };
+ IsSimpler: ValueElement;
+ PlaybackMode: ValueElement;
+ LegacyMode: ValueElement;
+ };
+ ViewSettings: {
+ SelectedPage: ValueElement;
+ ZoneEditorVisible: ValueElement;
+ Seconds: ValueElement;
+ SelectedSampleChannel: ValueElement;
+ VerticalSampleZoom: ValueElement;
+ IsAutoSelectEnabled: ValueElement;
+ SimplerBreakoutVisible: ValueElement;
+ };
+ SimplerSlicing: {
+ PlaybackMode: ValueElement;
+ };
+}
+
+export interface ALSSimpler {
+ OriginalSimpler: ALSOriginalSimplerContent;
+}
diff --git a/src/lib/exporters/ableton/types/trackSendHolder.ts b/src/lib/exporters/ableton/types/trackSendHolder.ts
new file mode 100644
index 0000000..2a4efb2
--- /dev/null
+++ b/src/lib/exporters/ableton/types/trackSendHolder.ts
@@ -0,0 +1,19 @@
+import { AutomationTarget, MidiControllerRange, ModulationTarget, ValueElement } from './common';
+
+interface Send {
+ LomId: ValueElement;
+ Manual: ValueElement;
+ MidiControllerRange: MidiControllerRange;
+ AutomationTarget: AutomationTarget;
+ ModulationTarget: ModulationTarget;
+}
+
+export interface ALSTrackSendHolderContent {
+ '@Id': number;
+ Send: Send;
+ Active: ValueElement;
+}
+
+export interface ALSTrackSendHolder {
+ TrackSendHolder: ALSTrackSendHolderContent;
+}
diff --git a/src/lib/exporters/ableton/utils.ts b/src/lib/exporters/ableton/utils.ts
new file mode 100644
index 0000000..c791129
--- /dev/null
+++ b/src/lib/exporters/ableton/utils.ts
@@ -0,0 +1,138 @@
+import { create } from 'xmlbuilder2';
+
+export const TIME_SIGNATURES: { [key: string]: number } = {
+ '1/4': 196,
+ '2/4': 198,
+ '3/4': 200,
+ '4/4': 201,
+ '5/4': 202,
+ '6/4': 203,
+ '7/4': 204,
+ '8/4': 205,
+ '5/8': 206,
+ '6/8': 207,
+ '7/8': 208,
+ '9/8': 209,
+ '12/8': 210,
+ '3/2': 211,
+ '4/2': 212,
+ '5/2': 213,
+ '6/2': 214,
+ '7/2': 215,
+ '1/2': 216,
+ '1/1': 217,
+ '2/1': 218,
+ '3/1': 219,
+ '4/1': 220,
+ '5/1': 221,
+ '3/8': 299,
+};
+const MIN_ID = 22000;
+const START_ID = 22000;
+
+let _id = START_ID;
+const templateCache: Record = {};
+
+const templateMap: Record Promise> = {
+ midiClip: () => import('./templates/midiClip.xml?raw'),
+ midiTrack: () => import('./templates/midiTrack.xml?raw'),
+ project: () => import('./templates/project.xml?raw'),
+ simpler: () => import('./templates/simpler.xml?raw'),
+ scene: () => import('./templates/scene.xml?raw'),
+ groupTrack: () => import('./templates/groupTrack.xml?raw'),
+ drumRack: () => import('./templates/drumRack.xml?raw'),
+ drumBranch: () => import('./templates/drumBranch.xml?raw'),
+ returnTrack: () => import('./templates/returnTrack.xml?raw'),
+ trackSendHolder: () => import('./templates/trackSendHolder.xml?raw'),
+ effectReverb: () => import('./templates/effectReverb.xml?raw'),
+ effectDelay: () => import('./templates/effectDelay.xml?raw'),
+ effectChorus: () => import('./templates/effectChorus.xml?raw'),
+ effectDistortion: () => import('./templates/effectDistortion.xml?raw'),
+ effectFilter: () => import('./templates/effectFilter.xml?raw'),
+ effectCompressor: () => import('./templates/effectCompressor.xml?raw'),
+};
+
+export async function loadTemplate(templateName: string): Promise {
+ if (templateCache[templateName]) {
+ return templateCache[templateName];
+ }
+
+ const importFn = templateMap[templateName];
+ if (!importFn) {
+ throw new Error(`Unknown template: ${templateName}`);
+ }
+
+ const templateModule = await importFn();
+ const parsed = create(templateModule.default).toObject();
+ templateCache[templateName] = parsed;
+ return parsed as T;
+}
+
+export function koEnvRangeToSeconds(value: number, maxSeconds: number) {
+ if (value < 0 || value > 255) {
+ throw new RangeError('Value must be between 0 and 255');
+ }
+ return (value / 255) * maxSeconds;
+}
+
+export function fixIds(node: any): any {
+ if (Array.isArray(node)) {
+ return node.map((n) => fixIds(n));
+ }
+
+ if (node?.['@Id']) {
+ const idNum = parseInt(String(node['@Id']), 10);
+
+ if (idNum > MIN_ID) {
+ node['@Id'] = _id;
+
+ _id++;
+ }
+ }
+
+ if (typeof node === 'object') {
+ Object.keys(node).forEach((key) => {
+ if (!key.startsWith('@')) {
+ node[key] = fixIds(node[key]);
+ }
+ });
+ }
+
+ return node;
+}
+
+export function getId() {
+ return _id;
+}
+
+export async function gzipString(content: string) {
+ const encoder = new TextEncoder();
+ const input = encoder.encode(content);
+
+ const cs = new CompressionStream('gzip');
+ const writer = cs.writable.getWriter();
+
+ writer.write(input);
+ writer.close();
+
+ const compressed = await new Response(cs.readable).arrayBuffer();
+
+ return compressed;
+}
+
+export function filterFreqFromNormalized(x: number) {
+ const fMin = 30;
+ const fMax = 22000;
+
+ return fMin * (fMax / fMin) ** x;
+}
+
+export function toNativePath(posixPath: string): string {
+ const isWindows = navigator.userAgent.includes('indows');
+ const pathSep = isWindows ? '\\' : '/';
+ if (pathSep === '/') {
+ return posixPath;
+ }
+
+ return posixPath.split('/').join(pathSep);
+}
diff --git a/src/lib/exporters/dawProject.ts b/src/lib/exporters/dawProject.ts
new file mode 100644
index 0000000..7f36547
--- /dev/null
+++ b/src/lib/exporters/dawProject.ts
@@ -0,0 +1,482 @@
+import { toXML } from 'jstoxml';
+import JSZip from 'jszip';
+import {
+ ExporterParams,
+ ExportResult,
+ ExportStatus,
+ Note,
+ ProjectRawData,
+ SampleReport,
+} from '../../types/types';
+import dawProjectTransformer, {
+ DawClip,
+ DawClipSlot,
+ DawLane,
+ DawScene,
+ DawTrack,
+} from '../transformers/dawProject';
+import { AbortError } from '../utils';
+import { collectSamples, getNextColor, getQuarterNotesPerBar } from './utils';
+
+const PROJECT_NAME = 'EP-133: Export To DAW';
+
+const XML_CONFIG = {
+ indent: ' ',
+ header: true,
+};
+
+let _id = 0;
+
+function genId() {
+ return `id${_id++}`;
+}
+
+function buildMasterTrack() {
+ return {
+ _name: 'Track',
+ _attrs: {
+ name: 'Master',
+ id: genId(),
+ loaded: 'true',
+ contentType: 'audio notes',
+ },
+ _content: {
+ _name: 'Channel',
+ _attrs: {
+ id: '__MASTER__',
+ role: 'master',
+ solo: 'false',
+ audioChannels: '2',
+ },
+ _content: [
+ {
+ _name: 'Mute',
+ _attrs: {
+ name: 'Mute',
+ value: 'false',
+ id: genId(),
+ },
+ },
+ {
+ _name: 'Pan',
+ _attrs: {
+ name: 'Pan',
+ id: genId(),
+ max: '1.000000',
+ min: '0.000000',
+ unit: 'normalized',
+ value: '0.500000',
+ },
+ },
+ {
+ _name: 'Volume',
+ _attrs: {
+ name: 'Volume',
+ id: genId(),
+ max: '2.000000',
+ min: '0.000000',
+ unit: 'linear',
+ value: '1.000000',
+ },
+ },
+ ],
+ },
+ };
+}
+
+function buildTrack(track: DawTrack) {
+ return {
+ _name: 'Track',
+ _attrs: {
+ name: track.soundId ? `${String(track.soundId).padStart(3, '0')} ${track.name}` : track.name,
+ id: `__TRACK_${track.padCode}__`,
+ loaded: 'true',
+ contentType: 'notes',
+ color: getNextColor(),
+ },
+ _content: {
+ _name: 'Channel',
+ _attrs: {
+ audioChannels: '2',
+ destination: '__MASTER__',
+ role: 'regular',
+ solo: 'false',
+ id: genId(),
+ },
+ _content: [
+ {
+ _name: 'Devices',
+ _content: {
+ _name: 'BuiltinDevice',
+ _attrs: {
+ deviceName: 'Sampler',
+ deviceRole: 'instrument',
+ loaded: 'true',
+ id: genId(),
+ name: track.name,
+ },
+ _content: [
+ {
+ _name: 'Parameters',
+ _attrs: {},
+ },
+ {
+ _name: 'Enabled',
+ _attrs: {
+ value: 'true',
+ id: genId(),
+ name: 'On/Off',
+ },
+ },
+ ],
+ },
+ },
+ {
+ _name: 'Mute',
+ _attrs: {
+ name: 'Mute',
+ value: 'false',
+ id: genId(),
+ },
+ },
+ {
+ _name: 'Pan',
+ _attrs: {
+ name: 'Pan',
+ id: genId(),
+ max: '1.000000',
+ min: '0.000000',
+ unit: 'normalized',
+ value: '0.500000',
+ },
+ },
+ {
+ _name: 'Volume',
+ _attrs: {
+ name: 'Volume',
+ id: genId(),
+ max: '2.000000',
+ min: '0.000000',
+ unit: 'linear',
+ value: `${track.volume}`,
+ },
+ },
+ ],
+ },
+ };
+}
+
+function buildStructure(tracks: DawTrack[]) {
+ return {
+ _name: 'Structure',
+ _content: [buildMasterTrack(), ...tracks.map((t) => buildTrack(t))],
+ };
+}
+
+function buildNote(note: Note, index: number, notes: Note[]) {
+ let dur = note.duration / 96;
+ const nextNote = notes[index + 1];
+
+ // making sure same notes are not overlapping
+ if (
+ nextNote &&
+ nextNote.note === note.note &&
+ note.position / 96 + dur > nextNote.position / 96
+ ) {
+ dur = nextNote.position / 96 - note.position / 96;
+ }
+
+ return {
+ _name: 'Note',
+ _attrs: {
+ time: note.position / 96,
+ duration: dur,
+ channel: 0,
+ key: note.note,
+ vel: note.velocity / 127,
+ },
+ };
+}
+
+function buildClip(clip: DawClip) {
+ const barLength = getQuarterNotesPerBar(
+ clip.sceneTimeSignature.numerator,
+ clip.sceneTimeSignature.denominator,
+ );
+
+ return {
+ _name: 'Clip',
+ _attrs: {
+ time: clip.offset * barLength,
+ duration: clip.sceneBars * barLength,
+ playStart: 0,
+ loopStart: 0,
+ loopEnd: clip.bars * barLength,
+ enable: 'true',
+ },
+ _content: {
+ _name: 'Notes',
+ _attrs: {
+ id: genId(),
+ },
+ _content: clip.notes.map(buildNote),
+ },
+ };
+}
+
+function buildLane(lane: DawLane) {
+ return {
+ _name: 'Lanes',
+ _attrs: {
+ id: genId(),
+ track: `__TRACK_${lane.padCode}__`,
+ },
+ _content: {
+ _name: 'Clips',
+ _attrs: {
+ id: genId(),
+ },
+ _content: lane.clips.map((clip) => buildClip(clip)),
+ },
+ };
+}
+
+function buildArrangement(lanes: DawLane[]) {
+ return {
+ _name: 'Arrangement',
+ _attrs: {
+ id: genId(),
+ },
+ _content: {
+ _name: 'Lanes',
+ _attrs: {
+ id: genId(),
+ timeUnit: 'beats',
+ },
+ _content: lanes.map((lane) => buildLane(lane)),
+ },
+ };
+}
+
+function buildSceneClip(clip: DawClip) {
+ const beatsInBar = clip.sceneTimeSignature.numerator;
+
+ return {
+ _name: 'Clip',
+ _attrs: {
+ time: clip.offset * beatsInBar,
+ duration: clip.bars * beatsInBar,
+ playStart: 0,
+ loopStart: 0,
+ loopEnd: clip.bars * beatsInBar,
+ enable: 'true',
+ },
+ _content: {
+ _name: 'Notes',
+ _attrs: {
+ id: genId(),
+ },
+ _content: clip.notes.map(buildNote),
+ },
+ };
+}
+
+function buildClipSlot(clipSlot: DawClipSlot) {
+ return {
+ _name: 'ClipSlot',
+ _attrs: {
+ track: `__TRACK_${clipSlot.track.padCode}__`,
+ id: genId(),
+ hasStop: 'true',
+ },
+ _content: clipSlot.clip.map((clip) => buildSceneClip(clip)),
+ };
+}
+
+function buildScene(scene: DawScene) {
+ return {
+ _name: 'Scene',
+ _attrs: {
+ name: scene.name,
+ id: `__SCENE_${scene.name}__`,
+ },
+ _content: {
+ _name: 'Lanes',
+ _content: scene.clipSlot.map((clipSlot) => buildClipSlot(clipSlot)),
+ },
+ };
+}
+
+function buildScenes(scenes: DawScene[]) {
+ return {
+ _name: 'Scenes',
+ _content: scenes.map((scene) => buildScene(scene)),
+ };
+}
+
+export function buildMetadataXml() {
+ const xml = toXML(
+ {
+ MetaData: {
+ Title: '',
+ Artist: '',
+ Album: '',
+ OriginalArtist: '',
+ Songwriter: '',
+ Producer: '',
+ Year: '',
+ Genre: '',
+ Copyright: '',
+ Comment: `Made with ${PROJECT_NAME}`,
+ },
+ },
+ XML_CONFIG,
+ );
+
+ return new Blob([xml], { type: 'text/xml' });
+}
+
+export async function buildProjectXml(projectData: ProjectRawData, exporterParams: ExporterParams) {
+ const transformedData = dawProjectTransformer(projectData, exporterParams);
+ const { timeSignature } = projectData.scenesSettings;
+
+ if (import.meta.env.DEV) {
+ console.log('projectData', projectData);
+ console.log('transformedData', transformedData);
+ }
+
+ _id = 0;
+
+ const application = {
+ _name: 'Application',
+ _attrs: {
+ name: PROJECT_NAME,
+ version: '1.0',
+ },
+ };
+
+ const transport = {
+ _name: 'Transport',
+ _content: [
+ {
+ _name: 'Tempo',
+ _attrs: {
+ unit: 'bpm',
+ value: `${projectData.settings.bpm}`,
+ name: 'Tempo',
+ id: genId(),
+ },
+ },
+ {
+ _name: 'TimeSignature',
+ _attrs: {
+ numerator: timeSignature.numerator,
+ denominator: timeSignature.denominator,
+ },
+ },
+ ],
+ };
+
+ const main = toXML(
+ {
+ _name: 'Project',
+ _attrs: {
+ version: '1.0',
+ },
+ _content: [
+ application,
+ transport,
+ buildStructure(transformedData.tracks),
+ buildArrangement(transformedData.lanes),
+ exporterParams.clips ? buildScenes(transformedData.scenes) : false,
+ ].filter(Boolean),
+ },
+ XML_CONFIG,
+ );
+
+ return new Blob([main], { type: 'text/xml' });
+}
+
+async function exportDawProject(
+ projectId: string,
+ data: ProjectRawData,
+ progressCallback: ({ progress, status }: ExportStatus) => void,
+ exporterParams: ExporterParams,
+ abortSignal: AbortSignal,
+) {
+ progressCallback({ progress: 1, status: 'Exporting project data...' });
+
+ if (abortSignal.aborted) {
+ throw new AbortError();
+ }
+
+ const metadataXml = buildMetadataXml();
+ const projectXml = await buildProjectXml(data, exporterParams);
+
+ progressCallback({ progress: 2, status: 'Creating project file...' });
+
+ const zipProject = new JSZip();
+
+ zipProject.file('metadata.xml', metadataXml);
+ zipProject.file('project.xml', projectXml);
+
+ const projectFile = await zipProject.generateAsync({
+ type: 'blob',
+ compression: 'DEFLATE',
+ });
+
+ const files: Array<{
+ name: string;
+ url: string;
+ type: 'project' | 'archive';
+ size: number;
+ }> = [
+ {
+ name: `${exporterParams.projectName || `project${projectId}`}.dawproject`,
+ url: URL.createObjectURL(projectFile),
+ type: 'project',
+ size: projectFile.size,
+ },
+ ];
+
+ let sampleReport: SampleReport | undefined;
+
+ if (exporterParams.includeArchivedSamples) {
+ const zipSamples = new JSZip();
+ const { samples, sampleReport: report } = await collectSamples(
+ data,
+ progressCallback,
+ abortSignal,
+ exporterParams.exportAllPadsWithSamples,
+ );
+
+ samples.forEach((s) => {
+ zipSamples.file(s.name, s.data);
+ });
+
+ progressCallback({ progress: 90, status: 'Bundle samples...' });
+
+ const sampleFile = await zipSamples.generateAsync({
+ type: 'blob',
+ compression: 'DEFLATE',
+ });
+
+ files.push({
+ name: `${exporterParams.projectName || `project${projectId}`}_samples.zip`,
+ url: URL.createObjectURL(sampleFile),
+ type: 'archive',
+ size: sampleFile.size,
+ });
+
+ sampleReport = report;
+ }
+
+ progressCallback({ progress: 100, status: 'Done' });
+
+ return {
+ files,
+ sampleReport,
+ } as ExportResult;
+}
+
+export default exportDawProject;
diff --git a/src/lib/exporters/midi.ts b/src/lib/exporters/midi.ts
new file mode 100644
index 0000000..fdbc71b
--- /dev/null
+++ b/src/lib/exporters/midi.ts
@@ -0,0 +1,112 @@
+import { Midi } from '@tonejs/midi';
+import JSZip from 'jszip';
+import {
+ ExporterParams,
+ ExportResult,
+ ExportStatus,
+ ProjectRawData,
+ SampleReport,
+} from '../../types/types';
+import midiTransformer from '../transformers/midi';
+import { AbortError } from '../utils';
+import { collectSamples, ticksToMidiTicks } from './utils';
+
+async function exportMidi(
+ projectId: string,
+ data: ProjectRawData,
+ progressCallback: ({ progress, status }: ExportStatus) => void,
+ exporterParams: ExporterParams,
+ abortSignal: AbortSignal,
+) {
+ progressCallback({ progress: 1, status: 'Exporting project data...' });
+
+ if (abortSignal.aborted) {
+ throw new AbortError();
+ }
+
+ const transformedData = midiTransformer(data, exporterParams);
+ const midi = new Midi();
+ const timeSignature = data.scenesSettings.timeSignature;
+
+ if (import.meta.env.DEV) {
+ console.log(data);
+ console.log(transformedData);
+ }
+
+ midi.header.setTempo(data.settings.bpm);
+ midi.header.timeSignatures.push({
+ ticks: 0,
+ timeSignature: [timeSignature.numerator, timeSignature.denominator],
+ });
+
+ transformedData.tracks.forEach((track) => {
+ const midiTrack = midi.addTrack();
+
+ midiTrack.name = track.name;
+ midiTrack.channel = 0;
+
+ track.notes.forEach((note) => {
+ midiTrack.addNote({
+ ticks: ticksToMidiTicks(note.position, midi.header.ppq),
+ durationTicks: ticksToMidiTicks(note.duration, midi.header.ppq),
+ velocity: Math.max(0, Math.min(1, note.velocity / 127)),
+ midi: note.note + 12,
+ });
+ });
+ });
+
+ // @ts-expect-error wrong typing?
+ const midiBlob = new Blob([midi.toArray()], { type: 'audio/midi' });
+
+ const files: Array<{
+ name: string;
+ url: string;
+ type: 'project' | 'archive';
+ size: number;
+ }> = [
+ {
+ name: `${exporterParams.projectName || `project${projectId}`}.mid`,
+ url: URL.createObjectURL(midiBlob),
+ type: 'project',
+ size: midiBlob.size,
+ },
+ ];
+
+ let sampleReport: SampleReport | undefined;
+
+ if (exporterParams.includeArchivedSamples) {
+ const zipSamples = new JSZip();
+ const { samples, sampleReport: report } = await collectSamples(
+ data,
+ progressCallback,
+ abortSignal,
+ exporterParams.exportAllPadsWithSamples,
+ );
+
+ samples.forEach((s) => {
+ zipSamples.file(s.name, s.data);
+ });
+
+ progressCallback({ progress: 90, status: 'Bundle samples...' });
+
+ const sampleFile = await zipSamples.generateAsync({ type: 'blob', compression: 'DEFLATE' });
+
+ files.push({
+ name: `${exporterParams.projectName || `project${projectId}`}_samples.zip`,
+ url: URL.createObjectURL(sampleFile),
+ type: 'archive',
+ size: sampleFile.size,
+ });
+
+ sampleReport = report;
+ }
+
+ progressCallback({ progress: 100, status: 'Done' });
+
+ return {
+ files,
+ sampleReport,
+ } as ExportResult;
+}
+
+export default exportMidi;
diff --git a/src/lib/exporters/reaper/index.ts b/src/lib/exporters/reaper/index.ts
new file mode 100644
index 0000000..d543789
--- /dev/null
+++ b/src/lib/exporters/reaper/index.ts
@@ -0,0 +1,200 @@
+import JSZip from 'jszip';
+import {
+ ExporterParams,
+ ExportResult,
+ ExportResultFile,
+ ExportStatus,
+ PadCode,
+ ProjectRawData,
+ SampleReport,
+} from '../../../types/types';
+import { RprTrack, reaperTransform } from '../../transformers/reaper';
+import { AbortError } from '../../utils';
+import { collectSamples, getQuarterNotesPerBar } from '../utils';
+import { generateReaperProject, ReaperTrack } from './reaperlib';
+
+function buildTrack(track: RprTrack): ReaperTrack {
+ return {
+ name: track.name,
+ tempo: track.bpm,
+ volume: track.volume,
+ pan: track.pan,
+ sample: track.sampleName
+ ? {
+ name: track.sampleName,
+ rate: track.sampleRate,
+ channels: track.sampleChannels,
+ length: track.soundLength,
+ timeStretch: track.timeStretch,
+ timeStretchBars: track.timeStretchBars,
+ timeStretchBpm: track.timeStretchBpm,
+ trimLeft: track.trimLeft,
+ trimRight: track.trimRight,
+ rootNote: track.rootNote,
+ attack: track.attack,
+ release: track.release,
+ playMode: track.playMode,
+ pitch: track.pitch,
+ }
+ : null,
+ timeSignature: track.timeSignature,
+ guid: crypto.randomUUID().toUpperCase(),
+ items: track.items.map((item) => ({
+ position:
+ (item.offset *
+ getQuarterNotesPerBar(track.timeSignature.numerator, track.timeSignature.denominator) *
+ 60) /
+ track.bpm,
+ length:
+ (item.sceneBars *
+ getQuarterNotesPerBar(track.timeSignature.numerator, track.timeSignature.denominator) *
+ 60) /
+ track.bpm,
+ lengthInBars: item.bars,
+ name: item.sceneName,
+ events: item.notes.map((n) => ({
+ note: n.note,
+ position: n.position,
+ length: n.duration,
+ velocity: n.velocity,
+ })),
+ })),
+ };
+}
+
+function buildReaperProject(
+ data: ProjectRawData,
+ projectId: string,
+ exporterParams: ExporterParams,
+) {
+ const transformedData = reaperTransform(data, exporterParams);
+
+ if (import.meta.env.DEV) {
+ console.log(transformedData);
+ }
+
+ let tracks: ReaperTrack[];
+ if (exporterParams.groupTracks) {
+ const groupedTracks: ReaperTrack[] = [];
+
+ ['a', 'b', 'c', 'd'].forEach((group) => {
+ const groupTracks = transformedData.tracks.filter((t) => t.group === group);
+
+ if (groupTracks.length) {
+ groupedTracks.push({
+ ...buildTrack({
+ name: `Group ${group.toUpperCase()}`,
+ timeSignature: data.scenesSettings.timeSignature,
+ bpm: data.settings.bpm,
+ volume: 1,
+ pan: 0,
+ sampleName: '',
+ sampleChannels: 0,
+ sampleRate: 0,
+ soundLength: 0,
+ attack: 0,
+ release: 0,
+ trimLeft: 0,
+ trimRight: 0,
+ rootNote: 60,
+ timeStretch: 'off',
+ timeStretchBpm: 0,
+ timeStretchBars: 0,
+ soundId: 0,
+ inChokeGroup: false,
+ playMode: 'oneshot',
+ pitch: 0,
+ items: [],
+ pad: 0,
+ padCode: `${group}0` as PadCode,
+ group,
+ }),
+
+ tracks: groupTracks.map(buildTrack),
+ });
+ }
+ });
+
+ tracks = groupedTracks;
+ } else {
+ tracks = transformedData.tracks.map(buildTrack);
+ }
+
+ const rprContent = generateReaperProject(
+ {
+ projectName: exporterParams.projectName || `Project ${projectId}`,
+ tempo: data.settings?.bpm ?? 120,
+ timeSignature: data.scenesSettings.timeSignature,
+ tracks,
+ },
+ exporterParams,
+ );
+
+ return rprContent;
+}
+
+async function exportReaper(
+ projectId: string,
+ data: ProjectRawData,
+ progressCallback: ({ progress, status }: ExportStatus) => void,
+ exporterParams: ExporterParams,
+ abortSignal: AbortSignal,
+): Promise {
+ let sampleReport: SampleReport | undefined;
+ const files: ExportResultFile[] = [];
+ const zippedProject = new JSZip();
+ const projectName = exporterParams.projectName || `Project${projectId}`;
+
+ progressCallback({ progress: 1, status: 'Preparing REAPER export...' });
+
+ if (abortSignal.aborted) {
+ throw new AbortError();
+ }
+
+ const rprContent = buildReaperProject(data, projectId, exporterParams);
+
+ zippedProject.file(`${projectName}/${projectName}.RPP`, rprContent);
+
+ if (exporterParams.includeArchivedSamples) {
+ const { samples, sampleReport: report } = await collectSamples(
+ data,
+ progressCallback,
+ abortSignal,
+ exporterParams.exportAllPadsWithSamples,
+ );
+ samples.forEach((s) => {
+ zippedProject.file(`${projectName}/Media/samples/${s.name}`, s.data);
+ });
+ sampleReport = report;
+ }
+
+ progressCallback({ progress: 90, status: 'Bundle everything...' });
+
+ const zippedProjectFile = await zippedProject.generateAsync({
+ type: 'blob',
+ compression: 'DEFLATE',
+ });
+
+ if (import.meta.env.DEV) {
+ const blob = new Blob([rprContent], { type: 'application/octet-stream' });
+ files.push({
+ name: `${projectName}.RPP`,
+ url: URL.createObjectURL(blob),
+ type: 'archive',
+ size: blob.size,
+ });
+ }
+
+ files.push({
+ name: `${projectName}.zip`,
+ url: URL.createObjectURL(zippedProjectFile),
+ type: 'archive',
+ size: zippedProjectFile.size,
+ });
+
+ progressCallback({ progress: 100, status: 'Done' });
+
+ return { files, sampleReport };
+}
+
+export default exportReaper;
diff --git a/src/lib/exporters/reaper/reaperlib.ts b/src/lib/exporters/reaper/reaperlib.ts
new file mode 100644
index 0000000..76eea5d
--- /dev/null
+++ b/src/lib/exporters/reaper/reaperlib.ts
@@ -0,0 +1,501 @@
+import { ExporterParams, TimeSignature } from '../../../types/types';
+import { getNextColor, getQuarterNotesPerBar, TICKS_PER_BEAT, ticksToMidiTicks } from '../utils';
+import { buildVstState } from './sampler';
+
+export type ReaperMidiEvent = {
+ note: number;
+ position: number;
+ length: number;
+ velocity: number;
+};
+
+export type ReaperMidiItem = {
+ position: number;
+ length: number;
+ lengthInBars: number;
+ name?: string;
+ events: ReaperMidiEvent[];
+};
+
+export type ReaperSample = {
+ name: string;
+ rate: number;
+ channels: number;
+ length: number;
+ timeStretch: string;
+ timeStretchBars: number;
+ timeStretchBpm: number;
+ trimLeft: number;
+ trimRight: number;
+ rootNote: number;
+ attack: number;
+ release: number;
+ playMode: string;
+ pitch: number;
+};
+
+export type ReaperTrack = {
+ name: string;
+ guid: string;
+ tempo: number;
+ volume: number;
+ pan: number;
+ sample: ReaperSample | null;
+ timeSignature: TimeSignature;
+ tracks?: ReaperTrack[];
+ items?: ReaperMidiItem[];
+};
+
+export type ReaperProject = {
+ projectName?: string;
+ tempo: number;
+ timeSignature: TimeSignature;
+ tracks?: ReaperTrack[];
+};
+
+type ReaperFileElem = {
+ name: string;
+ attrs?: (string | number)[];
+ content?: (ReaperFileElem | (string | number)[])[];
+};
+
+const PPQ = 960; // default reaper midi ppq
+
+function hexToReaperColor(hexColor: string, alpha = 0xff) {
+ hexColor = hexColor.replace('#', '');
+
+ const red = parseInt(hexColor.substring(0, 2), 16);
+ const green = parseInt(hexColor.substring(2, 4), 16);
+ const blue = parseInt(hexColor.substring(4, 6), 16);
+
+ return (alpha << 24) | (blue << 16) | (green << 8) | red;
+}
+
+function renderLine(name: string, attrs: (string | number)[] = []) {
+ return `${name} ${attrs
+ .map((block) => {
+ if (typeof block === 'string' && block.match(/\w+-\w+-\w+-\w+-\w+/)) {
+ return `{${block}}`;
+ }
+
+ return typeof block === 'string' ? `"${block}"` : block;
+ })
+ .join(' ')}`;
+}
+
+function addFxChain(root: ReaperFileElem['content'], rprTrack: ReaperTrack) {
+ if (!rprTrack.sample?.length) {
+ return;
+ }
+
+ const result = buildVstState({
+ filePath: `Media/samples/${rprTrack.sample.name}`,
+ sampleLengthMs: rprTrack.sample.length * 1000,
+ rootNote: rprTrack.sample.rootNote,
+ attack: rprTrack.sample.attack,
+ release: rprTrack.sample.release,
+ });
+
+ root?.push({
+ name: 'FXCHAIN',
+ content: [
+ ['WNDRECT', 32, 117, 1037, 681],
+ ['SHOW', 0],
+ ['LASTSEL', 0],
+ ['DOCKED', 0],
+ ['BYPASS', 0, 0, 0],
+ {
+ name: 'VST',
+ attrs: [
+ 'VSTi: ReaSamplOmatic5000 (Cockos)',
+ 'reasamplomatic.vst.so',
+ 0,
+ '',
+ '1920167789<56535472736F6D72656173616D706C6F>',
+ '',
+ ],
+ content: [[result.header], [result.body], ['AAAQAAAA']],
+ },
+ ],
+ });
+}
+
+function addTrackItem(
+ root: ReaperFileElem['content'],
+ rprItem: ReaperMidiItem,
+ rprTrack: ReaperTrack,
+ iid: number,
+) {
+ if (rprItem.events.length === 0) {
+ return iid;
+ }
+
+ let offset = 0;
+ let totalTicks = 0;
+ const barLength = getQuarterNotesPerBar(
+ rprTrack.timeSignature.numerator,
+ rprTrack.timeSignature.denominator,
+ );
+
+ const clipLengthInTicks = rprItem.lengthInBars * barLength * TICKS_PER_BEAT;
+
+ const events = rprItem.events
+ .flat()
+ .filter((evt) => evt.position < clipLengthInTicks)
+ .reduce(
+ (acc, evt, index) => {
+ const nextEvent = rprItem.events[index + 1];
+ let noteLength = evt.length;
+
+ if (
+ nextEvent &&
+ nextEvent.note === evt.note &&
+ nextEvent.position < evt.position + evt.length
+ ) {
+ // prevent notes overlapping
+ noteLength = nextEvent.position - evt.position;
+ }
+
+ // prevent notes going beyond the item length
+ if (evt.position + noteLength > clipLengthInTicks) {
+ noteLength = evt.position + noteLength - clipLengthInTicks;
+ }
+
+ const noteOn = [
+ 'e',
+ ticksToMidiTicks(evt.position - offset, PPQ),
+ '90',
+ evt.note.toString(16),
+ evt.velocity.toString(16),
+ ];
+ const noteOff = ['e', ticksToMidiTicks(noteLength, PPQ), '80', evt.note.toString(16), '00'];
+
+ offset = evt.position + noteLength;
+ totalTicks += (noteOn[1] as number) + (noteOff[1] as number);
+
+ return acc.concat([noteOn, noteOff]);
+ },
+ [] as (string | number)[][],
+ );
+
+ const barInTicks = PPQ * barLength;
+
+ events.push(['E', Math.max(barInTicks * rprItem.lengthInBars - totalTicks, 0), 'b0', '7b', '00']);
+
+ const item = {
+ name: 'ITEM',
+ content: [
+ ['POSITION', rprItem.position],
+ ['SNAPOFFS', 0],
+ ['LENGTH', rprItem.length],
+ ['LOOP', 1],
+ ['ALLTAKES', 0],
+ ['FADEIN', 1, 0, 0, 1, 0, 0, 0],
+ ['FADEOUT', 1, 0, 0, 1, 0, 0, 0],
+ ['MUTE', 0, 0],
+ ['SEL', 0],
+ ['IGUID', crypto.randomUUID().toUpperCase()],
+ ['IID', iid++],
+ ['NAME', rprItem.name || `MIDI Item ${iid}`],
+ ['VOLPAN', 1, 0, 1, -1],
+ ['SOFFS', 0],
+ ['PLAYRATE', 1, 1, 0, -1, 0, 0.0025],
+ ['CHANMODE', 0],
+ ['GUID', crypto.randomUUID().toUpperCase()],
+ {
+ name: 'SOURCE',
+ attrs: ['MIDI'],
+ content: [
+ ['HASDATA', 1, PPQ, 'QN'],
+ ['CCINTERP', 32],
+ ['POOLEDEVTS', crypto.randomUUID().toUpperCase()],
+ ...events,
+ ['CCINTERP', 32],
+ ['CHASE_CC_TAKEOFFS', 1],
+ ['GUID', crypto.randomUUID().toUpperCase()],
+ ['IGNTEMPO', 0, rprTrack.tempo, 4, 4],
+ ['SRCCOLOR', 6],
+ ['EVTFILTER', 0, -1, -1, -1, -1, 0, 0, 0, 0, -1, -1, -1, -1, 0, -1, 0, -1, -1],
+ ['VELLANE', -1, 100, 0, 0, 1],
+ ['CFGEDITVIEW', 0, 0.226823, 65, 12, 0, 0, 0, 0, 0, 0.5],
+ ['KEYSNAP', 0],
+ ['TRACKSEL', 0],
+ [
+ 'CFGEDIT',
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0.125,
+ 753,
+ 516,
+ 1975,
+ 1078,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0.5,
+ 0,
+ 0,
+ 1,
+ 64,
+ ],
+ ],
+ },
+ ],
+ };
+
+ root?.push(item);
+
+ return iid;
+}
+
+const addTrack = (
+ root: ReaperFileElem['content'],
+ rprTrack: ReaperTrack,
+ iid: number,
+ endOfGroup: boolean,
+ exporterParams: ExporterParams,
+) => {
+ let isBus = [0, 0];
+
+ if (rprTrack.tracks?.length) {
+ isBus = [1, 1];
+ }
+
+ if (endOfGroup) {
+ isBus = [2, -1];
+ }
+
+ const newTrack: ReaperFileElem = {
+ name: 'TRACK',
+ attrs: [rprTrack.guid],
+ content: [
+ ['NAME', rprTrack.name || 'Track'],
+ ['PEAKCOL', hexToReaperColor(getNextColor())],
+ ['BEAT', -1],
+ ['AUTOMODE', 0],
+ ['PANLAWFLAGS', 3],
+ ['VOLPAN', rprTrack.volume, rprTrack.pan, -1, -1, 1],
+ ['MUTESOLO', 0, 0, 0],
+ ['IPHASE', 0],
+ ['PLAYOFFS', 0, 1],
+ ['ISBUS', ...isBus],
+ ['BUSCOMP', 0, 0, 0, 0, 0],
+ ['SHOWINMIX', 1, 0.6667, 0.5, 1, 0.5, 0, 0, 0, 0],
+ ['FIXEDLANES', 9, 0, 0, 0, 0],
+ ['SEL', 0],
+ ['REC', 0, 0, 1, 0, 0, 0, 0, 0],
+ ['VU', 64],
+ ['TRACKHEIGHT', 0, 0, 0, 0, 0, 0, 0],
+ ['INQ', 0, 0, 0, 0.5, 100, 0, 0, 100],
+ ['NCHAN', 2],
+ ['FX', 1],
+ ['TRACKID', rprTrack.guid],
+ ['PERF', 0],
+ ['MIDIOUT', -1],
+ ['MAINSEND', 1, 0],
+ ],
+ };
+
+ if (exporterParams.includeArchivedSamples) {
+ addFxChain(newTrack.content, rprTrack);
+ }
+
+ if (rprTrack.items) {
+ rprTrack.items.forEach((rprItem) => {
+ iid = addTrackItem(newTrack.content, rprItem, rprTrack, iid);
+ });
+ }
+
+ root?.push(newTrack);
+
+ rprTrack.tracks?.forEach((_track, idx) => {
+ iid = addTrack(root, _track, iid, idx === rprTrack.tracks!.length - 1, exporterParams);
+ });
+
+ return iid;
+};
+
+function createReaperProject(
+ { tempo = 120, timeSignature, tracks = [] }: ReaperProject,
+ exporterParams: ExporterParams,
+) {
+ let _iid = 1;
+
+ const _root: ReaperFileElem[] = [
+ {
+ name: 'REAPER_PROJECT',
+ attrs: ['0.1', '7.48/unknown-x86_64', Math.round(Date.now() / 1000)],
+ content: [
+ {
+ name: 'NOTES',
+ attrs: [0, 2],
+ },
+ ['RIPPLE', 0, 0],
+ ['GROUPOVERRIDE', 0, 0, 0, 0],
+ ['AUTOXFADE', 129],
+ ['ENVATTACH', 3],
+ ['POOLEDENVATTACH', 0],
+ ['MIXERUIFLAGS', 11, 48],
+ ['ENVFADESZ10', 40],
+ ['PEAKGAIN', 1],
+ ['FEEDBACK', 0],
+ ['PANLAW', 1],
+ ['PROJOFFS', 0, 0, 0],
+ ['MAXPROJLEN', 0, 0],
+ ['GRID', 3199, 8, 1, 8, 1, 0, 0, 0],
+ ['TIMEMODE', 1, 5, -1, 30, 0, 0, -1],
+ ['VIDEO_CONFIG', 0, 0, 65792],
+ ['PANMODE', 3],
+ ['PANLAWFLAGS', 3],
+ ['CURSOR', 0],
+ ['ZOOM', 100, 0, 0],
+ ['VZOOMEX', 6, 0],
+ ['USE_REC_CFG', 0],
+ ['RECMODE', 1],
+ ['SMPTESYNC', 0, 30, 100, 40, 1000, 300, 0, 0, 1, 0, 0],
+ ['LOOP', 1],
+ ['LOOPGRAN', 0],
+ ['RECORD_PATH', 'Media', ''],
+ {
+ name: 'RECORD_CFG',
+ content: [['ZXZhdxgAAQ==']],
+ },
+ {
+ name: 'APPLYFX_CFG',
+ },
+ ['RENDER_FILE', ''],
+ ['RENDER_PATTERN', ''],
+ ['RENDER_FMT', 0, 2, 0],
+ ['RENDER_1X', 0],
+ ['RENDER_RANGE', 1, 0, 0, 0, 1000],
+ ['RENDER_RESAMPLE', 3, 0, 1],
+ ['RENDER_ADDTOPROJ', 0],
+ ['RENDER_STEMS', 0],
+ ['RENDER_DITHER', 0],
+ ['TIMELOCKMODE', 1],
+ ['TEMPOENVLOCKMODE', 1],
+ ['ITEMMIX', 1],
+ ['DEFPITCHMODE', 589824, 0],
+ ['TAKELANE', 1],
+ ['SAMPLERATE', 44100, 0, 0],
+ {
+ name: 'RENDER_CFG',
+ content: [['ZXZhdxgAAQ==']],
+ },
+ ['LOCK', 1],
+ {
+ name: 'METRONOME',
+ attrs: [6, 2],
+ content: [
+ ['VOL', 0.25, 0.125],
+ ['BEATLEN', 4],
+ ['FREQ', 1760, 880, 1],
+ ['SAMPLES', '', '', '', ''],
+ ['SPLIGNORE', 0, 0],
+ ['SPLDEF', 2, 660, '', 0, ''],
+ ['SPLDEF', 3, 440, '', 0, ''],
+ ['PATTERN', 0, 169],
+ ['PATTERNSTR', 'ABBB'],
+ ['MULT', 1],
+ ],
+ },
+ ['GLOBAL_AUTO', -1],
+ ['TEMPO', tempo, timeSignature.numerator, timeSignature.denominator, 0],
+ ['PLAYRATE', 1, 0, 0.25, 4],
+ ['SELECTION', 0, 0],
+ ['SELECTION2', 0, 0],
+ ['MASTERAUTOMODE', 0],
+ ['MASTERTRACKHEIGHT', 0, 0],
+ ['MASTERPEAKCOL', 16576],
+ ['MASTERMUTESOLO', 0],
+ ['MASTERTRACKVIEW', 0, 0.6667, 0.5, 0.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
+ ['MASTERHWOUT', 0, 0, 1, 0, 0, 0, 0, -1],
+ ['MASTER_NCH', 2, 2],
+ ['MASTER_VOLUME', 1, 0, -1, -1, 1],
+ ['MASTER_PANMODE', 3],
+ ['MASTER_PANLAWFLAGS', 3],
+ ['MASTER_FX', 1],
+ ['MASTER_SEL', 0],
+ {
+ name: 'MASTERPLAYSPEEDENV',
+ content: [
+ ['EGUID', crypto.randomUUID().toUpperCase()],
+ ['ACT', 0, -1],
+ ['VIS', 0, 1, 1],
+ ['LANEHEIGHT', 0, 0],
+ ['ARM', 0],
+ ['DEFSHAPE', 0, -1, -1],
+ ],
+ },
+ {
+ name: 'TEMPOENVEX',
+ content: [
+ ['EGUID', crypto.randomUUID().toUpperCase()],
+ ['ACT', 1, -1],
+ ['VIS', 1, 0, 1],
+ ['LANEHEIGHT', 0, 0],
+ ['ARM', 0],
+ ['DEFSHAPE', 1, -1, -1],
+ ],
+ },
+ {
+ name: 'PROJBAY',
+ content: [],
+ },
+ ],
+ },
+ ];
+
+ tracks.forEach((_track) => {
+ _iid = addTrack(_root[0].content, _track, _iid, false, exporterParams);
+ });
+
+ return {
+ toString(rootElems: any[] = _root, offset = 0) {
+ let result = '';
+
+ for (const elem of rootElems) {
+ if (Array.isArray(elem)) {
+ // biome-ignore lint/style/useTemplate: too messy
+ result += `${' '.repeat(2 * offset)}` + renderLine(elem[0], elem.slice(1)) + '\n';
+ continue;
+ }
+
+ // biome-ignore lint/style/useTemplate: too messy
+ result += `${' '.repeat(2 * offset)}<` + renderLine(elem.name, elem.attrs) + '\n';
+
+ if (elem.content && elem.content.length > 0) {
+ result += this.toString(elem.content, offset + 1);
+ }
+
+ result += `${' '.repeat(2 * offset)}>\n`;
+ }
+
+ return result;
+ },
+ };
+}
+
+export function generateReaperProject(
+ input: ReaperProject,
+ exporterParams: ExporterParams,
+): string {
+ const reaperProject = createReaperProject(input, exporterParams);
+
+ return reaperProject.toString();
+}
diff --git a/src/lib/exporters/reaper/sampler.ts b/src/lib/exporters/reaper/sampler.ts
new file mode 100644
index 0000000..64aea03
--- /dev/null
+++ b/src/lib/exporters/reaper/sampler.ts
@@ -0,0 +1,115 @@
+const VST_HEADER = [
+ 109, 111, 115, 114, 238, 94, 237, 254, 0, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0,
+ 0, 0, 0, 0, 90, 1, 0, 0, 1, 0, 0, 0, 0, 0, 16, 0,
+];
+
+const VST_BODY = [
+ 0, 0, 0, 0, 0, 0, 240, 63, 0, 0, 0, 0, 0, 0, 224, 63, 0, 0, 0, 0, 0, 0, 240, 63, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 240, 63, 154, 153, 153, 153, 153, 153, 177, 63, 205, 204, 204, 204, 204,
+ 204, 235, 63, 0, 0, 0, 0, 0, 0, 0, 0, 28, 199, 113, 28, 199, 113, 220, 63, 252, 169, 241, 210, 77,
+ 98, 64, 63, 252, 169, 241, 210, 77, 98, 64, 63, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 240, 63, 0, 0, 0, 0, 0, 0, 224, 63, 1, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 240, 63, 64, 0, 0, 0, 85, 85, 85, 85, 85, 85, 197, 63, 255, 255,
+ 255, 255, 8, 4, 2, 129, 64, 32, 128, 63, 0, 0, 0, 0, 0, 0, 240, 63, 0, 0, 0, 0, 0, 0, 240, 63, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 206,
+ 164, 33, 33, 26, 101, 144, 63, 0, 0, 0, 0, 0, 0, 240, 63, 252, 169, 241, 210, 77, 98, 48, 63, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0,
+];
+
+function int32ToBytes(value: number): Uint8Array {
+ const bytes = new Uint8Array(4);
+
+ bytes[0] = value & 0xff;
+ bytes[1] = (value >> 8) & 0xff;
+ bytes[2] = (value >> 16) & 0xff;
+ bytes[3] = (value >> 24) & 0xff;
+
+ return bytes;
+}
+
+function doubleToBytes(num: number): number[] {
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+
+ view.setFloat64(0, num, true);
+ const bytes = [];
+
+ for (let i = 0; i < 8; i++) {
+ bytes.push(view.getUint8(i));
+ }
+
+ return bytes;
+}
+
+function dbToBytes(dbValue: number): number[] {
+ // convert dB to linear gain: 10^(dB/20)
+ const linearGain = 10 ** (dbValue / 20);
+
+ return doubleToBytes(linearGain);
+}
+
+function msToBytes(msValue: number, sampleLengthMs: number): number[] {
+ const attackNormalized = msValue / 255;
+ const maxAttack = sampleLengthMs / 2000;
+ const cappedAttackMs = Math.min(attackNormalized, maxAttack);
+
+ return doubleToBytes(cappedAttackMs);
+}
+
+function midiNoteToBytes(midiNote: number): number[] {
+ const referenceNote = 60; // C4
+ const referenceValue = 0.125;
+ const valuePerSemitone = 0.075 / 12; // 0.00625
+
+ const semitoneOffset = referenceNote - midiNote;
+ const pluginValue = referenceValue + semitoneOffset * valuePerSemitone;
+
+ return doubleToBytes(pluginValue);
+}
+
+function bytesToBase64(bytes: Array): string {
+ let binary = '';
+ const len = bytes.length;
+
+ for (let i = 0; i < len; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+
+ return btoa(binary);
+}
+
+export function buildVstState({
+ sampleLengthMs,
+ filePath,
+ volume = 0,
+ attack = 0,
+ release = 0,
+ rootNote = 60,
+}: {
+ filePath: string;
+ sampleLengthMs: number;
+ volume?: number;
+ attack?: number;
+ release?: number;
+ rootNote?: number;
+}) {
+ const encoder = new TextEncoder();
+ const bufferHeader = [...VST_HEADER];
+ const bufferBody = [...VST_BODY];
+
+ bufferBody.splice(0, 8, ...dbToBytes(volume)); // volume
+ bufferBody.splice(72, 8, ...msToBytes(attack, sampleLengthMs)); // atack
+ bufferBody.splice(80, 8, ...msToBytes(release, sampleLengthMs)); // release
+ bufferBody.splice(40, 8, ...midiNoteToBytes(rootNote)); // root note
+ bufferBody.splice(88, 8, ...doubleToBytes(1)); // obey note off
+ bufferBody[128] = 2; // play mode: key
+
+ const fullBody = [...encoder.encode(filePath), 0, ...bufferBody];
+
+ bufferHeader.splice(32, 4, ...int32ToBytes(fullBody.length));
+
+ return {
+ header: bytesToBase64(bufferHeader),
+ body: bytesToBase64(fullBody),
+ };
+}
diff --git a/src/lib/exporters/utils.ts b/src/lib/exporters/utils.ts
new file mode 100644
index 0000000..7d0f35f
--- /dev/null
+++ b/src/lib/exporters/utils.ts
@@ -0,0 +1,358 @@
+import * as Sentry from '@sentry/react';
+import { unzippedBackupAtom } from '~/atoms/droppedProjectFile';
+import { ExportStatus, ProjectRawData, SoundInfo } from '../../types/types';
+import { getFile, getFileNodeByPath } from '../midi/fs';
+import { pcmToWavBlob } from '../pcmToWav';
+import { store } from '../store';
+import { AbortError, audioFormatAsBitDepth, findSoundIdByPad } from '../utils';
+
+let _colorIndex = 0;
+
+export const TICKS_PER_BEAT = 96; // (384 / 4 beats)
+const COLORS = [
+ '#B2B0E8',
+ '#E27D60',
+ '#26A693',
+ '#E8A87C',
+ '#85C1A9',
+ '#C38D9E',
+ '#41B3A3',
+ '#F2B880',
+ '#7DAF9C',
+ '#F47261',
+ '#9D6A89',
+ '#5AA9A4',
+ '#7A2A80',
+ '#8FB996',
+ '#47B267',
+ '#B089A3',
+ '#6CA6A3',
+ '#A0C4B0',
+ '#F5A97F',
+ '#BBA0C0',
+ '#79B2B2',
+];
+
+function extractPcmFromWav(wavData: Uint8Array): Uint8Array {
+ const view = new DataView(wavData.buffer, wavData.byteOffset, wavData.byteLength);
+
+ const riff = String.fromCharCode(wavData[0], wavData[1], wavData[2], wavData[3]);
+ if (riff !== 'RIFF') {
+ throw new Error('Invalid WAV file: missing RIFF header');
+ }
+
+ const wave = String.fromCharCode(wavData[8], wavData[9], wavData[10], wavData[11]);
+ if (wave !== 'WAVE') {
+ throw new Error('Invalid WAV file: missing WAVE format');
+ }
+
+ let offset = 12;
+ while (offset < wavData.length - 8) {
+ const chunkId = String.fromCharCode(
+ wavData[offset],
+ wavData[offset + 1],
+ wavData[offset + 2],
+ wavData[offset + 3],
+ );
+ const chunkSize = view.getUint32(offset + 4, true);
+
+ if (chunkId === 'data') {
+ return wavData.slice(offset + 8, offset + 8 + chunkSize);
+ }
+
+ offset += 8 + chunkSize;
+ if (chunkSize % 2 !== 0) {
+ offset += 1;
+ }
+ }
+
+ throw new Error('Invalid WAV file: data chunk not found');
+}
+
+export function parseWavMetadata(wavData: Uint8Array) {
+ const view = new DataView(wavData.buffer, wavData.byteOffset, wavData.byteLength);
+
+ const riff = String.fromCharCode(wavData[0], wavData[1], wavData[2], wavData[3]);
+ if (riff !== 'RIFF') {
+ throw new Error('Invalid WAV file: missing RIFF header');
+ }
+
+ const wave = String.fromCharCode(wavData[8], wavData[9], wavData[10], wavData[11]);
+ if (wave !== 'WAVE') {
+ throw new Error('Invalid WAV file: missing WAVE format');
+ }
+
+ let offset = 12;
+ let channels = 1;
+ let samplerate = 44100;
+ let bitsPerSample = 16;
+ let audioFormat = 1;
+ let rootNote = 60;
+ let teMeta: Record | null = null;
+
+ while (offset < wavData.length - 8) {
+ const chunkId = String.fromCharCode(
+ wavData[offset],
+ wavData[offset + 1],
+ wavData[offset + 2],
+ wavData[offset + 3],
+ );
+ const chunkSize = view.getUint32(offset + 4, true);
+
+ if (chunkId === 'fmt ') {
+ audioFormat = view.getUint16(offset + 8, true);
+ channels = view.getUint16(offset + 10, true);
+ samplerate = view.getUint32(offset + 12, true);
+ bitsPerSample = view.getUint16(offset + 22, true);
+ }
+
+ if (chunkId === 'smpl') {
+ rootNote = view.getUint32(offset + 20, true);
+ }
+
+ if (chunkId === 'LIST') {
+ const listData = wavData.subarray(offset + 8, offset + 8 + chunkSize);
+ const listType = String.fromCharCode(listData[0], listData[1], listData[2], listData[3]);
+ if (listType === 'INFO') {
+ let subOffset = 4;
+ while (subOffset < listData.length - 8) {
+ const subId = String.fromCharCode(
+ listData[subOffset],
+ listData[subOffset + 1],
+ listData[subOffset + 2],
+ listData[subOffset + 3],
+ );
+ const subSize = new DataView(
+ listData.buffer,
+ listData.byteOffset + subOffset + 4,
+ 4,
+ ).getUint32(0, true);
+
+ if (subId === 'TNGE') {
+ const raw = listData.subarray(subOffset + 8, subOffset + 8 + subSize);
+ const jsonStr = new TextDecoder('ascii')
+ .decode(raw)
+ .replace(/\0/g, '')
+ .trim();
+ try {
+ teMeta = JSON.parse(jsonStr);
+ } catch {
+ console.warn('Failed to parse TNGE metadata JSON:', jsonStr);
+ }
+ break;
+ }
+
+ subOffset += 8 + subSize;
+ if (subSize % 2 !== 0) {
+ subOffset += 1;
+ }
+ }
+ }
+ }
+
+ offset += 8 + chunkSize;
+ if (chunkSize % 2 !== 0) {
+ offset += 1;
+ }
+ }
+
+ let format: 's16' | 's24' | 'float';
+ if (audioFormat === 3 && bitsPerSample === 32) {
+ format = 'float';
+ } else if (bitsPerSample === 24) {
+ format = 's24';
+ } else {
+ format = 's16';
+ }
+
+ return {
+ channels,
+ samplerate,
+ format,
+ rootNote: (teMeta?.['sound.rootnote'] as number) ?? rootNote,
+ teMeta,
+ };
+}
+
+export async function downloadPcm(
+ soundId: number,
+ progressCallback?: (bytesRead: number, totalRemaining: number) => void,
+) {
+ const backupZip = store.get(unzippedBackupAtom);
+
+ if (backupZip) {
+ const wavFile = Object.values(backupZip.files).find((file) =>
+ file.name.startsWith(`/sounds/${String(soundId).padStart(3, '0')} `),
+ );
+
+ if (!wavFile) {
+ throw new Error(`Sound file for sound ID ${soundId} not found in backup`);
+ }
+
+ const wavData = await wavFile.async('uint8array');
+ return extractPcmFromWav(wavData);
+ }
+
+ const fileNode = await getFileNodeByPath(`/sounds/${String(soundId).padStart(3, '0')}.pcm`);
+
+ if (!fileNode) {
+ throw new Error(`Sound file for sound ID ${soundId} not found`);
+ }
+
+ const fileData = await getFile(fileNode.nodeId, progressCallback);
+
+ return fileData.data;
+}
+
+export function getSampleName(name: string | undefined, soundId: number, extension = true) {
+ if (soundId >= 1000) {
+ return '';
+ }
+
+ if (soundId === 0 && !name) {
+ return '';
+ }
+
+ const id = soundId.toString().padStart(3, '0');
+ const n = name ? `${id} ${name}` : `${id} sample`;
+
+ return extension ? `${n}.wav` : n;
+}
+
+function getSoundsInfoFromProject(data: ProjectRawData, exportAllPadsWithSamples = false) {
+ const snds: SoundInfo[] = [];
+ const existingSounds = new Set();
+ const usedSoundIds = new Set();
+
+ for (const scene of data.scenes) {
+ for (const pattern of scene.patterns) {
+ if (pattern.notes.length > 0) {
+ const sid = findSoundIdByPad(pattern.pad, data.pads);
+ if (sid) {
+ usedSoundIds.add(sid);
+ }
+ }
+ }
+ }
+
+ for (const group in data.pads) {
+ for (const pad of data.pads[group]) {
+ if (pad.soundId <= 0) {
+ continue;
+ }
+
+ if (!exportAllPadsWithSamples && !usedSoundIds.has(pad.soundId)) {
+ continue;
+ }
+
+ if (existingSounds.has(pad.soundId)) {
+ continue;
+ }
+
+ const soundMeta = data.sounds.find((s) => s.id === pad.soundId);
+ if (!soundMeta) {
+ continue;
+ }
+
+ snds.push({ soundId: pad.soundId, soundMeta: soundMeta.meta });
+ existingSounds.add(pad.soundId);
+ }
+ }
+
+ return snds;
+}
+
+export async function collectSamples(
+ data: ProjectRawData,
+ progressCallback: ({ progress, status }: ExportStatus) => void,
+ abortSignal: AbortSignal,
+ exportAllPadsWithSamples = false,
+) {
+ const projectSounds = getSoundsInfoFromProject(data, exportAllPadsWithSamples);
+
+ const samples: { name: string; data: Blob }[] = [];
+ const downloaded: string[] = [];
+ const missing: { name: string; error: string }[] = [];
+ const percentStart = 3;
+ const totalSounds = projectSounds.length || 1;
+ const percentPerSound = 80 / totalSounds;
+ let cnt = 0;
+
+ for (const snd of projectSounds) {
+ if (abortSignal.aborted) {
+ throw new AbortError();
+ }
+
+ const fileName = getSampleName(snd.soundMeta.name, snd.soundId);
+
+ try {
+ progressCallback({
+ progress: percentStart + percentPerSound * cnt,
+ status: `Downloading sound: ${fileName}`,
+ });
+
+ const result = await downloadPcm(snd.soundId, (bytesRead, totalRemaining) => {
+ if (abortSignal.aborted) {
+ throw new AbortError();
+ }
+
+ const currentSoundProgress = bytesRead / (totalRemaining / percentPerSound);
+ progressCallback({
+ progress: percentStart + percentPerSound * cnt + currentSoundProgress,
+ status: `Downloading sound: ${fileName}`,
+ });
+ });
+
+ const wavBlob = pcmToWavBlob(
+ result,
+ snd.soundMeta.samplerate,
+ audioFormatAsBitDepth(snd.soundMeta.format),
+ snd.soundMeta.channels,
+ );
+
+ samples.push({
+ name: fileName,
+ data: wavBlob,
+ });
+
+ downloaded.push(fileName);
+ } catch (err) {
+ // only report when not aborted
+ if (!abortSignal.aborted) {
+ console.error(err);
+ Sentry.captureException(err);
+ }
+
+ missing.push({
+ name: fileName,
+ error: err instanceof Error ? err.message : 'Unknown error',
+ });
+ } finally {
+ cnt++;
+ }
+ }
+
+ const sampleReport = { downloaded, missing };
+
+ progressCallback({
+ progress: projectSounds.length === 0 ? percentStart + 80 : percentStart + percentPerSound * cnt,
+ status: 'Sample collection completed',
+ sampleReport,
+ });
+
+ return { samples, sampleReport };
+}
+
+export function getNextColor() {
+ const color = COLORS[_colorIndex];
+ _colorIndex = (_colorIndex + 1) % COLORS.length;
+ return color;
+}
+
+export function ticksToMidiTicks(ticks: number, ppq = 480) {
+ return Math.round((ticks / TICKS_PER_BEAT) * ppq);
+}
+
+export function getQuarterNotesPerBar(numerator: number, denominator: number) {
+ return (4 / denominator) * numerator;
+}
diff --git a/src/lib/ga.ts b/src/lib/ga.ts
new file mode 100644
index 0000000..7d9fc65
--- /dev/null
+++ b/src/lib/ga.ts
@@ -0,0 +1,5 @@
+export function trackEvent(action: string, eventParams?: any) {
+ if (import.meta.env.VITE_GA_ID) {
+ window.gtag('event', action, eventParams);
+ }
+}
diff --git a/src/lib/midi/constants.ts b/src/lib/midi/constants.ts
new file mode 100644
index 0000000..2b3404a
--- /dev/null
+++ b/src/lib/midi/constants.ts
@@ -0,0 +1,31 @@
+export const TE_SYSEX = {
+ STATUS_OK: 0,
+ STATUS_ERROR: 1,
+ STATUS_COMMAND_NOT_FOUND: 2,
+ STATUS_BAD_REQUEST: 3,
+ STATUS_SPECIFIC_ERROR_START: 16,
+ STATUS_SPECIFIC_SUCCESS_START: 64,
+};
+
+export const BIT_IS_REQUEST = 64;
+export const BIT_REQUEST_ID_AVAILABLE = 32;
+export const MIDI_SYSEX_START = 240;
+export const MIDI_SYSEX_END = 247;
+export const TE_MIDI_ID_0 = 0;
+export const TE_MIDI_ID_1 = 32;
+export const TE_MIDI_ID_2 = 118;
+export const MIDI_SYSEX_TE = 64;
+
+export const IDENTITY_SYSEX = [0xf0, 0x7e, 0x7f, 0x06, 0x01, 0xf7];
+
+export const TE_SYSEX_GREET = 1;
+export const TE_SYSEX_FILE = 5;
+export const TE_SYSEX_FILE_INIT = 1;
+export const TE_SYSEX_FILE_GET = 3;
+export const TE_SYSEX_FILE_GET_TYPE_INIT = 0;
+export const TE_SYSEX_FILE_GET_TYPE_DATA = 1;
+export const TE_SYSEX_FILE_LIST = 4;
+export const TE_SYSEX_FILE_METADATA = 7;
+export const TE_SYSEX_FILE_METADATA_GET = 2;
+export const TE_SYSEX_FILE_FILE_TYPE_FILE = 1;
+export const TE_SYSEX_FILE_INFO = 11;
diff --git a/src/lib/midi/device.ts b/src/lib/midi/device.ts
new file mode 100644
index 0000000..9d6eda5
--- /dev/null
+++ b/src/lib/midi/device.ts
@@ -0,0 +1,337 @@
+import {
+ BIT_IS_REQUEST,
+ BIT_REQUEST_ID_AVAILABLE,
+ IDENTITY_SYSEX,
+ MIDI_SYSEX_END,
+ MIDI_SYSEX_START,
+ MIDI_SYSEX_TE,
+ TE_MIDI_ID_0,
+ TE_MIDI_ID_1,
+ TE_MIDI_ID_2,
+ TE_SYSEX,
+ TE_SYSEX_GREET,
+} from './constants';
+import { TEDevice, TEDeviceIdentification, TESysexMessage } from './types';
+import {
+ binToString,
+ getNextRequestId,
+ metadataStringToObject,
+ packToBuffer,
+ parseMidiIdentityResponse,
+ sysexStatusToString,
+ unpackInPlace,
+} from './utils';
+
+let deviceInputPort: MIDIInput | null = null;
+let deviceOutputPort: MIDIOutput | null = null;
+let pendingInitialization = false;
+let deviceInitialized = false;
+const messageHandler = new Map<
+ number,
+ (inputPort: MIDIInput, requestId: number, data: Uint8Array) => void
+>();
+const portListeners = new Map void>();
+
+async function startEventListener(midiAccess: MIDIAccess) {
+ const inputs = midiAccess.inputs.values();
+
+ // remove old listeners if any
+ // mainly for hot reload during development
+ stopEventListener();
+
+ const handleMidiMessage = (event: MIDIMessageEvent) => {
+ // skip empty messages and non-sysex messages (like clock)
+ if (!event.data || event.data.length === 0 || event.data[0] !== MIDI_SYSEX_START) {
+ return;
+ }
+
+ const inputPort = event.currentTarget as MIDIInput;
+
+ for (const [requestId, handler] of messageHandler.entries()) {
+ handler(inputPort, requestId, event.data);
+ }
+ };
+
+ for (const inputPort of inputs) {
+ inputPort.addEventListener('midimessage', handleMidiMessage);
+ portListeners.set(inputPort, handleMidiMessage);
+ }
+}
+
+function stopEventListener() {
+ for (const [port, callback] of portListeners) {
+ if (port) {
+ port.removeEventListener('midimessage', callback);
+ portListeners.delete(port);
+ }
+ }
+}
+
+function parseTeenageSysex(bytes: Uint8Array) {
+ const validHeader =
+ bytes.length >= 9 &&
+ bytes[0] === MIDI_SYSEX_START &&
+ bytes[1] === TE_MIDI_ID_0 &&
+ bytes[2] === TE_MIDI_ID_1 &&
+ bytes[3] === TE_MIDI_ID_2 &&
+ bytes[5] === MIDI_SYSEX_TE &&
+ bytes[bytes.length - 1] === MIDI_SYSEX_END;
+
+ if (!validHeader) {
+ return;
+ }
+
+ const msg: TESysexMessage = {
+ kind: 'te-sysex',
+ identityCode: bytes[4],
+ requestId: 0,
+ hasRequestId: false,
+ status: -1,
+ hStatus: '',
+ command: bytes[8],
+ type: bytes[6] & BIT_IS_REQUEST ? 'request' : 'response',
+ rawData: new Uint8Array(),
+ hexData: '',
+ hexCommand: '',
+ string: '',
+ };
+
+ if (bytes[6] & BIT_REQUEST_ID_AVAILABLE) {
+ msg.hasRequestId = true;
+ msg.requestId = ((bytes[6] & 0x1f) << 7) | (bytes[7] & 0x7f);
+ }
+
+ let index = 9;
+
+ if (msg.type === 'response') {
+ msg.status = bytes[index++];
+ }
+
+ msg.hStatus = sysexStatusToString(msg.status);
+ if (msg.hStatus === undefined) {
+ console.error(`Cannot handle message with status ${msg.status}`);
+ return;
+ }
+
+ msg.rawData = unpackInPlace(bytes.subarray(index, bytes.length - 1));
+ msg.string = binToString(msg.rawData);
+
+ return msg;
+}
+
+async function sendIdentAndWaitForReponse(output: MIDIOutput, timeoutMs: number = 2_000) {
+ return new Promise<{ inputPort: MIDIInput | null; data: Uint8Array } | null>((resolve) => {
+ const requestId = 0;
+
+ const timeoutId = setTimeout(() => {
+ messageHandler.delete(requestId);
+ console.warn('Timeout waiting for identity response');
+ resolve(null);
+ }, timeoutMs);
+
+ const handleMidiMessage = (inputPort: MIDIInput, requestId: number, data: Uint8Array) => {
+ if (requestId === 0) {
+ clearTimeout(timeoutId);
+ messageHandler.delete(requestId);
+ resolve({ inputPort, data });
+ }
+ };
+
+ messageHandler.set(requestId, handleMidiMessage);
+
+ output.send(IDENTITY_SYSEX);
+ });
+}
+
+export async function discoverDevicePorts(
+ midiAccess: MIDIAccess,
+): Promise {
+ const outputs = Array.from(midiAccess.outputs.values());
+ let parsedResponse: TEDeviceIdentification | null = null;
+
+ if (outputs.length === 0) {
+ return parsedResponse;
+ }
+
+ for (const output of outputs) {
+ try {
+ console.group('Trying output port:', output.name);
+
+ const response = await sendIdentAndWaitForReponse(output);
+ if (response) {
+ console.debug('Checking response for output port:', output.name);
+
+ parsedResponse = parseMidiIdentityResponse(response.data);
+ if (parsedResponse) {
+ console.log('Found TE device on port:', output.name);
+
+ setDeviceOutputPort(output);
+ setDeviceInputPort(response.inputPort);
+ // setDeviceInputPort(
+ // midiAccess.inputs.values().find((inp) => inp.name?.includes('EP-13')) || null, // handle both EP-133 and EP-1320
+ // );
+
+ break;
+ }
+ }
+ } finally {
+ console.groupEnd();
+ }
+ }
+
+ return parsedResponse;
+}
+
+function sendTESysEx(
+ midiOutput: MIDIOutput,
+ identityCode: number,
+ command: number,
+ data: Uint8Array,
+) {
+ const packedLength = data.length > 0 ? data.length + Math.ceil(data.length / 7) : 0;
+ const message = new Uint8Array(10 + packedLength);
+ const requestId = getNextRequestId(midiOutput.id);
+
+ const header = new Uint8Array([
+ MIDI_SYSEX_START,
+ TE_MIDI_ID_0,
+ TE_MIDI_ID_1,
+ TE_MIDI_ID_2,
+ identityCode,
+ MIDI_SYSEX_TE,
+ BIT_IS_REQUEST | BIT_REQUEST_ID_AVAILABLE | ((requestId >> 7) & 0x1f),
+ requestId & 0x7f,
+ command,
+ ]);
+
+ message.set(header, 0);
+ message[message.length - 1] = MIDI_SYSEX_END;
+
+ packToBuffer(data, message.subarray(9, 9 + packedLength));
+
+ midiOutput.send(message);
+
+ return requestId;
+}
+
+export async function sendSysexToDevice(
+ command: number,
+ payload: Uint8Array | Array = [],
+ timeoutMs: number = 5_000,
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (!deviceOutputPort || !deviceInputPort) {
+ reject('No device output port available');
+ return;
+ }
+
+ const currentRequestId = sendTESysEx(deviceOutputPort, 0, command, new Uint8Array(payload));
+
+ const timeoutHandler = () => {
+ messageHandler.delete(currentRequestId);
+ reject(`Timeout waiting for sysex response for request ${currentRequestId}`);
+ };
+
+ const timeoutId = setTimeout(timeoutHandler, timeoutMs);
+
+ const handleMidiMessage = (_inputPort: MIDIInput, requestId: number, data: Uint8Array) => {
+ if (requestId !== currentRequestId) {
+ return;
+ }
+
+ const response = parseTeenageSysex(data);
+ if (!response || response.type !== 'response' || response.requestId !== currentRequestId) {
+ return;
+ }
+
+ clearTimeout(timeoutId);
+
+ messageHandler.delete(currentRequestId);
+
+ if (response.status === TE_SYSEX.STATUS_OK) {
+ resolve(response);
+ } else if (response.status === TE_SYSEX.STATUS_SPECIFIC_SUCCESS_START) {
+ reject('Partial response handling not implemented yet');
+ } else {
+ reject(`Received error status in sysex response: ${JSON.stringify(response)}`);
+ }
+ };
+
+ messageHandler.set(currentRequestId, handleMidiMessage);
+ });
+}
+
+export async function tryInitDevice(
+ midiAccess: MIDIAccess,
+ onDeviceFound?: (deviceInfo: TEDevice) => void,
+) {
+ if (pendingInitialization || deviceInitialized) {
+ return null;
+ }
+
+ try {
+ pendingInitialization = true;
+
+ await startEventListener(midiAccess);
+
+ const deviceIdentification = await discoverDevicePorts(midiAccess);
+ if (!deviceIdentification) {
+ return;
+ }
+
+ const greetResponse = await sendSysexToDevice(TE_SYSEX_GREET);
+ if (!greetResponse) {
+ throw new Error('No greetings from device');
+ }
+
+ const deviceMetadata = metadataStringToObject(greetResponse.string);
+ const deviceInfo: TEDevice = {
+ inputId: getDeviceInputPort()?.id || '',
+ outputId: getDeviceOutputPort()?.id || '',
+ sku: deviceMetadata.sku,
+ serial: deviceMetadata.serial,
+ metadata: deviceMetadata,
+ };
+
+ onDeviceFound?.(deviceInfo);
+
+ deviceInitialized = true;
+
+ return deviceMetadata;
+ } catch (error) {
+ console.error('Error accessing MIDI:', error);
+
+ return null;
+ } finally {
+ pendingInitialization = false;
+ }
+}
+
+export function getDeviceInputPort() {
+ return deviceInputPort;
+}
+
+export function getDeviceOutputPort() {
+ return deviceOutputPort;
+}
+
+export function setDeviceInputPort(port: MIDIInput | null) {
+ deviceInputPort = port;
+}
+
+export function setDeviceOutputPort(port: MIDIOutput | null) {
+ deviceOutputPort = port;
+}
+
+export function disconnectDevice() {
+ stopEventListener();
+ setDeviceInputPort(null);
+ setDeviceOutputPort(null);
+ deviceInitialized = false;
+}
+
+export function canInitializeDevice() {
+ return (
+ !pendingInitialization && !deviceInitialized && getDeviceInputPort() && getDeviceOutputPort()
+ );
+}
diff --git a/src/lib/midi/fs.ts b/src/lib/midi/fs.ts
new file mode 100644
index 0000000..fe96390
--- /dev/null
+++ b/src/lib/midi/fs.ts
@@ -0,0 +1,195 @@
+import { TE_SYSEX_FILE, TE_SYSEX_FILE_FILE_TYPE_FILE } from './constants';
+import { sendSysexToDevice } from './device';
+import {
+ buildSysExFileGetMetadataRequest,
+ buildSysExFileInitRequest,
+ buildSysExFileListRequest,
+ buildSysExGetFileDataRequest,
+ buildSysExGetFileInitRequest,
+ parseGetMetadataResponse,
+ parseSysExFileListResponse,
+ parseSysExGetFileDataResponse,
+ parseSysexGetFileInitResponse,
+} from './fsSysex';
+import { TEFile, TEFileNode } from './types';
+import { crc32, sanitizeBrokenJson } from './utils';
+
+const fileNodesCache: Record = {};
+
+export async function getFile(
+ nodeId: number,
+ progressCallback?: (bytesRead: number, totalBytes: number) => void,
+): Promise {
+ const offset = 0;
+ const options = null;
+ const chunks = [];
+ let bytesRead = 0;
+ let pageIndex = 0;
+
+ const initResponseRaw = await sendSysexToDevice(
+ TE_SYSEX_FILE,
+ buildSysExGetFileInitRequest(nodeId, offset, options),
+ );
+
+ if (!initResponseRaw) {
+ throw new Error('Failed to get file init response from device');
+ }
+
+ const initResponse = parseSysexGetFileInitResponse(initResponseRaw.rawData);
+ const totalRemaining = initResponse.fileSize - offset;
+
+ while (bytesRead < totalRemaining) {
+ const chunkResponseRaw = await sendSysexToDevice(
+ TE_SYSEX_FILE,
+ buildSysExGetFileDataRequest(pageIndex),
+ );
+
+ if (!chunkResponseRaw) {
+ throw new Error('Failed to get file data response from device');
+ }
+
+ const chunkResponse = parseSysExGetFileDataResponse(chunkResponseRaw.rawData);
+ if (chunkResponse.page !== pageIndex) {
+ throw new Error(`Unexpected page number ${chunkResponse.page}, expected ${pageIndex}`);
+ }
+
+ if (chunkResponse.data.byteLength === 0) {
+ break;
+ }
+
+ chunks.push(chunkResponse.data);
+
+ bytesRead += chunkResponse.data.byteLength;
+ progressCallback?.(bytesRead, totalRemaining);
+ pageIndex = chunkResponse.nextPage;
+ }
+
+ const length = chunks.reduce((acc, buf) => acc + buf.length, 0);
+ const combined = new Uint8Array(length);
+ let bufOffset = 0;
+
+ for (const buf of chunks) {
+ combined.set(buf, bufOffset);
+ bufOffset += buf.length;
+ }
+
+ return {
+ name: initResponse.fileName,
+ size: initResponse.fileSize,
+ data: combined,
+ crc32: crc32(combined),
+ };
+}
+
+export async function getFileList(): Promise {
+ await sendSysexToDevice(TE_SYSEX_FILE, buildSysExFileInitRequest(4 * 1024 * 1024, 0));
+
+ return getFileListInternal();
+}
+
+async function getFileListInternal(
+ nodeId: number = 0,
+ filesList: TEFileNode[] = [],
+ path = '/',
+): Promise {
+ let page = 0;
+
+ while (true) {
+ const fileListResponse = await sendSysexToDevice(
+ TE_SYSEX_FILE,
+ buildSysExFileListRequest(page, nodeId),
+ );
+
+ if (!fileListResponse || !fileListResponse.rawData || fileListResponse.rawData.length < 2) {
+ break;
+ }
+
+ const pageNumber = ((fileListResponse.rawData[0] << 8) | fileListResponse.rawData[1]) & 0xffff;
+ if (pageNumber !== page) {
+ throw new Error(`Unexpected page ${pageNumber}, expected ${page}`);
+ }
+
+ page += 1;
+
+ const payload = fileListResponse?.rawData.slice(2);
+ const list = parseSysExFileListResponse(payload);
+ if (list.length === 0) {
+ break;
+ }
+
+ for (const entry of list) {
+ const filePath = path === '/' ? `${path}${entry.fileName}` : `${path}/${entry.fileName}`;
+ const fileType = entry.flags & TE_SYSEX_FILE_FILE_TYPE_FILE ? 'file' : 'folder';
+
+ const item = {
+ ...entry,
+ fileName: filePath,
+ fileType,
+ } as const;
+
+ fileNodesCache[filePath] = item;
+ filesList.push(item);
+
+ if (fileType === 'folder') {
+ await getFileListInternal(entry.nodeId, filesList, filePath);
+ }
+ }
+ }
+
+ return filesList;
+}
+
+export async function getFileMetadata>(nodeId: number, pages = null) {
+ const result: T = {} as T;
+
+ for (const pageSelector of pages || [null]) {
+ let metadataStr = '';
+ let page = 0;
+
+ while (true) {
+ const response = await sendSysexToDevice(
+ TE_SYSEX_FILE,
+ buildSysExFileGetMetadataRequest(nodeId, page, pageSelector),
+ );
+
+ if (!response || response?.rawData.byteLength <= 2) {
+ break;
+ }
+
+ const parsed = parseGetMetadataResponse(response.rawData);
+ if (parsed.page !== page) {
+ throw new Error(`unexpected page ${parsed.page}, expected ${page}`);
+ }
+
+ page += 1;
+ metadataStr += parsed.metadata;
+
+ if (response.rawData.slice(-1)[0] === 0) {
+ break;
+ }
+ }
+
+ try {
+ Object.assign(result, JSON.parse(sanitizeBrokenJson(metadataStr)));
+ } catch (err) {
+ throw new Error(`Could not parse ${metadataStr}: ${(err as Error).message}`);
+ }
+ }
+ return result;
+}
+
+export async function getFileNodeByPath(path: string) {
+ if (fileNodesCache[path]) {
+ return fileNodesCache[path];
+ }
+
+ const filesList = await getFileList();
+
+ return filesList.find((file) => file.fileName === path) || null;
+}
+
+export function resetFileCache() {
+ for (const key in fileNodesCache) {
+ delete fileNodesCache[key];
+ }
+}
diff --git a/src/lib/midi/fsSysex.ts b/src/lib/midi/fsSysex.ts
new file mode 100644
index 0000000..f00b607
--- /dev/null
+++ b/src/lib/midi/fsSysex.ts
@@ -0,0 +1,172 @@
+import {
+ TE_SYSEX_FILE_GET,
+ TE_SYSEX_FILE_GET_TYPE_DATA,
+ TE_SYSEX_FILE_GET_TYPE_INIT,
+ TE_SYSEX_FILE_INFO,
+ TE_SYSEX_FILE_INIT,
+ TE_SYSEX_FILE_LIST,
+ TE_SYSEX_FILE_METADATA,
+ TE_SYSEX_FILE_METADATA_GET,
+} from './constants';
+import { parseNullTerminatedString, writeStringToView } from './utils';
+
+export function buildSysExFileInitRequest(maxResponseLength: number, flags: number) {
+ const buffer = new Uint8Array(6);
+ const view = new DataView(buffer.buffer);
+
+ view.setUint8(0, TE_SYSEX_FILE_INIT);
+ view.setUint8(1, flags);
+ view.setUint32(2, maxResponseLength);
+
+ return buffer;
+}
+
+export function buildSysExFileInfoRequest(fileId: number) {
+ const buffer = new Uint8Array(3);
+ const view = new DataView(buffer.buffer);
+
+ view.setUint8(0, TE_SYSEX_FILE_INFO);
+ view.setUint16(1, fileId);
+
+ return buffer;
+}
+
+export function parseFileInfoResponse(data: Uint8Array) {
+ const nodeId = (data[0] << 8) | data[1];
+ const parentId = (data[2] << 8) | data[3];
+ const flags = data[4];
+ const fileSize = (data[5] << 24) | (data[6] << 16) | (data[7] << 8) | data[8];
+ const fileName = parseNullTerminatedString(data, 9);
+
+ return {
+ nodeId,
+ parentId,
+ flags,
+ fileSize,
+ fileName,
+ };
+}
+
+export function buildSysExFileListRequest(page: number, nodeId: number) {
+ const buffer = new Uint8Array(5);
+ const view = new DataView(buffer.buffer);
+
+ view.setUint8(0, TE_SYSEX_FILE_LIST);
+ view.setUint16(1, page);
+ view.setUint16(3, nodeId);
+
+ return buffer;
+}
+
+export function parseSysExFileListResponse(data: Uint8Array) {
+ const entries = [];
+ let offset = 0;
+
+ while (offset < data.byteLength) {
+ const nodeId = (data[offset] << 8) | data[offset + 1];
+ const flags = data[offset + 2];
+ const fileSize =
+ (data[offset + 3] << 24) |
+ (data[offset + 4] << 16) |
+ (data[offset + 5] << 8) |
+ data[offset + 6];
+ const fileName = parseNullTerminatedString(data, offset + 7);
+ const length = 7 + fileName.length;
+
+ entries.push({
+ nodeId,
+ flags,
+ fileSize,
+ fileName,
+ });
+
+ offset += length + 1;
+ }
+
+ return entries;
+}
+
+export function buildSysExGetFileInitRequest(
+ fileId: number,
+ offset: number,
+ extraArgs: Uint8Array | null = null,
+) {
+ const length = extraArgs ? 16 + extraArgs.length : 8;
+ const buffer = new Uint8Array(length);
+ const view = new DataView(buffer.buffer);
+
+ view.setUint8(0, TE_SYSEX_FILE_GET);
+ view.setUint8(1, TE_SYSEX_FILE_GET_TYPE_INIT);
+ view.setUint16(2, fileId);
+ view.setUint32(4, offset);
+
+ if (extraArgs != null) {
+ view.setBigUint64(8, 0n);
+ for (let i = 0; i < extraArgs.length; i++) {
+ view.setUint8(16 + i, extraArgs[i]);
+ }
+ }
+
+ return buffer;
+}
+
+export function parseSysexGetFileInitResponse(bytes: Uint8Array) {
+ const fileId = (bytes[0] << 8) | bytes[1];
+ const flags = bytes[2];
+ const fileSize = (bytes[3] << 24) | (bytes[4] << 16) | (bytes[5] << 8) | bytes[6];
+ const fileName = parseNullTerminatedString(bytes, 7);
+
+ return {
+ fileId,
+ flags,
+ fileSize,
+ fileName,
+ };
+}
+
+export function buildSysExGetFileDataRequest(page: number) {
+ const buffer = new Uint8Array(4);
+ const view = new DataView(buffer.buffer);
+
+ view.setUint8(0, TE_SYSEX_FILE_GET);
+ view.setUint8(1, TE_SYSEX_FILE_GET_TYPE_DATA);
+ view.setUint16(2, page);
+
+ return new Uint8Array(buffer);
+}
+
+export function parseSysExGetFileDataResponse(bytes: Uint8Array) {
+ return {
+ page: (bytes[0] << 8) | bytes[1],
+ nextPage: ((bytes[0] << 8) | (bytes[1] + 1)) & 0xffff,
+ data: bytes.subarray(2),
+ };
+}
+
+export function buildSysExFileGetMetadataRequest(
+ fileId: number,
+ page: number,
+ key: string | null = null,
+) {
+ const extraLength = key?.length ? key.length + 1 : 0;
+ const buffer = new Uint8Array(6 + extraLength);
+ const view = new DataView(buffer.buffer);
+
+ view.setUint8(0, TE_SYSEX_FILE_METADATA);
+ view.setUint8(1, TE_SYSEX_FILE_METADATA_GET);
+ view.setUint16(2, fileId);
+ view.setUint16(4, page);
+
+ if (key != null) {
+ writeStringToView(view, 6, key, true);
+ }
+
+ return buffer;
+}
+
+export function parseGetMetadataResponse(data: Uint8Array) {
+ const page = (data[0] << 8) | data[1];
+ const metadata = parseNullTerminatedString(data, 2);
+
+ return { page, metadata };
+}
diff --git a/src/lib/midi/index.ts b/src/lib/midi/index.ts
new file mode 100644
index 0000000..bdfecc7
--- /dev/null
+++ b/src/lib/midi/index.ts
@@ -0,0 +1,64 @@
+import { disconnectDevice, getDeviceInputPort, getDeviceOutputPort, tryInitDevice } from './device';
+import { getFile, getFileNodeByPath, resetFileCache } from './fs';
+import { TEDevice } from './types';
+
+export async function initDevice({
+ onDeviceFound,
+ onDeviceLost,
+ onNoMidiAccess,
+}: {
+ onDeviceFound?: (deviceInfo: TEDevice) => void;
+ onDeviceLost?: () => void;
+ onNoMidiAccess?: (error: Error) => void;
+}): Promise {
+ let midiAccess: MIDIAccess | null = null;
+
+ try {
+ midiAccess = await navigator.requestMIDIAccess({ sysex: true });
+ } catch (error) {
+ onNoMidiAccess?.(error as Error);
+
+ return;
+ }
+
+ await tryInitDevice(midiAccess, onDeviceFound);
+
+ const stateChangeMidiEventHandler = (event: MIDIConnectionEvent) => {
+ const currentInputPort = getDeviceInputPort();
+ const currentOutputPort = getDeviceOutputPort();
+
+ if (
+ event.port?.state === 'disconnected' &&
+ (currentInputPort?.id === event.port?.id || currentOutputPort?.id === event.port?.id)
+ ) {
+ disconnectDevice();
+ resetFileCache();
+ onDeviceLost?.();
+ }
+
+ if (event.port?.state === 'connected') {
+ setTimeout(() => tryInitDevice(midiAccess, onDeviceFound), 1000);
+ }
+ };
+
+ midiAccess.addEventListener('statechange', stateChangeMidiEventHandler);
+
+ if (import.meta.hot) {
+ import.meta.hot.on('vite:beforeUpdate', () => {
+ midiAccess?.removeEventListener('statechange', stateChangeMidiEventHandler);
+ });
+ }
+}
+
+export async function getProjectFile(projectId: number) {
+ const path = `/projects/${String(projectId).padStart(2, '0')}`;
+ const foundNode = await getFileNodeByPath(path);
+
+ if (!foundNode) {
+ throw new Error(`Project ${projectId} not found`);
+ }
+
+ const archive = await getFile(foundNode.nodeId);
+
+ return archive;
+}
diff --git a/src/lib/midi/types.ts b/src/lib/midi/types.ts
new file mode 100644
index 0000000..69c569b
--- /dev/null
+++ b/src/lib/midi/types.ts
@@ -0,0 +1,71 @@
+export type TEDeviceMetadata = {
+ chip_id: string;
+ mode: string;
+ os_version: string;
+ product: string;
+ serial: string;
+ sku: string;
+ sw_version: string;
+};
+
+export type TESysexMessage = {
+ kind: 'te-sysex';
+ identityCode: number;
+ requestId: number;
+ hasRequestId: boolean;
+ status: number;
+ hStatus: string;
+ command: number;
+ type: 'request' | 'response';
+ rawData: Uint8Array;
+ hexData: string;
+ hexCommand: string;
+ string: string;
+};
+
+export type TEFileNode = {
+ nodeId: number;
+ flags: number;
+ fileSize: number;
+ fileName: string;
+ fileType: 'file' | 'folder';
+};
+
+export type TEFile = {
+ name: string;
+ size: number;
+ data: Uint8Array;
+ crc32?: number;
+};
+
+export type TEDeviceIdentification = {
+ id: number;
+ sku: string;
+};
+
+export interface TEDevice {
+ inputId: string;
+ outputId: string;
+ sku: string;
+ serial: string;
+ metadata: TEDeviceMetadata;
+}
+
+export type TESoundMetadata = {
+ channels: number;
+ samplerate: number;
+ format: 's16' | 's24' | 'float';
+ crc: number;
+ name: string;
+ 'sound.loopstart': number;
+ 'sound.loopend': number;
+ 'sound.amplitude': number;
+ 'sound.playmode': 'oneshot' | 'loop' | 'key' | 'legato';
+ 'sound.pan': number;
+ 'sound.pitch': number;
+ 'sound.rootnote': number;
+ 'time.mode': string;
+ 'sound.bpm': number;
+ 'envelope.attack': number;
+ 'envelope.release': number;
+};
diff --git a/src/lib/midi/utils.ts b/src/lib/midi/utils.ts
new file mode 100644
index 0000000..28c264d
--- /dev/null
+++ b/src/lib/midi/utils.ts
@@ -0,0 +1,170 @@
+import { TE_MIDI_ID_0, TE_MIDI_ID_1, TE_MIDI_ID_2, TE_SYSEX } from './constants';
+import { TEDeviceIdentification, TEDeviceMetadata } from './types';
+
+const requestIds: Map = new Map();
+
+export const crc32 = (data: Uint8Array, initial = 0) => {
+ const normalize = (value: number) => (value >= 0 ? value : 0xffffffff + value + 1);
+
+ let crc = normalize(initial) ^ 0xffffffff;
+
+ for (const byte of data) {
+ crc ^= byte;
+ for (let i = 0; i < 8; i++) {
+ crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
+ }
+ }
+
+ return normalize(crc ^ 0xffffffff);
+};
+
+export function unpackInPlace(packedBytes: Uint8Array): Uint8Array {
+ let writeIndex = 0; // Index where unpacked bytes will be written
+ let msbIndex = 0; // Index in array holding the MSB flags
+ let bitIndex = 0; // Bit position within the MSB byte
+ let readIndex = 1; // Read position for data bytes
+ let msbByte = packedBytes[msbIndex];
+
+ while (readIndex < packedBytes.length) {
+ const msb = (msbByte & (1 << bitIndex) ? 1 : 0) << 7; // Extract MSB bit
+ const data = packedBytes[readIndex] & 0x7f; // Lower 7 bits
+ const fullByte = msb | data;
+
+ packedBytes[writeIndex] = fullByte;
+
+ bitIndex++;
+ readIndex++;
+ writeIndex++;
+
+ if (bitIndex > 6) {
+ // Move to next group of 8 bytes (1 MSB byte + 7 data bytes)
+ readIndex++;
+ bitIndex = 0;
+ msbIndex += 8;
+ msbByte = packedBytes[msbIndex];
+ }
+ }
+
+ return packedBytes.subarray(0, writeIndex);
+}
+
+export function parseMidiIdentityResponse(data: Uint8Array): TEDeviceIdentification | null {
+ if (
+ data.length === 17 &&
+ data[0] === 0xf0 && // SysEx start
+ data[1] === 0x7e && // Universal SysEx
+ data[5] === TE_MIDI_ID_0 && // TE manufacturer ID byte 0
+ data[6] === TE_MIDI_ID_1 && // TE manufacturer ID byte 1
+ data[7] === TE_MIDI_ID_2 // TE manufacturer ID byte 2
+ ) {
+ const productCode = data[8] ^ (data[9] << 7);
+ const assemblyCode = data[10] ^ (data[11] << 7);
+
+ return {
+ id: data[2],
+ sku: `TE${productCode.toString().padStart(3, '0')}AS${assemblyCode.toString().padStart(3, '0')}`,
+ };
+ }
+
+ return null;
+}
+
+export function metadataStringToObject(metaString: string) {
+ const result: TEDeviceMetadata = {
+ chip_id: '',
+ mode: '',
+ os_version: '',
+ product: '',
+ serial: '',
+ sku: '',
+ sw_version: '',
+ };
+
+ metaString.split(';').forEach((entry) => {
+ const [key, value] = entry.split(':');
+ if (key && value) {
+ (result as Record)[key] = value;
+ }
+ });
+
+ return result;
+}
+
+export function binToString(byteArray: Uint8Array) {
+ return String.fromCharCode.apply(null, Array.from(byteArray));
+}
+
+export function sysexStatusToString(status: number) {
+ if (status === TE_SYSEX.STATUS_OK) return 'ok';
+ if (status >= TE_SYSEX.STATUS_SPECIFIC_SUCCESS_START) return 'command-specific-success';
+ if (status === TE_SYSEX.STATUS_ERROR) return 'error';
+ if (status === TE_SYSEX.STATUS_COMMAND_NOT_FOUND) return 'not-found';
+ if (status === TE_SYSEX.STATUS_BAD_REQUEST) return 'bad-request';
+ if (
+ status >= TE_SYSEX.STATUS_SPECIFIC_ERROR_START &&
+ status < TE_SYSEX.STATUS_SPECIFIC_SUCCESS_START
+ ) {
+ return 'command-specific-error';
+ }
+ return 'unknown';
+}
+
+export function packToBuffer(data: Uint8Array, outBuffer: Uint8Array) {
+ let outIndex = 1;
+ let msbIndex = 0;
+
+ for (let i = 0; i < data.length; ++i) {
+ const positionInGroup = i % 7;
+ const msb = data[i] >> 7;
+
+ outBuffer[msbIndex] |= msb << positionInGroup;
+ outBuffer[outIndex++] = data[i] & 0x7f;
+
+ if (positionInGroup === 6 && i < data.length - 1) {
+ msbIndex += 8;
+ outIndex++;
+ }
+ }
+}
+
+export function getNextRequestId(outputId: string): number {
+ if (!requestIds.has(outputId)) {
+ requestIds.set(outputId, Math.floor(Math.random() * 4095));
+ }
+ const nextId = ((requestIds.get(outputId) ?? 0) + 1) % 4096;
+ requestIds.set(outputId, nextId);
+ return nextId;
+}
+
+export function parseNullTerminatedString(buffer: Uint8Array, startIndex: number) {
+ let endIndex = startIndex;
+
+ while (endIndex < buffer.length && buffer[endIndex] !== 0) {
+ endIndex++;
+ }
+
+ return new TextDecoder().decode(buffer.subarray(startIndex, endIndex));
+}
+
+export function writeStringToView(
+ dataView: DataView,
+ offset: number,
+ str: string,
+ nullTerminate = false,
+) {
+ let pos = offset;
+ for (let i = 0; i < str.length; i++) {
+ dataView.setUint8(pos++, str.charCodeAt(i));
+ }
+ if (nullTerminate && pos > 0) {
+ dataView.setUint8(pos++, 0);
+ }
+ return pos;
+}
+
+export function sanitizeBrokenJson(input: string) {
+ return input.replace(/:"([^"]*?)"([^,}]*)/g, (_, part1, part2) => {
+ const fixed = (part1 + part2).replace(/"/g, '\\"');
+ return `:"${fixed}"`;
+ });
+}
diff --git a/src/lib/parsers.ts b/src/lib/parsers.ts
new file mode 100644
index 0000000..acbc752
--- /dev/null
+++ b/src/lib/parsers.ts
@@ -0,0 +1,518 @@
+import JSZip from 'jszip';
+import {
+ GroupFaderParam,
+ Note,
+ Pad,
+ PadCode,
+ Pattern,
+ ProjectSettings,
+ Scene,
+ ScenesSettings,
+ Sound,
+} from '../types/types';
+import { GROUPS, NOTE_NAMES, PADS, SKU_EP40 } from './constants';
+import { parseWavMetadata } from './exporters/utils';
+import { getFileMetadata, getFileNodeByPath } from './midi/fs';
+import { TESoundMetadata } from './midi/types';
+import { TarFile } from './untar';
+import { calculateSoundLength } from './utils';
+
+type SceneMeta = {
+ a: number;
+ b: number;
+ c: number;
+ d: number;
+};
+
+type IntermediateScenes = Record<
+ number,
+ {
+ name: string;
+ groups: Record }>;
+ }
+>;
+
+const defaultProjectSettings = {
+ bpm: 120,
+ scale: 0,
+ rootNote: 0,
+ groupFaderParams: {
+ a: { 0: -1, 1: -1, 2: -1, 3: -1, 4: -1, 5: -1, 6: -1, 7: -1, 8: -1, 9: -1, 10: -1, 11: -1 },
+ b: { 0: -1, 1: -1, 2: -1, 3: -1, 4: -1, 5: -1, 6: -1, 7: -1, 8: -1, 9: -1, 10: -1, 11: -1 },
+ c: { 0: -1, 1: -1, 2: -1, 3: -1, 4: -1, 5: -1, 6: -1, 7: -1, 8: -1, 9: -1, 10: -1, 11: -1 },
+ d: { 0: -1, 1: -1, 2: -1, 3: -1, 4: -1, 5: -1, 6: -1, 7: -1, 8: -1, 9: -1, 10: -1, 11: -1 },
+ },
+ faderAssignment: { a: 0, b: 0, c: 0, d: 0 },
+};
+
+const defaultScenesSettings = {
+ timeSignature: { numerator: 4, denominator: 4 },
+};
+
+export function noteNumberToName(noteNumber: number): string {
+ const noteIndex = noteNumber % 12;
+ const octave = Math.floor(noteNumber / 12) - 1;
+
+ return `${NOTE_NAMES[noteIndex]}${octave}`;
+}
+
+function timeStretch(data: number): Pad['timeStretch'] {
+ switch (data) {
+ case 1:
+ return 'bpm';
+ case 2:
+ return 'bars';
+ case 3:
+ return 'rev';
+ default:
+ return 'off';
+ }
+}
+
+function timeStretchBars(data: number) {
+ switch (data) {
+ case 0:
+ return 1;
+ case 1:
+ return 2;
+ case 2:
+ return 4;
+ case 255:
+ return 0.5;
+ case 254:
+ return 0.25;
+ default:
+ return 0;
+ }
+}
+
+function bytesToFloat32(bytes: Uint8Array): number {
+ const buffer = new ArrayBuffer(4);
+ const view = new DataView(buffer);
+
+ bytes.forEach((b, i) => {
+ view.setUint8(i, b);
+ });
+
+ return view.getFloat32(0, true);
+}
+
+function genPadFileName(group: string, pad: number) {
+ return `pads/${group}/p${String(pad).padStart(2, '0')}`;
+}
+
+function chunkArray(arr: Uint8Array, size: number, offset = 0) {
+ const result = [];
+
+ for (let i = offset; i < arr.length; i += size) {
+ result.push(arr.slice(i, i + size));
+ }
+ return result;
+}
+
+export async function collectSounds(files: TarFile[]) {
+ const soundIds = new Set();
+
+ for (let g = 0; g < 4; g++) {
+ for (let i = 1; i <= 12; i++) {
+ const file = files.find((f) => f.name === genPadFileName(GROUPS[g].id, i));
+
+ if (file?.data) {
+ const soundId = (file.data[2] << 8) + file.data[1];
+ if (soundId > 0 && soundId < 1000) {
+ soundIds.add(soundId);
+ }
+ }
+ }
+ }
+
+ const selectedSounds: Sound[] = [];
+
+ for (const soundId of soundIds) {
+ const fileNode = await getFileNodeByPath(`/sounds/${String(soundId).padStart(3, '0')}.pcm`);
+
+ if (!fileNode) {
+ console.warn(`Sound file for sound ID ${soundId} not found`);
+ continue;
+ }
+
+ const meta = await getFileMetadata(fileNode.nodeId);
+
+ selectedSounds.push({
+ id: soundId,
+ fileNode,
+ meta,
+ });
+ }
+
+ return selectedSounds;
+}
+
+export function collectPads(files: TarFile[], sounds: Sound[]) {
+ const result: Record = {};
+
+ for (const group of GROUPS) {
+ result[group.id] = [];
+
+ for (let i = 1; i <= 12; i++) {
+ const file = files.find((f) => f.name === genPadFileName(group.id, i));
+ if (file?.data) {
+ const soundId = (file.data[2] << 8) + file.data[1];
+ const sound = sounds.find((s) => s.id === soundId);
+ const pitch = file.data[17] <= 12 ? file.data[17] : -(256 - file.data[17]); // pitch from -12 to +12
+ const pitchDecimal = file.data[26];
+ const pan = (file.data[18] >= 240 ? -(256 - file.data[18]) : file.data[18]) / 16; // normalized pan
+ const trimLeft = (file.data[6] << 16) + (file.data[5] << 8) + file.data[4];
+ const trimRight = trimLeft + (file.data[10] << 16) + (file.data[9] << 8) + file.data[8];
+
+ result[group.id].push({
+ pad: i,
+ group: group.id,
+ name: `${group.name} ${PADS[i - 1]}`,
+ file,
+ rawData: file.data,
+ soundId,
+ pan,
+ volume: file.data[16],
+ attack: file.data[19],
+ release: file.data[20],
+ trimLeft,
+ trimRight,
+ playMode: file.data[23] === 0 ? 'oneshot' : file.data[23] === 1 ? 'key' : 'legato',
+ soundLength: sound ? calculateSoundLength(sound) : 0,
+ pitch: Math.max(-12, Math.min(12, parseFloat(`${pitch}.${pitchDecimal}`))),
+ rootNote: file.data[24],
+ timeStretch: timeStretch(file.data[21]),
+ timeStretchBpm: Number(bytesToFloat32(file.data.slice(12, 16)).toFixed(2)),
+ timeStretchBars: timeStretchBars(file.data[25]),
+ inChokeGroup: file.data[22] === 1,
+ midiChannel: file.data[3],
+ });
+ }
+ }
+ }
+
+ return result;
+}
+
+function parsePatterns(data: Uint8Array, startOffset: number = 4) {
+ const chunks = chunkArray(data, 8, startOffset);
+ const notes: Record = {};
+
+ chunks.forEach((chunk) => {
+ // I discovered there could be some weird chunks with pad numbers not multiple of 8
+ // since I don't know what they are, just skip them
+ if (chunk[2] % 8 !== 0) {
+ return;
+ }
+
+ const pad = chunk[2] / 8;
+ if (!notes[pad]) {
+ notes[pad] = [];
+ }
+
+ notes[pad].push({
+ note: chunk[3],
+ position: (chunk[1] << 8) + chunk[0],
+ duration: (chunk[6] << 8) + chunk[5],
+ velocity: chunk[4],
+ });
+ });
+
+ return {
+ bars: data[1],
+ notes,
+ };
+}
+
+function getPatternsForScene(scenesIntermediate: IntermediateScenes, scenePatternUsage: SceneMeta) {
+ const patterns: Pattern[] = [];
+
+ for (const group of ['a', 'b', 'c', 'd'] as const) {
+ const scene = scenePatternUsage[group];
+ const lookupSceneData = scenesIntermediate[scene];
+
+ if (lookupSceneData?.groups[group]) {
+ const groupData = lookupSceneData.groups[group];
+ Object.entries(groupData.notes).forEach(([padNum, notes]) => {
+ patterns.push({
+ pad: `${group}${padNum}` as PadCode,
+ group,
+ notes,
+ bars: groupData.bars,
+ });
+ });
+ }
+ }
+
+ return patterns;
+}
+
+export function collectScenesAndPatterns(files: TarFile[], sku: string) {
+ const scenesIntermediateData: IntermediateScenes = {};
+ const scenes: Record = {};
+ const scenePatternUsage: Record = {};
+
+ // build default scene metadata (in case "scenes" file is missing)
+ for (let i = 1; i <= 99; i++) {
+ scenePatternUsage[i] = {
+ a: i,
+ b: i,
+ c: i,
+ d: i,
+ };
+ }
+
+ // build group/pattern lookup table
+ const scenesFile = files.find((f) => f.name === 'scenes' && f.type === 'file');
+ if (scenesFile?.data) {
+ const chunks = chunkArray(scenesFile.data, 6, 7);
+ chunks.forEach((chunk, i) => {
+ if (i > 98) {
+ return;
+ }
+
+ scenePatternUsage[i + 1] = {
+ a: chunk[0],
+ b: chunk[1],
+ c: chunk[2],
+ d: chunk[3],
+ };
+ });
+ }
+
+ for (const file of files) {
+ if (!file.name.startsWith('patterns') || file.type !== 'file' || !file.data) {
+ continue;
+ }
+
+ const matches = file.name.match(/patterns\/([abcd])(\d+)/);
+ if (!matches) {
+ continue;
+ }
+
+ const [, group, scene] = matches;
+ const sceneIndex = Number(scene);
+ if (!scenesIntermediateData[sceneIndex]) {
+ scenesIntermediateData[sceneIndex] = {
+ name: String(scene).padStart(2, '0'),
+ groups: {},
+ };
+ }
+
+ // build intermediate scenes data
+ // will be used to resolve patterns later using sceneMetadata
+ scenesIntermediateData[sceneIndex] = {
+ ...scenesIntermediateData[sceneIndex],
+ groups: {
+ ...scenesIntermediateData[sceneIndex].groups,
+ [group]: parsePatterns(file.data, sku === SKU_EP40 ? 6 : 4), // EP-40 has 6-byte header
+ },
+ };
+ }
+
+ Object.entries(scenesIntermediateData).forEach(([sceneIndex, sceneData]) => {
+ if (!scenePatternUsage[sceneIndex]) {
+ return;
+ }
+
+ const patterns = getPatternsForScene(scenesIntermediateData, scenePatternUsage[sceneIndex]);
+ if (patterns.length === 0) {
+ return;
+ }
+
+ scenes[sceneIndex] = {
+ name: sceneData.name,
+ patterns,
+ };
+ });
+
+ return Object.values(scenes).sort((a, b) => a.name.localeCompare(b.name));
+}
+
+export function collectSettings(files: TarFile[]): ProjectSettings {
+ const settings = files.find((f) => f.name === 'settings' && f.type === 'file');
+
+ if (!settings || !settings.data) {
+ console.warn('Could not find "settings" file');
+
+ return {
+ ...defaultProjectSettings,
+ rawData: new Uint8Array(),
+ };
+ }
+
+ const faderParamsData: GroupFaderParam = {};
+
+ for (const groupNum of [0, 1, 2, 3]) {
+ const groupId = GROUPS[groupNum].id;
+ for (let paramNum = 0; paramNum <= 11; paramNum++) {
+ faderParamsData[groupId] = {
+ ...faderParamsData[groupId],
+ [paramNum]: Number(
+ bytesToFloat32(
+ settings.data.slice(
+ 24 + groupNum * 48 + paramNum * 4,
+ 28 + groupNum * 48 + paramNum * 4,
+ ),
+ ).toFixed(2),
+ ),
+ };
+ }
+ }
+
+ return {
+ bpm: Number(bytesToFloat32(settings.data.slice(4, 8)).toFixed(2)),
+ scale: settings.data?.[222] ?? 0,
+ rootNote: settings.data?.[223] ?? 0,
+ groupFaderParams: faderParamsData,
+ faderAssignment: {
+ a: settings.data[216],
+ b: settings.data[217],
+ c: settings.data[218],
+ d: settings.data[219],
+ },
+ rawData: settings.data,
+ };
+}
+
+export function collectEffects(files: TarFile[]) {
+ const fxFile = files.find((f) => f.name === 'fx_settings' && f.type === 'file');
+
+ if (!fxFile || !fxFile.data) {
+ console.warn('Could not find "fx_settings" file');
+
+ return {
+ rawData: new Uint8Array(),
+ effectType: 0,
+ param1: 0,
+ param2: 0,
+ };
+ }
+
+ const effectType = fxFile.data[4];
+ const param1 = bytesToFloat32(
+ fxFile.data.slice(12 + (effectType - 1) * 4, 16 + (effectType - 1) * 4),
+ );
+ const param2 = bytesToFloat32(
+ fxFile.data.slice(76 + (effectType - 1) * 4, 80 + (effectType - 1) * 4),
+ );
+
+ return {
+ rawData: fxFile.data,
+ effectType,
+ param1,
+ param2,
+ };
+}
+
+export function collectScenesSettings(files: TarFile[]): ScenesSettings {
+ const scenesSettingsFile = files.find((f) => f.name === 'scenes' && f.type === 'file');
+
+ if (!scenesSettingsFile || !scenesSettingsFile.data) {
+ console.warn('Could not find "scenes" file');
+ return defaultScenesSettings;
+ }
+
+ const numerator = scenesSettingsFile.data[11];
+ const denominator = scenesSettingsFile.data[12];
+
+ return {
+ ...defaultScenesSettings,
+ timeSignature: { numerator, denominator },
+ };
+}
+
+export async function loadSoundsFromBackup(
+ unzippedBackup: JSZip,
+ projectFiles: TarFile[],
+): Promise {
+ const soundIds = new Set();
+
+ for (let group = 0; group < 4; group++) {
+ for (let pad = 1; pad <= 12; pad++) {
+ const file = projectFiles.find((f) => f.name === genPadFileName(GROUPS[group].id, pad));
+
+ if (file?.data) {
+ const soundId = (file.data[2] << 8) + file.data[1];
+ if (soundId > 0 && soundId < 1000) {
+ soundIds.add(soundId);
+ }
+ }
+ }
+ }
+
+ const selectedSounds: Sound[] = [];
+
+ for (const soundId of soundIds) {
+ const wavFile = Object.values(unzippedBackup.files).find((file) =>
+ file.name.startsWith(`/sounds/${String(soundId).padStart(3, '0')} `),
+ );
+
+ if (!wavFile) {
+ console.warn(`Sound file for sound ID ${soundId} not found in backup`);
+ continue;
+ }
+
+ const soundFile = unzippedBackup.file(wavFile.name);
+ if (!soundFile) {
+ console.warn(`Sound file for sound ID ${soundId} not found in backup`);
+ continue;
+ }
+
+ const soundData = await soundFile.async('uint8array');
+ const fileName = (soundFile.name.split('/').pop()?.split(' ').slice(1).join(' ') || '').replace(
+ '.wav',
+ '',
+ );
+ const fileNode = {
+ nodeId: soundId,
+ flags: 0,
+ fileSize: soundData.length,
+ fileName,
+ fileType: 'file' as const,
+ };
+
+ let wavMeta: ReturnType;
+ try {
+ wavMeta = parseWavMetadata(soundData);
+ } catch (err) {
+ console.warn(`Failed to parse WAV metadata for sound ${soundId}:`, err);
+ wavMeta = {
+ channels: 1,
+ samplerate: 44100,
+ format: 's16' as const,
+ rootNote: 60,
+ teMeta: null,
+ };
+ }
+
+ const te = wavMeta.teMeta;
+
+ selectedSounds.push({
+ id: soundId,
+ fileNode,
+ meta: {
+ channels: wavMeta.channels,
+ samplerate: wavMeta.samplerate,
+ format: wavMeta.format,
+ crc: 0,
+ name: fileName,
+ 'sound.loopstart': (te?.['sound.loopstart'] as number) ?? 0,
+ 'sound.loopend': (te?.['sound.loopend'] as number) ?? 0,
+ 'sound.amplitude': (te?.['sound.amplitude'] as number) ?? 1,
+ 'sound.playmode':
+ (te?.['sound.playmode'] as TESoundMetadata['sound.playmode']) ?? 'oneshot',
+ 'sound.pan': (te?.['sound.pan'] as number) ?? 0,
+ 'sound.pitch': (te?.['sound.pitch'] as number) ?? 0,
+ 'sound.rootnote': wavMeta.rootNote,
+ 'time.mode': (te?.['time.mode'] as string) ?? '',
+ 'sound.bpm': 0,
+ 'envelope.attack': (te?.['envelope.attack'] as number) ?? 0,
+ 'envelope.release': (te?.['envelope.release'] as number) ?? 0,
+ },
+ });
+ }
+
+ return selectedSounds;
+}
diff --git a/src/lib/pcmToWav.ts b/src/lib/pcmToWav.ts
new file mode 100644
index 0000000..5f264dd
--- /dev/null
+++ b/src/lib/pcmToWav.ts
@@ -0,0 +1,58 @@
+function createWavHeader(
+ dataLength: number,
+ sampleRate: number,
+ channels: number,
+ bitDepth: number,
+): Uint8Array {
+ const header = new Uint8Array(44);
+ const view = new DataView(header.buffer);
+
+ // RIFF header
+ header.set([0x52, 0x49, 0x46, 0x46], 0); // "RIFF"
+ view.setUint32(4, 36 + dataLength, true); // File size - 8 bytes
+ header.set([0x57, 0x41, 0x56, 0x45], 8); // "WAVE"
+
+ // Format chunk
+ header.set([0x66, 0x6d, 0x74, 0x20], 12); // "fmt "
+ view.setUint32(16, 16, true); // Format chunk size
+ view.setUint16(20, 1, true); // Audio format (PCM)
+ view.setUint16(22, channels, true); // Number of channels
+ view.setUint32(24, sampleRate, true); // Sample rate
+ view.setUint32(28, sampleRate * channels * (bitDepth / 8), true); // Byte rate
+ view.setUint16(32, channels * (bitDepth / 8), true); // Block align
+ view.setUint16(34, bitDepth, true); // Bits per sample
+
+ // Data chunk
+ header.set([0x64, 0x61, 0x74, 0x61], 36); // "data"
+ view.setUint32(40, dataLength, true); // Data size
+
+ return header;
+}
+
+export function pcmToWavBlob(
+ pcmData: Uint8Array,
+ sampleRate: number = 44100,
+ bitDepth: number = 16,
+ channels: number = 1,
+): Blob {
+ const header = createWavHeader(pcmData.length, sampleRate, channels, bitDepth);
+
+ const wavData = new Uint8Array(header.length + pcmData.length);
+ wavData.set(header, 0);
+ wavData.set(pcmData, header.length);
+
+ return new Blob([wavData], { type: 'audio/wav' });
+}
+
+export function pcmToWavArray(
+ pcmData: Uint8Array,
+ sampleRate: number = 44100,
+ bitDepth: number = 16,
+ channels: number = 1,
+): Uint8Array {
+ const header = createWavHeader(pcmData.length, sampleRate, channels, bitDepth);
+ const wavData = new Uint8Array(header.length + pcmData.length);
+ wavData.set(header, 0);
+ wavData.set(pcmData, header.length);
+ return wavData;
+}
diff --git a/src/lib/queryClient.ts b/src/lib/queryClient.ts
new file mode 100644
index 0000000..87fd588
--- /dev/null
+++ b/src/lib/queryClient.ts
@@ -0,0 +1,12 @@
+import { QueryClient } from '@tanstack/react-query';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ refetchOnMount: false,
+ },
+ },
+});
+
+export default queryClient;
diff --git a/src/lib/store.ts b/src/lib/store.ts
new file mode 100644
index 0000000..09a7aa5
--- /dev/null
+++ b/src/lib/store.ts
@@ -0,0 +1,3 @@
+import { createStore } from 'jotai';
+
+export const store = createStore();
diff --git a/src/lib/toast.ts b/src/lib/toast.ts
new file mode 100644
index 0000000..1788762
--- /dev/null
+++ b/src/lib/toast.ts
@@ -0,0 +1,15 @@
+import { type ToastSeverity, toastsAtom } from '../atoms/toast';
+import { store } from './store';
+
+export function showToast(message: string, severity: ToastSeverity = 'info') {
+ const existingToasts = store.get(toastsAtom);
+
+ const newToast = {
+ message,
+ severity,
+ id: Math.random().toString(36).substring(2, 9),
+ timestamp: Date.now(),
+ };
+
+ store.set(toastsAtom, [...existingToasts, newToast]);
+}
diff --git a/src/lib/transformers/ableton.ts b/src/lib/transformers/ableton.ts
new file mode 100644
index 0000000..9e4a655
--- /dev/null
+++ b/src/lib/transformers/ableton.ts
@@ -0,0 +1,316 @@
+import * as Sentry from '@sentry/react';
+import omit from 'lodash/omit';
+import {
+ ExporterParams,
+ FaderParam,
+ Note,
+ Pad,
+ PadCode,
+ ProjectRawData,
+ TimeSignature,
+} from '../../types/types';
+import { getSampleName } from '../exporters/utils';
+import { findPad, findSoundByPad, findSoundIdByPad } from '../utils';
+
+export type AblData = {
+ tracks: AblTrack[];
+ scenes: AblScene[];
+};
+
+export type AblTrack = Omit & {
+ padCode: PadCode;
+ group: string;
+ sampleName: string;
+ sampleChannels: number;
+ sampleRate: number;
+ sampleRootNote: number;
+ samplePitch: number;
+ bpm: number;
+ drumRack: boolean;
+ lane?: AblLane;
+ tracks: AblTrack[];
+ faderParams: { [K in FaderParam]: number };
+ timeSignature: TimeSignature;
+};
+
+export type AblLane = {
+ padCode: PadCode;
+ clips: AblClip[];
+};
+
+export type AblNote = Note;
+
+export type AblClip = {
+ notes: AblNote[];
+ bars: number;
+ offset: number;
+ sceneBars: number;
+ sceneIndex: number;
+ sceneName: string;
+ timeSignature: TimeSignature;
+ faderParams: { [K in FaderParam]: number };
+};
+
+export type AblScene = {
+ name: string;
+};
+
+function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams) {
+ const { pads, scenes } = data;
+ const customSceneNames = exporterParams.customSceneNames || {};
+ const lanes: AblLane[] = [];
+ const ablScenes: AblScene[] = [];
+ let tracks: AblTrack[] = [];
+ let offset = 0;
+
+ scenes.forEach((scene, sceneIndex) => {
+ const sceneBars = Math.max(...scene.patterns.map((p) => p.bars));
+ const ablScene: AblScene = {
+ name: customSceneNames[scene.name] || scene.name,
+ };
+
+ scene.patterns.forEach((pattern) => {
+ let track = tracks.find((c) => c.padCode === pattern.pad);
+ let pad = findPad(pattern.pad, pads);
+
+ if (!pad) {
+ console.warn('Could not find pad data for', pattern.pad);
+
+ pad = {
+ group: pattern.group,
+ volume: 100,
+ attack: 0,
+ release: 0,
+ trimLeft: 0,
+ trimRight: 0,
+ pad: 0,
+ playMode: 'oneshot',
+ pan: 0,
+ pitch: 0,
+ rootNote: 60,
+ timeStretch: 'off',
+ timeStretchBpm: 0,
+ timeStretchBars: 0,
+ soundId: 0,
+ name: '',
+ file: null,
+ rawData: null,
+ soundLength: 0,
+ inChokeGroup: false,
+ midiChannel: 0,
+ };
+ }
+
+ const faderParams = data.settings.groupFaderParams[pad.group];
+
+ if (!track) {
+ const soundId = findSoundIdByPad(pattern.pad, pads) || 0;
+ const sound = findSoundByPad(pattern.pad, pads, data.sounds);
+ track = {
+ ...omit(pad, ['file', 'rawData']),
+ soundId,
+ padCode: pattern.pad,
+ name: sound?.meta?.name || pattern.pad,
+ volume: pad.volume / 200, // converting from 0-200 to 0.0-1.0
+ sampleName: getSampleName(sound?.meta?.name, soundId),
+ sampleChannels: sound?.meta?.channels || 0,
+ sampleRate: sound?.meta?.samplerate || 0,
+ sampleRootNote: pad.rootNote,
+ samplePitch: sound?.meta?.['sound.pitch'] ?? 0,
+ bpm: data.settings.bpm,
+ drumRack: false,
+ tracks: [],
+ faderParams,
+ timeSignature: data.scenesSettings.timeSignature,
+ };
+
+ tracks.push(track);
+ }
+
+ let lane = lanes.find((c) => c.padCode === pattern.pad);
+ if (!lane) {
+ lane = {
+ padCode: pattern.pad,
+ clips: [],
+ };
+
+ lanes.push(lane);
+ }
+
+ lane.clips.push({
+ offset,
+ notes: pattern.notes,
+ bars: pattern.bars,
+ sceneBars,
+ sceneIndex,
+ sceneName: customSceneNames[scene.name] || scene.name,
+ timeSignature: data.scenesSettings.timeSignature,
+ faderParams,
+ });
+
+ track.lane = lane;
+ });
+
+ offset += sceneBars;
+
+ ablScenes.push(ablScene);
+ });
+
+ tracks.sort((a, b) =>
+ a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
+ );
+
+ if (exporterParams.exportAllPadsWithSamples) {
+ for (const group in pads) {
+ pads[group].forEach((pad, index) => {
+ if (pad.soundId <= 0) {
+ return;
+ }
+
+ const padCode = `${group}${index}` as PadCode;
+ if (tracks.some((t) => t.padCode === padCode)) {
+ return;
+ }
+
+ const sound = data.sounds.find((s) => s.id === pad.soundId);
+ const faderParams = data.settings.groupFaderParams[pad.group];
+
+ tracks.push({
+ ...omit(pad, ['file', 'rawData']),
+ soundId: pad.soundId,
+ padCode,
+ name: sound?.meta?.name || padCode,
+ volume: pad.volume / 200,
+ sampleName: getSampleName(sound?.meta?.name, pad.soundId),
+ sampleChannels: sound?.meta?.channels || 0,
+ sampleRate: sound?.meta?.samplerate || 0,
+ sampleRootNote: pad.rootNote,
+ samplePitch: sound?.meta?.['sound.pitch'] ?? 0,
+ bpm: data.settings.bpm,
+ drumRack: false,
+ tracks: [],
+ faderParams,
+ timeSignature: data.scenesSettings.timeSignature,
+ });
+ });
+ }
+
+ tracks.sort((a, b) =>
+ a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
+ );
+ }
+
+ // Helper function to create a drum rack track for a specific group
+ const createDrumRackTrack = (group: 'a' | 'b' | 'c' | 'd'): AblTrack | null => {
+ const groupTracksForDrumRack = tracks.filter((t) => t.group === group);
+ if (groupTracksForDrumRack.length === 0) {
+ return null;
+ }
+
+ const drumTrack: AblTrack = {
+ padCode: `${group}0` as PadCode,
+ group,
+ sampleName: '',
+ sampleChannels: 0,
+ sampleRate: 0,
+ sampleRootNote: 60,
+ samplePitch: 0,
+ bpm: data.settings.bpm,
+ drumRack: true,
+ soundId: 0,
+ name: `Drums ${group.toUpperCase()}`,
+ volume: 1, // 1 means 0dB
+ attack: 0,
+ release: 0,
+ trimLeft: 0,
+ trimRight: 0,
+ pad: 0,
+ lane: undefined,
+ playMode: 'oneshot',
+ pan: 0,
+ pitch: 0,
+ rootNote: 60,
+ timeStretch: 'off',
+ timeStretchBpm: 0,
+ timeStretchBars: 0,
+ soundLength: 0,
+ tracks: groupTracksForDrumRack,
+ inChokeGroup: false,
+ faderParams: data.settings.groupFaderParams[group],
+ timeSignature: data.scenesSettings.timeSignature,
+ };
+
+ // We need to merge notes from all tracks in the group into one track
+ const newClips: Record = {};
+
+ drumTrack.tracks
+ .toSorted((a, b) =>
+ a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
+ )
+ .forEach((track) => {
+ const padNumber = parseInt(track.padCode.slice(1), 10);
+ // EP133 is 3x4 (top→bottom), Ableton Drum Rack is 4x4 (bottom→top)
+ // flip vertically: row 3 → row 0, row 0 → row 3
+ const row = 3 - Math.floor(padNumber / 3);
+ const col = padNumber % 3;
+ const drumPad = 36 + row * 4 + col; // remaping notes starting from C1 (36)
+
+ track.lane?.clips.forEach((clip) => {
+ if (!newClips[clip.sceneName]) {
+ newClips[clip.sceneName] = structuredClone(clip);
+ newClips[clip.sceneName].notes = [];
+ }
+
+ newClips[clip.sceneName].notes = [
+ ...newClips[clip.sceneName].notes,
+ ...clip.notes.map((n) => ({
+ ...n,
+ note: drumPad,
+ })),
+ ];
+ });
+ });
+
+ drumTrack.lane = {
+ padCode: `${group}0` as PadCode,
+ clips: Object.values(newClips),
+ };
+
+ return drumTrack;
+ };
+
+ // Process drum racks for each group that has the option enabled
+ const drumRackTracks: AblTrack[] = [];
+ const groupsToProcess: Array<{ group: 'a' | 'b' | 'c' | 'd'; enabled: boolean }> = [
+ { group: 'a', enabled: exporterParams.drumRackGroupA || false },
+ { group: 'b', enabled: exporterParams.drumRackGroupB || false },
+ { group: 'c', enabled: exporterParams.drumRackGroupC || false },
+ { group: 'd', enabled: exporterParams.drumRackGroupD || false },
+ ];
+
+ for (const { group, enabled } of groupsToProcess) {
+ if (enabled) {
+ const drumTrack = createDrumRackTrack(group);
+ if (drumTrack) {
+ drumRackTracks.push(drumTrack);
+ // Remove tracks from this group from the main tracks array
+ tracks = tracks.filter((t) => t.group !== group);
+ }
+ }
+ }
+
+ // Insert drum rack tracks at the beginning, maintaining group order (A, B, C, D)
+ tracks.unshift(...drumRackTracks);
+
+ Sentry.setContext(`abletonData`, {
+ tracks,
+ scenes: ablScenes,
+ });
+
+ return {
+ tracks,
+ scenes: ablScenes,
+ } as AblData;
+}
+
+export default abletonTransformer;
diff --git a/src/lib/transformers/dawProject.ts b/src/lib/transformers/dawProject.ts
new file mode 100644
index 0000000..86d9102
--- /dev/null
+++ b/src/lib/transformers/dawProject.ts
@@ -0,0 +1,290 @@
+import * as Sentry from '@sentry/react';
+import { omit } from 'lodash';
+import {
+ ExporterParams,
+ Note,
+ Pad,
+ PadCode,
+ ProjectRawData,
+ TimeSignature,
+} from '../../types/types';
+import { getSampleName } from '../exporters/utils';
+import { findPad, findSoundByPad, findSoundIdByPad } from '../utils';
+
+export type DawData = {
+ tracks: DawTrack[];
+ lanes: DawLane[];
+ scenes: DawScene[];
+};
+
+export type DawTrack = Omit & {
+ padCode: PadCode;
+ group: string;
+ sampleName: string;
+ sampleChannels: number;
+ sampleRate: number;
+ bpm: number;
+};
+
+export type DawLane = {
+ padCode: PadCode;
+ group?: string;
+ clips: DawClip[];
+};
+
+export type DawClip = {
+ notes: Note[];
+ bars: number;
+ offset: number;
+ sceneBars: number;
+ sceneIndex: number;
+ sceneName: string;
+ sceneTimeSignature: TimeSignature;
+};
+
+export type DawClipSlot = {
+ clip: DawClip[];
+ track: DawTrack;
+};
+
+export type DawScene = {
+ name: string;
+ clipSlot: DawClipSlot[];
+};
+
+function dawProjectTransformer(data: ProjectRawData, exporterParams: ExporterParams) {
+ const { pads, scenes } = data;
+ const customSceneNames = exporterParams.customSceneNames || {};
+ const dawScenes: DawScene[] = [];
+ let lanes: DawLane[] = [];
+ let tracks: DawTrack[] = [];
+ let offset = 0;
+
+ scenes.forEach((scene, sceneIndex) => {
+ const sceneBars = Math.max(...scene.patterns.map((p) => p.bars));
+ const dawScene: DawScene = {
+ name: customSceneNames[scene.name] || scene.name,
+ clipSlot: [],
+ };
+
+ for (const pattern of scene.patterns) {
+ let track = tracks.find((c) => c.padCode === pattern.pad);
+
+ if (!track) {
+ const soundId = findSoundIdByPad(pattern.pad, pads) || 0;
+ const sound = findSoundByPad(pattern.pad, pads, data.sounds);
+ const pad = findPad(pattern.pad, pads);
+
+ if (!pad) {
+ throw new Error(`Could not find pad for ${pattern.pad}`);
+ }
+
+ track = {
+ ...omit(pad, ['file', 'rawData']),
+ soundId,
+ padCode: pattern.pad,
+ name: sound?.meta?.name || pattern.pad,
+ volume: pad.volume * (2 / 200),
+ sampleName: getSampleName(sound?.meta?.name, soundId),
+ sampleChannels: sound?.meta?.channels || 0,
+ sampleRate: sound?.meta?.samplerate || 0,
+ bpm: data.settings.bpm,
+ };
+
+ tracks.push(track);
+ }
+
+ let lane = lanes.find((c) => c.padCode === pattern.pad);
+
+ if (!lane) {
+ lane = {
+ padCode: pattern.pad,
+ group: track.group,
+ clips: [],
+ };
+
+ lanes.push(lane);
+ }
+
+ lane.clips.push({
+ offset,
+ notes: pattern.notes,
+ bars: pattern.bars,
+ sceneBars,
+ sceneIndex,
+ sceneName: customSceneNames[scene.name] || scene.name,
+ sceneTimeSignature: data.scenesSettings.timeSignature,
+ });
+
+ dawScene.clipSlot.push({
+ clip: lane.clips,
+ track: track,
+ });
+ }
+
+ offset += sceneBars;
+
+ dawScenes.push(dawScene);
+ });
+
+ if (exporterParams.exportAllPadsWithSamples) {
+ for (const group in pads) {
+ pads[group].forEach((pad, index) => {
+ if (pad.soundId <= 0) {
+ return;
+ }
+
+ const padCode = `${group}${index}` as PadCode;
+ if (tracks.some((t) => t.padCode === padCode)) {
+ return;
+ }
+
+ const sound = data.sounds.find((s) => s.id === pad.soundId);
+
+ tracks.push({
+ ...omit(pad, ['file', 'rawData']),
+ soundId: pad.soundId,
+ padCode,
+ name: sound?.meta?.name || padCode,
+ volume: pad.volume * (2 / 200),
+ sampleName: getSampleName(sound?.meta?.name, pad.soundId),
+ sampleChannels: sound?.meta?.channels || 0,
+ sampleRate: sound?.meta?.samplerate || 0,
+ bpm: data.settings.bpm,
+ });
+ });
+ }
+
+ tracks.sort((a, b) =>
+ a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
+ );
+ }
+
+ // Helper function to create a drum rack track and lane for a specific group
+ const createDrumRackForGroup = (
+ group: 'a' | 'b' | 'c' | 'd',
+ ): {
+ drumTrack: DawTrack;
+ drumLane: DawLane;
+ } | null => {
+ const groupTracks = tracks.filter((t) => t.group === group);
+ const groupLanes = lanes.filter((l) => l.group === group);
+
+ if (groupTracks.length === 0) {
+ return null;
+ }
+
+ const drumTrack: DawTrack = {
+ padCode: `${group}0` as PadCode,
+ group,
+ sampleName: '',
+ sampleChannels: 0,
+ sampleRate: 0,
+ bpm: data.settings.bpm,
+ soundId: 0,
+ name: `Drums ${group.toUpperCase()}`,
+ volume: 1,
+ attack: 0,
+ release: 0,
+ trimLeft: 0,
+ trimRight: 0,
+ pad: 0,
+ playMode: 'oneshot',
+ pan: 0,
+ pitch: 0,
+ rootNote: 60,
+ timeStretch: 'off',
+ timeStretchBpm: 0,
+ timeStretchBars: 0,
+ soundLength: 0,
+ inChokeGroup: false,
+ };
+
+ const drumLane: DawLane = {
+ padCode: `${group}0` as PadCode,
+ group,
+ clips: [],
+ };
+
+ const newClips: Record = {};
+
+ groupLanes
+ .toSorted((a, b) => a.padCode.localeCompare(b.padCode))
+ .forEach((lane, idx) => {
+ lane.clips.forEach((clip) => {
+ if (!newClips[clip.sceneName]) {
+ newClips[clip.sceneName] = structuredClone(clip);
+ newClips[clip.sceneName].notes = []; // resetting notes, they will be added below with new mapping
+ }
+
+ newClips[clip.sceneName].notes = [
+ ...newClips[clip.sceneName].notes,
+ ...clip.notes.map((n) => ({
+ ...n,
+ note: 36 + idx,
+ })), // remaping notes starting from C1 (36)
+ ];
+ });
+ });
+
+ drumLane.clips = Object.values(newClips);
+
+ return { drumTrack, drumLane };
+ };
+
+ // Process drum racks for each group that has the option enabled
+ const drumRackData: Array<{ drumTrack: DawTrack; drumLane: DawLane }> = [];
+ const groupsToProcess: Array<{ group: 'a' | 'b' | 'c' | 'd'; enabled: boolean }> = [
+ { group: 'a', enabled: exporterParams.drumRackGroupA || false },
+ { group: 'b', enabled: exporterParams.drumRackGroupB || false },
+ { group: 'c', enabled: exporterParams.drumRackGroupC || false },
+ { group: 'd', enabled: exporterParams.drumRackGroupD || false },
+ ];
+
+ for (const { group, enabled } of groupsToProcess) {
+ if (enabled) {
+ const drumRack = createDrumRackForGroup(group);
+ if (drumRack) {
+ drumRackData.push(drumRack);
+ // Remove tracks and lanes from this group from the main arrays
+ tracks = tracks.filter((t) => t.group !== group);
+ lanes = lanes.filter((l) => l.group !== group);
+ }
+ }
+ }
+
+ // Insert drum rack tracks and lanes at the beginning, maintaining group order (A, B, C, D)
+ tracks.unshift(...drumRackData.map((d) => d.drumTrack));
+ lanes.unshift(...drumRackData.map((d) => d.drumLane));
+
+ // Update scenes to include drum rack clip slots
+ const groupsConvertedToDrumRack = drumRackData.map((d) => d.drumTrack.group);
+ dawScenes.forEach((scene) => {
+ // Remove clip slots for groups that were converted to drum racks
+ scene.clipSlot = scene.clipSlot.filter(
+ (cs) => !groupsConvertedToDrumRack.includes(cs.track.group),
+ );
+
+ // Add drum rack clip slots at the beginning, maintaining group order
+ for (const { drumTrack, drumLane } of drumRackData) {
+ scene.clipSlot.unshift({
+ clip: drumLane.clips.filter((c) => c.sceneName === scene.name),
+ track: drumTrack,
+ });
+ }
+ });
+
+ Sentry.setContext(`dawprojectData`, {
+ tracks,
+ lanes,
+ scenes: dawScenes,
+ });
+
+ return {
+ tracks,
+ lanes,
+ scenes: dawScenes,
+ } as DawData;
+}
+
+export default dawProjectTransformer;
diff --git a/src/lib/transformers/midi.ts b/src/lib/transformers/midi.ts
new file mode 100644
index 0000000..11336eb
--- /dev/null
+++ b/src/lib/transformers/midi.ts
@@ -0,0 +1,160 @@
+import * as Sentry from '@sentry/react';
+import { ExporterParams, Note, ProjectRawData } from '../../types/types';
+import { getQuarterNotesPerBar, TICKS_PER_BEAT } from '../exporters/utils';
+import { findSoundByPad } from '../utils';
+
+export type MidiData = {
+ tracks: MidiTrack[];
+};
+
+export type MidiTrack = {
+ name: string;
+ group: string;
+ padCode: string;
+ notes: Note[];
+};
+
+function midiTransformer(data: ProjectRawData, exporterParams: ExporterParams) {
+ const { pads, scenes } = data;
+ let midiTracks: MidiTrack[] = [];
+ let offset = 0;
+
+ const barLength =
+ getQuarterNotesPerBar(
+ data.scenesSettings.timeSignature.numerator,
+ data.scenesSettings.timeSignature.denominator,
+ ) * TICKS_PER_BEAT;
+
+ scenes.forEach((scene) => {
+ const sceneMaxBars = Math.max(...scene.patterns.map((p) => p.bars));
+ scene.patterns.forEach((pattern) => {
+ let track = midiTracks.find((t) => t.padCode === pattern.pad);
+ if (!track) {
+ const sound = findSoundByPad(pattern.pad, pads, data.sounds);
+
+ track = {
+ name: sound?.meta.name || pattern.pad,
+ padCode: pattern.pad,
+ group: pattern.group,
+ notes: [],
+ };
+
+ midiTracks.push(track);
+ }
+
+ track.notes.push(
+ ...pattern.notes.map((note) => ({
+ ...note,
+ position: note.position + offset * barLength,
+ })),
+ );
+
+ // copy pattern for the rest of the scene
+ if (pattern.bars < sceneMaxBars) {
+ for (let ofs = offset + pattern.bars; ofs < offset + sceneMaxBars; ofs++) {
+ track.notes.push(
+ ...pattern.notes.map((note) => ({
+ ...note,
+ position: note.position + ofs * barLength,
+ })),
+ );
+ }
+ }
+ });
+
+ offset += sceneMaxBars;
+ });
+
+ if (exporterParams.exportAllPadsWithSamples) {
+ for (const group in pads) {
+ pads[group].forEach((pad, index) => {
+ if (pad.soundId <= 0) {
+ return;
+ }
+
+ const padCode = `${group}${index}`;
+ if (midiTracks.some((t) => t.padCode === padCode)) {
+ return;
+ }
+
+ const sound = data.sounds.find((s) => s.id === pad.soundId);
+
+ midiTracks.push({
+ name: sound?.meta?.name || padCode,
+ padCode,
+ group,
+ notes: [],
+ });
+ });
+ }
+
+ midiTracks.sort((a, b) =>
+ a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
+ );
+ }
+
+ // Helper function to create a drum rack track for a specific group
+ const createDrumRackTrack = (group: 'a' | 'b' | 'c' | 'd'): MidiTrack | null => {
+ const groupTracks = midiTracks.filter((t) => t.group === group);
+ if (groupTracks.length === 0) {
+ return null;
+ }
+
+ const mergedNotes: Note[] = [];
+
+ groupTracks
+ .toSorted((a, b) =>
+ a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
+ )
+ .forEach((track, idx) => {
+ mergedNotes.push(
+ ...track.notes.map((note) => ({
+ ...note,
+ note: 36 + idx,
+ })),
+ );
+ });
+
+ const drumTrack: MidiTrack = {
+ name: `Drums ${group.toUpperCase()}`,
+ group,
+ padCode: `${group}0`,
+ notes: mergedNotes,
+ };
+
+ return drumTrack;
+ };
+
+ // Process drum racks for each group that has the option enabled
+ const drumRackTracks: MidiTrack[] = [];
+ const groupsToProcess: Array<{ group: 'a' | 'b' | 'c' | 'd'; enabled: boolean }> = [
+ { group: 'a', enabled: exporterParams.drumRackGroupA || false },
+ { group: 'b', enabled: exporterParams.drumRackGroupB || false },
+ { group: 'c', enabled: exporterParams.drumRackGroupC || false },
+ { group: 'd', enabled: exporterParams.drumRackGroupD || false },
+ ];
+
+ for (const { group, enabled } of groupsToProcess) {
+ if (enabled) {
+ const drumTrack = createDrumRackTrack(group);
+ if (drumTrack) {
+ drumRackTracks.push(drumTrack);
+ // Remove tracks from this group from the main tracks array
+ midiTracks = midiTracks.filter((t) => t.group !== group);
+ }
+ }
+ }
+
+ // Insert drum rack tracks at the beginning, maintaining group order (A, B, C, D)
+ midiTracks.unshift(...drumRackTracks);
+
+ Sentry.setContext('midiData', {
+ tracks: midiTracks,
+ });
+
+ return {
+ tracks: midiTracks,
+ } as MidiData;
+}
+
+export default midiTransformer;
diff --git a/src/lib/transformers/reaper.ts b/src/lib/transformers/reaper.ts
new file mode 100644
index 0000000..d672882
--- /dev/null
+++ b/src/lib/transformers/reaper.ts
@@ -0,0 +1,132 @@
+import * as Sentry from '@sentry/react';
+import { omit } from 'lodash';
+import {
+ ExporterParams,
+ Note,
+ Pad,
+ PadCode,
+ ProjectRawData,
+ TimeSignature,
+} from '../../types/types';
+import { getSampleName } from '../exporters/utils';
+import { findPad, findSoundByPad, findSoundIdByPad } from '../utils';
+
+export type RprData = {
+ tracks: RprTrack[];
+};
+
+export type RprTrack = Omit & {
+ padCode: PadCode;
+ group: string;
+ sampleName: string;
+ sampleChannels: number;
+ sampleRate: number;
+ bpm: number;
+ items: RprTrackItem[];
+ timeSignature: TimeSignature;
+};
+
+export type RprTrackItem = {
+ notes: Note[];
+ bars: number;
+ offset: number;
+ sceneBars: number;
+ sceneIndex: number;
+ sceneName: string;
+};
+
+export function reaperTransform(data: ProjectRawData, exporterParams: ExporterParams) {
+ const { pads, scenes } = data;
+ const customSceneNames = exporterParams.customSceneNames || {};
+ const tracks: RprTrack[] = [];
+ let offset = 0;
+
+ scenes.forEach((scene, sceneIndex) => {
+ const sceneBars = Math.max(...scene.patterns.map((p) => p.bars));
+ scene.patterns.forEach((pattern) => {
+ let track = tracks.find((c) => c.padCode === pattern.pad);
+
+ if (!track) {
+ const soundId = findSoundIdByPad(pattern.pad, pads) || 0;
+ const sound = findSoundByPad(pattern.pad, pads, data.sounds);
+ const pad = findPad(pattern.pad, pads);
+
+ if (!pad) {
+ throw new Error(`Could not find pad for ${pattern.pad}`);
+ }
+
+ track = {
+ ...omit(pad, ['file', 'rawData']),
+ soundId,
+ padCode: pattern.pad,
+ name: sound?.meta?.name || pattern.pad,
+ volume: pad.volume * (2 / 200),
+ sampleName: getSampleName(sound?.meta?.name, soundId),
+ sampleChannels: sound?.meta?.channels || 0,
+ sampleRate: sound?.meta?.samplerate || 0,
+ bpm: data.settings.bpm,
+ timeSignature: data.scenesSettings.timeSignature,
+ items: [],
+ };
+
+ tracks.push(track);
+ }
+
+ track.items.push({
+ offset,
+ notes: pattern.notes,
+ bars: pattern.bars,
+ sceneBars,
+ sceneIndex,
+ sceneName: customSceneNames[scene.name] || scene.name,
+ });
+ });
+
+ offset += sceneBars;
+ });
+
+ if (exporterParams.exportAllPadsWithSamples) {
+ for (const group in pads) {
+ pads[group].forEach((pad, index) => {
+ if (pad.soundId <= 0) {
+ return;
+ }
+
+ const padCode = `${group}${index}` as PadCode;
+ if (tracks.some((t) => t.padCode === padCode)) {
+ return;
+ }
+
+ const sound = data.sounds.find((s) => s.id === pad.soundId);
+
+ tracks.push({
+ ...omit(pad, ['file', 'rawData']),
+ soundId: pad.soundId,
+ padCode,
+ name: sound?.meta?.name || padCode,
+ volume: pad.volume * (2 / 200),
+ sampleName: getSampleName(sound?.meta?.name, pad.soundId),
+ sampleChannels: sound?.meta?.channels || 0,
+ sampleRate: sound?.meta?.samplerate || 0,
+ bpm: data.settings.bpm,
+ timeSignature: data.scenesSettings.timeSignature,
+ items: [],
+ });
+ });
+ }
+
+ tracks.sort((a, b) =>
+ a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
+ );
+ }
+
+ Sentry.setContext('reaperData', {
+ tracks,
+ });
+
+ return {
+ tracks,
+ } as RprData;
+}
+
+export default reaperTransform;
diff --git a/src/lib/transformers/webView.ts b/src/lib/transformers/webView.ts
new file mode 100644
index 0000000..72373f0
--- /dev/null
+++ b/src/lib/transformers/webView.ts
@@ -0,0 +1,136 @@
+import * as Sentry from '@sentry/react';
+import { Note, Pad, ProjectRawData } from '../../types/types';
+import { getSampleName } from '../exporters/utils';
+import { noteNumberToName } from '../parsers';
+import { findPad, findSoundByPad, findSoundIdByPad } from '../utils';
+
+export type ViewNote = Note & { name: string };
+
+export type ViewPattern = {
+ pad: string;
+ bars: number;
+ soundName: string;
+ notes: ViewNote[];
+ group: string;
+ padNumber: number;
+ midiChannel: number;
+ soundId: number;
+};
+
+export type ViewScene = {
+ name: string;
+ patterns: ViewPattern[];
+ maxBars: number;
+};
+
+export type ViewPad = Pad;
+
+export type ViewData = {
+ pads: Record;
+ scenes: ViewScene[];
+ scenesSettings: ProjectRawData['scenesSettings'];
+};
+
+function webViewTransformer(data: ProjectRawData): ViewData {
+ const { pads, scenes } = data;
+ const newScenes: ViewScene[] = [];
+ const usedPads = new Set();
+
+ if (import.meta.env.DEV) {
+ console.log(data);
+ }
+
+ // construct new scenes array
+ // and collect the used pads
+ scenes.forEach((scene, idx) => {
+ newScenes[idx] = {
+ name: scene.name,
+ maxBars: Math.max(...scene.patterns.map((p) => p.bars), 0),
+ patterns: [],
+ };
+
+ for (const pattern of scene.patterns) {
+ if (pattern.notes.length > 0) {
+ usedPads.add(pattern.pad);
+ }
+ }
+ });
+
+ // also add pads that have samples assigned (even without patterns)
+ for (const group in pads) {
+ pads[group].forEach((pad, index) => {
+ if (pad.soundId > 0) {
+ usedPads.add(`${group}${index}`);
+ }
+ });
+ }
+
+ newScenes.forEach((scene, idx) => {
+ // make sure each scene have the same tracks/pads
+ usedPads.forEach((pad) => {
+ const patternByPad = scene.patterns.find((p) => p.pad === pad);
+ if (!patternByPad) {
+ const group = pad[0];
+ const padNumber = parseInt(pad.slice(1), 10);
+ const soundId = findSoundIdByPad(pad, pads) || 0;
+ const sound = findSoundByPad(pad, pads, data.sounds);
+ const padObject = findPad(pad, pads);
+
+ scene.patterns.push({
+ pad,
+ notes: [],
+ bars: 0,
+ group,
+ padNumber,
+ soundName: getSampleName(sound?.meta?.name, soundId, false),
+ midiChannel: padObject?.midiChannel || 0,
+ soundId,
+ });
+ }
+ });
+
+ // copy notes to exiting patterns
+ scenes[idx].patterns.forEach((pattern) => {
+ const patternInScene = scene.patterns.find((p) => p.pad === pattern.pad);
+ if (!patternInScene) {
+ return;
+ }
+
+ patternInScene.notes = pattern.notes
+ .map((note) => ({
+ ...note,
+ name: noteNumberToName(note.note),
+ }))
+ .reduce((acc, note) => {
+ const existingNoteInThisPosition = acc.find((n) => n.position === note.position);
+
+ if (existingNoteInThisPosition) {
+ existingNoteInThisPosition.name = `${existingNoteInThisPosition.name},${note.name}`;
+ return acc;
+ }
+
+ acc.push({ ...note });
+ return acc;
+ }, [] as ViewNote[]);
+ patternInScene.bars = pattern.bars;
+ });
+
+ scene.patterns.sort((a, b) =>
+ a.pad.localeCompare(b.pad, undefined, { numeric: true, sensitivity: 'base' }),
+ );
+ });
+
+ Sentry.setContext('webViewData', {
+ pads,
+ scenes: newScenes,
+ scenesSettings: data.scenesSettings,
+ });
+
+ return {
+ pads,
+ scenes: newScenes,
+ scenesSettings: data.scenesSettings,
+ };
+}
+
+export default webViewTransformer;
diff --git a/src/lib/untar.ts b/src/lib/untar.ts
new file mode 100644
index 0000000..1456691
--- /dev/null
+++ b/src/lib/untar.ts
@@ -0,0 +1,224 @@
+class UntarError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'UntarError';
+ }
+}
+
+interface TarHeader {
+ name: string;
+ mode: number;
+ uid: number;
+ gid: number;
+ size: number;
+ mtime: number;
+ checksum: number;
+ type: string;
+ linkname: string;
+ magic: string;
+ version: string;
+ uname: string;
+ gname: string;
+ devmajor: number;
+ devminor: number;
+ prefix: string;
+ fullName: string;
+}
+
+export class TarFile {
+ name: string;
+ size: number;
+ data: Uint8Array | null;
+ type: string;
+ mode: number;
+ uid: number;
+ gid: number;
+ mtime: Date;
+ linkname: string;
+ uname: string;
+ gname: string;
+
+ constructor(name: string, size: number, data: Uint8Array | null, type: string = 'file') {
+ this.name = name;
+ this.size = size;
+ this.data = data;
+ this.type = type;
+ this.mode = 0;
+ this.uid = 0;
+ this.gid = 0;
+ this.mtime = new Date();
+ this.linkname = '';
+ this.uname = '';
+ this.gname = '';
+ }
+}
+
+function parseTarHeader(buffer: ArrayBuffer, offset: number): TarHeader | null {
+ const view = new Uint8Array(buffer, offset, 512);
+
+ if (view.every((byte) => byte === 0)) {
+ return null;
+ }
+
+ function readString(start: number, length: number): string {
+ let str = '';
+ for (let i = start; i < start + length && view[i] !== 0; i++) {
+ str += String.fromCharCode(view[i]);
+ }
+ return str;
+ }
+
+ function readOctal(start: number, length: number): number {
+ const str = readString(start, length).trim();
+ return str ? parseInt(str, 8) : 0;
+ }
+
+ const header: TarHeader = {
+ name: readString(0, 100),
+ mode: readOctal(100, 8),
+ uid: readOctal(108, 8),
+ gid: readOctal(116, 8),
+ size: readOctal(124, 12),
+ mtime: readOctal(136, 12),
+ checksum: readOctal(148, 8),
+ type: String.fromCharCode(view[156]) || '0',
+ linkname: readString(157, 100),
+ magic: readString(257, 6),
+ version: readString(263, 2),
+ uname: readString(265, 32),
+ gname: readString(297, 32),
+ devmajor: readOctal(329, 8),
+ devminor: readOctal(337, 8),
+ prefix: readString(345, 155),
+ fullName: '',
+ };
+
+ let fullName = header.name;
+ if (header.prefix) {
+ fullName = `${header.prefix}/${header.name}`;
+ }
+ header.fullName = fullName;
+
+ return header;
+}
+
+function validateChecksum(buffer: ArrayBuffer, offset: number, expectedChecksum: number): boolean {
+ const view = new Uint8Array(buffer, offset, 512);
+ let sum = 0;
+
+ for (let i = 0; i < 512; i++) {
+ if (i >= 148 && i < 156) {
+ sum += 32;
+ } else {
+ sum += view[i];
+ }
+ }
+
+ return sum === expectedChecksum;
+}
+
+export async function untar(file: File | ArrayBuffer | Uint8Array): Promise {
+ if (!(file instanceof File) && !(file instanceof ArrayBuffer) && !(file instanceof Uint8Array)) {
+ throw new UntarError('Input must be a File object or an ArrayBuffer or Uint8Array');
+ }
+
+ let buffer: ArrayBuffer;
+
+ if (file instanceof File) {
+ buffer = await file.arrayBuffer();
+ } else if (file instanceof Uint8Array) {
+ buffer = new ArrayBuffer(file.length);
+ const view = new Uint8Array(buffer);
+ view.set(file);
+ } else {
+ buffer = file;
+ }
+
+ const files: TarFile[] = [];
+ let offset = 0;
+
+ while (offset < buffer.byteLength) {
+ const header = parseTarHeader(buffer, offset);
+
+ if (!header) {
+ break;
+ }
+
+ if (header.checksum > 0) {
+ if (!validateChecksum(buffer, offset, header.checksum)) {
+ throw new UntarError(`Invalid checksum for file: ${header.fullName}`);
+ }
+ }
+
+ offset += 512;
+
+ let fileData: Uint8Array | null = null;
+ if (header.size > 0) {
+ fileData = new Uint8Array(buffer, offset, header.size);
+ }
+
+ let fileType = 'file';
+ switch (header.type) {
+ case '0':
+ case '\0':
+ fileType = 'file';
+ break;
+ case '1':
+ fileType = 'hard-link';
+ break;
+ case '2':
+ fileType = 'symbolic-link';
+ break;
+ case '3':
+ fileType = 'character-device';
+ break;
+ case '4':
+ fileType = 'block-device';
+ break;
+ case '5':
+ fileType = 'directory';
+ break;
+ case '6':
+ fileType = 'fifo';
+ break;
+ case '7':
+ fileType = 'contiguous-file';
+ break;
+ default:
+ fileType = 'unknown';
+ }
+
+ const tarFile = new TarFile(header.fullName, header.size, fileData, fileType);
+
+ tarFile.mode = header.mode;
+ tarFile.uid = header.uid;
+ tarFile.gid = header.gid;
+ tarFile.mtime = new Date(header.mtime * 1000);
+ tarFile.linkname = header.linkname;
+ tarFile.uname = header.uname;
+ tarFile.gname = header.gname;
+
+ files.push(tarFile);
+
+ const paddedSize = Math.ceil(header.size / 512) * 512;
+ offset += paddedSize;
+ }
+
+ return files;
+}
+
+export function arrayBufferToText(data: Uint8Array, encoding: string = 'utf-8'): string {
+ const decoder = new TextDecoder(encoding);
+ return decoder.decode(data);
+}
+
+export function createBlobUrl(
+ tarFile: TarFile,
+ mimeType: string = 'application/octet-stream',
+): string {
+ if (!tarFile.data) {
+ throw new Error('Tar file data is null');
+ }
+ const blob = new Blob([new Uint8Array(tarFile.data)], { type: mimeType });
+ return URL.createObjectURL(blob);
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bb18745
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,98 @@
+import { Pad, Sound } from '../types/types';
+import { PAD_DISPLAY_FROM_INDEX } from './constants';
+
+export function findPad(pad: string, pads: Record) {
+ const group = pad[0];
+ const padNumber = parseInt(pad.slice(1), 10);
+ const padData = pads[group][padNumber];
+
+ return padData;
+}
+
+export function findSoundIdByPad(pad: string, pads: Record) {
+ const padData = findPad(pad, pads);
+
+ if (!padData) {
+ return null;
+ }
+
+ if (padData.soundId === 0) {
+ return null;
+ }
+
+ return padData.soundId;
+}
+
+export function findSoundByPad(pad: string, pads: Record, sounds: Sound[]) {
+ const soundId = findSoundIdByPad(pad, pads);
+
+ return sounds.find((s) => s.id === soundId) || null;
+}
+
+export function audioFormatAsBitDepth(s: string) {
+ switch (s) {
+ case 's16':
+ return 16;
+ case 's24':
+ return 24;
+ case 'float':
+ return 32;
+ default:
+ throw new Error('unknown bit depth');
+ }
+}
+
+export function calculateSoundLength(sound: Sound) {
+ const sampleRate = sound.meta?.samplerate;
+ const channels = sound.meta?.channels;
+ const fileSize = sound.fileNode?.fileSize;
+
+ if (!sampleRate || !channels || !fileSize) {
+ return 0;
+ }
+
+ return fileSize / 2 / sampleRate / channels;
+}
+
+export class AbortError extends Error {
+ constructor() {
+ super('The operation was aborted.');
+ this.name = 'AbortError';
+ }
+}
+
+export function getPadDisplayName(group: string, index: number): string {
+ const padChar = PAD_DISPLAY_FROM_INDEX[index] ?? '?';
+ return `${group.toUpperCase()} ${padChar}`;
+}
+
+export function hasMultipleNoteVariations(
+ group: string,
+ scenes: { patterns: { pad: string; notes: { note: number }[] }[] }[],
+) {
+ const trackNotes: Record> = {};
+
+ for (const scene of scenes) {
+ for (const pattern of scene.patterns) {
+ if (!pattern.pad.startsWith(group)) {
+ continue;
+ }
+
+ if (!trackNotes[pattern.pad]) {
+ trackNotes[pattern.pad] = new Set();
+ }
+
+ for (const note of pattern.notes) {
+ trackNotes[pattern.pad].add(note.note);
+ }
+ }
+ }
+
+ for (const padCode in trackNotes) {
+ if (trackNotes[padCode].size > 1) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/src/routes/faq/Faq.tsx b/src/routes/faq/Faq.tsx
new file mode 100644
index 0000000..07ad5f0
--- /dev/null
+++ b/src/routes/faq/Faq.tsx
@@ -0,0 +1,127 @@
+import IconArrowLeft from '~/components/icons/arrow-left.svg?react';
+import Button from '~/components/ui/Button';
+
+function Faq() {
+ return (
+
+
+
+
+ Back
+
+
+
+ FAQ
+
+
+
+ Why the resulting project doesn’t sound the same as on the device?
+
+
+ Several reasons. The main one is that the EP devices uses a custom FX processor that is
+ hard to recreate in a DAW one to one. Also, fader automation is not exported yet, so if
+ you used fader automation, the result will differ. Sample stretching algorithms are
+ different as well.
+
+
+ Initially this project was intended to export notes and samples so you could continue
+ working in a DAW. I personally use the EP133 mainly for sketching ideas, not for finishing
+ tracks. Later, many people asked to export with FX applied, so I added FX export as well.
+ Still, the sound will not be exactly the same.
+
+
+
+
+ Please add support for [my DAW here]
+
+ I’d love to. Reverse engineering DAW project formats takes a tremendous amount of time and
+ effort. It’s easier for DAWs that uses text, XML or JSON formats (like REAPER or Ableton).
+ For proprietary binary formats, it’s much more challenging. But if you have a specific DAW
+ in mind, let me know.
+
+
+ If your DAW is not supported natively, you can always export to MIDI. Every DAW supports
+ MIDI today. Also check DAWproject - lots of DAWs supports it.
+
+
+
+
+
+ Can I export audio for individual tracks with this tool?
+
+
+ No. If you're thinking of something like Overbridge - it is not possible, the device
+ cannot stream multichannel audio directly to your DAW. You need to record tracks manually,
+ one by one, muting the others.
+
+
+
+
+
+ I found a bug or the page crashed. What should I do?
+
+
+ PLEASE submit a report. There are several ways to do it:
+
+
+
+
+ I can only fix issues if you report them, so please do. I don’t have a QA team to test the
+ app thoroughly, so your help is very much appreciated.
+
+
+
+
+
+ Can I use this tool without connecting my device?
+
+
+ Yes! You can drop a .pak backup file directly onto the page. The backup file
+ contains all your projects and samples, so you can browse and export them without
+ connecting your device. To create a backup file, use the official EP Sample Tool from
+ Teenage Engineering.
+
+
+ Note: .tar and .ppak project files still require a device
+ connection because they don't include the sample data.
+
+
+
+
+
+ Is this officially approved by Teenage Engineering?
+
+
+ No. This is an open source project made by a fan, not affiliated with Teenage Engineering
+ in any way. Initially it used source code from the official EP SAMPLE TOOL that had been
+ reverse engineered with help from AI, but the SysEx library has since been rewritten.
+
+
+ TE contacted me to ask for a disclaimer in a few places. That’s it. They’ve been chill
+ about this project so far.
+
+
+
+ );
+}
+
+export default Faq;
diff --git a/src/routes/home/AppStateDisplay.tsx b/src/routes/home/AppStateDisplay.tsx
new file mode 100644
index 0000000..5c004fc
--- /dev/null
+++ b/src/routes/home/AppStateDisplay.tsx
@@ -0,0 +1,15 @@
+interface AppStateDisplayProps {
+ title: string;
+ message: string | React.ReactNode;
+}
+
+function AppStateDisplay({ title, message }: AppStateDisplayProps) {
+ return (
+
+ );
+}
+
+export default AppStateDisplay;
diff --git a/src/routes/home/Donate.tsx b/src/routes/home/Donate.tsx
new file mode 100644
index 0000000..d7f611f
--- /dev/null
+++ b/src/routes/home/Donate.tsx
@@ -0,0 +1,98 @@
+import clsx from 'clsx';
+import { useState } from 'preact/hooks';
+import Button from '../../components/ui/Button';
+import Dialog from '../../components/ui/Dialog';
+
+type Wallet = {
+ currency: string;
+ address: string;
+ label: string;
+};
+
+const WALLETS: Wallet[] = [
+ {
+ currency: 'BTC',
+ address: 'bc1qclpnhdlvk607llzuux609nezay8fk3vyhphgsr',
+ label: 'BTC',
+ },
+ {
+ currency: 'ETH',
+ address: '0x3f8072285e1C3a2F651bF98A5A839f6cFf8C4047',
+ label: 'ETH',
+ },
+ {
+ currency: 'USDT TRC20',
+ address: 'TDqf9eJPGWPZafP3ASS37r2cWvkKsp1aL8',
+ label: 'USDT',
+ },
+];
+
+function Donate({ className = '' }: { className?: string }) {
+ const [open, setOpen] = useState(false);
+ const [copiedAddress, setCopiedAddress] = useState(null);
+
+ const copyToClipboard = async (address: string, label: string) => {
+ try {
+ await navigator.clipboard.writeText(address);
+ setCopiedAddress(label);
+ setTimeout(() => setCopiedAddress(null), 2000);
+ } catch (err) {
+ console.error('Failed to copy address:', err);
+ }
+ };
+
+ return (
+ <>
+ setOpen(true)}
+ >
+ Donate!
+
+ setOpen(false)}>
+
+
Donate
+
If you like this tool, please consider donating to support the development.
+
Thank you!
+
+
+
Crypto
+
+ {WALLETS.map((wallet) => (
+
+ {wallet.currency}:
+ copyToClipboard(wallet.address, wallet.label)}
+ className="bg-gray-100 hover:bg-gray-200 px-3 py-1 rounded text-sm font-mono transition-colors cursor-pointer select-all"
+ title="Copy address"
+ >
+ {wallet.address}
+
+ {copiedAddress === wallet.label && (
+ ✓ Copied!
+ )}
+
+ ))}
+
+
+
+
Ko-fi
+
+
+
+
+
+
+ setOpen(false)} variant="secondary">
+ Close
+
+
+
+
+ >
+ );
+}
+
+export default Donate;
diff --git a/src/routes/home/EPToolkitDialog.tsx b/src/routes/home/EPToolkitDialog.tsx
new file mode 100644
index 0000000..9a54daa
--- /dev/null
+++ b/src/routes/home/EPToolkitDialog.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useState } from 'preact/hooks';
+import Button from '~/components/ui/Button';
+import Dialog from '../../components/ui/Dialog';
+
+function EPToolkitDialog() {
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ if (!localStorage.getItem('eptoolkit-dialog-shown')) {
+ setOpen(true);
+ }
+ }, []);
+
+ const handleClose = () => {
+ localStorage.setItem('eptoolkit-dialog-shown', 'true');
+ setOpen(false);
+ };
+
+ return (
+
+
+
+
+
+
EP Toolkit is here!
+
+
+
+ Hey! I just released EP Toolkit - an all-in-one toolkit for EP series
+ devices.
+
+
A standalone cross-platform app for all your needs:
+
+ Export projects to popular DAW formats
+
+ Manage samples: batch copy from local drive, copy & paste between slots, decode to
+ mono, trim silence, normalize, batch remove
+
+ Create and explore backups
+ No browser required!
+
+
+
+ https://eptoolkit.ep133-to-daw.cc
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+export default EPToolkitDialog;
diff --git a/src/routes/home/Export/ExportOptions.tsx b/src/routes/home/Export/ExportOptions.tsx
new file mode 100644
index 0000000..1301bc9
--- /dev/null
+++ b/src/routes/home/Export/ExportOptions.tsx
@@ -0,0 +1,227 @@
+import { useAtomValue } from 'jotai';
+import { useMemo } from 'preact/hooks';
+import { useFormContext, useWatch } from 'react-hook-form';
+import { projectIdAtom } from '~/atoms/project';
+import CheckboxField from '~/components/form/CheckboxField';
+import useProject from '~/hooks/useProject';
+import { hasMultipleNoteVariations } from '~/lib/utils';
+import { ExportFormValues } from './exportFormSchema';
+
+function ExportOptions({ disabled = false }: { disabled?: boolean }) {
+ const { control } = useFormContext();
+ const format = useWatch({ control, name: 'format' });
+ const includeArchivedSamples = useWatch({ control, name: 'includeArchivedSamples' });
+ const drumRackGroupA = useWatch({ control, name: 'drumRackGroupA' });
+ const drumRackGroupB = useWatch({ control, name: 'drumRackGroupB' });
+ const drumRackGroupC = useWatch({ control, name: 'drumRackGroupC' });
+ const drumRackGroupD = useWatch({ control, name: 'drumRackGroupD' });
+
+ const projectId = useAtomValue(projectIdAtom);
+ const { data: projectData } = useProject(projectId);
+
+ const notesVariationWarnings = useMemo(() => {
+ if (format === 'reaper') {
+ return [];
+ }
+
+ const result: string[] = [];
+ const scenes = projectData?.scenes ?? [];
+
+ if (drumRackGroupA && hasMultipleNoteVariations('a', scenes)) {
+ result.push('Group A');
+ }
+ if (drumRackGroupB && hasMultipleNoteVariations('b', scenes)) {
+ result.push('Group B');
+ }
+ if (drumRackGroupC && hasMultipleNoteVariations('c', scenes)) {
+ result.push('Group C');
+ }
+ if (drumRackGroupD && hasMultipleNoteVariations('d', scenes)) {
+ result.push('Group D');
+ }
+
+ return result;
+ }, [format, drumRackGroupA, drumRackGroupB, drumRackGroupC, drumRackGroupD, projectData]);
+
+ return (
+
+
Options
+ {format === 'dawproject' && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+
+ {format === 'ableton' && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {format === 'reaper' && (
+ <>
+
+
+
+ >
+ )}
+
+ {format === 'midi' && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ {notesVariationWarnings.length > 0 && (
+
+ Warning: The following groups contain tracks with multiple note
+ variations. Merging them into a single track or Drum Rack will flatten all notes to a
+ single pitch: {notesVariationWarnings.join(', ')}
+
+ )}
+
+ );
+}
+
+export default ExportOptions;
diff --git a/src/routes/home/Export/ExportProject.tsx b/src/routes/home/Export/ExportProject.tsx
new file mode 100644
index 0000000..a2565bf
--- /dev/null
+++ b/src/routes/home/Export/ExportProject.tsx
@@ -0,0 +1,32 @@
+import { useState } from 'preact/hooks';
+import IconArrowDialog from '~/components/icons/arrow-dialog.svg?react';
+import Button from '~/components/ui/Button';
+import { APP_STATES, useAppState } from '~/hooks/useAppState';
+import ExportProjectDialog from './ExportProjectDialog';
+
+function ExportProject() {
+ const [open, setOpen] = useState(false);
+ const appState = useAppState();
+
+ return (
+ <>
+
+ setOpen(true)}
+ disabled={!appState.includes(APP_STATES.CAN_EXPORT_PROJECT)}
+ size="sm"
+ >
+
+
+ Export
+
+
+
+
+ {open && }
+ >
+ );
+}
+
+export default ExportProject;
diff --git a/src/routes/home/Export/ExportProjectDialog.tsx b/src/routes/home/Export/ExportProjectDialog.tsx
new file mode 100644
index 0000000..9d715be
--- /dev/null
+++ b/src/routes/home/Export/ExportProjectDialog.tsx
@@ -0,0 +1,232 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useAtom, useAtomValue } from 'jotai';
+import { useEffect } from 'preact/hooks';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
+import { feedbackDialogAtom } from '~/atoms/feedbackDialog';
+import { projectIdAtom } from '~/atoms/project';
+import InputField from '~/components/form/InputField';
+import SelectField from '~/components/form/SelectField';
+import IconFile from '~/components/icons/file.svg?react';
+import Button from '~/components/ui/Button';
+import Dialog from '~/components/ui/Dialog';
+import useExportProject, { EXPORT_FORMATS } from '~/hooks/useExportProject';
+import { usePersistedFormValues } from '~/hooks/usePersistedFormValues';
+import { ExportFormatId } from '~/types/types';
+import ExportOptions from './ExportOptions';
+import ExportScenes from './ExportScenes';
+import { ExportFormValues, exportFormSchema, PERSISTED_FIELDS } from './exportFormSchema';
+
+const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
+
+function sanitizeFileName(value: string): string {
+ return value.replace(INVALID_FILENAME_CHARS, '');
+}
+
+const NOTES: Record = {
+ ableton: `Please note that the exported project won't sound exactly the same as it does on the device.`,
+ dawproject: `Unfortunately, the DAWproject format does not currently support the "Sampler" instrument, so you will need to manually assign the samples in your DAW.`,
+ midi: `The simplest format supported by any DAW. But you have to assign the samples manually.`,
+ reaper: `Only basic sampler features are supported`,
+};
+
+function ExportProjectDialog({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const projectId = useAtomValue(projectIdAtom);
+
+ const form = useForm({
+ resolver: zodResolver(exportFormSchema),
+ defaultValues: {
+ format: EXPORT_FORMATS[0].value,
+ projectName: `Project${projectId}`,
+ includeArchivedSamples: true,
+ exportAllPadsWithSamples: false,
+ clips: false,
+ groupTracks: true,
+ drumRackGroupA: false,
+ drumRackGroupB: false,
+ drumRackGroupC: false,
+ drumRackGroupD: false,
+ sendEffects: true,
+ allScenes: true,
+ selectedScenes: [],
+ },
+ });
+
+ usePersistedFormValues(form, PERSISTED_FIELDS, exportFormSchema);
+
+ const format = useWatch({ control: form.control, name: 'format' });
+ const projectName = useWatch({ control: form.control, name: 'projectName' });
+ const allScenes = useWatch({ control: form.control, name: 'allScenes' });
+ const selectedScenes = useWatch({ control: form.control, name: 'selectedScenes' });
+ const trimmedName = (projectName ?? '').trim();
+ const canExport = (allScenes || (selectedScenes?.length ?? 0) > 0) && trimmedName.length > 0;
+
+ const {
+ startExport,
+ cancelExport,
+ isPending,
+ pendingStatus,
+ percentage,
+ reset,
+ result,
+ error,
+ sampleReport,
+ } = useExportProject();
+ const [_, openFeedbackDialog] = useAtom(feedbackDialogAtom);
+
+ const handleExport = form.handleSubmit(async (values) => {
+ await startExport({
+ ...values,
+ projectName: values.projectName.trim(),
+ });
+ });
+
+ useEffect(() => {
+ reset();
+ }, [format]);
+
+ return (
+
+ onOpenChange(false)} className="min-w-200 max-w-175">
+
+
+
+ {percentage > 0 && (
+ <>
+
+
{pendingStatus}
+ >
+ )}
+
+ {result && percentage >= 100 && (
+
+
Files to download
+ {result.files.map((f) => (
+
+ ))}
+
+ {sampleReport && (
+
+
Sample Report
+
+
+ ✓ Downloaded: {sampleReport.downloaded.length} samples
+
+ {sampleReport.missing.length > 0 && (
+
+
+ ✗ Missing: {sampleReport.missing.length} samples
+
+
+ {sampleReport.missing.map((missing, index) => (
+
+ {missing.name}: {missing.error}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ )}
+
+ {error && (
+
+
Error
+
+ Opps, that's not good. Hope the error tracking system helps me to find the problem.
+ If you can, please submit an error report - it really helps me fix things faster.
+ Thanks!
+
+
+
+ )}
+
+
+
+ openFeedbackDialog(true)}>
+ Submit error report
+
+ {isPending ? (
+
+ Cancel
+
+ ) : (
+ onOpenChange(false)}>
+ Close
+
+ )}
+
+ Export
+
+
+
+
+ );
+}
+
+export default ExportProjectDialog;
diff --git a/src/routes/home/Export/ExportScenes.tsx b/src/routes/home/Export/ExportScenes.tsx
new file mode 100644
index 0000000..303f10f
--- /dev/null
+++ b/src/routes/home/Export/ExportScenes.tsx
@@ -0,0 +1,68 @@
+import { clsx } from 'clsx';
+import { useAtomValue } from 'jotai';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
+import { projectIdAtom } from '~/atoms/project';
+import CheckBox from '~/components/ui/CheckBox';
+import useProject from '~/hooks/useProject';
+import useSceneName from '~/hooks/useSceneName.ts';
+import { Scene } from '~/types/types';
+import { ExportFormValues } from './exportFormSchema';
+
+function ExportScenes({ disabled = false }: { disabled?: boolean }) {
+ const projectId = useAtomValue(projectIdAtom);
+ const { data } = useProject(projectId);
+ const { getSceneName } = useSceneName(projectId);
+ const { control, setValue } = useFormContext();
+
+ const scenes: Scene[] = data?.scenes ?? [];
+ const allScenes = useWatch({ control, name: 'allScenes' });
+ const selectedScenes = useWatch({ control, name: 'selectedScenes' });
+
+ const handleSceneToggle = (sceneName: string, checked: boolean) => {
+ const current = selectedScenes ?? [];
+ if (checked) {
+ setValue('selectedScenes', [...current, sceneName]);
+ } else {
+ setValue(
+ 'selectedScenes',
+ current.filter((s) => s !== sceneName),
+ );
+ }
+ };
+
+ return (
+
+
Scenes
+
(
+ field.onChange(checked)}
+ title="All scenes"
+ disabled={disabled}
+ />
+ )}
+ />
+ 5,
+ })}
+ >
+ {scenes.map((scene) => (
+ handleSceneToggle(scene.name, checked)}
+ title={getSceneName(scene.name)}
+ disabled={disabled || allScenes}
+ className="ml-4"
+ />
+ ))}
+
+
+ );
+}
+
+export default ExportScenes;
diff --git a/src/routes/home/Export/exportFormSchema.ts b/src/routes/home/Export/exportFormSchema.ts
new file mode 100644
index 0000000..6c6dc4f
--- /dev/null
+++ b/src/routes/home/Export/exportFormSchema.ts
@@ -0,0 +1,40 @@
+import { z } from 'zod';
+import { ExportFormatId } from '../../../types/types';
+
+const FORMAT_IDS: [ExportFormatId, ...ExportFormatId[]] = [
+ 'ableton',
+ 'dawproject',
+ 'midi',
+ 'reaper',
+];
+
+export const exportFormSchema = z.object({
+ format: z.enum(FORMAT_IDS),
+ projectName: z.string(),
+ includeArchivedSamples: z.boolean(),
+ exportAllPadsWithSamples: z.boolean(),
+ clips: z.boolean(),
+ groupTracks: z.boolean(),
+ drumRackGroupA: z.boolean(),
+ drumRackGroupB: z.boolean(),
+ drumRackGroupC: z.boolean(),
+ drumRackGroupD: z.boolean(),
+ sendEffects: z.boolean(),
+ allScenes: z.boolean(),
+ selectedScenes: z.array(z.string()),
+});
+
+export type ExportFormValues = z.infer;
+
+export const PERSISTED_FIELDS: (keyof ExportFormValues)[] = [
+ 'format',
+ 'includeArchivedSamples',
+ 'exportAllPadsWithSamples',
+ 'clips',
+ 'groupTracks',
+ 'drumRackGroupA',
+ 'drumRackGroupB',
+ 'drumRackGroupC',
+ 'drumRackGroupD',
+ 'sendEffects',
+];
diff --git a/src/routes/home/FacePlateHeader.tsx b/src/routes/home/FacePlateHeader.tsx
new file mode 100644
index 0000000..c56b8ff
--- /dev/null
+++ b/src/routes/home/FacePlateHeader.tsx
@@ -0,0 +1,52 @@
+import { useAtomValue } from 'jotai';
+import { droppedBackupFileAtom } from '~/atoms/droppedProjectFile';
+import useTheme from '~/hooks/useTheme';
+import useDevice from '../../hooks/useDevice';
+
+function FacePlateHeader() {
+ const { device } = useDevice();
+ const droppedBackupFile = useAtomValue(droppedBackupFileAtom);
+ const theme = useTheme();
+
+ return (
+
+
+
+
+ {theme.name}: Export To DAW
+
+
+ Export your projects to Ableton Live, DAWproject, REAPER or MIDI
+
+
+
+ {!device && !droppedBackupFile && (
+
+
+ No device connected
+
+ )}
+
+ {!device && !!droppedBackupFile && (
+
+ )}
+
+ {!!device && (
+
+ )}
+
+
+ Firmware version: {device?.metadata.os_version || 'N/A'}
+
+
+
+ );
+}
+
+export default FacePlateHeader;
diff --git a/src/routes/home/FeedbackDialog.tsx b/src/routes/home/FeedbackDialog.tsx
new file mode 100644
index 0000000..a929879
--- /dev/null
+++ b/src/routes/home/FeedbackDialog.tsx
@@ -0,0 +1,104 @@
+import { useAtom, useAtomValue } from 'jotai';
+import { useState } from 'preact/hooks';
+import { feedbackDialogAtom } from '../../atoms/feedbackDialog';
+import { projectIdAtom } from '../../atoms/project';
+import Button from '../../components/ui/Button';
+import CheckBox from '../../components/ui/CheckBox';
+import Dialog from '../../components/ui/Dialog';
+import FileInput from '../../components/ui/FileInput';
+import Input from '../../components/ui/Input';
+import useSubmitFeedback from '../../hooks/useSubmitFeedback';
+import { showToast } from '../../lib/toast';
+
+function FeedbackDialog() {
+ const [open, setOpen] = useAtom(feedbackDialogAtom);
+ const [description, setDescription] = useState('');
+ const [email, setEmail] = useState('');
+ const [files, setFiles] = useState([]);
+ const [attachProject, setAttachProject] = useState(true);
+ const { mutate: submitErrorReport, isPending, isError } = useSubmitFeedback();
+ const projectId = useAtomValue(projectIdAtom);
+
+ const handleSubmit = (e: Event) => {
+ e.preventDefault();
+
+ submitErrorReport(
+ {
+ description,
+ email,
+ files: files.length > 0 ? files : null,
+ attachProject,
+ },
+ {
+ onSuccess: () => {
+ showToast('Feedback submitted successfully');
+
+ setOpen(false);
+ setDescription('');
+ setEmail('');
+ setFiles([]);
+ setAttachProject(true);
+ },
+ },
+ );
+ };
+
+ return (
+ setOpen(false)}>
+
+
Feedback / error report / feature request
+
+ If your project fails to export, or something looks wrong, please submit an error report
+ to the developer. You can also use this form to send general feedback or request new
+ features.
+
+
+ {isError && (
+
+ Failed to submit error report. Please try again.
+
+ )}
+
+
+ );
+}
+
+export default FeedbackDialog;
diff --git a/src/routes/home/Home.tsx b/src/routes/home/Home.tsx
new file mode 100644
index 0000000..98eb4c3
--- /dev/null
+++ b/src/routes/home/Home.tsx
@@ -0,0 +1,114 @@
+import { useAtom } from 'jotai';
+
+import { projectIdAtom } from '~/atoms/project';
+import OfflineInformer from '~/components/OfflineInformer';
+import UpdateNotifier from '~/components/UpdateNotifier';
+import Toast from '~/components/ui/Toast';
+import { APP_STATES, useAppState } from '~/hooks/useAppState';
+import useDevice from '~/hooks/useDevice';
+import useDroppedFile from '~/hooks/useDroppedFile';
+import AppStateDisplay from './AppStateDisplay';
+import EPToolkitDialog from './EPToolkitDialog';
+import FacePlateHeader from './FacePlateHeader';
+import FeedbackDialog from './FeedbackDialog';
+import Menu from './Menu';
+import ProjectMeta from './ProjectMeta';
+import Arrangements from './ProjectView/Arrangements';
+import RiddimDialog from './RiddimDialog';
+
+function Home() {
+ const [projectId] = useAtom(projectIdAtom);
+ const { error } = useDevice();
+ const appState = useAppState();
+ const { dropRef } = useDroppedFile();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {appState.includes(APP_STATES.CAN_DISPLAY_PROJECT) && (
+
+ )}
+
+ {appState.includes(APP_STATES.LOADING) && (
+
+ )}
+
+ {appState.includes(APP_STATES.NO_PROJECT_SELECTED) && (
+
+ )}
+
+ {appState.includes(APP_STATES.NO_DEVICE_CONNECTED) && (
+
+ Please connect a device and allow access to MIDI
+
+ or drop a .pak/.ppak backup file
+ >
+ }
+ />
+ )}
+
+ {appState.includes(APP_STATES.ERROR) && error && (
+
+ )}
+
+
+ This project is not affiliated with or officially authorized by Teenage Engineering
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Home;
diff --git a/src/routes/home/Menu.tsx b/src/routes/home/Menu.tsx
new file mode 100644
index 0000000..44c608d
--- /dev/null
+++ b/src/routes/home/Menu.tsx
@@ -0,0 +1,40 @@
+import { useAtom } from 'jotai';
+import { Link } from 'wouter';
+import { feedbackDialogAtom } from '~/atoms/feedbackDialog';
+import Donate from './Donate';
+
+const menuItemClassName =
+ 'text-black underline hover:no-underline cursor-pointer px-1 font-semibold';
+
+function Menu() {
+ const [_, openFeedbackDialog] = useAtom(feedbackDialogAtom);
+
+ return (
+
+ );
+}
+
+export default Menu;
diff --git a/src/routes/home/ProjectManager.tsx b/src/routes/home/ProjectManager.tsx
new file mode 100644
index 0000000..08b823d
--- /dev/null
+++ b/src/routes/home/ProjectManager.tsx
@@ -0,0 +1,67 @@
+import { useAtom } from 'jotai';
+import { JSX } from 'preact';
+import IconReload from '~/components/icons/reload.svg?react';
+import IconResetSettings from '~/components/icons/reset-settings.svg?react';
+import Button from '~/components/ui/Button';
+import Select from '~/components/ui/Select';
+import useCustomSceneNames from '~/hooks/useCustomSceneNames';
+import useSceneName from '~/hooks/useSceneName.ts';
+import { projectIdAtom } from '../../atoms/project';
+import { APP_STATES, useAppState } from '../../hooks/useAppState';
+import useProject from '../../hooks/useProject';
+import useProjectsList from '../../hooks/useProjectsList';
+import ExportProject from './Export/ExportProject';
+
+function ProjectManager() {
+ const [projectId, setProjectId] = useAtom(projectIdAtom);
+ const { refetch } = useProject(projectId);
+ const { resetSceneNames } = useSceneName(projectId);
+ const { hasCustomNames } = useCustomSceneNames(projectId);
+ const appState = useAppState();
+ const projects = useProjectsList();
+
+ return (
+
+ ) =>
+ setProjectId((e.target as HTMLSelectElement).value)
+ }
+ value={projectId}
+ name="project"
+ size="sm"
+ variant="secondary"
+ disabled={!appState.includes(APP_STATES.CAN_SELECT_PROJECT)}
+ >
+ Select project
+ {projects.map((p) => (
+
+ {p.label}
+
+ ))}
+
+ refetch()}
+ disabled={!appState.includes(APP_STATES.CAN_RELOAD_PROJECT)}
+ size="sm"
+ variant="secondary"
+ title="Reload project data from device"
+ >
+
+
+ {hasCustomNames && (
+
+
+
+ )}
+
+
+ );
+}
+
+export default ProjectManager;
diff --git a/src/routes/home/ProjectMeta.tsx b/src/routes/home/ProjectMeta.tsx
new file mode 100644
index 0000000..ef8bb13
--- /dev/null
+++ b/src/routes/home/ProjectMeta.tsx
@@ -0,0 +1,130 @@
+import clsx from 'clsx';
+import { ReactNode } from 'preact/compat';
+import IconMusic from '~/components/icons/music.svg?react';
+import useDevice from '../../hooks/useDevice';
+import useProject from '../../hooks/useProject';
+import { EFFECTS_SHORT, NOTE_NAMES, SCALES_SHORT } from '../../lib/constants';
+import ProjectManager from './ProjectManager';
+
+function Knob({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function valueToPercent(value: number | undefined) {
+ if (value === undefined) {
+ return 0;
+ }
+
+ const val = Math.round(value * 100);
+
+ return `${val < 10 ? '0' : ''}${val.toFixed(1)}%`;
+}
+
+function Value({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | number | undefined | ReactNode;
+}) {
+ return (
+
+ {label}:{value || 'N/A'}
+
+ );
+}
+
+function ProjectMeta({ projectId }: { projectId?: string }) {
+ const { data } = useProject(projectId);
+ const { device } = useDevice();
+
+ return (
+
+
+
+ {data.settings.bpm}
+
+ ({data.scenesSettings.timeSignature.numerator}/
+ {data.scenesSettings.timeSignature.denominator})
+
+
+ ) : (
+ 'N/A'
+ )
+ }
+ />
+
+
+
+
+ FX:
+
+ {data?.effects.effectType !== undefined
+ ? EFFECTS_SHORT[data?.effects.effectType]
+ : 'N/A'}
+
+
+
+ {data?.effects.param1 !== undefined ? valueToPercent(data?.effects.param1) : 'N/A'}
+
+ {data?.effects.param2 !== undefined ? valueToPercent(data?.effects.param2) : 'N/A'}
+
+
+
+
+
+ :
+
+
+ {data?.settings.scale !== undefined && data?.settings.rootNote !== undefined
+ ? `${SCALES_SHORT[data.settings.scale] ?? 'N/A'}/${NOTE_NAMES[data.settings.rootNote] ?? ''}`
+ : 'N/A'}
+
+
+
+
+
+ );
+}
+
+export default ProjectMeta;
diff --git a/src/routes/home/ProjectView/Arrangements.tsx b/src/routes/home/ProjectView/Arrangements.tsx
new file mode 100644
index 0000000..feafdea
--- /dev/null
+++ b/src/routes/home/ProjectView/Arrangements.tsx
@@ -0,0 +1,67 @@
+import useProject from '~/hooks/useProject';
+import { getQuarterNotesPerBar } from '~/lib/exporters/utils';
+import webViewTransformer from '~/lib/transformers/webView';
+import { Bar } from './Bar';
+import SceneName from './SceneName';
+import { SingleNote } from './SingleNote';
+import Track from './Track';
+import TrackMeta from './TrackMeta';
+
+function Arrangements({ projectId }: { projectId: string }) {
+ const { data } = useProject(projectId);
+
+ if (!data) {
+ return null;
+ }
+
+ const transformedData = webViewTransformer(data);
+ const barLength =
+ getQuarterNotesPerBar(
+ transformedData.scenesSettings.timeSignature.numerator,
+ transformedData.scenesSettings.timeSignature.denominator,
+ ) * 4;
+
+ return (
+
+ {transformedData.scenes.map((sceneData) => (
+
+
+ {sceneData.patterns.map((pattern) => (
+
+
+
+
+ {pattern.bars > 0 && sceneData.maxBars % pattern.bars === 0
+ ? [...Array(sceneData.maxBars / pattern.bars).keys()].map((index) => (
+
+ {pattern.notes.map((note) => (
+
+ ))}
+
+ ))
+ : null}
+ {pattern.bars > 0 && sceneData.maxBars % pattern.bars !== 0 ? (
+
+ {pattern.notes.map((note) => (
+
+ ))}
+
+ ) : null}
+
+
+
+ ))}
+
+ ))}
+
+ {transformedData.scenes.length === 0 && (
+
No arrangements found
+ )}
+
+ );
+}
+
+export default Arrangements;
diff --git a/src/routes/home/ProjectView/Bar.tsx b/src/routes/home/ProjectView/Bar.tsx
new file mode 100644
index 0000000..f86dd7a
--- /dev/null
+++ b/src/routes/home/ProjectView/Bar.tsx
@@ -0,0 +1,27 @@
+import clsx from 'clsx';
+import { ComponentChild as ReactNode } from 'preact';
+
+export function Bar({
+ children,
+ lengthInBars,
+ barLength,
+ index,
+}: {
+ children: ReactNode;
+ lengthInBars: number;
+ barLength: number;
+ index: number;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/routes/home/ProjectView/SceneName.tsx b/src/routes/home/ProjectView/SceneName.tsx
new file mode 100644
index 0000000..343a8e3
--- /dev/null
+++ b/src/routes/home/ProjectView/SceneName.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useRef, useState } from 'preact/hooks';
+import IconSave from '~/components/icons/check.svg?react';
+import IconCancel from '~/components/icons/close.svg?react';
+import IconEdit from '~/components/icons/edit.svg?react';
+import Button from '~/components/ui/Button.tsx';
+import Input from '~/components/ui/Input';
+import useSceneName, { getDefaultSceneName } from '~/hooks/useSceneName';
+
+function SceneName({ projectId, defaultName }: { projectId: string; defaultName: string }) {
+ const { sceneName, setSceneName } = useSceneName(projectId, defaultName);
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [temporalSceneName, setTemporalSceneName] = useState(sceneName);
+ const inputRef = useRef(null);
+
+ const handleCancel = () => {
+ setTemporalSceneName(sceneName);
+ setIsRenaming(false);
+ };
+
+ const handleSave = () => {
+ setSceneName(temporalSceneName);
+ setIsRenaming(false);
+
+ if (temporalSceneName.trim() === '') {
+ setTemporalSceneName(getDefaultSceneName(defaultName));
+ }
+ };
+
+ const handleRename = (e: Event) => {
+ setTemporalSceneName((e.target as HTMLInputElement).value);
+ };
+
+ const handleRenameClick = () => {
+ setTemporalSceneName(sceneName);
+ setIsRenaming(true);
+ };
+
+ const handleKeyboard = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ handleCancel();
+ }
+
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleSave();
+ }
+ };
+
+ useEffect(() => {
+ if (isRenaming && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isRenaming]);
+
+ return (
+
+
+ {isRenaming ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+ {sceneName}
+
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+export default SceneName;
diff --git a/src/routes/home/ProjectView/SingleNote.tsx b/src/routes/home/ProjectView/SingleNote.tsx
new file mode 100644
index 0000000..15ab28b
--- /dev/null
+++ b/src/routes/home/ProjectView/SingleNote.tsx
@@ -0,0 +1,16 @@
+import { ViewNote } from '~/lib/transformers/webView';
+
+export function SingleNote({ note }: { note: ViewNote }) {
+ return (
+
+ {note.duration > 16 ? note.name : null}
+
+ );
+}
diff --git a/src/routes/home/ProjectView/Track.tsx b/src/routes/home/ProjectView/Track.tsx
new file mode 100644
index 0000000..7a1300a
--- /dev/null
+++ b/src/routes/home/ProjectView/Track.tsx
@@ -0,0 +1,14 @@
+import { ComponentChild as ReactNode } from 'preact';
+
+function Track({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default Track;
diff --git a/src/routes/home/ProjectView/TrackMeta.tsx b/src/routes/home/ProjectView/TrackMeta.tsx
new file mode 100644
index 0000000..2058ae9
--- /dev/null
+++ b/src/routes/home/ProjectView/TrackMeta.tsx
@@ -0,0 +1,31 @@
+import { ViewPattern } from '~/lib/transformers/webView';
+import { getPadDisplayName } from '~/lib/utils';
+
+function TrackMeta({ pattern }: { pattern: ViewPattern }) {
+ let name = pattern.soundName;
+
+ if (!name && pattern.midiChannel) {
+ name = `[MIDI ch. ${pattern.midiChannel}]`;
+ }
+
+ if (!name && pattern.soundId >= 1000) {
+ name = `[SUPERTONE]`;
+ }
+
+ const isEmptyWithSample = pattern.notes.length === 0 && pattern.soundId > 0;
+ const padDisplay = getPadDisplayName(pattern.group, pattern.padNumber);
+
+ return (
+
+
+ {padDisplay[0]}
+ {padDisplay.slice(2)}
+
+
+ {name}
+
+
+ );
+}
+
+export default TrackMeta;
diff --git a/src/routes/home/RiddimDialog.tsx b/src/routes/home/RiddimDialog.tsx
new file mode 100644
index 0000000..863c933
--- /dev/null
+++ b/src/routes/home/RiddimDialog.tsx
@@ -0,0 +1,48 @@
+import { useAtomValue } from 'jotai';
+import { useEffect, useState } from 'preact/hooks';
+import { deviceSkuAtom } from '~/atoms/deviceSku';
+import Button from '~/components/ui/Button';
+import { SKU_EP40 } from '~/lib/constants';
+import Dialog from '../../components/ui/Dialog';
+
+function FeedbackDialog() {
+ const [open, setOpen] = useState(false);
+ const deviceSku = useAtomValue(deviceSkuAtom);
+
+ useEffect(() => {
+ if (deviceSku && !localStorage.getItem('riddim-dialog-shown') && deviceSku === SKU_EP40) {
+ setOpen(true);
+ }
+ }, [deviceSku]);
+
+ const handleClose = () => {
+ localStorage.setItem('riddim-dialog-shown', 'true');
+ setOpen(false);
+ };
+
+ return (
+
+
+
+
Hey, looks like you are using EP-40 Riddim!
+
+ Unfortunately not all features are supported at the moment. Since I don't own the real
+ device I can only explore the backups and blindly guess how things work.
+
+
+ If you would like to help me improve the support for EP-40 Riddim, please share your
+ experience with the current implementation via feedback form or email.
+
+
And if you like the project, please consider donating!
+
Thank you for your understanding and support.
+
+ setOpen(false)} variant="secondary">
+ Close
+
+
+
+
+ );
+}
+
+export default FeedbackDialog;
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..3a578e9
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,73 @@
+@import "tailwindcss";
+
+@theme {
+ --font-sans: "Atkinson Hyperlegible Mono", sans-serif;
+ --font-mono: "IBM Plex Mono", monospace;
+
+ --color-brand: #ef4e27;
+ --color-face: #dbdddb;
+ --color-screen: #000000;
+ --color-creme: #f5f0e6;
+ --color-border: rgba(0, 0, 0, 0.05);
+ --bg-url: url("/bg-ep133.png");
+ --shadow-my: 1px 1px 0px 1px #00000077;
+}
+
+:root {
+ .ep40 {
+ --color-brand: #ef4e27;
+ --color-face: #e2d8c5;
+ --color-screen: #00503a;
+
+ --bg-url: url("/bg-ep40.png");
+ }
+
+ .ep1320 {
+ --color-brand: #b22e20;
+ --color-face: #b3ad8f;
+ --color-screen: #0c3235;
+
+ --bg-url: url("/bg-ep1320.png");
+ }
+}
+
+@layer base {
+ a {
+ @apply text-blue-500;
+ }
+}
+
+@layer utilities {
+ .animate-pulse-slow {
+ animation: pulse-slow 2s ease-in-out infinite;
+ }
+
+ @keyframes pulse-slow {
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.8;
+ transform: scale(1.1);
+ }
+ }
+}
+
+:popover-open {
+ position: fixed;
+ inset: unset;
+ left: 32px;
+ bottom: 32px;
+ margin: 0;
+ background: transparent;
+ z-index: 100;
+}
+
+.bg-grid {
+ background-image:
+ linear-gradient(to right, var(--color-border) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--color-border) 1px, transparent 1px);
+ background-size: 40px 40px;
+}
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
new file mode 100644
index 0000000..5a812a4
--- /dev/null
+++ b/src/types/global.d.ts
@@ -0,0 +1,80 @@
+///
+
+declare global {
+ interface Window {
+ gtag: GtagFunction;
+ }
+}
+
+type GtagFunction = {
+ (
+ command: 'config',
+ targetId: string,
+ config?: {
+ [key: string]: any;
+ anonymize_ip?: boolean;
+ allow_ad_features?: boolean;
+ allow_google_signals?: boolean;
+ cookie_domain?: string;
+ cookie_expires?: number;
+ cookie_flags?: string;
+ cookie_name?: string;
+ cookie_prefix?: string;
+ cookie_update?: boolean;
+ page_title?: string;
+ page_location?: string;
+ send_page_view?: boolean;
+ user_id?: string;
+ },
+ ): void;
+
+ (command: 'set', targetId: string | 'user_properties', config: Record): void;
+
+ (
+ command: 'event',
+ action: string,
+ parameters?: {
+ [key: string]: any;
+ event_category?: string;
+ event_label?: string;
+ value?: number;
+ custom_map?: Record;
+ items?: Array<{
+ item_id?: string;
+ item_name?: string;
+ affiliation?: string;
+ coupon?: string;
+ currency?: string;
+ discount?: number;
+ index?: number;
+ item_brand?: string;
+ item_category?: string;
+ item_category2?: string;
+ item_category3?: string;
+ item_category4?: string;
+ item_category5?: string;
+ item_list_id?: string;
+ item_list_name?: string;
+ item_variant?: string;
+ location_id?: string;
+ price?: number;
+ quantity?: number;
+ }>;
+ transaction_id?: string;
+ affiliation?: string;
+ currency?: string;
+ tax?: number;
+ shipping?: number;
+ checkout_step?: number;
+ checkout_option?: string;
+ method?: string;
+ search_term?: string;
+ content_type?: string;
+ content_id?: string;
+ },
+ ): void;
+
+ (command: 'get', targetId: string, fieldName: string, callback: (value: any) => void): void;
+};
+
+export {};
diff --git a/src/types/types.ts b/src/types/types.ts
new file mode 100644
index 0000000..a3e2db4
--- /dev/null
+++ b/src/types/types.ts
@@ -0,0 +1,180 @@
+import { TEFileNode, TESoundMetadata } from '../lib/midi/types';
+import { TarFile } from '../lib/untar';
+
+export type Group = 'a' | 'b' | 'c' | 'd';
+export type PadNumber = `${number}`;
+export type PadCode = `${Group}${PadNumber}`;
+
+export type Sound = {
+ id: number;
+ fileNode: TEFileNode;
+ meta: TESoundMetadata;
+};
+
+export enum EffectType {
+ Off = 0,
+ Delay = 1,
+ Reverb = 2,
+ Distortion = 3,
+ Chorus = 4,
+ Filter = 5,
+ Compressor = 6,
+}
+
+export enum FaderParam {
+ LVL = 0,
+ PTC = 1,
+ TIM = 2,
+ LPF = 3,
+ HPF = 4,
+ FX = 5,
+ ATK = 6,
+ REL = 7,
+ PAN = 8,
+ TUNE = 9,
+ VEL = 10,
+ MOD = 11,
+}
+
+export type GroupFaderParam = {
+ [group: string]: {
+ [K in FaderParam]: number;
+ };
+};
+
+export type ProjectSettings = {
+ bpm: number;
+ scale: number;
+ rootNote: number;
+ groupFaderParams: GroupFaderParam;
+ faderAssignment: Record;
+ rawData: Uint8Array;
+};
+
+export type TimeSignature = {
+ numerator: number;
+ denominator: number;
+};
+
+export type ScenesSettings = {
+ timeSignature: TimeSignature;
+};
+
+export type Effects = {
+ rawData: Uint8Array;
+ effectType: EffectType;
+ param1: number;
+ param2: number;
+};
+
+export type Pattern = {
+ pad: PadCode;
+ group: Group;
+ notes: Note[];
+ bars: number;
+};
+
+export type Scene = {
+ name: string;
+ patterns: Pattern[];
+};
+
+export type Note = {
+ note: number;
+ position: number;
+ duration: number;
+ velocity: number;
+};
+
+export type Pad = {
+ pad: number;
+ name: string;
+ group: Group;
+ file: TarFile | null;
+ rawData: Uint8Array | null;
+ soundId: number;
+ volume: number;
+ attack: number;
+ release: number;
+ trimLeft: number;
+ trimRight: number;
+ soundLength: number;
+ playMode: 'oneshot' | 'key' | 'legato';
+ pan: number;
+ pitch: number;
+ rootNote: number;
+ timeStretch: 'off' | 'bpm' | 'bars' | 'rev';
+ timeStretchBpm: number;
+ timeStretchBars: number;
+ inChokeGroup: boolean;
+ midiChannel: number;
+};
+
+export type ProjectRawData = {
+ pads: Record;
+ scenes: Scene[];
+ settings: ProjectSettings;
+ effects: Effects;
+ sounds: Sound[];
+ projectFile: File;
+ scenesSettings: ScenesSettings;
+};
+
+export type ExportResultFile = {
+ name: string;
+ url: string;
+ type: 'project' | 'archive';
+ size: number;
+};
+
+export type ExportResult = {
+ files: ExportResultFile[];
+ sampleReport?: SampleReport;
+};
+
+export type ExportFormatId = 'ableton' | 'dawproject' | 'midi' | 'reaper';
+
+export type ExportStatus = {
+ status: string;
+ progress: number;
+ sampleReport?: SampleReport;
+};
+
+export type SampleReport = {
+ downloaded: string[];
+ missing: { name: string; error: string }[];
+};
+
+export type ExportFormat = {
+ name: string;
+ value: ExportFormatId;
+ exportFn?: (
+ projectId: string,
+ data: ProjectRawData,
+ sounds: Sound[],
+ progressCallback: ({ progress, status }: ExportStatus) => void,
+ exporterParams: ExporterParams,
+ ) => Promise;
+};
+
+export type ExporterParams = {
+ projectName?: string; // custom project name for exported files
+ includeArchivedSamples?: boolean;
+ exportAllPadsWithSamples?: boolean; // export all pads with assigned samples even if not used
+ clips?: boolean; // build clips not arrangements
+ noSampler?: boolean; // don't use sampler/simpler at all
+ groupTracks?: boolean; // group tracks by group name
+ drumRackGroupA?: boolean; // merge tracks for group A
+ drumRackGroupB?: boolean; // merge tracks for group B
+ drumRackGroupC?: boolean; // merge tracks for group C
+ drumRackGroupD?: boolean; // merge tracks for group D
+ sendEffects?: boolean; // send effects to return tracks
+ allScenes?: boolean; // export all scenes
+ selectedScenes?: string[]; // export only selected scenes (when allScenes is false)
+ customSceneNames?: Record;
+};
+
+export type SoundInfo = {
+ soundId: number;
+ soundMeta: TESoundMetadata;
+};
diff --git a/src/types/vite-env.d.ts b/src/types/vite-env.d.ts
new file mode 100644
index 0000000..460d00d
--- /dev/null
+++ b/src/types/vite-env.d.ts
@@ -0,0 +1,12 @@
+///
+
+declare module '*.svg?react' {
+ import { JSX } from 'preact';
+ const SVG: (props: JSX.SVGProps) => JSX.Element;
+ export default SVG;
+}
+
+declare module '*.svg' {
+ const content: string;
+ export default content;
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..ab0622c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "allowJs": true,
+ "checkJs": false,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "paths": {
+ "~/*": ["./src/*"],
+ "react": ["./node_modules/preact/compat"],
+ "react-dom": ["./node_modules/preact/compat"],
+ "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"]
+ },
+ "types": ["preact", "vite/client"]
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..70cffab
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "allowJs": true
+ },
+ "include": ["vite.config.js"]
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..74c5ae5
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,100 @@
+import path from 'node:path';
+import preact from '@preact/preset-vite';
+import { sentryVitePlugin } from '@sentry/vite-plugin';
+import tailwindcss from '@tailwindcss/vite';
+import { defineConfig } from 'vite';
+import { ViteMinifyPlugin } from 'vite-plugin-minify';
+import { VitePWA } from 'vite-plugin-pwa';
+import svgr from 'vite-plugin-svgr';
+
+export default defineConfig({
+ plugins: [
+ tailwindcss(),
+ preact(),
+ svgr(),
+ ViteMinifyPlugin({}),
+ VitePWA({
+ registerType: 'autoUpdate',
+ // devOptions: {
+ // enabled: true,
+ // },
+ workbox: {
+ globPatterns: ['**/*'],
+ clientsClaim: true,
+ skipWaiting: true,
+ maximumFileSizeToCacheInBytes: 1024 * 1024 * 10,
+ },
+ includeAssets: ['**/*'],
+ manifest: {
+ name: 'EP-133: Export To DAW',
+ short_name: 'ep133-to-daw',
+ description: 'Export your projects to Ableton Live, DAWproject or MIDI',
+ theme_color: '#dbdddb',
+ scope: '/',
+ start_url: '/',
+ display: 'standalone',
+ icons: [
+ {
+ src: '/android-icon-36x36.png',
+ sizes: '36x36',
+ type: 'image/png',
+ },
+ {
+ src: '/android-icon-48x48.png',
+ sizes: '48x48',
+ type: 'image/png',
+ },
+ {
+ src: '/android-icon-72x72.png',
+ sizes: '72x72',
+ type: 'image/png',
+ },
+ {
+ src: '/android-icon-96x96.png',
+ sizes: '96x96',
+ type: 'image/png',
+ },
+ {
+ src: '/android-icon-144x144.png',
+ sizes: '144x144',
+ type: 'image/png',
+ },
+ {
+ src: '/android-icon-192x192.png',
+ sizes: '192x192',
+ type: 'image/png',
+ },
+ ],
+ },
+ }),
+ sentryVitePlugin({
+ org: 'private-developer-d9',
+ project: 'ep133-to-daw',
+ telemetry: false,
+ }),
+ ],
+ build: {
+ outDir: 'dist',
+ sourcemap: true,
+ rolldownOptions: {
+ output: {
+ manualChunks: (id) => {
+ if (id.includes('xmlbuilder2')) {
+ return 'xmlbuilder2';
+ }
+ },
+ },
+ },
+ },
+ resolve: {
+ extensions: ['.tsx', '.ts', '.jsx', '.js'],
+ alias: {
+ '~': path.resolve('./src'),
+
+ react: 'preact/compat',
+ 'react-dom/test-utils': 'preact/test-utils',
+ 'react-dom': 'preact/compat',
+ 'react/jsx-runtime': 'preact/jsx-runtime',
+ },
+ },
+});