import numpy as np
import math
import random
from keras.models import Sequential
from keras.layers import Dense, Activation

# Chord accompaniment
# From a 4 notes melody predicts the accompanying chord
# This is a simplistic pedagogical example
# Jean-Pierre Briot, UNIRIO, 05/09/2018 

# This is the value encoded version

# Structures

# Input : Melody with 4 notes : Value encoding
# Output : Chord : represented by its root note : Value encoding

# Notes
# Reference : C = C3 : 60

# List of note names
note_name_list = ['DO', 'DO#', 'RE', 'RE#', 'MI', 'FA', 'FA#', 'SOL', 'SOL#', 'LA', 'LA#', 'SI']
# English notation: = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']

# Dictionary making correspondence between a note name and its value
note_value_dictionary = {'DO': 0, 'DO#': 1, 'RE': 2, 'RE#': 3, 'MI': 4, 'FA': 5, 'FA#': 6, 'SOL': 7, 'SOL#': 8, 'LA': 9, 'LA#': 10, 'SI': 11}

# Note values are MIDI note numbers
# DO : C1 : 0 : 8.1757989156 Hz
# These are the lowest MIDI notes, very low ! But they are easier to understand because of the 12 modulo operation

#  List of note values
note_value_list = list(note_value_dictionary.values())			# -> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]

# Note and melody manipulation functions

# From a symbolic note name to the note value
# Ex: note_name_to_value('A') -> (69)
def note_name_to_value(note_name):
	return(note_name_list.index(note_name))

# From a note value to the symbolic note name
# Ex: note_value_to_name(69) -> 'A'
# math.fmod is used for modulo (12)
def note_value_to_name(note_value):
	return(note_name_list[round(math.fmod(round(note_value), 12))])

# From a melody (list) of symbolic notes to the melody list of notes (pitch) values
# Ex: melody_notes_names_to_values(['D', 'FA#', 'LA', DO#']) -> [62, 66, 69, 61]
# In Python the first element of a collection (list, array...) has 0 as its index
def melody_names_to_values(melody_names):
	return([note_name_to_value(melody_names[0]), note_name_to_value(melody_names[1]), note_name_to_value(melody_names[2]), note_name_to_value(melody_names[3])])

# Example of melody serving as a basis for constructing other melodies
# Corresponds to the tetrachord (4 notes) of a DO Major chord with its major 7th (1st = DO, 3rd major = MI, 5th = SOL, 7th major = SI)
do_major_chord_melody_names = ['DO', 'MI', 'SOL', 'SI']

# Transposition function for a note name
# Ex : transpose_note_name('DO', 2) -> 'RE'
# The arithmetic and modulo operation is done on the corresponding values
def transpose_note_name(note_name, transposition_value):
	return(note_value_to_name(note_name_to_value(note_name) + transposition_value))

# Transposition function for a melody notes names
# Ex : transpose_melody_names['DO', 'MI', 'SOL', 'SI'], 2) -> ['RE', 'FA#', 'LA', 'DO#']
def transpose_melody_names(melody_names, transposition_value):
	return([transpose_note_name(melody_names[0], transposition_value), transpose_note_name(melody_names[1], transposition_value), transpose_note_name(melody_names[2], transposition_value), transpose_note_name(melody_names[3], transposition_value)])
	
# Data set construction
# For symbolic note names
# X_melody : List of melody examples - each example melody will be an input of the network
# y_chord : List of corresponding chord examples - the predictions to be made - each example chord will be an output of the network
# Both are initially empty lists
X_melody = []
y_chord = []

# Generate and append all transpositions of the do_major_chord_melody_names melody (['DO', 'MI', 'SOL', 'SI']) and of the corresponding chord ('DO')
for transposition_value in range(12):
	X_melody.append(transpose_melody_names(do_major_chord_melody_names, transposition_value))
	y_chord.append(transpose_note_name('DO', transposition_value))

# Generate and append possible permutations
# Ex : ['DO', 'MI', 'SOL', 'SI'] -> ['SOL', 'DO, 'SI', 'SOL']
# random.sample(list, n) creates a list of length n with random permutations of the elements from list
# random.sample(X_melody[i], 4) creates a random permutation of the notes names of the ith example melody
max_permutations = 24		# max : 4! = 24

for i in range(len(X_melody)):
	for n in range(max_permutations):
		X_melody.append(random.sample(X_melody[i], 4))
		y_chord.append(y_chord[i])

# Data set creation by transforming note names into values and by normalization and scaling of values
X_train = []
y_train = []

# Create corresponding melodies with note values
for i in range(len(X_melody)):
	X_train.append(melody_names_to_values(X_melody[i]))
	y_train.append(note_name_to_value(y_chord[i]))

# Normalization and scaling in order for values to be within [-0.5 +0.5] interval
# This improves training
# Normalization : center the interval of values by subtracting the mean value
# Scaling : reduce the interval of values by dividing by the range of the interval

# Normalize and scale a note value
def normalize_and_scale_note_value(note_value, mean, range):
	return((note_value - mean) / range)

# Unnormalize and unscale a value
# This inverse operation is necessary in order to transform the output value into a correct value in order to get a valid note name
def un_normalize_and_un_scale(value, mean, range):
	return((value * range) + mean)

# Normalize and scale values of a melody
def normalize_and_scale_melody_values(melody_values, mean, range):
	return([normalize_and_scale_note_value(melody_values[0], mean, range), normalize_and_scale_note_value(melody_values[1], mean, range), normalize_and_scale_note_value(melody_values[2], mean, range), normalize_and_scale_note_value(melody_values[3], mean, range)])

# Normalize and scale a list of note values
# Warning ! Python imposes not to use 'range' as the name of a function variable, otherwise it creates name conflict (and error) with the function name range...
def normalize_and_scale_list_note_value(list_note_value, notes_values_mean, notes_values_range):
	new_list = []
	for i in range(len(list_note_value)):
		new_list.append(normalize_and_scale_note_value(list_note_value[i], notes_values_mean, notes_values_range))
	return(new_list)

# Normalize and scale a list of melody values
def normalize_and_scale_list_melody_values(list_melody_values, notes_values_mean, notes_values_range):
	new_list = []
	for i in range(len(list_melody_values)):
		new_list.append(normalize_and_scale_melody_values(list_melody_values[i], notes_values_mean, notes_values_range))
	return(new_list)

# Mean and range of the list of notes ('DO' to 'SI')
notes_values_mean = (note_name_to_value('DO') + note_name_to_value('SI')) / 2
notes_values_range = note_name_to_value('SI') - note_name_to_value('DO')

# Scaling and normalization of the X and y lists of examples
X_train = normalize_and_scale_list_melody_values(X_train, notes_values_mean, notes_values_range)
y_train = normalize_and_scale_list_note_value(y_train, notes_values_mean, notes_values_range)

# Encoding in numpy arrays, the data type used by neural networks
X_train = np.array(X_train)
y_train = np.array(y_train)

# Input size : 4 notes melody : 4
input_size = 4

# Output size : Chord : Note : 1
output_size = 1

# Hidden layer sizes
# 2 hidden layers
hidden1_size = 50
hidden2_size = 50

# Definition of the model (the deep network architecture)
# Sequential : a succession of layers
model = Sequential()

# First layer
# The size of the input interface is provided
model.add(Dense(hidden1_size,
    		input_dim = input_size))
# Activation function of the first layer
model.add(Activation('tanh'))
# Second layer
model.add(Dense(hidden2_size))
# Activation function of the second layer
model.add(Activation('tanh'))
# Third (output) layer
model.add(Dense(output_size))
# There is no activation needed : Last layer output is linear : This is a prediction task

# Creation of the model
model.compile(loss = 'mean_squared_error',
	optimizer = 'sgd')

# Training the model

model.fit(x = X_train,
	y = y_train)

# Prediction by the model

def predict_chord(melody_names):
# Getting the values corresponding to the note names of the melody
	melody_values = melody_names_to_values(melody_names)
# Normalization and scaling of the values
	melody_values = normalize_and_scale_melody_values(melody_values, notes_values_mean, notes_values_mean)
# predict by the model needs as an input a list of x inputs (we can simultaneously predict multiple y)
# and encoding in a numpy array
	input_list = np.array([melody_values])
# The model computes the list of predicted chords associated to the list of melodies
	prediction_list = model.predict(input_list)
# Retrieve the first (and only) chord
	print(prediction_list)
	predicted_note_value = prediction_list[0][0]
# Unnormalize and unscale, in order to compute the values of the chord within the original note value range
	note_value = un_normalize_and_un_scale(predicted_note_value, notes_values_mean, notes_values_range)
# Get the corresponding note (chord) name
	return(note_value_to_name(note_value))

# Testing/examples of prediction
['DO', 'MI', 'SOL', 'SI']

def print_result(test_melody_names, expected_chord_name):
	print(str(test_melody_names) + ' -> ' + str(predict_chord(test_melody_names)) + ' expected: ' + str(expected_chord_name))

print_result(['DO', 'MI', 'SOL', 'DO'], 'DO')

print_result(['MI', 'SI', 'DO', 'SOL'], 'DO')

print_result(['RE', 'FA#', 'LA', 'DO#'], 'RE')

print_result(['FA', 'MI', 'LA', 'DO'], 'FA')
