microfreak-tools 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. microfreak/__init__.py +29 -0
  2. microfreak/analyze.py +137 -0
  3. microfreak/audio.py +273 -0
  4. microfreak/bank.py +625 -0
  5. microfreak/capabilities.py +414 -0
  6. microfreak/cli.py +338 -0
  7. microfreak/config.py +737 -0
  8. microfreak/confirm.py +27 -0
  9. microfreak/doctor.py +379 -0
  10. microfreak/firmware/__init__.py +0 -0
  11. microfreak/firmware/flash.py +655 -0
  12. microfreak/firmware/image.py +127 -0
  13. microfreak/firmware/mff.py +258 -0
  14. microfreak/firmware/patching.py +631 -0
  15. microfreak/firmware/peek.py +88 -0
  16. microfreak/jsonout.py +101 -0
  17. microfreak/listing.py +68 -0
  18. microfreak/lpc.py +363 -0
  19. microfreak/mcc.py +221 -0
  20. microfreak/mccfile.py +200 -0
  21. microfreak/midi.py +164 -0
  22. microfreak/midifile.py +228 -0
  23. microfreak/panel.py +44 -0
  24. microfreak/play.py +140 -0
  25. microfreak/plugin.py +270 -0
  26. microfreak/preset/__init__.py +0 -0
  27. microfreak/preset/backup.py +149 -0
  28. microfreak/preset/convert.py +307 -0
  29. microfreak/preset/encode.py +196 -0
  30. microfreak/preset/erase.py +117 -0
  31. microfreak/preset/fields.py +974 -0
  32. microfreak/preset/inits.py +874 -0
  33. microfreak/preset/listing.py +122 -0
  34. microfreak/preset/read.py +142 -0
  35. microfreak/preset/restore.py +279 -0
  36. microfreak/preset/select.py +85 -0
  37. microfreak/preset/tlv.py +448 -0
  38. microfreak/preset/write.py +340 -0
  39. microfreak/render.py +207 -0
  40. microfreak/sample/__init__.py +0 -0
  41. microfreak/sample/encode.py +197 -0
  42. microfreak/sample/erase.py +117 -0
  43. microfreak/sample/listing.py +159 -0
  44. microfreak/sample/restore.py +205 -0
  45. microfreak/sample/write.py +373 -0
  46. microfreak/wavetable/__init__.py +0 -0
  47. microfreak/wavetable/backup.py +125 -0
  48. microfreak/wavetable/encode.py +179 -0
  49. microfreak/wavetable/erase.py +119 -0
  50. microfreak/wavetable/listing.py +74 -0
  51. microfreak/wavetable/read.py +141 -0
  52. microfreak/wavetable/restore.py +213 -0
  53. microfreak/wavetable/write.py +265 -0
  54. microfreak_tools-0.1.0.dist-info/METADATA +305 -0
  55. microfreak_tools-0.1.0.dist-info/RECORD +59 -0
  56. microfreak_tools-0.1.0.dist-info/WHEEL +4 -0
  57. microfreak_tools-0.1.0.dist-info/entry_points.txt +2 -0
  58. microfreak_tools-0.1.0.dist-info/licenses/LICENSE +202 -0
  59. microfreak_tools-0.1.0.dist-info/licenses/NOTICE +103 -0
microfreak/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """microfreak-tools -- read, write and patch an Arturia MicroFreak.
2
+
3
+ Not affiliated with, authorised by or endorsed by Arturia. No Arturia code or
4
+ data is in this package; every command that needs a firmware image takes the
5
+ path to one you already own.
6
+
7
+ microfreak.midi the transport: port discovery, framing, replies
8
+ microfreak.preset presets: fields, TLV, read, write, select, encode
9
+ microfreak.sample sample encoding and upload
10
+ microfreak.wavetable wavetable encoding, read and upload
11
+ microfreak.bank .mfprojz / .mfsbz / .mfwbz bank files
12
+ microfreak.lpc the Speech engine's LPC bitstream: encode and decode
13
+ microfreak.firmware .mff containers, patching, and USB DFU flashing
14
+ microfreak.plugin plugins: custom firmware and the commands for it
15
+ microfreak.cli the `mfreak` entry point -- a dispatch table
16
+
17
+ Docstrings here cite the documents that record the evidence for a claim --
18
+ `docs/FORMATS.md` for the preset format, `docs/PROTOCOL.md` for the SysEx
19
+ protocol, `docs/SPEECH.md` for the LPC engine, `docs/FIRMWARE.md` for the
20
+ firmware map. Those live in the companion analysis repository,
21
+ **microfreak-emu**, together with the Renode harness the firmware work was
22
+ done in. They are cited rather than copied so a claim can be checked against
23
+ the run that produced it.
24
+
25
+ Presets are 1-512, samples 1-128, wavetables 1-16 -- the numbers the
26
+ instrument shows, which are what every command takes and prints. The
27
+ 0-based indices the wire uses stay inside the package; `--json` carries
28
+ both, `slot` 0-based and `panel` 1-based. See `microfreak.panel`.
29
+ """
microfreak/analyze.py ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """Show the structure of a recorded SysEx capture.
3
+
4
+ mfreak capture analyze captures/FILE.jsonl
5
+ mfreak capture analyze captures/FILE.jsonl --presets
6
+
7
+ Groups messages by header and length, and shows which byte positions stay
8
+ constant and which vary. This is for working out an unknown protocol. For
9
+ speech audio, see `speech analyze`.
10
+ """
11
+ import argparse
12
+ import collections
13
+ import json
14
+
15
+
16
+ def load(path):
17
+ out = []
18
+ with open(path) as fh:
19
+ for line in fh:
20
+ line = line.strip()
21
+ if not line:
22
+ continue
23
+ r = json.loads(line)
24
+ if r.get("type") == "sysex":
25
+ out.append((r.get("dir", r.get("src", "?")),
26
+ [int(b, 16) for b in r["raw"].split()]))
27
+ return out
28
+
29
+
30
+ def dump_presets(msgs):
31
+ """Decodes preset-name records: F0 00 20 6B 07 01 <seq> 23 .. slot .. name."""
32
+ records = [m for _, m in msgs
33
+ if len(m) == 45 and m[:6] == [0xF0, 0x00, 0x20, 0x6B, 0x07, 0x01] and m[7] == 0x23]
34
+ if not records:
35
+ print("No preset-name records (command 0x23) in this capture.")
36
+ return
37
+
38
+ def name_of(m):
39
+ s = ""
40
+ for x in m[21:-1]:
41
+ if x == 0:
42
+ break
43
+ s += chr(x) if 0x20 <= x < 0x7F else "?"
44
+ return s
45
+
46
+ rows = sorted(((m[9] * 128 + m[10]), m) for m in records)
47
+ slots = [i for i, _ in rows]
48
+ print(f"{len(rows)} preset records, slots {min(slots)}..{max(slots)}")
49
+ if len(set(slots)) != len(slots):
50
+ print(" WARNING: duplicate slot numbers present")
51
+ print()
52
+ for i, m in rows:
53
+ print(f" {i:3} b19={m[19]:2} {name_of(m)}")
54
+
55
+
56
+ def main():
57
+ ap = argparse.ArgumentParser(
58
+ description=__doc__.splitlines()[0],
59
+ epilog="\n".join(__doc__.splitlines()[1:]),
60
+ formatter_class=argparse.RawDescriptionHelpFormatter)
61
+ ap.add_argument("capture", help="a .jsonl capture file")
62
+ ap.add_argument("--header-len", type=int, default=6, metavar="N",
63
+ help="bytes that count as the header (default: 6)")
64
+ ap.add_argument("--max-groups", type=int, default=8, metavar="N",
65
+ help="how many message groups to show (default: 8)")
66
+ ap.add_argument("--presets", action="store_true",
67
+ help="decode preset-name records instead")
68
+ args = ap.parse_args()
69
+
70
+ msgs = load(args.capture)
71
+ if not msgs:
72
+ print("No SysEx messages found.")
73
+ return
74
+
75
+ if args.presets:
76
+ dump_presets(msgs)
77
+ return
78
+
79
+ print(f"{len(msgs)} SysEx messages\n")
80
+
81
+ lengths = collections.Counter(len(m) for _, m in msgs)
82
+ print("Lengths:")
83
+ for ln, n in lengths.most_common(10):
84
+ print(f" {ln:5} bytes x{n}")
85
+
86
+ groups = collections.defaultdict(list)
87
+ for direction, m in msgs:
88
+ key = (direction, " ".join(f"{b:02X}" for b in m[:args.header_len]), len(m))
89
+ groups[key].append(m)
90
+
91
+ print(f"\n{len(groups)} distinct (direction, header, length) groups")
92
+ print("Showing the largest:\n")
93
+
94
+ for (direction, header, length), members in sorted(
95
+ groups.items(), key=lambda kv: -len(kv[1]))[:args.max_groups]:
96
+ print("=" * 72)
97
+ print(f"{direction} header {header} length {length} count {len(members)}")
98
+
99
+ # Which positions vary, and over what values?
100
+ varying = []
101
+ for pos in range(length):
102
+ values = {m[pos] for m in members}
103
+ if len(values) > 1:
104
+ varying.append((pos, values))
105
+
106
+ const = [p for p in range(length) if p not in {v[0] for v in varying}]
107
+ print(f" constant at {len(const)} positions, varying at {len(varying)}")
108
+
109
+ if varying:
110
+ print(" varying positions:")
111
+ for pos, values in varying[:24]:
112
+ vs = sorted(values)
113
+ shown = " ".join(f"{v:02X}" for v in vs[:16])
114
+ more = f" ... ({len(vs)} distinct)" if len(vs) > 16 else f" ({len(vs)} distinct)"
115
+ step = ""
116
+ if len(vs) > 2:
117
+ diffs = {b - a for a, b in zip(vs, vs[1:])}
118
+ if len(diffs) == 1:
119
+ step = f" [uniform step {diffs.pop()}]"
120
+ print(f" byte {pos:3}: {shown}{more}{step}")
121
+ if len(varying) > 24:
122
+ print(f" ... and {len(varying) - 24} more varying positions")
123
+
124
+ print(" example:")
125
+ ex = members[0]
126
+ for off in range(0, min(len(ex), 64), 16):
127
+ chunk = ex[off:off + 16]
128
+ h = " ".join(f"{b:02X}" for b in chunk).ljust(47)
129
+ a = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in chunk)
130
+ print(f" {off:04d} {h} |{a}|")
131
+ if len(ex) > 64:
132
+ print(f" ... {len(ex) - 64} more bytes")
133
+ print()
134
+
135
+
136
+ if __name__ == "__main__":
137
+ main()
microfreak/audio.py ADDED
@@ -0,0 +1,273 @@
1
+ """Audio input and output: find an interface, record from it, play a file.
2
+
3
+ `render` and `speech preview` record through this, and microfreak-emu's
4
+ filter_cal rig does too. It uses sounddevice, a PortAudio binding, so the
5
+ same code runs on macOS, Windows and Linux.
6
+
7
+ sounddevice is imported inside `_sd()`, not here, so a machine without
8
+ PortAudio still runs every command that does not touch audio.
9
+ """
10
+ import sys
11
+ import threading
12
+ import time
13
+ import wave
14
+
15
+ import numpy as np
16
+
17
+ DEFAULT_RATE = 48000
18
+
19
+ # How long a stream may take to deliver its first block before the device is
20
+ # treated as dead. Core Audio opens in well under a second.
21
+ OPEN_TIMEOUT = 3.0
22
+
23
+ # Gaps within this are clock drift between the audio clock and the host
24
+ # clock, not dropped audio: 4 ms plus 100 ppm of the length. A USB codec here
25
+ # measured 10 ppm. The shortest real loss, one 256-frame block at 48 kHz, is
26
+ # 5.3 ms.
27
+ DROP_TOLERANCE = 0.004
28
+ DRIFT_PPM = 100
29
+
30
+
31
+ class AudioError(Exception):
32
+ """Raised for anything the user can fix: no device, no PortAudio."""
33
+
34
+
35
+ def _sd():
36
+ try:
37
+ import sounddevice
38
+ except ImportError:
39
+ raise AudioError(
40
+ "sounddevice is not installed in %s. If mfreak was installed "
41
+ "with `uv tool install`, reinstall it: uv tool install "
42
+ "--editable --reinstall <microfreak-tools checkout>" % sys.prefix)
43
+ except OSError as exc:
44
+ raise AudioError("PortAudio is missing (%s). On Linux, install "
45
+ "libportaudio2." % exc)
46
+ return sounddevice
47
+
48
+
49
+ def inputs():
50
+ """-> [(index, name, channels, default_rate)] for each device with inputs."""
51
+ sd = _sd()
52
+ return [(i, d["name"], d["max_input_channels"], int(d["default_samplerate"]))
53
+ for i, d in enumerate(sd.query_devices())
54
+ if d["max_input_channels"] > 0]
55
+
56
+
57
+ def find_input(spec=None):
58
+ """-> (index, name, channels) for `spec`.
59
+
60
+ None is the system default input. A number is a device index. Anything
61
+ else matches a device name, ignoring case; an exact name wins over a
62
+ partial one.
63
+ """
64
+ devs = inputs()
65
+ if not devs:
66
+ raise AudioError("no audio inputs on this machine")
67
+ if spec is None:
68
+ idx = _sd().default.device[0]
69
+ found = [d for d in devs if d[0] == idx]
70
+ if not found:
71
+ raise AudioError("no default audio input. Pass --device; "
72
+ "--list-devices shows them.")
73
+ elif str(spec).isdigit():
74
+ found = [d for d in devs if d[0] == int(spec)]
75
+ if not found:
76
+ raise AudioError("no audio input %s. --list-devices shows them."
77
+ % spec)
78
+ else:
79
+ want = str(spec).lower()
80
+ found = ([d for d in devs if d[1].lower() == want]
81
+ or [d for d in devs if want in d[1].lower()])
82
+ if not found:
83
+ raise AudioError("no audio input matching %r. Inputs:\n%s"
84
+ % (spec, describe(devs)))
85
+ if len(found) > 1:
86
+ raise AudioError("%r matches %d inputs. Pass a number:\n%s"
87
+ % (spec, len(found), describe(found)))
88
+ idx, name, channels, _rate = found[0]
89
+ return idx, name, channels
90
+
91
+
92
+ def describe(devs=None):
93
+ """The input list as text, one device per line."""
94
+ devs = inputs() if devs is None else devs
95
+ return "\n".join(" %2d %-40s %d in, %d Hz" % d for d in devs)
96
+
97
+
98
+ class Recorder:
99
+ """Record from an input in the background. Use it as a context manager.
100
+
101
+ with Recorder("USB Audio CODEC") as rec:
102
+ start = rec.mark()
103
+ ...play something...
104
+ x = rec.audio()[start:]
105
+
106
+ `mark()` returns the frame index of the current moment, so a caller can
107
+ cut the recording at the instant it started playing. `dropped()` reports
108
+ audio lost on the way, whether or not PortAudio flagged it, and `gaps`
109
+ says where.
110
+ """
111
+
112
+ def __init__(self, device=None, rate=DEFAULT_RATE, channels=None):
113
+ sd = _sd()
114
+ self.index, self.name, available = find_input(device)
115
+ self.channels = min(available, 2) if channels is None else channels
116
+ self.rate = rate
117
+ self.overflows = 0
118
+ self.gaps = [] # (seconds into the recording, seconds missing)
119
+ self._lost = 0.0 # frames already counted in self.gaps
120
+ self._blocks = []
121
+ self._frames = 0
122
+ self._first_adc = None
123
+ self._last = None # (adc time, frame index, frames) of the newest block
124
+ self._lock = threading.Lock()
125
+ self._ready = threading.Event()
126
+ try:
127
+ self._stream = sd.InputStream(
128
+ device=self.index, channels=self.channels, samplerate=rate,
129
+ dtype="float32", callback=self._callback)
130
+ except Exception as exc:
131
+ raise AudioError("could not open %s at %d Hz, %d channel(s): %s"
132
+ % (self.name, rate, self.channels, exc))
133
+
134
+ def _callback(self, indata, frames, time_info, status):
135
+ if status.input_overflow:
136
+ self.overflows += 1
137
+ # Some host APIs report an ADC time of 0. The callback time less the
138
+ # block's length is the next best estimate of when it was captured.
139
+ adc = time_info.inputBufferAdcTime or (time_info.currentTime
140
+ - frames / self.rate)
141
+ with self._lock:
142
+ if self._first_adc is None:
143
+ self._first_adc = adc
144
+ missing = ((adc - self._first_adc) * self.rate - self._frames
145
+ - self._lost)
146
+ if missing > DROP_TOLERANCE * self.rate:
147
+ self.gaps.append((self._frames / self.rate,
148
+ missing / self.rate))
149
+ self._lost += missing
150
+ self._last = (adc, self._frames, frames)
151
+ self._blocks.append(indata.copy())
152
+ self._frames += frames
153
+ self._ready.set()
154
+
155
+ def __enter__(self):
156
+ self._stream.start()
157
+ if not self._ready.wait(OPEN_TIMEOUT):
158
+ self._stream.close()
159
+ raise AudioError("no audio arrived from %s within %.0f s"
160
+ % (self.name, OPEN_TIMEOUT))
161
+ return self
162
+
163
+ def __exit__(self, *exc):
164
+ self._stream.stop()
165
+ self._stream.close()
166
+ return False
167
+
168
+ def mark(self):
169
+ """-> the frame index that corresponds to now."""
170
+ now = self._stream.time
171
+ with self._lock:
172
+ adc, frame, _ = self._last
173
+ return max(0, frame + int(round((now - adc) * self.rate)))
174
+
175
+ def wait_for(self, frame, timeout=1.0):
176
+ """Wait until audio up to `frame` has arrived, or `timeout` passes.
177
+
178
+ The newest audio is still in the driver's buffers when the caller
179
+ stops, so stopping at once would cut the end short.
180
+ """
181
+ deadline = time.monotonic() + timeout
182
+ while self._frames < frame and time.monotonic() < deadline:
183
+ time.sleep(0.005)
184
+
185
+ def audio(self):
186
+ """-> float32 array, (frames, channels)."""
187
+ with self._lock:
188
+ if not self._blocks:
189
+ return np.zeros((0, self.channels), np.float32)
190
+ return np.concatenate(self._blocks)
191
+
192
+ def dropped(self):
193
+ """-> seconds of audio that the clock says should be here and is not.
194
+
195
+ Measured up to the newest block received, so audio still in the
196
+ driver when the stream stopped does not count as lost.
197
+ """
198
+ adc, frame, frames = self._last
199
+ expected = (adc - self._first_adc) * self.rate + frames
200
+ missing = (expected - self._frames) / self.rate
201
+ allowed = DROP_TOLERANCE + DRIFT_PPM * 1e-6 * self._frames / self.rate
202
+ return missing if missing > allowed else 0.0
203
+
204
+
205
+ def mono(x):
206
+ """Fold a two-channel recording to one channel, polarity-aware.
207
+
208
+ The MicroFreak's output is mono. Through a TS cable into a stereo or
209
+ differential input it arrives on both channels in opposite polarity, and
210
+ averaging them cancels it to the noise floor. So the channels are
211
+ subtracted when they are anti-phase and averaged when they are not.
212
+ """
213
+ if x.ndim == 1:
214
+ return x
215
+ if x.shape[1] == 1:
216
+ return x[:, 0]
217
+ L, R = x[:, 0], x[:, 1]
218
+ if L.std() > 1e-9 and R.std() > 1e-9 and np.corrcoef(L, R)[0, 1] < -0.5:
219
+ return (L - R) / 2.0
220
+ return x.mean(axis=1)
221
+
222
+
223
+ def onset(x, rate, threshold=0.1):
224
+ """-> seconds to the first sample above `threshold` of the peak, or None."""
225
+ a = np.abs(x if x.ndim == 1 else x.max(axis=1))
226
+ peak = float(a.max()) if a.size else 0.0
227
+ if peak <= 0:
228
+ return None
229
+ return int(np.argmax(a >= threshold * peak)) / float(rate)
230
+
231
+
232
+ def levels(x):
233
+ """-> (peak dBFS, clipped samples). Peak is -inf for silence."""
234
+ peak = float(np.abs(x).max()) if x.size else 0.0
235
+ db = 20 * np.log10(peak) if peak > 0 else float("-inf")
236
+ return db, int((np.abs(x) >= 0.999).sum())
237
+
238
+
239
+ def write_wav(path, x, rate):
240
+ """Write 16-bit PCM. `x` is float, (frames,) or (frames, channels)."""
241
+ x = np.asarray(x, np.float32)
242
+ channels = 1 if x.ndim == 1 else x.shape[1]
243
+ with wave.open(path, "wb") as w:
244
+ w.setnchannels(channels)
245
+ w.setsampwidth(2)
246
+ w.setframerate(rate)
247
+ w.writeframes((np.clip(x, -1, 1) * 32767).astype("<i2").tobytes())
248
+
249
+
250
+ def read_wav(path):
251
+ """-> (float32 array, rate) from a 16-bit PCM WAV."""
252
+ with wave.open(path, "rb") as w:
253
+ if w.getsampwidth() != 2:
254
+ raise ValueError("%s is not 16-bit PCM" % path)
255
+ raw = w.readframes(w.getnframes())
256
+ channels, rate = w.getnchannels(), w.getframerate()
257
+ x = np.frombuffer(raw, "<i2").astype(np.float32) / 32768.0
258
+ return (x if channels == 1 else x.reshape(-1, channels)), rate
259
+
260
+
261
+ def play(x, rate):
262
+ """Play `x` on the default output and wait for it to finish."""
263
+ sd = _sd()
264
+ sd.play(x, rate)
265
+ sd.wait()
266
+
267
+
268
+ def record(seconds, device=None, rate=DEFAULT_RATE, channels=None):
269
+ """-> (audio, recorder): `seconds` of audio from `device`."""
270
+ with Recorder(device, rate, channels) as rec:
271
+ start = rec.mark()
272
+ time.sleep(seconds)
273
+ return rec.audio()[start:start + int(seconds * rate)], rec