From ad9ddf5ab9e5503ef1f800778ec6c955c52e7df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20Doar=C3=A9?= Date: Sat, 29 Mar 2025 17:43:09 +0100 Subject: [PATCH 1/3] Fix bug in circle_of_5ths_rotate if cmajor is not the strating scale --- scale.lua | 2 +- test/test_scale.lua | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scale.lua b/scale.lua index 057e98e..7279e5c 100644 --- a/scale.lua +++ b/scale.lua @@ -115,7 +115,7 @@ end ]] function Scale:circle_of_5ths_rotate(iterations) local total_alterations = self:count_alterations()+iterations - local rotation = ( (total_alterations + 5) % 12 ) - 5 + local rotation = ( (total_alterations + 5) % 12 ) - 5 - self:count_alterations() if(rotation == 0) then return self elseif(rotation < 0) then diff --git a/test/test_scale.lua b/test/test_scale.lua index 132c015..92b8664 100644 --- a/test/test_scale.lua +++ b/test/test_scale.lua @@ -368,4 +368,10 @@ function TestScale:test_c5ths_rotate() luaunit.assertEquals(c_major:circle_of_5ths_rotate(-8), c_major:circle_of_5ths_rotate(4)) luaunit.assertEquals(c_major:circle_of_5ths_rotate(123), c_major:circle_of_5ths_rotate(3)) luaunit.assertEquals(c_major:circle_of_5ths_rotate(-123), c_major:circle_of_5ths_rotate(-3)) + + local next = c_major:circle_of_5ths_rotate(1) + for i = 1,11 do + next = next:circle_of_5ths_rotate(1) + end + luaunit.assertEquals(next, c_major) end From b5fcb69bb154f75511ba11471253be881a6c042e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20Doar=C3=A9?= Date: Sat, 4 Apr 2026 19:08:07 +0200 Subject: [PATCH 2/3] Add rotate_to_note --- scale.lua | 14 +++++++++++++- test/test_scale.lua | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/scale.lua b/scale.lua index 7279e5c..4e6996d 100644 --- a/scale.lua +++ b/scale.lua @@ -127,6 +127,18 @@ function Scale:circle_of_5ths_rotate(iterations) end end +--[[ + Rotates the scale so that the specified note becomes the new root. + This is a permutation of the existing notes, not a transposition. +]] +function Scale:rotate_to_note(target_note) + for i, note in ipairs(self.notes) do + if note.name == target_note.name then + return self:rotate(i) + end + end + return self +end --[[ Returns a new scale based on self, rotated from note_offset @@ -240,4 +252,4 @@ end function Scale:get_note_from_degree(degree) local note = self.notes[degree.name] return Note(note.name, note.alteration + degree.alteration) -end \ No newline at end of file +end diff --git a/test/test_scale.lua b/test/test_scale.lua index 92b8664..3f39fc4 100644 --- a/test/test_scale.lua +++ b/test/test_scale.lua @@ -375,3 +375,20 @@ function TestScale:test_c5ths_rotate() end luaunit.assertEquals(next, c_major) end + +function TestScale:testRotateToNote() + local c_major = Scale.c_major() -- C, D, E, F, G, A, B + + -- Test successful rotation to F (Lydian mode) + local f_note = Note(NotesNames.F, 0) + local rotated = c_major:rotate_to_note(f_note) + luaunit.assertEquals(rotated.notes[1].name, NotesNames.F) + luaunit.assertEquals(rotated.notes[2].name, NotesNames.G) + luaunit.assertEquals(rotated.notes[7].name, NotesNames.E) + + -- Test rotating to current root (C) + local c_note = Note(NotesNames.C, 0) + local rotated_c = c_major:rotate_to_note(c_note) + luaunit.assertEquals(rotated_c.notes[1].name, NotesNames.C) + +end From 99e3f34ff9a4d50a253badc99f3ccc47f4a176e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20Doar=C3=A9?= Date: Sat, 4 Apr 2026 19:08:50 +0200 Subject: [PATCH 3/3] Nearest semitones functions --- chord.lua | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/chord.lua b/chord.lua index 345c2c8..1324466 100644 --- a/chord.lua +++ b/chord.lua @@ -307,6 +307,38 @@ function Chord:to_ascending_semitones() return asc_semitones end +function get_nearest_semitones(reference,new) + local nearest_semitones = {} + for k = 1, #new do + local note = new[k] + local nearest = note + local min_distance = math.huge + for _, target_note in ipairs(reference) do + for octave_shift = -1, 1 do + local candidate = note + 12 * octave_shift + local distance = math.abs(candidate - target_note) + if distance < min_distance then + nearest = candidate + min_distance = distance + end + end + end + nearest_semitones[k] = nearest + end + return nearest_semitones +end + +-- Chord inversion that maximizes proximity with a previous chord +-- Returns the notes as semitones that are nearest +-- to the given reference notes +-- The notes are sorted in ascending order +function Chord:to_nearest_semitones_from(reference) + local f = function(n) return n:to_semitone() end + local semitones = self:to_notes():map(f) + return get_nearest_semitones(reference,semitones) +end + + ChordBuilder = {} ChordBuilder.__index = ChordBuilder