Skip to content

feat: progressioni armoniche — strategy voce-pitch chord_progression (glissando + voice leading tra voicing) #86

@DMGiulioRomano

Description

@DMGiulioRomano

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/cubicglissando: le voci scivolano con continuità da un voicing al successivo (interpolazione in semitoni → glissando esponenziale in frequenza, lineare in pitch, musicalmente corretto).
  • stepblocchi: 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

  1. 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.
  2. 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.
  3. ValidazioneinterpEnvelopeBuilder.VALID_INTERP_TYPES; voice_leading{positional, nearest}; nomi accordo ∈ CHORD_INTERVALS (riusa InvalidStrategyConfigError come ChordPitchStrategy); progression non vuota e tempi non decrescenti.
  4. Renderer / Grain / _create_grainnessuna 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNuova funzionalita'tddLavoro test-first (rosso-verde-refactor)yaml-configSchema/parsing YAML

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions