Skip to content

Commit

Permalink
add am32 tune edit and rtttl convert
Browse files Browse the repository at this point in the history
  • Loading branch information
Huibean committed Oct 9, 2024
1 parent c6706de commit dd07957
Show file tree
Hide file tree
Showing 2 changed files with 425 additions and 18 deletions.
332 changes: 332 additions & 0 deletions dronecan_gui_tool/am32_rtttl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
#
# Copyright (C) 2024 DroneCAN Development Team <dronecan.org>
#
# This software is distributed under the terms of the MIT License.
#
# Author: Huibean Luo <[email protected]>
#
import re
import math
import dronecan

class AM32_Rtttl:
@staticmethod
def parse(rtttl):
REQUIRED_SECTIONS_NUM = 3
SECTIONS = rtttl.split(':')

if len(SECTIONS) != REQUIRED_SECTIONS_NUM:
raise ValueError('Invalid RTTTL string.')

NAME = AM32_Rtttl.get_name(SECTIONS[0])
DEFAULTS = AM32_Rtttl.get_defaults(SECTIONS[1])
MELODY = AM32_Rtttl.get_data(SECTIONS[2], DEFAULTS)

return {
'name': NAME,
'defaults': DEFAULTS,
'melody': MELODY
}

@staticmethod
def to_am32_startup_melody(rtttl, startup_melody_length=128):
if rtttl == '':
return {
'data': bytearray(128),
'errorCodes': None
}
parsed_data = AM32_Rtttl.parse(rtttl)

if startup_melody_length < 4:
raise ValueError('startupMelodyLength is too small to fit a am32 Startup Melody')

MAX_ITEM_VALUE = 2**8
melody = parsed_data['melody']
result = bytearray(startup_melody_length)
error_codes = [0] * len(melody)

bpm = int(parsed_data['defaults']['bpm']) % (2**16)
result[0] = (bpm >> 8) & (2**8 - 1)
result[1] = bpm & (2**8 - 1)
result[2] = int(parsed_data['defaults']['octave']) % MAX_ITEM_VALUE
result[3] = int(parsed_data['defaults']['duration']) % MAX_ITEM_VALUE

current_result_index = 4
current_melody_index = 0

while current_melody_index < len(melody) and current_result_index < len(result):
item = melody[current_melody_index]

if item['frequency'] != 0:
temp3 = AM32_Rtttl._calculate_am32_temp3_from_frequency(item['frequency'])

if 0 < temp3 < MAX_ITEM_VALUE:
duration_per_pulse_ms = 1000 / item['frequency']
pulses_needed = round(item['duration'] / duration_per_pulse_ms)

while pulses_needed > 0 and current_result_index < len(result):
result[current_result_index] = min(pulses_needed, MAX_ITEM_VALUE - 1)
result[current_result_index + 1] = temp3
current_result_index += 2
pulses_needed -= result[current_result_index - 2]

if pulses_needed > 0:
error_codes[current_melody_index] = 2
else:
error_codes[current_melody_index] = 0
else:
error_codes[current_melody_index] = 1
else:
duration = round(item['duration'])

while duration > 0 and current_result_index < len(result):
result[current_result_index] = min(duration, MAX_ITEM_VALUE - 1)
result[current_result_index + 1] = 0
current_result_index += 2
duration -= result[current_result_index - 2]

if duration > 0:
error_codes[current_melody_index] = 2
else:
error_codes[current_melody_index] = 0

current_melody_index += 1

while current_melody_index < len(melody):
error_codes[current_melody_index] = 2
current_melody_index += 1

return {
'data': result,
'errorCodes': error_codes
}

@staticmethod
def is_am32_melody_param(param_struct):
return param_struct.name == "STARTUP_TUNE" and dronecan.get_active_union_field(param_struct.value) == 'string_value'

@staticmethod
def is_am32_melody_param_from_file(name):
return name == "STARTUP_TUNE"

@staticmethod
def from_am32_startup_melody(startup_melody_data, melody_name='Melody'):
if isinstance(startup_melody_data, bytearray) and all(byte == 0 for byte in startup_melody_data):
return ''

if len(startup_melody_data) < 4:
return f'{melody_name}:d=1,o=4,bpm=100:'

defaults = {
'bpm': (startup_melody_data[0] << 8) + startup_melody_data[1],
'octave': startup_melody_data[2],
'duration': startup_melody_data[3]
}

melody_notes = []
for i in range(4, len(startup_melody_data) - 1, 2):
freq = AM32_Rtttl._calculate_frequency_from_am32_temp3(startup_melody_data[i + 1])
note = AM32_Rtttl._calculate_note_name_from_frequency(freq)
octave = AM32_Rtttl._calculate_note_octave_from_frequency(freq)
dur = startup_melody_data[i] if freq == 0 else (1000 / AM32_Rtttl._calculate_frequency(note, octave)) * startup_melody_data[i]

if dur > 0:
if melody_notes and abs(melody_notes[-1]['frequency'] - freq) < 0.01 and startup_melody_data[i - 2] == 255:
melody_notes[-1]['duration'] += dur
else:
melody_notes.append({
'duration': dur,
'frequency': freq,
'musicalNote': note,
'musicalOctave': octave
})
else:
break

full_note_duration = 4 * 60000 / defaults['bpm']
smallest_musical_duration = full_note_duration / 64

def quantized_duration(duration):
return round(duration / smallest_musical_duration) * smallest_musical_duration

melody_string = ''
for item in melody_notes:
musical_duration = quantized_duration(item['duration']) / full_note_duration

while musical_duration > 1 / 64:
current_duration = min(1.5, musical_duration)
rtttl_duration = 2 ** -math.floor(math.log2(current_duration))
is_dotted_note = current_duration * rtttl_duration > 1
melody_string += ('' if rtttl_duration == defaults['duration'] else str(rtttl_duration)) + \
item['musicalNote'] + \
('' if item['musicalOctave'] == defaults['octave'] or item['musicalOctave'] == 0 else str(item['musicalOctave'])) + \
('.' if is_dotted_note else '') + ','
musical_duration -= current_duration

return f"{melody_name}:b={defaults['bpm']},o={defaults['octave']},d={defaults['duration']}:{melody_string.rstrip(',')}"

@staticmethod
def get_melody_string_from_dronecan_param_value(value):
#dronecan.transport.ArrayValue
melody_array = bytearray(128)
for i in range(len(value)):
melody_array[i] = value[i]
melody_string = AM32_Rtttl.from_am32_startup_melody(melody_array, "Melody")
return melody_string

@staticmethod
def get_name(name):
MAX_LENGTH = 10

if len(name) > MAX_LENGTH:
print('Warning: Tune name should not exceed 10 characters.')

return name or 'Unknown'

@staticmethod
def get_defaults(defaults):
VALUES = defaults.split(',')

ALLOWED_DURATION = ['1', '2', '4', '8', '16', '32']
ALLOWED_OCTAVE = ['4', '5', '6', '7']
ALLOWED_BPM = [
'25', '28', '31', '35', '40', '45', '50', '56', '63', '70', '80', '90', '100',
'112', '125', '140', '160', '180', '200', '225', '250', '285', '320', '355',
'400', '450', '500', '565', '570', '635', '715', '800', '900'
]

DEFAULT_VALUES = {
'duration': '4',
'octave': '6',
'bpm': '63'
}

for value in VALUES:
if value:
KEY, VAL = value.split('=')
if KEY == 'd' and VAL in ALLOWED_DURATION:
DEFAULT_VALUES['duration'] = VAL
elif KEY == 'o' and VAL in ALLOWED_OCTAVE:
DEFAULT_VALUES['octave'] = VAL
elif KEY == 'b' and VAL in ALLOWED_BPM:
DEFAULT_VALUES['bpm'] = VAL

return {**DEFAULT_VALUES}

@staticmethod
def _calculate_semitones_from_c4(note, octave):
NOTE_ORDER = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b']
MIDDLE_OCTAVE = 4
SEMITONES_IN_OCTAVE = 12
OCTAVE_JUMP = (int(octave) - MIDDLE_OCTAVE) * SEMITONES_IN_OCTAVE
return NOTE_ORDER.index(note) + OCTAVE_JUMP

@staticmethod
def get_data(melody, defaults):
NOTES = melody.split(',')
BEAT_EVERY = 60000 / int(defaults['bpm'])

def calculate_duration(beat_every, note_duration, dots):
DURATION = (beat_every * 4) / note_duration
return DURATION * (1.9375 if dots == 4 else 1.875 if dots == 3 else 1.75 if dots == 2 else 1.5 if dots == 1 else 1)

def calculate_frequency(note, octave):
if note == 'p':
return 0
C4 = 261.63
TWELFTH_ROOT = 2 ** (1 / 12)
N = AM32_Rtttl._calculate_semitones_from_c4(note, octave)
return round(C4 * (TWELFTH_ROOT ** N) * 10) / 10

def calculate_semitones_from_c4(note, octave):
NOTE_ORDER = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b']
MIDDLE_OCTAVE = 4
SEMITONES_IN_OCTAVE = 12
OCTAVE_JUMP = (octave - MIDDLE_OCTAVE) * SEMITONES_IN_OCTAVE
return NOTE_ORDER.index(note) + OCTAVE_JUMP

NOTE_REGEX = re.compile(r'(1|2|4|8|16|32|64)?((?:[a-g]|h|p)#?){1}(\.*)(1|2|3|4|5|6|7|8)?(\.*)')
parsed_notes = []

for note in NOTES:
match = NOTE_REGEX.match(note)
if match:
NOTE_DURATION = match.group(1) or int(defaults['duration'])
NOTE = 'b' if match.group(2) == 'h' else match.group(2)
NOTE_OCTAVE = match.group(4) or int(defaults['octave'])
NOTE_DOTS = match.group(3).count('.') if match.group(3) else match.group(5).count('.') if match.group(5) else 0

parsed_notes.append({
'note': NOTE,
'duration': calculate_duration(BEAT_EVERY, float(NOTE_DURATION), NOTE_DOTS),
'frequency': calculate_frequency(NOTE, NOTE_OCTAVE)
})

return parsed_notes

@staticmethod
def _calculate_am32_temp3_from_frequency(freq):
return 0 if freq == 0 else round(1000000 / (freq * 24.72) - 399.3 / 24.72)

@staticmethod
def _calculate_frequency_from_am32_temp3(temp3):
return 0 if temp3 == 0 else 1000000 / (24.72 * temp3 + 399.3)

@staticmethod
def _calculate_note_name_from_frequency(freq):
if freq == 0:
return 'p'
C4 = 261.63
NOTE_ORDER = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b']
SEMITONES_IN_OCTAVE = 12
note_semitones = round(SEMITONES_IN_OCTAVE * math.log2(freq / C4))
note_index = note_semitones % SEMITONES_IN_OCTAVE if note_semitones >= 0 else 12 + note_semitones % SEMITONES_IN_OCTAVE
return NOTE_ORDER[note_index]

@staticmethod
def _calculate_frequency(note, octave):
if note == 'p':
return 0
C4 = 261.63
NOTE_ORDER = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b']
SEMITONES_IN_OCTAVE = 12
MIDDLE_OCTAVE = 4
note_index = NOTE_ORDER.index(note)
octave_diff = int(octave) - MIDDLE_OCTAVE
semitone_diff = note_index + (octave_diff * SEMITONES_IN_OCTAVE)
return C4 * (2 ** (semitone_diff / SEMITONES_IN_OCTAVE))

@staticmethod
def _calculate_note_octave_from_frequency(freq):
if freq == 0:
return 0
C4 = 261.63
MIDDLE_OCTAVE = 4
SEMITONES_IN_OCTAVE = 12
note_semitones = round(SEMITONES_IN_OCTAVE * math.log2(freq / C4))
return MIDDLE_OCTAVE + note_semitones // SEMITONES_IN_OCTAVE

if __name__ == '__main__':
rtttl_string = "bluejay:b=570,o=4,d=32:4b,p,4e5,p,4b,p,4f#5,2p,4e5,2b5,8b5"

print("Test RTTTL String:", rtttl_string)

# Convert the RTTTL string to a am32 startup melody
am32_melody = AM32_Rtttl.to_am32_startup_melody(rtttl_string, 128)

# Extract the data array
data_array = am32_melody['data']

# Print the data array
print("AM32 EEPROM Struct:", list(data_array))

# Convert the data array back to a melody string
melody_string = AM32_Rtttl.from_am32_startup_melody(data_array, "bluejay_converted")

# Print the converted melody string
print("Converted Melody String:", melody_string)

print("Test Empty String")
am32_melody = AM32_Rtttl.to_am32_startup_melody("", 128)
data_array = am32_melody['data']
print("Empty String to AM32 EEPROM Struct:", list(data_array))
Loading

0 comments on commit dd07957

Please sign in to comment.