Source code for chiptunesak.c128_basic

# Lower MChirp to C128 BASIC PLAY commands

import collections
from chiptunesak import constants
from chiptunesak import base
from chiptunesak import gen_prg
from chiptunesak import chirp
from chiptunesak.errors import ChiptuneSAKValueError, ChiptuneSAKContentError

WHOLE_NOTE = 1152  # counter found in the PLAY routines in the BASIC ROM

# These are the defaults that can be overwritten by the BASIC ENVELOPE command
# Note: waveform (WF) is a little different in the BASIC, it's
#    0=triangle, 1=sawtooth, 2=pulse, 3=noise, and 4=ring modulation
C128_INSTRUMENTS = {
    'piano': 0,         # ADSR  0, 9,  0, 0, WF 2, PW 1536
    'accordion': 1,     # ADSR 12, 0, 12, 0, WF 1
    'calliope': 2,      # ADSR  0, 0, 15, 0, WF 0
    'drum': 3,          # ADSR  0, 5,  5, 0, WF 3
    'flute': 4,         # ADSR  9, 4,  4, 0, WF 0
    'guitar': 5,        # ADSR  0, 9,  2, 1, WF 1
    'harpsichord': 6,   # ADSR  0, 9,  0, 0, WF 2, PW  512
    'organ': 7,         # ADSR  0, 9,  9, 0, WF 2, PW 2048
    'trumpet': 8,       # ADSR  8, 9,  4, 1, WF 2, PW  512
    'xylophone': 9,     # ADSR  0, 9,  0, 0, WF 0
}

# These types are similar to standard notes and rests but with voice added
BasicNote = collections.namedtuple('BasicNote', ['start_time', 'note_num', 'duration', 'voice'])
BasicRest = collections.namedtuple('BasicRest', ['start_time', 'duration', 'voice'])

# These appear to be the only allowed note durations for C128 BASIC
basic_durations = {
    constants.Fraction(6, 1): "w.", constants.Fraction(4, 1): 'w',
    constants.Fraction(3, 1): 'h.', constants.Fraction(2, 1): 'h',
    constants.Fraction(3, 2): 'q.', constants.Fraction(1, 1): 'q',
    constants.Fraction(3, 4): 'i.', constants.Fraction(1, 2): 'i',
    constants.Fraction(1, 4): 's'
}


[docs]class C128Basic(base.ChiptuneSAKIO): """ The IO interface for C128BASIC Supports to_bin() and to_file() conversions from mchirp to C128 BASIC options: format, arch, instruments """ @classmethod def cts_type(cls): return 'C128Basic' def __init__(self): base.ChiptuneSAKIO.__init__(self) self.set_options(format='prg', arch=constants.DEFAULT_ARCH, instruments=['piano', 'piano', 'piano'])
[docs] def set_options(self, **kwargs): """ Sets the options for commodore export :param kwargs: keyword arguments for options :type kwargs: keyword arguments """ for op, value in kwargs.items(): op = op.lower() # All option names must be lowercase if op not in ['arch', 'format', 'instruments', 'tempo_override', 'rem_override']: raise ChiptuneSAKValueError(f'Error: unknown option "{op}"') if op == 'arch': if value not in constants.ARCH.keys(): raise ChiptuneSAKValueError(f"Error: Invalid architecture setting {value}") elif op == 'format': if value == 'ascii': value = 'bas' if value not in ['prg', 'bas']: ChiptuneSAKValueError(f"Error: Invalid format setting {value}") elif op == 'instruments': if len(value) != 3: raise ChiptuneSAKValueError("Error: 3 instruments required for C128") value = [v.lower() for v in value] if any(v not in C128_INSTRUMENTS for v in value): raise ChiptuneSAKValueError("Error: Illegal instrument name(s)") elif op == 'tempo_override': if not 1 <= value <= 255: # Note: some Commodore manuals erroneously show 0 as the slowest # tempo. "TEMPO 0" will throw a BASIC illegal quantity error. raise ChiptuneSAKContentError("Error: tempo must be between 1 and 255") elif op == 'rem_override': value = value[:72].lower() self._options[op] = value
[docs] def to_bin(self, mchirp_song, **kwargs): """ Convert an MChirpSong into a C128 BASIC music program :param mchirp_song: mchirp data :type mchirp_song: MChirpSong :return: C128 BASIC program :rtype: str or bytearray :keyword options: see `to_file()` """ self.set_options(**kwargs) if mchirp_song.cts_type() != 'MChirp': raise Exception("Error: C128Basic to_bin() only supports mchirp so far") ascii_prog = self.export_mchirp_to_C128_BASIC(mchirp_song) if self.get_option('format') == 'bas': return ascii_prog tokenized_program = gen_prg.ascii_to_prg_c128(ascii_prog) return tokenized_program
[docs] def to_file(self, mchirp_song, filename, **kwargs): """ Converts and saves MChirpSong as a C128 BASIC music program :param mchirp_song: mchirp data :type mchirp_song: MChirpSong :param filename: path and filename :type filename: str :keyword options: * **arch** (str) - architecture name (see base for complete list) * **format** (str) - 'bas' for BASIC source code or 'prg' for prg * **instruments** (list of str) - list of 3 instruments for the three voices (in order). - Default is ['piano', 'piano', 'piano'] - Supports the default C128 BASIC instruments: 0:'piano', 1:'accordion', 2:'calliope', 3:'drum', 4:'flute', 5:'guitar', 6:'harpsichord', 7:'organ', 8:'trumpet', 9:'xylophone * **tempo_override** (int) - override the computed tempo * **rem_override** (string) - use passed string for leading REM statement instead of filename """ prog = self.to_bin(mchirp_song, **kwargs) if self.get_option('format') == 'bas': with open(filename, 'w') as out_file: out_file.write(prog) else: # 'prg' with open(filename, 'wb') as out_file: out_file.write(prog)
[docs] def export_mchirp_to_C128_BASIC(self, mchirp_song): """ Convert mchirp into a C128 Basic program that plays the song. This method is invoked via the C128Basic ChiptuneSAKIO class :param mchirp_song: An mchirp song :type mchirp_song: MChirpSong :return: Returns an ascii BASIC program :rtype: str """ basic_strings = measures_to_basic(mchirp_song) result = [] current_line = 10 if self.get_option('rem_override'): rem_desc = self.get_option('rem_override') else: rem_desc = mchirp_song.metadata.name.lower() result.append('%d rem %s' % (current_line, rem_desc)) current_line += 10 # Tempo 1 is slowest, and 255 is fastest if self.get_option('tempo_override'): tempo = self.get_option('tempo_override') else: tempo = (mchirp_song.metadata.qpm * WHOLE_NOTE / constants.ARCH[self.get_option('arch')].frame_rate / 60 / 4) tempo = int(round(tempo)) result.append('%d tempo %d' % (current_line, tempo)) current_line = 100 for measure_num, s in enumerate(basic_strings): tmp_line = '%d %s$="%s"' % (current_line, num_to_str_name(measure_num), s) if len(tmp_line) >= constants.BASIC_LINE_MAX_C128: # it's ok if space removed between line number and first character tmp_line = tmp_line.replace(" ", "") # If the line is still too long... if len(tmp_line) >= constants.BASIC_LINE_MAX_C128: raise ChiptuneSAKContentError( "C128 BASIC line too long: Line %d length %d" % (current_line, len(tmp_line))) result.append(tmp_line) current_line += 10 current_line = 7000 # data might reach line 6740 volume = 9 # FUTURE: For each voice, provide a way to pick (or override) the default envelopes instr_assign = 'u%dv1t%dv2t%dv3t%d' % \ (volume, *(C128_INSTRUMENTS[inst] for inst in self.get_option('instruments'))) result.append('%d play"%s":rem init instruments' % (current_line, instr_assign)) current_line += 10 # FUTURE: Using FILTER command likely out of scope, but could be added as another option: """ FILTER [freq] [,lp] [,bp] [,hp] [,res] "Xn" in PLAY: Filter on (n=1), off (n=0) """ # Create the PLAY lines at the end (like an orderlist for string patterns) # TODO: Can later repeat a measure by PLAYing its string more than once to # achieve measure-level compression PLAYS_PER_LINE = 8 line_buf = [] for measure_num in range(len(basic_strings)): if measure_num != 0 and measure_num % PLAYS_PER_LINE == 0: result.append('%d %s' % (current_line, ':'.join(line_buf))) line_buf = [] current_line += 10 line_buf.append("play %s$" % (num_to_str_name(measure_num))) if len(line_buf) > 0: result.append('%d %s' % (current_line, ':'.join(line_buf))) current_line += 10 return '\n'.join(result)
def sort_order(c): """ Sort function for measure contents. Items are sorted by time and then, for equal times, by duration (decreasing) and voice :return: 3-tuple used for sorting :rtype: tuple """ if isinstance(c, BasicNote): return (c.start_time, -c.duration, c.voice) elif isinstance(c, BasicRest): return (c.start_time, -c.duration, c.voice) def pitch_to_basic_note_name(note_num, octave_offset=0): """ Gets note name for a given MIDI pitch :return: note name string and octave number :rtype: str, int """ note_name = base.pitch_to_note_name(note_num)[::-1] # Reverse the note name return note_name[1:], note_name[0] def duration_to_basic_name(duration, ppq): """ Gets a note duration name for a given duration. :param duration: duration :type duration: int :param ppq: ppq (midi pulses per quarter note) :type ppq: int :return: C128 BASIC name for the duration :rtype: str """ f = constants.Fraction(duration / ppq).limit_denominator(16) if f not in basic_durations: raise ChiptuneSAKValueError("Illegal note duration %s" % str(f)) return basic_durations[f] def trim_note_lengths(song): """ Trims the note lengths in a ChirpSong to only those allowed in C128 Basic """ for i_t, t in enumerate(song.tracks): for i_n, n in enumerate(t.notes): f = constants.Fraction(n.duration / song.metadata.ppq).limit_denominator(8) if f not in basic_durations: for d in sorted(basic_durations, reverse=True): if f >= d: n.duration = d * song.metadata.ppq break song.tracks[i_t].notes[i_n] = n # Trim the note in place def measures_to_basic(mchirp_song): """ Converts an MChirpSong to C128 Basic command strings. :param mchirp_song: :return: """ commands = [] n_measures = len(mchirp_song.tracks[0].measures) # in mchirp, all tracks have the same number of measures. last_voice = 0 last_octave = -10 last_duration = 0 ppq = mchirp_song.metadata.ppq for im in range(n_measures): contents = [] # Combine events from all three voices into a single list corresponding to the measure for v in range(min(3, len(mchirp_song.tracks))): m = mchirp_song.tracks[v].measures[im] # If the voice doesn't have any notes in the measure, just ignore it. note_count = sum(1 for e in m.events if isinstance(e, chirp.Note)) if note_count == 0: continue # Extract the notes and rests and put them into a list. for e in m.events: if isinstance(e, chirp.Note): if not e.tied_to: start_time = e.start_time for d in base.decompose_duration(e.duration, ppq, basic_durations): contents.append(BasicNote(start_time, e.note_num, d * ppq, v + 1)) start_time += d * ppq else: start_time = e.start_time for d in base.decompose_duration(e.duration, ppq, basic_durations): contents.append(BasicRest(start_time, d * ppq, v + 1)) start_time += d * ppq elif isinstance(e, base.Rest): start_time = e.start_time for d in base.decompose_duration(e.duration, ppq, basic_durations): contents.append(BasicRest(start_time, d * ppq, v + 1)) start_time += d * ppq # Use the sort order to sort all the events in the measure contents.sort(key=sort_order) measure_commands = [] # Last voice gets reset at the start of each measure. last_voice = 0 for e in contents: # We only care about notes and rests. For now. if isinstance(e, BasicNote): d_name = duration_to_basic_name(e.duration, mchirp_song.metadata.ppq) note_name, octave = pitch_to_basic_note_name(e.note_num) current_command = [] # Build the command for this note if e.voice != last_voice: current_command.append(' v%d' % e.voice) if octave != last_octave: current_command.append('o%s' % octave) if e.duration != last_duration: current_command.append(d_name) current_command.append(note_name.lower()) measure_commands.append(''.join(current_command)) # Set all the state variables last_voice = e.voice last_octave = octave last_duration = e.duration elif isinstance(e, BasicRest): d_name = duration_to_basic_name(e.duration, mchirp_song.metadata.ppq) current_command = [] if e.voice != last_voice: current_command.append(' v%d' % e.voice) if e.duration != last_duration: current_command.append(d_name) current_command.append('r') measure_commands.append(''.join(current_command)) # Set the state variables last_voice = e.voice last_duration = e.duration finished_basic_line = (''.join(measure_commands) + ' m').strip() commands.append(finished_basic_line) return commands def num_to_str_name(num, upper=False): """ Convert measure number to a BASIC variable name :param num: index for a BASIC variable name :type num: int :param upper: return upper case, defaults to False :type upper: bool, optional :return: C128 BASIC variable name :rtype: str """ if num < 0 or num > 675: raise ChiptuneSAKValueError("number to convert to str var name out of range") if upper: offset = ord('A') else: offset = ord('a') str_name = chr((num // 26) + offset) + chr((num % 26) + offset) return str_name