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.
- microfreak/__init__.py +29 -0
- microfreak/analyze.py +137 -0
- microfreak/audio.py +273 -0
- microfreak/bank.py +625 -0
- microfreak/capabilities.py +414 -0
- microfreak/cli.py +338 -0
- microfreak/config.py +737 -0
- microfreak/confirm.py +27 -0
- microfreak/doctor.py +379 -0
- microfreak/firmware/__init__.py +0 -0
- microfreak/firmware/flash.py +655 -0
- microfreak/firmware/image.py +127 -0
- microfreak/firmware/mff.py +258 -0
- microfreak/firmware/patching.py +631 -0
- microfreak/firmware/peek.py +88 -0
- microfreak/jsonout.py +101 -0
- microfreak/listing.py +68 -0
- microfreak/lpc.py +363 -0
- microfreak/mcc.py +221 -0
- microfreak/mccfile.py +200 -0
- microfreak/midi.py +164 -0
- microfreak/midifile.py +228 -0
- microfreak/panel.py +44 -0
- microfreak/play.py +140 -0
- microfreak/plugin.py +270 -0
- microfreak/preset/__init__.py +0 -0
- microfreak/preset/backup.py +149 -0
- microfreak/preset/convert.py +307 -0
- microfreak/preset/encode.py +196 -0
- microfreak/preset/erase.py +117 -0
- microfreak/preset/fields.py +974 -0
- microfreak/preset/inits.py +874 -0
- microfreak/preset/listing.py +122 -0
- microfreak/preset/read.py +142 -0
- microfreak/preset/restore.py +279 -0
- microfreak/preset/select.py +85 -0
- microfreak/preset/tlv.py +448 -0
- microfreak/preset/write.py +340 -0
- microfreak/render.py +207 -0
- microfreak/sample/__init__.py +0 -0
- microfreak/sample/encode.py +197 -0
- microfreak/sample/erase.py +117 -0
- microfreak/sample/listing.py +159 -0
- microfreak/sample/restore.py +205 -0
- microfreak/sample/write.py +373 -0
- microfreak/wavetable/__init__.py +0 -0
- microfreak/wavetable/backup.py +125 -0
- microfreak/wavetable/encode.py +179 -0
- microfreak/wavetable/erase.py +119 -0
- microfreak/wavetable/listing.py +74 -0
- microfreak/wavetable/read.py +141 -0
- microfreak/wavetable/restore.py +213 -0
- microfreak/wavetable/write.py +265 -0
- microfreak_tools-0.1.0.dist-info/METADATA +305 -0
- microfreak_tools-0.1.0.dist-info/RECORD +59 -0
- microfreak_tools-0.1.0.dist-info/WHEEL +4 -0
- microfreak_tools-0.1.0.dist-info/entry_points.txt +2 -0
- microfreak_tools-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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
|