This doesn't work with the factory sounds as I don't understand its audio format.
I purchased a Ninevolt bundle years back, such as Action Drums.
This is my current attempt at a Python script that converts it to a .sfz file
compatible at least with Renoise Redux. Apologies if the code isn't pretty.
At present it outputs .flac files (which is what Renoise uses, and Renoise's
sfz output was the template I used for the sfz files generated here), though
by changing the "flac" to "wav" it could just as easily output wav files,
and possibly with a couple more lines store it at a chosen bit depth. This code
doesn't change the data type of the data read by soundfile, nor does it
modify the sound data in any way, except taking slices of it to produce the individual
slice files. This is sufficient that I can use the resulting .sfz files in Renoise Redux,
which is a sampler/renoise instrument player that runs on all OS's I use.
This works in Falcon3 and sforzando and Bitwig (Ableton still doesn't open .sfz in its sampler,
and NI with their usual degree of suckage haven't implemented, or removed, sfz import in Kontakt).
It doesn't extract midi correctly for non 4/4 files. I'll investigate why at some point.
Extractor
#!/usr/bin/env python
import os
import re
import json
import soundfile
import numpy
import math
import jdamidi
import rmxseq
from collections import defaultdict
from subprocess import run, DEVNULL
import xml.etree.ElementTree as ET
# later we want to point to a root of an rmx pack
# and have it create folders elsewhere
# though we can do this and move with a bash script
tree = ET.parse('data.xml')
root = tree.getroot()
for child in root:
print(child.tag, child.attrib)
root_note = 48
odr = "out"
os.makedirs(odr,exist_ok=True)
seqs = {}
for loop in root:
lattr = loop.attrib
loop_dirname = lattr['AUDIOFILENAME']
audio_input_filename = os.path.join(loop_dirname,"Audio")
loop_outname = loop_dirname.replace(" ","_")
sounddata, samplerate = soundfile.read(audio_input_filename)
sfz_filename = f"{loop_outname}.sfz"
samples_dirname = f"{loop_outname}_samples"
os.makedirs(os.path.join(odr,samples_dirname),exist_ok=True)
note = root_note
l = len(loop)
n = math.ceil(math.log(l+1,10)) # how many digits in sample_00.flac
sfz_regions = []
# extract slice audio
for i,sl in enumerate(loop):
if sl.tag != "SLICE":
continue
attr = sl.attrib
a = attr['BEGIN']
e = attr['END']
a = int(a)
e = int(e)
sample_fn = f"{loop_outname}_sample_{i:0{n}}.flac"
sample_path = os.path.join(samples_dirname,sample_fn)
sample_data = sounddata[a:e,:]
ofn = os.path.join(odr,sample_path)
if not os.path.exists(ofn):
soundfile.write(ofn,sample_data,samplerate)
print(f"written sample {ofn}")
sfz_regions.append(f"""<region>
pitch_keycenter={note}
lokey={note} hikey={note}
sample={sample_path}""")
note += 1
sfz = f"""// {loop_dirname}
{"\n\n".join(sfz_regions)}
"""
ofn = os.path.join(odr,sfz_filename)
with open(ofn,"wt") as f:
f.write(sfz)
print(f"Written {ofn}")
# extract midi seq info
for elt in loop:
if elt.tag.lower() == "sliceseq":
break
else:
print("no sliceseq")
continue
attr = elt.attrib
ppqn = int(attr["TICKSPERQUARTER"])
timesignum = int(attr["TIMESIGNUM"])
timesigdenom = int(attr["TIMESIGDENOM"])
tempo = attr["TEMPO"]
seq = []
for step in elt:
print(" ",step)
attr = step.attrib
print(">>",attr)
b = int(attr["BEGIN"])
e = int(attr["END"])
si = int(attr["SLICEINDEX"])
if si == -1:
seqlen = b
break
else:
seq.append([b,e,si])
sseq = { "seqlen": seqlen, "seq": seq, "ppqn": ppqn, "tempo": tempo, "timesig": [timesignum,timesigdenom] }
seqs[loop_dirname] = sseq
so = rmxseq.Seq(data=sseq)
midi_data = so.makemidi()
mid_filename = f"{loop_outname}.mid"
ofn = os.path.join(odr,mid_filename)
with open(ofn,"wb") as f:
f.write(midi_data)
print(f"Written {ofn}")
ofn = os.path.join(odr,"seqs.json")
with open(ofn,"wt") as f:
json.dump(seqs,f)
print(f"Written {ofn}")
rmxseq.py
This is a quick and dirty module to handle the slice sequence data
from a stylus rmx data.xml file.
#!/usr/bin/env python
import jdamidi
import json
def fread(fn,mode="rt"):
with open(fn,mode) as f:
return f.read()
def jload(fn):
with open(fn) as f:
return json.load(f)
class Seq:
def __init__(self,fn=None,data=None,root_note=48):
self.root_note = root_note
self.fn = fn
if fn is not None:
data = jload(fn)
self.__dict__.update(data)
elif data is not None:
self.__dict__.update(data)
else:
self.seq = []
self.ppqn = 960
self.seqlen = int(4.0*self.ppqn)
self.tempo = "42c80001"
self.timesig = [ 4, 4 ]
def __repr__(self):
seqe = f"{len(self.seq)} events"
return f"Seq<seqlen={self.seqlen} ppqn={self.ppqn} tempo={self.tempo} timesig={self.timesig[0]}/{self.timesig[1]} {seqe}>"
def makemidi(self,channel=0):
root_note = self.root_note
events = []
for i,note in enumerate(self.seq):
b,e,sidx = note
events.append([b,[0x90+channel,i+root_note,100]])
events.append([e,[0x80+channel,i+root_note,100]])
events = list(sorted(events,key=lambda t: t[0]))
mtrk = jdamidi.event_list_to_midi(events,self.seqlen)
midi =jdamidi.make_smf([mtrk],ppqn=self.ppqn)
return midi
jdamidi.py
This is a quick and dirty module to generate .mid files.
It doesn't do everything yet, such as tempo.
It has the beginnings of a more general idea I'm playing with,
that of an event list of the form:
[
[40,["midi",0x90,0x60,0x70]]
[event_time,["eventtype",...event params]]
]
inspired by the Csound score language. The idea is that if you want midi,
the non-midi events are either dropped or converted to midi. Then you can have
an arbitrary bit of code that expands non-midi events to midi events, or
influences the state of the process converting the event list. And this is by
no means restricted to generating midi: if you have some DSP code, your event
list could be a recipe for generating a .wav file, much as Csound does.
import struct
def encode_length(x):
xs = []
y = x
while x >= 0x80:
xs.append(x&0x7f)
x >>= 7
xs.append(x)
for i in range(1,len(xs)):
xs[i] += 0x80
xs = list(reversed(xs))
return bytes(xs)
def parse_length(b,di):
dt = 0
while b & 0x80:
dt |= (b&0x7f)
dt <<= 7
b = next(di)
dt |= (b&0x7f)
return dt
def make_mthd(smf_format=1,num_tracks=1,ppqn=960):
if smf_format < 0 or smf_format > 2:
raise ValueError("smf_format must be 0..2")
if num_tracks < 1:
raise ValueError("number of tracks must be positive")
return b"MThd"+struct.pack(">IHHH",6,smf_format,num_tracks,ppqn)
def make_trk(midi_data):
# takes raw midi data and wraps in a MTrk
o = b"MTrk"
o += struct.pack(">I",len(midi_data))
o += midi_data
return o
def make_smf(tracks,smf_format=None, ppqn=960):
if smf_format is None:
if len(tracks) == 1:
smf_format = 0
else:
smf_format = 1
mthd = make_mthd(smf_format=smf_format,num_tracks=len(tracks),ppqn=ppqn)
mtracks = [ make_trk(track) for track in tracks ]
mid = mthd + b"".join(mtracks)
return mid
def fevent_list_to_event_list(fevents,multiplier=1.0,ppqn=960):
# t, data
# [ 1.0, [ 0x90, 0x60, 0x61 ] ]
# or
# [ 2.0, b"\x90\x60\x61 ]
# where 1.0, 2.0 is position in quater notes
# times are multiplied by multiplier (e.g. multiplier=0.25 will turn quarters into sixteenths)
events = []
for event in fevents:
t,d = event
t *= multiplier
t = int(t*ppqn)
events.append([t,d])
return events
def event_list_to_midi(events,length):
# t,data
# [ 45, [ 0x90, 0x60, 0x61 ] ]
# or
# [ 45, b"\x90\x60\x61 ]
# sort by time
events = list(sorted(events,key = lambda t: t[0]))
eventsd = []
data = b""
t0 = 0
for event in events:
t,d = event
dt = t - t0 # delta time from previous event
t0 = t
data += encode_length(dt)
data += bytes(d)
if length < t0:
raise ValueError(f"Events exceed given length {length}<{t0}")
# append all notes off and end track meta event
dt = length - t0
data += encode_length(dt)
data += b"\xb0\x7b\x00\x00\xff\x2f\x00"
return data