Contesto e motivazione
Oggi il pitch multi-voce supporta accordi statici (voices.pitch.strategy: chord): un solo accordo fisso per l'intero stream. Non è possibile fare progressioni armoniche né far evolvere il voicing nel tempo. Questa issue propone una nuova strategy chord_progression che rende l'accordo una funzione del tempo — di fatto un envelope di accordi — in cui le voci si muovono lungo i voicing, con glissando continuo o cambi a blocchi, e con voice leading opzionale.
L'infrastruttura time-varying esiste già: VoiceManager.get_voice_config(voice_index, t) è invocata per ogni grano col tempo reale della voce, e le strategy step/range/stochastic valutano già envelope per-grano. La strategy chord fu lasciata statica di proposito (vedi docs/plans/done/2026-04-25-002-feat-dynamic-strategy-params-plan.md, "ChordPitchStrategy... receive time but ignore it") perché il nome accordo è una stringa. Questa issue colma quel buco riusando il sistema Envelope esistente.
Stato attuale (accordi statici)
ChordPitchStrategy (src/strategies/voice_pitch_strategy.py): da un nome in CHORD_INTERVALS (+ inversion) calcola, per voce i, intervals[i % n] + (i//n)*12 (extend all'ottava). Voce 0 → 0.0. time ignorato. È semitone-locked (SEMITONE_LOCKED). L'offset diventa ratio in _create_grain via pitch_unit.to_ratio(offset) = 2^(offset/12).
Proposta: strategy chord_progression
Nuova VoicePitchStrategy registrata come chord_progression. Idea centrale: per ogni voce si costruisce un Envelope di offset in semitoni i cui breakpoint sono i target dell'accordo a ciascun istante della progressione; get_pitch_offset(i, nv, t) restituisce voice_env[i].evaluate(t). Questo riusa integralmente l'interpolazione Envelope (linear/cubic/step) — la feature è, letteralmente, un envelope di accordi.
Sintassi YAML
voices:
num_voices: 4
pitch:
strategy: chord_progression
progression: # sequenza [tempo_in_secondi, accordo]
- [0, "maj7"]
- [8, "min7"]
- [16, "dom7"]
- [24, "maj7"]
interp: linear # linear|cubic = glissando · step = blocchi (default: linear)
voice_leading: nearest # nearest (default) | positional
Inversione opzionale per-accordo (due forme accettate):
progression:
- [8, "min7", 1] # forma compatta: [t, chord, inversion]
- [8, {chord: "min7", inversion: 1}] # forma esplicita
Moto di radice ortogonale — le progressioni "vere" (I–IV–V) si ottengono combinando la qualità (qui) col moto di radice nell'envelope di pitch dello stream:
pitch:
semitones: [[0,0],[8,5],[16,7],[24,0]] # radice I-IV-V-I in semitoni
voices:
num_voices: 4
pitch:
strategy: chord_progression
progression: [[0,"maj"],[8,"min7"],[16,"dom7"],[24,"maj"]]
Voce 0 = radice mobile (segue pitch); le voci alte = voicing che evolve sopra.
Semantica
Modello armonico (voicing-relativo). La progressione codifica solo la qualità/voicing relativo alla voce 0. Voce 0 → sempre offset 0.0 (invariante voce-0 preservato): rappresenta la radice/riferimento, il cui moto assoluto vive nell'envelope pitch dello stream. Conseguenza: una progressione di sole triadi maggiori (I-IV-V) ha offset di voicing costanti [0,4,7] e tutto il moto è nel base pitch (moto parallelo) — corretto e atteso. Il voicing cambia quando cambia la qualità (maj→min7→dom7…).
Transizione (interp). Riusa i tipi Envelope:
linear/cubic → glissando: le voci scivolano con continuità da un voicing al successivo (interpolazione in semitoni → glissando esponenziale in frequenza, lineare in pitch, musicalmente corretto).
step → blocchi: cambio d'accordo istantaneo all'onset di ogni accordo (armonia a blocchi / progressione classica).
Prima del primo accordo e dopo l'ultimo: hold del primo/ultimo valore (già comportamento di Envelope.evaluate).
Voice leading (voice_leading). Come vengono abbinate le voci alle note del nuovo accordo:
-
positional — voce i → i-esima nota dell'accordo (con extend/inversion come chord). Prevedibile; il glissando segue l'indice. Gestisce banalmente cardinalità diverse.
-
nearest (default) — le voci 1..N-1 vengono riabbinate per minimizzare il movimento totale in semitoni tra voicing consecutivi, con octave-folding (una nota può essere presa in un'ottava più vicina) e note comuni tenute. Voce 0 resta vincolata a 0 (esclusa dal riabbinamento, è il riferimento). È il voice leading parsimonioso (Tymoczko / neo-Riemann).
Nota onesta sul comportamento: per voicing ordinati in modo ascendente (quelli prodotti da extend) l'abbinamento per indice è spesso già ottimale (disuguaglianza di riordinamento). Il valore distintivo di nearest emerge con l'octave-folding e con le inversioni: evita salti d'ottava inutili e tiene ferme le note comuni. nearest non fa mai peggio di positional.
Algoritmo (costruzione envelope per-voce)
init(progression, num_voices N, interp, voice_leading):
per ogni accordo k: (t_k, intervals_k già invertiti via _invert, n_k)
targets_k[i] = intervals_k[i % n_k] + (i // n_k) * 12 # extend, come ChordPitchStrategy
se voice_leading == "positional":
offsets_k[i] = targets_k[i]
se voice_leading == "nearest":
offsets_0 = targets_0 (con voce0 := 0)
per k = 1..K-1:
# voce 0 pinned a 0; assegna voci 1..N-1 alle note di targets_k
# (pitch-class con octave-folding) minimizzando sum |nuovo - prev|,
# note comuni tenute. N piccolo (<= ~8) -> brute-force/Hungarian, no nuove dep pesanti.
offsets_k = assign_min_motion(prev=offsets_{k-1}, target_pcs=intervals_k)
per ogni voce i:
voice_env[i] = Envelope([[t_0, offsets_0[i]], [t_1, offsets_1[i]], ...], type=interp)
# voce 0 -> envelope di soli zeri (o short-circuit a 0.0)
get_pitch_offset(i, nv, t):
se i == 0: return 0.0
return voice_env[i].evaluate(t)
Esempio (positional, maj7 [0,4,7,11] → min7 [0,3,7,10], 4 voci): note comuni 0 e 7 tenute; 4→3, 11→10. Con interp: linear queste due voci glissano di un semitono; con step saltano all'onset.
Punti di integrazione nel codice
src/strategies/voice_pitch_strategy.py — nuova ChordProgressionPitchStrategy; registrala come 'chord_progression' in VOICE_PITCH_STRATEGIES; aggiungila a SEMITONE_LOCKED (offset in semitoni, anche frazionari dopo interp). Riusa CHORD_INTERVALS e ChordPitchStrategy._invert. La strategy riceve num_voices al costruttore (serve per pre-costruire gli envelope per-voce) — passato da _init_voice_manager.
src/core/stream.py _init_voice_manager (ramo PITCH) — i kwarg strutturali progression, interp, voice_leading vanno estratti (pop) PRIMA del kw = {k: _parse_strategy_kwarg(...)}. ⚠️ progression è una lista di [t, str] e Envelope.is_envelope_like la rileverebbe erroneamente come envelope (ogni elemento è una lista di 2) → crash su evaluate. Stesso pattern già usato per unit/strategy/stream_id. Passare num_voices (= max_voices) alla factory per questa strategy.
- Validazione —
interp ∈ EnvelopeBuilder.VALID_INTERP_TYPES; voice_leading ∈ {positional, nearest}; nomi accordo ∈ CHORD_INTERVALS (riusa InvalidStrategyConfigError come ChordPitchStrategy); progression non vuota e tempi non decrescenti.
- Renderer /
Grain / _create_grain — nessuna modifica: l'offset (float, semitoni) passa già per pitch_unit.to_ratio.
Casi limite
- Progressione con un solo accordo → equivalente a
chord statico.
num_voices > cardinalità accordo → extend per-accordo (come oggi).
- Cardinalità diverse tra accordi:
positional usa indice+extend; nearest abbina le voci comuni e tratta le eccedenti per indice/extend (documentare).
num_voices time-varying (Envelope): max_voices = picco; envelope per-voce costruiti fino a max; le voci oltre active non generano grani (guardia già esistente).
- Tempo per-voce: con
onset_offset/scatter, le voci attraversano i confini d'accordo in istanti leggermente diversi → arpeggiamento naturale del cambio (coerente con la semantica per-voce già documentata, piano 2026-04-25 U5).
Invarianti preservati
- Voce 0 =
0.0 a ogni t (riferimento; moto di radice nel base pitch).
- Pitch moltiplicativo
2^(offset/12); semitoni frazionari ammessi (glissando).
- Backward-compat totale:
chord statico invariato; nessun YAML esistente rotto.
Piano di test (TDD)
Specchiare tests/strategies/test_voice_pitch_strategy.py:
- voce-0 invariante a qualsiasi
t (linear e step).
positional: offset esatti agli onset + valore di glissando a metà segmento (linear).
step: hold fino all'onset successivo (salto netto).
nearest: note comuni tenute; moto totale ≤ positional; caso octave-folding.
- progressione a un accordo ==
ChordPitchStrategy statico.
- extend per-accordo (num_voices > cardinalità); inversione per-accordo.
- accordo sconosciuto → solleva;
unit ≠ semitones → solleva (SEMITONE_LOCKED).
tests/core/test_stream_voices_yaml.py: progression NON interpretata come envelope; parsing kwarg corretto; pitch_ratio del grano a t.
tests/core/test_stream_multivoice.py: glissando → pitch_ratio variabile per-grano lungo lo stream.
Documentazione da aggiornare
docs/explanation/multi-voice.md § 3.3 — sottosezione ChordProgressionPitchStrategy.
docs/reference/yaml.md § voices.pitch — sintassi chord_progression (riusa tabella accordi).
CHANGELOG.md § [Unreleased] → Aggiunto.
Fuori scope (eventuali follow-up)
- Modello "radice nella progressione" (alternativa scartata: rompe l'invariante voce-0).
- Tabelle di accordi micro-tonali / definite dall'utente (
CHORD_INTERVALS è 12-EDO).
time_mode: normalized per progression (i tempi sono in secondi; rinviabile).
- Real-time / MIDI.
Riferimenti
Contesto e motivazione
Oggi il pitch multi-voce supporta accordi statici (
voices.pitch.strategy: chord): un solo accordo fisso per l'intero stream. Non è possibile fare progressioni armoniche né far evolvere il voicing nel tempo. Questa issue propone una nuova strategychord_progressionche rende l'accordo una funzione del tempo — di fatto un envelope di accordi — in cui le voci si muovono lungo i voicing, con glissando continuo o cambi a blocchi, e con voice leading opzionale.L'infrastruttura time-varying esiste già:
VoiceManager.get_voice_config(voice_index, t)è invocata per ogni grano col tempo reale della voce, e le strategystep/range/stochasticvalutano già envelope per-grano. La strategychordfu lasciata statica di proposito (vedidocs/plans/done/2026-04-25-002-feat-dynamic-strategy-params-plan.md, "ChordPitchStrategy... receivetimebut ignore it") perché il nome accordo è una stringa. Questa issue colma quel buco riusando il sistema Envelope esistente.Stato attuale (accordi statici)
ChordPitchStrategy(src/strategies/voice_pitch_strategy.py): da un nome inCHORD_INTERVALS(+inversion) calcola, per voce i,intervals[i % n] + (i//n)*12(extend all'ottava). Voce 0 → 0.0.timeignorato. È semitone-locked (SEMITONE_LOCKED). L'offset diventa ratio in_create_grainviapitch_unit.to_ratio(offset)=2^(offset/12).Proposta: strategy
chord_progressionNuova
VoicePitchStrategyregistrata comechord_progression. Idea centrale: per ogni voce si costruisce unEnvelopedi offset in semitoni i cui breakpoint sono i target dell'accordo a ciascun istante della progressione;get_pitch_offset(i, nv, t)restituiscevoice_env[i].evaluate(t). Questo riusa integralmente l'interpolazione Envelope (linear/cubic/step) — la feature è, letteralmente, un envelope di accordi.Sintassi YAML
Inversione opzionale per-accordo (due forme accettate):
Moto di radice ortogonale — le progressioni "vere" (I–IV–V) si ottengono combinando la qualità (qui) col moto di radice nell'envelope di pitch dello stream:
Voce 0 = radice mobile (segue
pitch); le voci alte = voicing che evolve sopra.Semantica
Modello armonico (voicing-relativo). La progressione codifica solo la qualità/voicing relativo alla voce 0. Voce 0 → sempre offset 0.0 (invariante voce-0 preservato): rappresenta la radice/riferimento, il cui moto assoluto vive nell'envelope
pitchdello stream. Conseguenza: una progressione di sole triadi maggiori (I-IV-V) ha offset di voicing costanti[0,4,7]e tutto il moto è nel base pitch (moto parallelo) — corretto e atteso. Il voicing cambia quando cambia la qualità (maj→min7→dom7…).Transizione (
interp). Riusa i tipi Envelope:linear/cubic→ glissando: le voci scivolano con continuità da un voicing al successivo (interpolazione in semitoni → glissando esponenziale in frequenza, lineare in pitch, musicalmente corretto).step→ blocchi: cambio d'accordo istantaneo all'onset di ogni accordo (armonia a blocchi / progressione classica).Prima del primo accordo e dopo l'ultimo: hold del primo/ultimo valore (già comportamento di
Envelope.evaluate).Voice leading (
voice_leading). Come vengono abbinate le voci alle note del nuovo accordo:positional— voce i → i-esima nota dell'accordo (con extend/inversion comechord). Prevedibile; il glissando segue l'indice. Gestisce banalmente cardinalità diverse.nearest(default) — le voci 1..N-1 vengono riabbinate per minimizzare il movimento totale in semitoni tra voicing consecutivi, con octave-folding (una nota può essere presa in un'ottava più vicina) e note comuni tenute. Voce 0 resta vincolata a 0 (esclusa dal riabbinamento, è il riferimento). È il voice leading parsimonioso (Tymoczko / neo-Riemann).Nota onesta sul comportamento: per voicing ordinati in modo ascendente (quelli prodotti da extend) l'abbinamento per indice è spesso già ottimale (disuguaglianza di riordinamento). Il valore distintivo di
nearestemerge con l'octave-folding e con le inversioni: evita salti d'ottava inutili e tiene ferme le note comuni.nearestnon fa mai peggio dipositional.Algoritmo (costruzione envelope per-voce)
Esempio (positional,
maj7 [0,4,7,11] → min7 [0,3,7,10], 4 voci): note comuni 0 e 7 tenute;4→3,11→10. Coninterp: linearqueste due voci glissano di un semitono; constepsaltano all'onset.Punti di integrazione nel codice
src/strategies/voice_pitch_strategy.py— nuovaChordProgressionPitchStrategy; registrala come'chord_progression'inVOICE_PITCH_STRATEGIES; aggiungila aSEMITONE_LOCKED(offset in semitoni, anche frazionari dopo interp). RiusaCHORD_INTERVALSeChordPitchStrategy._invert. La strategy ricevenum_voicesal costruttore (serve per pre-costruire gli envelope per-voce) — passato da_init_voice_manager.src/core/stream.py_init_voice_manager(ramo PITCH) — i kwarg strutturaliprogression,interp,voice_leadingvanno estratti (pop) PRIMA delkw = {k: _parse_strategy_kwarg(...)}.progressionè una lista di[t, str]eEnvelope.is_envelope_likela rileverebbe erroneamente come envelope (ogni elemento è una lista di 2) → crash suevaluate. Stesso pattern già usato perunit/strategy/stream_id. Passarenum_voices(=max_voices) alla factory per questa strategy.interp∈EnvelopeBuilder.VALID_INTERP_TYPES;voice_leading∈{positional, nearest}; nomi accordo ∈CHORD_INTERVALS(riusaInvalidStrategyConfigErrorcomeChordPitchStrategy);progressionnon vuota e tempi non decrescenti.Grain/_create_grain— nessuna modifica: l'offset (float, semitoni) passa già perpitch_unit.to_ratio.Casi limite
chordstatico.num_voices> cardinalità accordo → extend per-accordo (come oggi).positionalusa indice+extend;nearestabbina le voci comuni e tratta le eccedenti per indice/extend (documentare).num_voicestime-varying (Envelope):max_voices= picco; envelope per-voce costruiti fino a max; le voci oltreactivenon generano grani (guardia già esistente).onset_offset/scatter, le voci attraversano i confini d'accordo in istanti leggermente diversi → arpeggiamento naturale del cambio (coerente con la semantica per-voce già documentata, piano 2026-04-25 U5).Invarianti preservati
0.0a ognit(riferimento; moto di radice nel base pitch).2^(offset/12); semitoni frazionari ammessi (glissando).chordstatico invariato; nessun YAML esistente rotto.Piano di test (TDD)
Specchiare
tests/strategies/test_voice_pitch_strategy.py:t(linear e step).positional: offset esatti agli onset + valore di glissando a metà segmento (linear).step: hold fino all'onset successivo (salto netto).nearest: note comuni tenute; moto totale ≤positional; caso octave-folding.ChordPitchStrategystatico.unit≠ semitones → solleva (SEMITONE_LOCKED).tests/core/test_stream_voices_yaml.py:progressionNON interpretata come envelope; parsing kwarg corretto; pitch_ratio del grano at.tests/core/test_stream_multivoice.py: glissando → pitch_ratio variabile per-grano lungo lo stream.Documentazione da aggiornare
docs/explanation/multi-voice.md§ 3.3 — sottosezioneChordProgressionPitchStrategy.docs/reference/yaml.md§ voices.pitch — sintassichord_progression(riusa tabella accordi).CHANGELOG.md§ [Unreleased] → Aggiunto.Fuori scope (eventuali follow-up)
CHORD_INTERVALSè 12-EDO).time_mode: normalizedperprogression(i tempi sono in secondi; rinviabile).Riferimenti