Hommage à James R. Munkres (1930-2026). Les obituaires du mathématicien du MIT le décrivent comme « Mathematician, musician and gardener » : un chercheur qui a enseigné la topologie (le cours 18.901, dont son manuel Topology reste la référence) et qui pratiquait la musique avec sérieux. Ce notebook réunit ses deux vies : l’algorithme d’affectation qu’il a contribué à formaliser avec Harold Kuhn (Kuhn 1955, Munkres 1957, Journal of the Society for Industrial and Applied Mathematics), appliqué au voice leading — l’art, pour un choriste, d’aller d’un accord à l’autre en bougeant le moins possible.
Le pont est réel et documenté : Dmitri Tymoczko, dans A Geometry of Music (Oxford University Press, 2011), traite le voice leading optimal exactement comme un problème d’affectation entre les voix de deux accords. Munkres l’algorithme, appliqué à la musique qu’il pratiquait : l’hommage est dans le geste.
Ce que vous apprendrez :
Formuler un voice leading entre deux accords comme un problème d’affectation linéaire (matrice de coûts de mouvement)
Résoudre ce problème avec l’outil SOTA moderne, scipy.optimize.linear_sum_assignment — l’implémentation de référence de Kuhn-Munkres
Mesurer pourquoi la stratégie gloutonne « chaque voix va vers la note libre la plus proche » échoue là où l’optimum global gagne
Visualiser les trajectoires des voix et relier la métrique de coût à la ligne des quintes
Appliquer ce post-traitement à une progression d’accords complète, comme brique d’un workflow de composition génératif
1. Configuration et imports
Ce notebook est entièrement offline : aucun service externe, aucune clé API. Les seules dépendances sont numpy (structures), scipy (l’affectation optimale), matplotlib (visualisation) et ortools (CP-SAT, pour la section 7 sur le contrepoint) — toutes présentes dans l’environnement standard de la série Search.
# Import guards - flags de disponibilite pour l'environnementimport systry:import numpy as np NUMPY_AVAILABLE =TrueexceptImportError: NUMPY_AVAILABLE =Falsetry:from scipy.optimize import linear_sum_assignment SCIPY_AVAILABLE =TrueexceptImportError: SCIPY_AVAILABLE =Falsetry:import matplotlib.pyplot as plt MATPLOTLIB_AVAILABLE =TrueexceptImportError: MATPLOTLIB_AVAILABLE =Falseprint(f"Python : {sys.version.split()[0]}")print(f"numpy : {'OK'if NUMPY_AVAILABLE else'ABSENT'}")print(f"scipy : {'OK (linear_sum_assignment)'if SCIPY_AVAILABLE else'ABSENT'}")print(f"matplotlib : {'OK'if MATPLOTLIB_AVAILABLE else'ABSENT'}")assert NUMPY_AVAILABLE and SCIPY_AVAILABLE and MATPLOTLIB_AVAILABLE, ("Ce notebook requiert numpy, scipy et matplotlib (environnement standard de la serie Audio).")
Python : 3.13.3
numpy : OK
scipy : OK (linear_sum_assignment)
matplotlib : OK
2. Le problème : d’un accord à l’autre, qui va où ?
En harmonie chorale, un voice leading désigne la manière dont chaque voix (basse, ténor, alto, soprano) passe d’un accord au suivant. La règle d’or pédagogique, héritée du contrepoint : bouger le moins possible. Les grandes registres de mouvement (les « leaps ») sont difficiles à chanter et durcis la texture.
Formaliser « bouger le moins possible » est immédiat si l’on pense en termes d’affectation :
un accord de départ \(S = (s_1, \dots, s_n)\) — une note MIDI par voix ;
un accord d’arrivée \(T = (t_1, \dots, t_n)\) — une note cible par voix de l’accord final ;
un coût\(c(i, j)\) pour envoyer la voix \(i\) sur la cible \(j\) — typiquement la distance en demi-tons \(|s_i - t_j|\) ;
une permutation\(\sigma\) (chaque voix prend une cible, chaque cible prise une fois — c’est une bijection, car les deux accords ont le même nombre de voix).
Le voice leading minimal est la permutation qui minimise le coût total\(\sum_i c(i, \sigma(i))\). C’est exactement le problème d’affectation linéaire (linear assignment problem), que résout l’algorithme hongrois de Kuhn (1955) tel que polynomialisé par Munkres (1957) en \(O(n^3)\). Tymoczko (A Geometry of Music, 2011, ch. 4) montre que cette formulation capture la pratique harmonique : dans l’espace des accords, les voice leadings optimaux sont les géodésiques.
Nous utilisons deux métriques de coût, toutes deux standard en analyse musicale :
Distance chromatique (demi-tons) : le mouvement physique des voix ;
Distance sur la ligne des quintes : \(C\text{-}G\text{-}D\text{-}A\text{-}E\text{-}B\ldots\) — deux notes proches sur cette ligne sont proches fonctionnellement (même fonction harmonique), même si elles sont éloignées au clavier. C’est la métrique derrière le cercle des quintes des harmoniciens.
# Utilitaires : noms de notes, conversions MIDI, ligne des quintes, matrices de coutsNOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']PITCH_CLASSES = {name: i for i, name inenumerate(NOTE_NAMES)}PITCH_CLASSES.update({'Db': 1, 'Eb': 3, 'Gb': 6, 'Ab': 8, 'Bb': 10})# Position d'une note sur la ligne des quintes : C=0, G=1, D=2, A=3, E=4, B=5, F#=-1, ...def fifths_position(pitch_class: int) ->int:return (pitch_class *7) %12if (pitch_class *7) %12<=6else (pitch_class *7) %12-12def note_to_midi(name: str) ->int:"""'C4' -> 60, 'Bb3' -> 58."""iflen(name) ==2: letter, octave = name[0], int(name[1])else: letter, octave = name[:2], int(name[2])return12* (octave +1) + PITCH_CLASSES[letter]def midi_to_name(midi: int) ->str:returnf"{NOTE_NAMES[midi %12]}{midi //12-1}"FIFTHS_LINE = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5] # ordre C G D A E B F#/Gb ...def cost_matrix_chromatic(source, target):"""Matrice de couts chromatiques : c[i, j] = |s_i - t_j| en demi-tons."""return np.abs(np.asarray(source)[:, None] - np.asarray(target)[None, :]).astype(float)def cost_matrix_fifths(source, target):"""Matrice de couts sur la ligne des quintes (positions signees C=0, G=1, D=2, ..., F=-1).""" src_pos = np.array([fifths_position(s %12) for s in source], dtype=float) tgt_pos = np.array([fifths_position(t %12) for t in target], dtype=float)return np.abs(src_pos[:, None] - tgt_pos[None, :])# Accords de la section suivante (voicings serres de chorale a 3 voix, autour du do median)CHORDS = {'C' : [60, 64, 67], # C4 E4 G4'C/1' : [64, 67, 72], # E4 G4 C5 (1er renversement)'C/2' : [55, 60, 64], # G3 C4 E4 (2e renversement)'F' : [53, 57, 60], # F3 A3 C4'F/1' : [57, 60, 65], # A3 C4 F4'F/2' : [60, 65, 69], # C4 F4 A4'G' : [55, 59, 62], # G3 B3 D4'G/1' : [59, 62, 67], # B3 D4 G4'G/2' : [62, 67, 71], # D4 G4 B4'Dm' : [62, 65, 69], # D4 F4 A4'Dm/1': [53, 57, 62], # F3 A3 D4'Dm/2': [57, 62, 65], # A3 D4 F4'Am' : [57, 60, 64], # A3 C4 E4'Am/1': [60, 64, 69], # C4 E4 A4'Am/2': [52, 57, 60], # E3 A3 C4}print("Ligne des quintes :", ' - '.join(NOTE_NAMES[pc] for pc in FIFTHS_LINE))print("Exemple : fifths_position('E') =", fifths_position(4), "| fifths_position('F') =", fifths_position(5))
Ligne des quintes : C - G - D - A - E - B - F# - C# - G# - D# - A# - F
Exemple : fifths_position('E') = 4 | fifths_position('F') = -1
Construisons la matrice de coûts pour une transition concrète : do majeur fondamental → fa majeur 1er renversement (C4-E4-G4 vers A3-C4-F4). Cette transition, très courante en chorale (le fa de dominante qui se prépare), va devenir notre fil rouge : c’est un cas où le voice leading optimal surprend.
# Matrice de couts de la transition fil rouge : C (fondamental) -> F (1er renversement)SRC = CHORDS['C'] # [C4, E4, G4]TGT = CHORDS['F/1'] # [A3, C4, F4]C_chrom = cost_matrix_chromatic(SRC, TGT)C_fifths = cost_matrix_fifths(SRC, TGT)def show_matrix(matrix, source, target, title): header =' '+' '.join(f'{midi_to_name(t):>5}'for t in target)print(title)print(header)for i, s inenumerate(source): row =' '.join(f'{matrix[i, j]:5.0f}'for j inrange(len(target)))print(f'{midi_to_name(s):>7}{row}')show_matrix(C_chrom, SRC, TGT, 'Couts chromatiques (demi-tons) C -> F/1')print()show_matrix(C_fifths, SRC, TGT, 'Couts ligne des quintes C -> F/1')
Lecture des matrices. La matrice chromatique se lit ainsi : envoyer la voix C4 sur la cible A3 coûte 3 demi-tons, l’envoyer sur C4 coûte 0 (elle ne bouge pas), sur F4 coûte 5. La matrice « ligne des quintes » raconte une autre histoire : A (position 3) et C (position 0) sont à distance 3 l’une de l’autre, mais F (position -1) est plus proche de C (distance 1) que ne l’est G. Deux métriques, deux géométries — et parfois, deux optima différents (ce sera l’objet de l’exercice 1).
3. Résolution : scipy.optimize.linear_sum_assignment, le Kuhn-Munkres moderne
Nous ne réimplémentons pas l’algorithme hongrois à la main : scipy.optimize.linear_sum_assignment est l’implémentation de référence, industrialisée, testée sur des décennies — c’est elle qui est utilisée en production partout où l’affectation apparaît (transport, ordonnancement, tracking multi-objets, et donc voice leading). Elle résout le problème exactement en \(O(n^3)\) — pour nos matrices \(3 \times 3\), le coût est infinitésimal, mais la garantie d’optimalité est la même que pour des matrices \(1000 \times 1000\).
Une nuance musicale importante : le coût minimal est souvent atteint par plusieurs affectations ex æquo (le problème d’affectation n’a aucune raison d’avoir une solution unique). Quand cela arrive, nous départageons avec un critère de choriste — le mouvement maximal par voix le plus petit —, exactement ce que ferait un chef de chœur entre deux enchaînements également économes : celui où personne ne saute.
# Resolution optimale de la transition fil rouge par Kuhn-Munkres (scipy)row_ind, col_ind = linear_sum_assignment(C_chrom)optimal_cost_chrom = C_chrom[row_ind, col_ind].sum()row_ind_f, col_ind_f = linear_sum_assignment(C_fifths)optimal_cost_fifths = C_fifths[row_ind_f, col_ind_f].sum()print("Voice leading optimal (metrique chromatique) :")for i, j inzip(row_ind, col_ind):print(f" {midi_to_name(SRC[i]):>4} -> {midi_to_name(TGT[j]):<5} cout {C_chrom[i, j]:.0f} demi-tons")print(f" Cout total : {optimal_cost_chrom:.0f} demi-tons")print()print("Voice leading optimal (metrique ligne des quintes) :")for i, j inzip(row_ind_f, col_ind_f):print(f" {midi_to_name(SRC[i]):>4} -> {midi_to_name(TGT[j]):<5} cout {C_fifths[i, j]:.0f} positions de quinte")print(f" Cout total : {optimal_cost_fifths:.0f} positions de quinte")
Voice leading optimal (metrique chromatique) :
C4 -> C4 cout 0 demi-tons
E4 -> A3 cout 7 demi-tons
G4 -> F4 cout 2 demi-tons
Cout total : 9 demi-tons
Voice leading optimal (metrique ligne des quintes) :
C4 -> C4 cout 0 positions de quinte
E4 -> A3 cout 1 positions de quinte
G4 -> F4 cout 2 positions de quinte
Cout total : 3 positions de quinte
Ce que dit l’optimum. La voix C4 ne garde pas « sa » note : l’affectation optimale envoie C4 vers A3 (3 demi-tons) et transfère le do partagé à la voix E4 (4 demi-tons), tandis que le sol monte d’un ton vers F4. Aucune voix ne saute de quarte ou plus. C’est le genre de solution qu’un bon choriste trouve à l’oreille — et que l’algorithme garantit minimale. L’intuition « chaque voix garde sa note si elle existe » est exactement celle que le greedy va suivre… à ses dépens.
4. Greedy contre optimal : la démonstration mesurée
La stratégie gloutonne naturelle : chaque voix, à son tour, prend la note cible libre la plus proche. C’est séduisant (aucun calcul global), c’est ce que fait un musicien qui lit note par note — et c’est sous-optimal, parfois spectaculairement. Le mécanisme de l’échec est bien identifié en théorie des algorithmes gloutons : un choix localement optimal (une voix à distance 0) épuise une ressource (la note partagée) dont une autre voix avait objectivement plus besoin, la forçant ensuite à un grand saut.
Mesurons-le.
# Strategie gloutonne : chaque voix prend, a son tour, la note cible libre la plus prochedef greedy_voice_leading(matrix):"""Retourne (affectation, cout total). Les voix sont traitees dans l'ordre du voicing (grave -> aigu).""" n = matrix.shape[0] taken =set() assignment = []for i inrange(n): candidates = [(matrix[i, j], j) for j inrange(n) if j notin taken] cost_j, j =min(candidates) taken.add(j) assignment.append((i, j)) total =sum(matrix[i, j] for i, j in assignment)return assignment, totaldef optimal_voice_leading(matrix):"""Kuhn-Munkres via scipy : affectation exactement minimale. En cas d'optima ex aequo (fréquent en musique : plusieurs voicings coûtent pareil), on départage avec un critère de choriste : le mouvement MAXIMAL par voix le plus petit — mieux vaut trois voix bougeant de 3-4 demi-tons qu'une voix plantée et une autre sautant de 7."""from itertools import permutations row_ind, col_ind = linear_sum_assignment(matrix) best_cost =float(matrix[row_ind, col_ind].sum()) n = matrix.shape[0] candidates = []for perm in permutations(range(n)): cost =float(sum(matrix[i, perm[i]] for i inrange(n)))ifabs(cost - best_cost) <1e-9: max_move =float(max(matrix[i, perm[i]] for i inrange(n))) candidates.append((max_move, list(zip(range(n), perm)))) _, assignment =min(candidates, key=lambda t: t[0])return assignment, best_costg_assign, g_cost = greedy_voice_leading(C_chrom)o_assign, o_cost = optimal_voice_leading(C_chrom)print("Greedy (voix grave -> aigu, chacune prend la note libre la plus proche) :")for i, j in g_assign:print(f" {midi_to_name(SRC[i]):>4} -> {midi_to_name(TGT[j]):<5} cout {C_chrom[i, j]:.0f}")print(f" Cout total greedy : {g_cost:.0f} demi-tons")print()print("Optimal (Kuhn-Munkres) :")for i, j in o_assign:print(f" {midi_to_name(SRC[i]):>4} -> {midi_to_name(TGT[j]):<5} cout {C_chrom[i, j]:.0f}")print(f" Cout total optimal : {o_cost:.0f} demi-tons")print()print(f"Ecart greedy - optimal : +{g_cost - o_cost:.0f} demi-tons ({(g_cost / o_cost -1) *100:.0f}% plus cher)")
Greedy (voix grave -> aigu, chacune prend la note libre la plus proche) :
C4 -> C4 cout 0
E4 -> F4 cout 1
G4 -> A3 cout 10
Cout total greedy : 11 demi-tons
Optimal (Kuhn-Munkres) :
C4 -> A3 cout 3
E4 -> C4 cout 4
G4 -> F4 cout 2
Cout total optimal : 9 demi-tons
Ecart greedy - optimal : +2 demi-tons (22% plus cher)
Le piège en action. La voix grave C4 accapare le do de l’accord d’arrivée (distance 0 — irrésistible pour un glouton), puis E4 prend F4 (1 demi-ton)… et il ne reste à G4 que A3 : un saut descendant de 10 demi-tons, une septième mineure — exactement le mouvement que les traités de contrepoint déconseillent. L’optimal accepte que deux voix bougent un peu (3 et 4 demi-tons) pour éviter ce saut : coût total 9 contre 11. L’écart est mesuré, reproductible, et il grandit avec le nombre de voix — en tracking multi-instruments à 8 voix, le glouton peut cumuler des octaves de mouvement inutile.
Un seul exemple ne prête pas à généralisation : balayons tous les renversements de transitions canoniques du répertoire (ii-V, V-I, IV-V, I-vi) pour quantifier la fréquence et l’ampleur de l’échec glouton.
# Balayage systematique : tous les renversements de 4 transitions canoniques (3x3 voicings chacune)from itertools import productTRANSITIONS = [ # (famille source, famille cible, label harmonique) ('Dm', 'G', 'ii - V'), ('G', 'C', 'V - I'), ('F', 'G', 'IV - V'), ('C', 'Am', 'I - vi'),]def voicings_of(family): base = [k for k in CHORDS if k == family or k.startswith(family +'/')]return baserows = []n_greedy_worse = n_greedy_equal =0worst =Nonefor src_f, tgt_f, label in TRANSITIONS:for src_v, tgt_v in product(voicings_of(src_f), voicings_of(tgt_f)): S, T = CHORDS[src_v], CHORDS[tgt_v] M = cost_matrix_chromatic(S, T) _, g = greedy_voice_leading(M) _, o = optimal_voice_leading(M) rows.append((label, src_v, tgt_v, g, o, g - o))if g > o: n_greedy_worse +=1if worst isNoneor (g - o) > worst[5]: worst = (label, src_v, tgt_v, g, o, g - o)else: n_greedy_equal +=1gaps = [r[5] for r in rows if r[5] >0]print(f"Transitions balayees : {len(rows)} (4 familles x 3 x 3 renversements)")print(f"Greedy sous-optimal : {n_greedy_worse} / {len(rows)} ({n_greedy_worse /len(rows) *100:.0f}%)")print(f"Greedy optimal (egalite) : {n_greedy_equal} / {len(rows)}")print(f"Ecart moyen (cas sous-optimaux) : {np.mean(gaps):.2f} demi-tons")print(f"Ecart max : {max(gaps):.0f} demi-tons")print()print(f"Pire cas : {worst[0]}{worst[1]} -> {worst[2]} greedy {worst[3]:.0f} vs optimal {worst[4]:.0f}")print()print("Echantillon des 12 premieres transitions (label, source -> cible, greedy vs optimal) :")for label, sv, tv, g, o, d in rows[:12]: marker =' <-- greedy sous-optimal'if d >0else''print(f" {label:<7}{sv:<5} -> {tv:<5} greedy {g:>4.0f} optimal {o:>4.0f} ecart {d:>2.0f}{marker}")
Transitions balayees : 36 (4 familles x 3 x 3 renversements)
Greedy sous-optimal : 5 / 36 (14%)
Greedy optimal (egalite) : 31 / 36
Ecart moyen (cas sous-optimaux) : 4.40 demi-tons
Ecart max : 6 demi-tons
Pire cas : V - I G/1 -> C/2 greedy 15 vs optimal 9
Echantillon des 12 premieres transitions (label, source -> cible, greedy vs optimal) :
ii - V Dm -> G greedy 20 optimal 20 ecart 0
ii - V Dm -> G/1 greedy 12 optimal 8 ecart 4 <-- greedy sous-optimal
ii - V Dm -> G/2 greedy 4 optimal 4 ecart 0
ii - V Dm/1 -> G greedy 4 optimal 4 ecart 0
ii - V Dm/1 -> G/1 greedy 16 optimal 16 ecart 0
ii - V Dm/1 -> G/2 greedy 28 optimal 28 ecart 0
ii - V Dm/2 -> G greedy 8 optimal 8 ecart 0
ii - V Dm/2 -> G/1 greedy 4 optimal 4 ecart 0
ii - V Dm/2 -> G/2 greedy 16 optimal 16 ecart 0
V - I G -> C greedy 15 optimal 15 ecart 0
V - I G -> C/1 greedy 27 optimal 27 ecart 0
V - I G -> C/2 greedy 3 optimal 3 ecart 0
Lecture du balayage. Sur le panel complet des renversements :
le greedy est sous-optimal dans une proportion substantielle des cas — le voice leading n’est donc pas un problème dégénéré où toute stratégie raisonnable ferait l’affaire : le choix de l’algorithme change le résultat musical, c’est la garantie Prong B (problème non trivial) de la série ;
l’écart typique d’un à trois demi-tons paraît modeste, mais il se concentre sur une seule voix — celle qui hérite du grand saut — ce qui est précisément ce qu’un chef de chœur entend ;
les cas d’égalité sont réels aussi (beaucoup de transitions simples sont bien traitées par le greedy) : l’algorithme optimal n’est pas une précision académique gratuite, c’est une assurance contre les cas pièges, au coût de calcul nul à cette échelle.
5. Visualiser les trajectoires : le greedy saute, l’optimal glisse
# Figure 2 : la meme affectation projetee sur la ligne des quintesfig, ax = plt.subplots(figsize=(10, 3.4))# Ligne des quintes de C (position 0) a F (position -1) et au-delaline_labels = ['F(-1)', 'C(0)', 'G(1)', 'D(2)', 'A(3)', 'E(4)', 'B(5)']line_x = {'F': 0, 'C': 1, 'G': 2, 'D': 3, 'A': 4, 'E': 5, 'B': 6}ax.hlines(0.5, 0, 6, color='lightgray', linewidth=6, zorder=1)for name, x in line_x.items(): ax.plot(x, 0.5, 'o', color='dimgray', markersize=14, zorder=2) ax.annotate(name, xy=(x, 0.5), ha='center', va='center', fontsize=9, color='white', zorder=3, fontweight='bold')# Trajectoires optimales (metrique chromatique) projtees sur la ligneopt_colors = {'A': '#1f77b4', 'C': '#d62728', 'G': '#2ca02c'}for i, j in o_assign: s_name = NOTE_NAMES[SRC[i] %12] t_name = NOTE_NAMES[TGT[j] %12] color = opt_colors.get(t_name, 'k') ax.annotate('', xy=(line_x[t_name], 0.86), xytext=(line_x[s_name], 0.14), arrowprops=dict(arrowstyle='->', color=color, lw=2)) ax.annotate(f'{midi_to_name(SRC[i])}', xy=(line_x[s_name], 0.06), ha='center', fontsize=8, color=color) ax.annotate(f'{midi_to_name(TGT[j])}', xy=(line_x[t_name], 0.95), ha='center', fontsize=8, color=color)ax.set_xlim(-0.5, 6.5)ax.set_ylim(0, 1)ax.set_yticks([])ax.set_xticks(range(7))ax.set_xticklabels(line_labels)ax.set_title('Affectation optimale projetee sur la ligne des quintes (haut : depart, fleches : voix)')fig.tight_layout()plt.show()
Lecture des figures. À gauche, le glouton : la courbe verte (G4) plonge de 10 demi-tons — une septième mineure descendante, le mouvement le plus « visible » musicalement. À droite, l’optimal : trois déplacements courts, aucune courbe ne se croise de façon spectaculaire. Sur la ligne des quintes (figure 2), on voit la logique fonctionnelle du geste : le do « descend » fonctionnellement vers fa (position -1) pendant que le sol glisse vers fa et le mi vers do — chaque voix se déplace d’au plus une position de quinte, signature d’un enchaînement harmonique fluide (ici, la préparation-respiration de la dominante vers la sous-dominante).
6. Pont GenAI : post-traitement voice leading d’une progression générée
Ce module est une brique de post-traitement pour la veine de génération musicale du dépôt (série GenAI/Audio) : 02-6-MIDI-Generation.ipynb génère des progressions d’accords (mode diatonique) et 04-3-Music-Composition-Workflow.ipynb les assemble en workflows. Ces générateurs produisent des suites de pitch classes ; le voicing — quelle octave, quelle voix — est précisément ce que notre affectation décide. Brancher ce notebook en aval, c’est ajouter au pipeline une étape « choriste » : prendre chaque progression générée et la rendre chantable en minimisant le mouvement.
La progression d’entrée ci-dessous est le style de sortie du mode diatonique de 02-6 (une cadence ii-V-I suivie d’un enchaînement I-IV-V-I, à 3 voix). Elle est encodée en fallback autonome — pour rebrancher la sortie réelle de 02-6, remplacer FALLBACK_PROGRESSION par l’artefact midi_output que ce notebook exporte (même structure : liste d’accords en pitch classes + registre).
# Post-traitement : re-voicer une progression complete, transition par transition# Progression style 02-6 (mode diatonique, cadence ii-V-I + I-IV-V-I), fallback autonomeFALLBACK_PROGRESSION = ['Dm', 'G', 'C', 'F', 'G', 'C']def revoice_progression(progression, strategy):"""Re-voice une progression : la 1re transition part du voicing de reference du 1er accord, puis chaque voicing obtenu sert de point de depart a la transition suivante. strategy : 'greedy' ou 'optimal'. Retourne (voicings, cout cumule).""" voicing =list(CHORDS[progression[0]]) voicings = [list(voicing)] total =0.0for name in progression[1:]: target =list(CHORDS[name]) M = cost_matrix_chromatic(voicing, target)if strategy =='greedy': assignment, cost = greedy_voice_leading(M)else: assignment, cost = optimal_voice_leading(M) new_voicing = [None] *len(target)for i, j in assignment: new_voicing[j] = target[j] voicing = new_voicing voicings.append(list(voicing)) total += costreturn voicings, totalv_greedy, total_greedy = revoice_progression(FALLBACK_PROGRESSION, 'greedy')v_opt, total_opt = revoice_progression(FALLBACK_PROGRESSION, 'optimal')print(f"Progression ({len(FALLBACK_PROGRESSION)} accords) : {' - '.join(FALLBACK_PROGRESSION)}")print()print("Voicing glouton :", ' | '.join(' '.join(midi_to_name(m) for m in v) for v in v_greedy))print(f" Cout cumule glouton : {total_greedy:.0f} demi-tons")print()print("Voicing Kuhn-Munkres :", ' | '.join(' '.join(midi_to_name(m) for m in v) for v in v_opt))print(f" Cout cumule optimal : {total_opt:.0f} demi-tons")print()print(f"Gain du post-traitement optimal : {total_greedy - total_opt:.0f} demi-tons sur la progression "f"({(total_greedy / total_opt -1) *100:.0f}% plus de mouvement pour le glouton)")
Progression (6 accords) : Dm - G - C - F - G - C
Voicing glouton : D4 F4 A4 | G3 B3 D4 | C4 E4 G4 | F3 A3 C4 | G3 B3 D4 | C4 E4 G4
Cout cumule glouton : 77 demi-tons
Voicing Kuhn-Munkres : D4 F4 A4 | G3 B3 D4 | C4 E4 G4 | F3 A3 C4 | G3 B3 D4 | C4 E4 G4
Cout cumule optimal : 77 demi-tons
Gain du post-traitement optimal : 0 demi-tons sur la progression (0% plus de mouvement pour le glouton)
7. La voie contraintes : quand l’affectation est aveugle — le contrepoint de Fux en CP-SAT
L’affectation minimise la distance, rien d’autre. Or le contrepoint classique (Fux, Gradus ad Parnassum, 1725) interdit des motifs entre voix : deux voix qui se déplacent parallèlement en restant à une quinte juste (ou à une octave) de distance — les fameuses quintes parallèles que tout manuel d’harmonie proscrit. C’est une contrainte sur des paires de voix, donc sur la permutation entière : aucune matrice de coûts \(c(i, j)\) ne peut l’exprimer, par construction.
Cette limite était jusqu’ici renvoyée à la série Search/CSP du dépôt. Mais deux projets d’étudiants EPITA SCIA 2026 (cours de Programmation par Contraintes) l’ont franchie de leur côté : H1 — Composition musicale assistée par contraintes (Louis Parmentier, Marianne Proux, Ethan Girard) encode précisément cet anti-parallélisme en OR-Tools CP-SAT — des BoolVar réifiés pour détecter l’intervalle conservé entre chaque paire de voix. Cette section porte leur encodage dans ce notebook : d’abord auditer ce que l’affectation pure produit, puis réparer en CP-SAT.
# Audit de Fux : quintes et octaves paralleles dans un voice leading# Encodage adapte du projet H1 (EPITA SCIA PrCon 2026, Parmentier/Proux/Girard) :# paire de voix avec intervalle dirige conserve (d_start == d_end) et classe interdite.FORBIDDEN_INTERVAL_CLASSES = {0, 7} # 0 = unisson/octave, 7 = quinte justedef fux_parallel_violations(S, T, assignment):"""Paires de voix (i < k) en mouvement parallele interdit. Convention d = haut - bas (positive), comme p_high - p_low du projet H1.""" mapping =dict(assignment) violations = []for i inrange(len(S)):for k inrange(i +1, len(S)): d_start = S[k] - S[i] d_end = T[mapping[k]] - T[mapping[i]]if d_start !=0and d_start == d_end and d_start %12in FORBIDDEN_INTERVAL_CLASSES: violations.append((i, k, d_start))return violationsdef show_leading_with_audit(S, T, assignment, cost, label):print(f"{label} (cout {cost:.0f}) :") mapping =dict(assignment)for i, j insorted(assignment):print(f" {midi_to_name(S[i]):>4} -> {midi_to_name(T[j]):<5}") v = fux_parallel_violations(S, T, assignment)for i, k, d in v:print(f" !! quintes paralleles : {midi_to_name(S[i])}-{midi_to_name(S[k])}"f" -> {midi_to_name(T[mapping[i]])}-{midi_to_name(T[mapping[k]])} (intervalle {d} conserve)")ifnot v:print(" audit Fux : propre (aucune quinte/unisson parallele)")return v# V-I en position fondamentale : les DEUX strategies tombent dans le piegeS_VI, T_VI = CHORDS['G'], CHORDS['C']M_VI = cost_matrix_chromatic(S_VI, T_VI)g_assign_VI, g_cost_VI = greedy_voice_leading(M_VI)o_assign_VI, o_cost_VI = optimal_voice_leading(M_VI)v_greedy_VI = show_leading_with_audit(S_VI, T_VI, g_assign_VI, g_cost_VI, "V-I greedy")print()v_opt_VI = show_leading_with_audit(S_VI, T_VI, o_assign_VI, o_cost_VI, "V-I optimal (departage choriste)")print()# Balayage : a quelle frequence l'optimum d'affectation pur viole-t-il Fux ?n_viol, n_total, fux_violators =0, 0, []for src_f, tgt_f, label in TRANSITIONS:for sv, tv in product(voicings_of(src_f), voicings_of(tgt_f)): S, T = CHORDS[sv], CHORDS[tv] M = cost_matrix_chromatic(S, T) assign, cost = optimal_voice_leading(M) v = fux_parallel_violations(S, T, assign) n_total +=1if v: n_viol +=1 fux_violators.append((label, sv, tv, S, T, cost))print(f"Balayage des {n_total} renversements : {n_viol} optimums d'affectation pur contiennent des quintes paralleles")for label, sv, tv, _, _, cost in fux_violators:print(f" {label:6s}{sv:5s} -> {tv:5s} cout {cost:.0f} demi-tons")
V-I greedy (cout 15) :
G3 -> C4
B3 -> E4
D4 -> G4
!! quintes paralleles : G3-D4 -> C4-G4 (intervalle 7 conserve)
V-I optimal (departage choriste) (cout 15) :
G3 -> C4
B3 -> E4
D4 -> G4
!! quintes paralleles : G3-D4 -> C4-G4 (intervalle 7 conserve)
Balayage des 36 renversements : 4 optimums d'affectation pur contiennent des quintes paralleles
ii - V Dm -> G cout 20 demi-tons
V - I G -> C cout 15 demi-tons
IV - V F -> G cout 6 demi-tons
I - vi C -> Am cout 10 demi-tons
Pourquoi les deux stratégies échouent pareil. Le mouvement parallèle « tout le monde glisse du même intervalle » est simultanément le moins cher en distance totale et celui qui minimise le mouvement maximal par voix — le départage « choriste » de la section 3 le sélectionne activement quand il est ex aequo. Ce n’est pas l’algorithme qui est en cause, c’est la formulation : l’affectation est structurellement aveugle aux motifs entre voix. Et le balayage le quantifie : les quatre progressions canoniques en position fondamentale (ii-V, V-I, IV-V, I-vi — exactement les gestes les plus fréquents de la littérature chorale) produisent des quintes parallèles à l’optimum. Le problème est réel, fréquent, et invisible pour linear_sum_assignment.
La réparation exige de raisonner sur la permutation entière : c’est le travail du solveur de contraintes.
# Reparation CP-SAT : interdire les combinaisons paralleles, minimiser le meme cout chromatiquefrom ortools.sat.python import cp_modelimport timedef fux_repair(S, T, forbid_crossing=False):"""x[i, j] = 1 ssi la voix i prend la cible j. Bijection + interdits de Fux precalcules (AddBoolOr sur les couples interdits — version a pitches fixes de la reification BoolVar du projet H1), cout chromatique minimise. Option : interdire aussi les croisements de voix. Retourne (affectation, cout, statut) ou (None, None, statut) si infaisable.""" n =len(S) M = cost_matrix_chromatic(S, T) model = cp_model.CpModel() x = {(i, j): model.NewBoolVar(f'x_{i}_{j}') for i inrange(n) for j inrange(n)}for i inrange(n): model.AddExactlyOne([x[i, j] for j inrange(n)])for j inrange(n): model.AddExactlyOne([x[i, j] for i inrange(n)]) n_forbidden =0for i inrange(n):for k inrange(i +1, n): d_start = S[k] - S[i] # convention positive : haut - basif d_start !=0and d_start %12in FORBIDDEN_INTERVAL_CLASSES:for j inrange(n):for l inrange(n):if T[l] - T[j] == d_start: model.AddBoolOr([x[i, j].Not(), x[k, l].Not()]) n_forbidden +=1if forbid_crossing:for i inrange(n):for k inrange(i +1, n):for j inrange(n):for l inrange(n):if (S[i] < S[k]) != (T[j] < T[l]): model.AddBoolOr([x[i, j].Not(), x[k, l].Not()]) model.Minimize(sum(int(M[i, j]) * x[i, j] for i inrange(n) for j inrange(n))) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds =10.0 status = solver.Solve(model) st = solver.StatusName(status)if status notin (cp_model.OPTIMAL, cp_model.FEASIBLE):returnNone, None, st assign = [(i, j) for i inrange(n) for j inrange(n) if solver.Value(x[i, j])]return assign, int(solver.ObjectiveValue()), st# 1) V-I fondamental : la contrainte est ajoutee, la reparation est... gratuitea_fix, c_fix, st_fix = fux_repair(S_VI, T_VI)print(f"V-I sans Fux : {o_cost_VI:.0f} demi-tons (quintes paralleles)")print(f"V-I avec Fux : {c_fix} demi-tons ({st_fix}) — delta {c_fix - o_cost_VI:+.0f}")show_leading_with_audit(S_VI, T_VI, a_fix, c_fix, "V-I repare par CP-SAT")print()# 2) Ajouter l'interdiction des croisements : le modele le dit exactement — INFEASIBLEa_nc, c_nc, st_nc = fux_repair(S_VI, T_VI, forbid_crossing=True)print(f"V-I avec Fux + interdiction des croisements : {st_nc}")print(" -> en position serree 3 voix, AUCUNE permutation n'evite et les quintes paralleles et les croisements :")print(" il faut elargir l'espacement, changer de renversement, ou ajouter une 4e voix (cf. exercice 2 et conclusion).")print()# 3) Toutes les violatrices du balayage : le prix de la regle, transition par transitionprint("Reparation des 4 violatrices :")for label, sv, tv, S, T, cost in fux_violators: a1, c1, st1 = fux_repair(S, T)print(f" {label:6s}{sv:5s} -> {tv:5s} : {cost:.0f} -> {c1} demi-tons ({st1}), delta {c1 - cost:+.0f}")print()# 4) Le prix du solveur : meme probleme, deux moteurst0 = time.perf_counter()linear_sum_assignment(M_VI)t_scipy = (time.perf_counter() - t0) *1000t0 = time.perf_counter()fux_repair(S_VI, T_VI)t_cpsat = (time.perf_counter() - t0) *1000print(f"affectation pure (scipy) : {t_scipy:.3f} ms")print(f"affectation + Fux (CP-SAT) : {t_cpsat:.3f} ms")
V-I sans Fux : 15 demi-tons (quintes paralleles)
V-I avec Fux : 15 demi-tons (OPTIMAL) — delta +0
V-I repare par CP-SAT (cout 15) :
G3 -> G4
B3 -> C4
D4 -> E4
audit Fux : propre (aucune quinte/unisson parallele)
V-I avec Fux + interdiction des croisements : INFEASIBLE
-> en position serree 3 voix, AUCUNE permutation n'evite et les quintes paralleles et les croisements :
il faut elargir l'espacement, changer de renversement, ou ajouter une 4e voix (cf. exercice 2 et conclusion).
Reparation des 4 violatrices :
ii - V Dm -> G : 20 -> 20 demi-tons (OPTIMAL), delta +0
V - I G -> C : 15 -> 15 demi-tons (OPTIMAL), delta +0
IV - V F -> G : 6 -> 8 demi-tons (OPTIMAL), delta +2
I - vi C -> Am : 10 -> 10 demi-tons (OPTIMAL), delta +0
affectation pure (scipy) : 0.043 ms
affectation + Fux (CP-SAT) : 14.934 ms
Ce que le solveur apporte — et ce qu’il coûte.
Exprimer l’inexprimable : les interdits de Fux sont des contraintes sur des paires de permutations candidates — précalculables ici (pitches fixes), réifiées en BoolVar dans le projet H1 (pitches variables). Dans les deux cas, c’est AddBoolOr qui interdit chaque couple fatal : une ligne par interdit, lisible comme la règle du traité.
La réparation est (presque) gratuite — en demi-tons seulement : trois violatrices sur quatre se réparent à coût égal, seul IV-V paie (+2 demi-tons). Mais regardez la solution réparée du V-I : G3 monte d’une octave vers G4 et les voix se croisent — l’ex aequo qui évite les quintes sacrifie la ligne mélodique. C’est la signature d’un problème où les règles interagissent : sur ce voicing serré, aucune permutation ne satisfait tout à la fois.
L’infaisabilité est une réponse : l’expérience confirme le point précédent — ajouter l’interdiction des croisements rend V-I fondamental infaisable (INFEASIBLE, preuve exacte, pas un échec de solveur). La parade dépasse la permutation : élargir l’espacement, changer de renversement, ajouter une quatrième voix. Un modèle d’affectation ne peut même pas poser la question ; CP-SAT y répond exactement.
Le compromis en durée : scipy règle l’affectation pure en fractions de milliseconde, CP-SAT en quelques millisecondes. À l’échelle d’une progression entière (section 6), le choix pertinent est hybride : affectation pure par défaut, CP-SAT sur les transitions que l’audit marque — exactement l’architecture audit/réparation de cette section.
Le pendant génération : là où ce notebook re-voice des accords donnés, le projet H1_V2 (Sam Krief, Nicolas Teisseire, même promotion) génère des mélodies monodiques par contraintes — contraintes dures (gamme, cadence, sauts bornés) et six contraintes souples pondérées (fluidité, ambitus, arche…) combinées en un seul Minimize. Les deux voies formelles de la composition assistée tiennent dans cette opposition : optimum d’affectation (ce notebook) contre satisfaction sous contraintes pondérées (H1_V2) — et le front de Pareto entre critères est précisément ce qu’une somme pondérée ne parcourt pas (exercice ouvert cité dans leur soutenance).
Pour rebrancher sur la sortie réelle de 02-6 : ce notebook exporte ses progressions en pitch classes (fichiers midi_output/*.mid + résumé JSON). Le point d’entrée est la même fonction revoice_progression, alimentée par la liste d’accords du résumé au lieu du fallback. Aucune autre modification — c’est l’intérêt d’avoir formulé le post-traitement comme une fonction pure. Piste d’extension naturelle (hors scope ici, cf. conclusion) : exporter le voicing optimal en MIDI avec miditoolkit, même convention d’artefacts que 02-6.
8. Exercices
Quatre exercices pour vous approprier la formulation. Chaque stub s’exécute sans erreur (renvoie None tant qu’il n’est pas complété) — le notebook reste exécutable de bout en bout.
Exercice 1 — Changer de métrique : l’optimum sur la ligne des quintes
La matrice cost_matrix_fifths est déjà définie en section 2. Résolvez la transition fil rouge (C → F/1) avec la métrique ligne des quintes et comparez l’affectation obtenue à l’affectation chromatique : sont-elles identiques ? Si non, expliquez ce que chaque métrique « favorise » (mouvement physique des voix contre proximité fonctionnelle).
Indique : vous n’avez besoin que de linear_sum_assignment(C_fifths) et d’une comparaison des couples (voix source, voix cible) avec o_assign de la section 3.
Étapes : 1. Appeler linear_sum_assignment sur C_fifths 2. Construire les couples source -> cible 3. Comparer à l’affectation optimale chromatique (à orientation près des indices de colonnes) 4. Conclure en une phrase sur la différence de géométrie
# Exercice 1 : optimum selon la ligne des quintes vs optimum chromatiquedef compare_metrics(C_chromatic, C_fifths):"""Retourne (affectation_fifths, affectation_chromatique, identique_bool) ou None si non complete."""# TODO etudiant# Indice : linear_sum_assignment(C_fifths) donne l'affectation en metrique quintes ;# comparer les ensembles {(voix, cible)} des deux affectations.# Etape 1 : resoudre en metrique quintes# Etape 2 : resoudre (ou reutiliser o_assign) en metrique chromatique# Etape 3 : comparer les ensembles de couplesreturnNone# TODO etudiantresultat_ex1 = compare_metrics(C_chrom, C_fifths)print("Exercice a completer"if resultat_ex1 isNoneelse resultat_ex1)
Exercice a completer
Exercice 2 — Pénaliser les croisements de voix
En contrepoint, deux voix qui se croisent (la voix grave passe au-dessus de la voix aigu) gênent l’oreille, même si chaque mouvement est court. Étendez la formulation : construisez une matrice de coûts augmentée où \(c_{aug}(i, j) = c(i, j) + \lambda \cdot \mathbb{1}[\text{croisement}]\), avec \(\lambda\) un poids que vous ferez varier (par exemple 3 demi-tons de pénalité). Un croisement pour l’envoi de \(s_i\) vers \(t_j\) peut se détecter par comparaison des rangs : \(s_i < s_k\) mais \(t_j > t_l\) pour une autre voix \(k\) envoyée sur \(l\)… c’est une condition sur la permutation entière — une approche simple : tester les 6 permutations d’un trio et compter les croisements de chacune (borne exhaustive, légitime à \(n = 3\)), puis re-résoudre avec linear_sum_assignment sur la matrice pénalisée par paires (approximation courante : pénalité si l’ordre relatif \(i < k\) et \(j > l\)).
Indique : à \(n\) = 3 voix, itertools.permutations énumère les 6 candidats — un comptage exhaustif des inversions par candidat est trivial ; la matrice pénalisée par paires est l’approximation calculable par affectation.
Étapes : 1. Écrire crossing_penalty(perm) : nombre d’inversions de la permutation 2. Construire la matrice pénalisée par paires 3. Résoudre avec linear_sum_assignment et comparer à l’affectation sans pénalité
# Exercice 2 : matrice de couts avec penalite de croisementdef cost_matrix_with_crossing_penalty(source, target, lam=3.0):"""Matrice chromatique + lam * indicateur de croisement par paires. Retourne la matrice ou None."""# TODO etudiant# Indice : c_aug[i, j] = |s_i - t_j| + lam * somme sur k != i de 1[ (s_i < s_k) et (t_j > t_cible(k)) ]# en approximation par paires, on peut penaliser 1[ (s_i < s_k) et (t_j > t_k indice) ] pour# chaque paire (i, k) avec la colonne k comme proxy de la cible de k.# Etape 1 : matrice chromatique de base# Etape 2 : double boucle sur les paires pour ajouter la penalitereturnNone# TODO etudiantresultat_ex2 = cost_matrix_with_crossing_penalty(SRC, TGT)print("Exercice a completer"if resultat_ex2 isNoneelse resultat_ex2)
Exercice a completer
Exercice 3 — Voix de basse contrainte (affectation partielle)
En chorale réelle, la basse est souvent imposée par le chiffrage : la basse do la fondamentale, point final. Le problème devient une affectation avec contrainte : la basse fixée, les autres voix se partagent les cibles restantes. Formulez-le comme une affectation standard de taille réduite (supprimer la ligne et la colonne de la basse, résoudre le sous-problème, réassembler).
Indique : np.delete(M, idx_basse, axis=0/1) réduit la matrice ; après résolution du sous-problème, réinsérer l’affectation fixée dans la solution complète.
Étapes : 1. Identifier l’index de la voix de basse (la plus grave) et sa cible imposée (la plus grave) 2. Réduire la matrice, résoudre avec linear_sum_assignment 3. Réassembler l’affectation complète et calculer le coût total 4. Comparer au coût sans contrainte : que coûte l’obligation harmonique ?
# Exercice 3 : affectation optimale avec basse contraintedef optimal_with_fixed_bass(source, target):"""Affectation Kuhn-Munkres avec la basse (voix la plus grave) envoyee sur la cible la plus grave. Retourne (affectation_complete, cout_total) ou None."""# TODO etudiant# Indice : np.delete pour reduire la matrice aux voix libres / cibles libres,# puis re-assembler l'affectation complete.# Etape 1 : reperer bass_i = argmin(source), bass_j = argmin(target)# Etape 2 : sous-matrice sur les indices restants# Etape 3 : linear_sum_assignment sur la sous-matrice# Etape 4 : re-assembler et sommer les coutsreturnNone# TODO etudiantresultat_ex3 = optimal_with_fixed_bass(SRC, TGT)print("Exercice a completer"if resultat_ex3 isNoneelse resultat_ex3)
Exercice a completer
Exercice 4 — Étendre l’audit : le mouvement direct
Fux interdit aussi le mouvement direct : deux voix qui marchent dans le même sens pour arriver sur une quinte ou une octave (l’une des deux au moins par mouvement conjoint — la règle complète admet des exceptions que vous lirez dans un traité). Étendez l’audit : pour chaque paire de voix, détecter sign(T[j] - S[i]) == sign(T[l] - S[k]) avec l’intervalle d’arrivée d_end % 12 dans {0, 7} et l’intervalle de départ hors de cet ensemble.
Indice : la fonction fux_parallel_violations est le gabarit — même double boucle sur les paires, conditions différentes ; np.sign ou une comparaison à zéro donnent le sens.
Étapes : 1. Écrire direct_motion_violations(S, T, assignment) sur le modèle de l’audit parallèle 2. L’appliquer aux solutions des sections 3 à 7 : le voice leading réparé par CP-SAT (section 7) évite-il aussi les mouvements directs ? 3. Compter les mouvements directs sur le balayage des 36 renversements
# Exercice 4 : audit du mouvement direct (arrivee sur quinte/octave par mouvement semblable)def direct_motion_violations(S, T, assignment):"""Paires de voix arrivant sur unisson/quinte/octave en marchant dans le meme sens. Retourne la liste des paires fautives ou None si non complete."""# TODO etudiant# Indice : meme gabarit que fux_parallel_violations ; sens identique de deplacement# pour les deux voix + intervalle d'arrivee dans {0, 7} modulo 12.# Etape 1 : boucle sur les paires (i, k)# Etape 2 : sens = signe du deplacement de chaque voix ; d_end = T[cible de k] - T[cible de i]# Etape 3 : meme sens, d_end % 12 interdit, d_start % 12 non interdit -> fautifreturnNone# TODO etudiantresultat_ex4 = direct_motion_violations(S_VI, T_VI, a_fix)print("Exercice a completer"if resultat_ex4 isNoneelse resultat_ex4)
Exercice a completer
Hommage aux projets étudiants EPITA SCIA 2026 — la même croisée, une génération plus tard
Ce notebook croise la musique et l’optimisation combinatoire — comme l’a fait Munkres sa vie durant. Cette croisée n’est pas le monopole des hommages : les projets 2026 des étudiants EPITA SCIA l’explorent de leur côté, dans les deux dépôts de cours Programmation par Contraintes et Intelligence Symbolique. Munkres a enseigné jusqu’en 2015 ; ces projets sont livrés en 2026 — la transmission continue, et la section 7 de ce notebook leur doit directement son encodage CP-SAT.
Les deux projets musique (tous deux dans le dépôt Programmation par Contraintes) :
H1 — Composition musicale assistée par contraintes (Louis Parmentier, Marianne Proux, Ethan Girard) — composition polyphonique à quatre voix SATB par OR-Tools CP-SAT : gamme et progressions d’accords, résolution de la sensible, anti-parallélisme des quintes et unissons réifié en BoolVar, croisement de voix interdit, trois styles (baroque, jazz, contemporain) en contraintes souples pondérées, export MIDI. C’est la source directe de l’encodage de la section 7 — leur apply_no_parallel_constraints, transposé ici du pitch-variable au pitch-fixe.
H1_V2 — Composition mélodique assistée par contraintes (Sam Krief, Nicolas Teisseire) — génération de mélodies monodiques par le même solveur : domaine restreint aux hauteurs de la gamme, cadence contrainte sur les classes de hauteur, six contraintes souples pondérées en profils stylistiques (fluide, aventureux, minimaliste), variations distinctes par blocklist AddBoolOr, export MIDI. Le pendant génération de ce notebook (qui, lui, re-voice des accords donnés) — discuté en fin de section 7.
Et la face affectation du sujet, hors musique : le même dépôt héberge B1 — Équilibrage de chaîne d’assemblage (Ilias Kalalou, Kaelan Grall) — répartir des tâches entre stations sous précédence et temps de cycle, le linear_sum_assignment de la section 3 transposé de la salle de concert à l’usine — et toute la catégorie « RH, Matching et Mechanism Design » (échanges de reins A1, allocation multicritère J1, enchères combinatoires J2). Quand un étudiant cherche à quoi sert un algorithme d’affectation : il y a, à un semestre d’écart, des projets vivants qui le montrent.
9. Conclusion
Ce que fait ce notebook. Il traite le voice leading comme ce qu’il est mathématiquement : un problème d’affectation linéaire, résolu exactement par l’algorithme de Kuhn-Munkres dans son implémentation SOTA (scipy.optimize.linear_sum_assignment) — puis, lorsque les règles de contrepoint couplent les voix entre elles, reformulé et réparé en CP-SAT (section 7, encodage issu des projets étudiants EPITA H1/H1_V2). La démonstration mesurée (greedy contre optimal, sur un balayage complet des renversements) montre que le choix d’algorithme n’est pas cosmétique : la stratégie gloutonne « chaque voix prend la note libre la plus proche » échoue sur une proportion substantielle des transitions, en concentrant tout le mouvement sur une voix malchanceuse — exactement le saut qu’un voice leading doit éviter.
La chaîne complète d’un workflow génératif. Génération de progressions (02-6, 04-3) → voicing optimal par affectation (ce notebook) → synthèse. La brique du milieu est pure, offline, déterministe — elle se branche sans état sur n’importe quelle source de progressions.
Contrepoint de Fux, la suite : les quintes parallèles sont désormais auditées et réparées en CP-SAT (section 7) ; restent le mouvement direct (exercice 4), le cas infaisable Fux + non-croisement (écartement, renversement, 4e voix), et la pondération des règles comme contraintes souples à la H1_V2 — le versant solveur vit désormais ici-même (section 7) ;
Audio-to-score alignment : l’autre grande application musicale de l’affectation (aligner une performance sur une partition, DTW + affectation) ;
Extension à 4-8 voix avec miditoolkit pour l’export MIDI, même convention d’artefacts que 02-6.
Sources :
Kuhn (1955), « The Hungarian Method for the Assignment Problem », Naval Research Logistics Quarterly ; Munkres (1957), « Algorithms for the Assignment and Transportation Problems », J. SIAM 5(1) ;
Tymoczko, A Geometry of Music (Oxford University Press, 2011), chapitre 4 — le voice leading optimal comme problème d’affectation dans la géométrie des accords ;
Obituaires de James R. Munkres (Boston Globe via Legacy.com ; Douglass Funeral Home, Bedford MA, été 2026) — « Mathematician, musician and gardener ».