Dup Ver Goto 📝

ConvertToSfz

PT2/music/plugins/stylusrmx does not exist
To
290 lines, 1100 words, 8396 chars Page 'ConvertToSfz' does not exist.

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