st-language 2.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ """
2
+ ST-language: la notazione musicale testuale di SoundText come libreria
3
+ Python autonoma (nessuna dipendenza esterna, Python 3.8+).
4
+
5
+ import st_language as st
6
+
7
+ events = st.parse("4: c d e f | 2g 2g") # eventi con tempi in quarti
8
+ ok, error = st.validate("4: C Am | F G")
9
+ warnings = st.check("4: c d e | f") # controlli di battuta, testo cantato
10
+
11
+ song = st.load_song("brano.st") # o st.read_song(testo)
12
+ st.to_midi(song, "brano.mid")
13
+ st.to_musicxml(song, "brano.musicxml")
14
+ st.to_abc(song, "brano.abc")
15
+ st.to_mtxt(song, "brano.mtxt") # MTXT 1.0, un evento per riga
16
+ st.mtxt_to_midi(open("take.mtxt").read(), "take.mid")
17
+
18
+ La specifica del linguaggio e del formato .st e' in docs/spec/ del
19
+ repository di SoundText; i comandi da terminale sono 'st-language'
20
+ (check, midi, musicxml, abc, mtxt, events), 'stcheck', 'st2mid', 'st2musicxml',
21
+ 'st2abc', 'st2mtxt'.
22
+ """
23
+
24
+ from typing import Dict, List, Optional, Tuple
25
+
26
+ from ._i18n import language, set_language, set_translator # noqa: F401
27
+ from .notation import ( # noqa: F401
28
+ BarIssue, Event, Meter, NotationError, Pattern, check_bar_lines, notation_warnings, parse_track_text,
29
+ tokenize, transpose_tokens, validate_track_text,
30
+ )
31
+ from .song import Clip, Part, Song, load_song, read_song, song_from_notation, text_to_song # noqa: F401
32
+
33
+ __version__ = "2.7.0"
34
+
35
+ __all__ = [
36
+ "BarIssue", "Clip", "Event", "Meter", "NotationError", "Part", "Pattern", "Song", "check", "check_bar_lines",
37
+ "language", "load_song", "notation_warnings", "parse", "parse_track_text", "read_song",
38
+ "mtxt_to_midi", "set_language", "set_translator", "song_from_notation", "text_to_song", "to_abc",
39
+ "to_midi", "to_musicxml", "to_mtxt", "tokenize", "transpose_tokens", "validate", "validate_track_text",
40
+ ]
41
+
42
+
43
+ def parse(text: str, patterns: Optional[Dict[str, Pattern]] = None, default_octave: int = 4) -> List[Event]:
44
+ """Gli eventi di un testo in notazione (una traccia)."""
45
+ return parse_track_text(text, patterns or {}, default_octave=default_octave)
46
+
47
+
48
+ def validate(text: str, patterns: Optional[Dict[str, Pattern]] = None) -> Tuple[bool, str]:
49
+ """(True, '') se il testo e' valido, altrimenti (False, messaggio)."""
50
+ return validate_track_text(text, patterns or {})
51
+
52
+
53
+ def check(text: str, patterns: Optional[Dict[str, Pattern]] = None, time_sig: str = "4/4",
54
+ metrica_changes=(), pickup=0) -> List[BarIssue]:
55
+ """Gli avvisi che non impediscono di suonare: controlli di battuta e
56
+ testo cantato con troppe sillabe. 'pickup': quarti del levare."""
57
+ return notation_warnings(text, patterns or {}, time_sig, metrica_changes, pickup=pickup)
58
+
59
+
60
+ def to_midi(song: Song, path: str, only_audible: bool = True) -> str:
61
+ from .midi import song_to_midi
62
+ return song_to_midi(song, path, only_audible=only_audible)
63
+
64
+
65
+ def to_musicxml(song: Song, path: str, only_audible: bool = True) -> str:
66
+ from .musicxml import export_project_to_musicxml
67
+ return export_project_to_musicxml(song, path, only_audible=only_audible)
68
+
69
+
70
+ def to_abc(song: Song, path: str, only_audible: bool = True) -> str:
71
+ from .abc import export_project_to_abc
72
+ return export_project_to_abc(song, path, only_audible=only_audible)
73
+
74
+
75
+ def to_mtxt(song: Song, path: str, only_audible: bool = True) -> str:
76
+ """Il brano come file MTXT 1.0 (vedi st_language.mtxt); ritorna path."""
77
+ from .mtxt import song_to_mtxt
78
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
79
+ f.write(song_to_mtxt(song, only_audible=only_audible))
80
+ return path
81
+
82
+
83
+ def mtxt_to_midi(text: str, path: str) -> str:
84
+ """Un testo MTXT come file MIDI; ritorna path. Solleva
85
+ st_language.mtxt.MtxtError (un ValueError) se il testo non e' valido."""
86
+ from .mtxt import mtxt_to_midi as convert
87
+ return convert(text, path)
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
st_language/_i18n.py ADDED
@@ -0,0 +1,77 @@
1
+ """
2
+ Messaggi della libreria (errori di sintassi, avvisi) nella lingua scelta.
3
+
4
+ I testi sono scritti in italiano nel codice, dentro tr(), come in
5
+ SoundText; i cataloghi locales/<lingua>.json ({testo italiano:
6
+ traduzione}) danno l'inglese, il francese e lo spagnolo. La lingua si
7
+ sceglie con set_language() o con la variabile d'ambiente ST_LANGUAGE
8
+ (altrimenti LANG); senza indicazioni, l'inglese.
9
+
10
+ Un programma che ha gia' le sue traduzioni (SoundText) puo' sostituire
11
+ tr con set_translator(): tutti i messaggi passano allora da li'.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ from typing import Callable, Dict, Optional
17
+
18
+ LANGUAGES = ("it", "en", "fr", "es")
19
+ SOURCE_LANGUAGE = "it"
20
+ DEFAULT_LANGUAGE = "en"
21
+
22
+ _translator: Optional[Callable] = None
23
+ _language: Optional[str] = None
24
+ _catalog: Dict[str, str] = {}
25
+
26
+
27
+ def set_translator(fn: Optional[Callable]) -> None:
28
+ """fn(testo_italiano, *args, **kwargs) -> testo; None torna ai cataloghi."""
29
+ global _translator
30
+ _translator = fn
31
+
32
+
33
+ def _env_language() -> str:
34
+ for var in ("ST_LANGUAGE", "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"):
35
+ for part in os.environ.get(var, "").split(":"):
36
+ code = part.strip().lower()[:2]
37
+ if code in LANGUAGES:
38
+ return code
39
+ return DEFAULT_LANGUAGE
40
+
41
+
42
+ def set_language(code: Optional[str] = None) -> str:
43
+ """Sceglie la lingua dei messaggi (None: dall'ambiente); ritorna quella in uso."""
44
+ global _language, _catalog
45
+ code = (code or _env_language()).lower()[:2]
46
+ if code not in LANGUAGES:
47
+ code = DEFAULT_LANGUAGE
48
+ _language = code
49
+ _catalog = {}
50
+ if code != SOURCE_LANGUAGE:
51
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "locales", f"{code}.json")
52
+ try:
53
+ with open(path, encoding="utf-8") as f:
54
+ _catalog = {k: v for k, v in json.load(f).items() if isinstance(v, str) and v}
55
+ except (OSError, ValueError):
56
+ _catalog = {}
57
+ return code
58
+
59
+
60
+ def language() -> str:
61
+ if _language is None:
62
+ set_language()
63
+ return _language
64
+
65
+
66
+ def tr(text: str, *args, **kwargs) -> str:
67
+ if _translator is not None:
68
+ return _translator(text, *args, **kwargs)
69
+ if _language is None:
70
+ set_language()
71
+ out = _catalog.get(text, text)
72
+ if args or kwargs:
73
+ try:
74
+ return out.format(*args, **kwargs)
75
+ except (IndexError, KeyError, ValueError):
76
+ return text.format(*args, **kwargs)
77
+ return out
st_language/abc.py ADDED
@@ -0,0 +1,370 @@
1
+ """
2
+ Esportazione del progetto in notazione ABC (standard 2.1), il formato di
3
+ testo per partiture usato da abcjs, EasyABC, abcm2ps, abc2midi e dalle
4
+ grandi raccolte di musica tradizionale.
5
+
6
+ Lavora sugli stessi attacchi dell'esportazione MusicXML (st_language.
7
+ musicxml): note, accordi gia' risolti dal motore di voicing, blocchi,
8
+ percussioni, battute, tuplet, legature fra battute. Ogni pentagramma di
9
+ ogni traccia diventa una voce ABC (V:), e le voci dei blocchi { ; } voci
10
+ in piu' sullo stesso pentagramma, raggruppate con %%score: pianoforti e
11
+ organi hanno due pentagrammi uniti da una graffa. Lo strumento passa con
12
+ %%MIDI program (la batteria sul canale 10, con le note dei suoni GM), la
13
+ tonalita' con K:, la metrica con M: e i cambi [M:] in tutte le voci, il
14
+ tempo con Q: e [Q:], le sigle degli accordi fra virgolette, le dinamiche
15
+ come decorazioni (!mf!), il testo cantato con w:. Il pedale del sustain
16
+ non c'e' (non e' nello standard).
17
+ Chitarre e bassi usano la chiave all'ottava bassa (treble-8, bass-8),
18
+ con le note scritte un'ottava sopra come vuole lo standard.
19
+ """
20
+
21
+ import dataclasses
22
+ from fractions import Fraction
23
+ from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
24
+
25
+ from .instruments import PERCUSSION_MAP
26
+ from .musicxml import (QUANT_GRID, _Direction, _Entry, _Item, _clefs, _drop_repeated_harmonies,
27
+ _dynamic_changes, _frac, _grid_divisions, _mark_lyrics, _measures, _monophonic,
28
+ _repeat_layout, _spell_midi, _staff_entries, _track_harmonies, _track_items,
29
+ _track_navigation, _track_repeats, _track_texts, _item_syllables, key_fifths,
30
+ song_key_changes, NAVIGATION_WORDS)
31
+
32
+ if TYPE_CHECKING: # solo per le annotazioni (nessun import circolare)
33
+ from .song import Part, Song # noqa: F401
34
+
35
+ # Unita' di nota (L:) in quarti: l'ottavo, la piu' comune.
36
+ UNIT = Fraction(1, 2)
37
+ BARS_PER_LINE = 4
38
+ DRUM_CHANNEL = 10
39
+
40
+ _SHARP_ORDER = "FCGDAEB"
41
+ _MAJOR_NAMES = {-7: "Cb", -6: "Gb", -5: "Db", -4: "Ab", -3: "Eb", -2: "Bb", -1: "F", 0: "C",
42
+ 1: "G", 2: "D", 3: "A", 4: "E", 5: "B", 6: "F#", 7: "C#"}
43
+ _MINOR_NAMES = {-7: "Abm", -6: "Ebm", -5: "Bbm", -4: "Fm", -3: "Cm", -2: "Gm", -1: "Dm", 0: "Am",
44
+ 1: "Em", 2: "Bm", 3: "F#m", 4: "C#m", 5: "G#m", 6: "D#m", 7: "A#m"}
45
+ _ARTICULATIONS = {"staccato": ".", "mute": "!wedge!", "legato": "!tenuto!"}
46
+ _DECORATIONS = {"accent": "!accent!", "marcato": "!marcato!", "tenuto": "!tenuto!", "fermata": "!fermata!",
47
+ "tr": "!trill!", "mordent": "!lowermordent!", "turn": "!turn!",
48
+ "arp": "!arpeggio!", "staccatissimo": "!wedge!", "sfz": "!sfz!", "fp": "!fp!",
49
+ "trem": "!///!", "harmonic": "!open!"}
50
+
51
+
52
+ def key_name(key: str) -> str:
53
+ """La tonalita' del progetto come valore di K: ('C', 'Am', 'Bb', 'F#m')."""
54
+ found = key_fifths(key)
55
+ if not found:
56
+ return "C"
57
+ fifths, mode = found
58
+ return (_MINOR_NAMES if mode == "minor" else _MAJOR_NAMES)[fifths]
59
+
60
+
61
+ def key_alters(fifths: int) -> Dict[str, int]:
62
+ """Alterazione in chiave di ogni nota (lettera maiuscola -> -1/0/+1)."""
63
+ alters = {step: 0 for step in "ABCDEFG"}
64
+ for step in (_SHARP_ORDER[:fifths] if fifths > 0 else _SHARP_ORDER[::-1][:-fifths]):
65
+ alters[step] = 1 if fifths > 0 else -1
66
+ return alters
67
+
68
+
69
+ def length_suffix(units: Fraction) -> str:
70
+ """Il moltiplicatore di durata ABC ('', '2', '3/2', '/2', '/4')."""
71
+ if units == 1:
72
+ return ""
73
+ if units.denominator == 1:
74
+ return str(units.numerator)
75
+ if units.numerator == 1:
76
+ return "/" if units.denominator == 2 else f"/{units.denominator}"
77
+ return f"{units.numerator}/{units.denominator}"
78
+
79
+
80
+ def pitch_text(step: str, alter: int, octave: int, bar_alters: Dict[str, int],
81
+ signature: Dict[str, int]) -> str:
82
+ """Una nota ABC: l'alterazione solo se diversa da quella in vigore
83
+ (armatura, o alterazione precedente nella battuta: per lo standard vale
84
+ per quella nota in tutte le ottave, %%propagate-accidentals pitch)."""
85
+ current = bar_alters.get(step, signature[step])
86
+ accidental = ""
87
+ if alter != current:
88
+ accidental = {2: "^^", 1: "^", 0: "=", -1: "_", -2: "__"}[alter]
89
+ bar_alters[step] = alter
90
+ if octave >= 5:
91
+ return accidental + step.lower() + "'" * (octave - 5)
92
+ return accidental + step + "," * (4 - octave)
93
+
94
+
95
+ def chord_symbol(symbol: str, bass: Optional[str]) -> str:
96
+ text = symbol.replace("♭", "b")
97
+ if len(text) > 1 and text[1] == "-":
98
+ text = text[0] + "b" + text[2:]
99
+ if bass:
100
+ text += "/" + bass.replace("♭", "b").replace("-", "b")
101
+ return '"' + text.replace('"', "") + '"'
102
+
103
+
104
+ def _drum_items(items: List[_Item]) -> List[_Item]:
105
+ """I colpi di batteria come note: la nota GM del suono (canale 10)."""
106
+ out = []
107
+ for item in items:
108
+ pitches = sorted({PERCUSSION_MAP.get(d, 38) for d in item.drums})
109
+ graces = tuple((tuple(_spell_midi(PERCUSSION_MAP.get(d, 38), False) for d in drums), (), slash)
110
+ for _pitches, drums, slash in item.graces)
111
+ out.append(dataclasses.replace(item, pitches=[_spell_midi(p, False) for p in pitches], drums=[],
112
+ graces=graces))
113
+ return out
114
+
115
+
116
+ def _clef_text(sign: str, octave_change: int) -> str:
117
+ if sign == "percussion":
118
+ return "perc"
119
+ name = "treble" if sign == "G" else "bass"
120
+ return name + ("-8" if octave_change < 0 else "+8" if octave_change > 0 else "")
121
+
122
+
123
+ def _lyric_syllable(entry: _Entry, verse: int = 1) -> str:
124
+ """La sillaba w: di una nota nella strofa 'verse': '*' senza testo, '_'
125
+ per la nota legata che prosegue (lo standard le conta come note distinte)."""
126
+ if not entry.first_of_item:
127
+ return "_ "
128
+ syllable = _item_syllables(entry.item).get(verse)
129
+ if not syllable:
130
+ return "* "
131
+ if syllable == "_":
132
+ return "_ "
133
+ text = syllable.replace(" ", "~").replace("*", "\\*")
134
+ return text if text.endswith("-") and len(text) > 1 else text + " "
135
+
136
+
137
+ class _Voice:
138
+ def __init__(self, vid: str, name: str, clef: str, program: Optional[int], drums: bool,
139
+ items: List[_Item], first: bool, transpose: int):
140
+ self.vid = vid
141
+ self.name = name
142
+ self.clef = clef
143
+ self.program = program
144
+ self.drums = drums
145
+ self.items = items
146
+ self.first = first # prima voce del pentagramma (pause visibili)
147
+ self.transpose = transpose # semitoni fra suono e scrittura (chiave all'ottava)
148
+ self.directions: List[_Direction] = []
149
+ self.verses = sorted({n for i in items for n in _item_syllables(i)})
150
+ self.has_lyrics = bool(self.verses)
151
+
152
+
153
+ def _entry_text(entry: _Entry, voice: _Voice, bar_alters, signature) -> str:
154
+ units = entry.duration / UNIT
155
+ if entry.tuplet:
156
+ actual, normal = entry.tuplet
157
+ units = units * actual / normal
158
+ out = "".join(entry.directions)
159
+ if entry.item is None:
160
+ rest = "z" if voice.first else "x"
161
+ return out + rest + length_suffix(units)
162
+ if entry.first_of_item and entry.item.slur == "start":
163
+ out += "("
164
+ if entry.first_of_item and entry.item.articulation in _ARTICULATIONS:
165
+ out += _ARTICULATIONS[entry.item.articulation]
166
+ if entry.first_of_item:
167
+ out += "".join(_DECORATIONS[d] for d in entry.item.decorations if d in _DECORATIONS)
168
+ if entry.first_of_item and entry.item.graces:
169
+ # {/g} acciaccatura, {g} appoggiatura; un abbellimento di piu' note si
170
+ # scrive come sequenza (le note di un abbellimento ABC sono singole)
171
+ slash = "/" if entry.item.graces[0][2] else ""
172
+ out += "{" + slash + "".join(pitch_text(p.step, p.alter, p.octave + voice.transpose // 12, bar_alters,
173
+ signature)
174
+ for pitches, _drums, _slash in entry.item.graces for p in pitches) + "}"
175
+ notes = [pitch_text(p.step, p.alter, p.octave + voice.transpose // 12, bar_alters, signature)
176
+ for p in entry.item.pitches]
177
+ body = notes[0] if len(notes) == 1 else "[" + "".join(notes) + "]"
178
+ slur_end = ")" if not entry.tie_start and entry.item.slur == "stop" else ""
179
+ return out + body + length_suffix(units) + ("-" if entry.tie_start else "") + slur_end
180
+
181
+
182
+ def _measure_text(entries: List[_Entry], voice: _Voice, signature) -> Tuple[str, Dict[int, List[str]]]:
183
+ """La battuta di una voce e le sue sillabe, per strofa."""
184
+ parts: List[str] = []
185
+ syllables: Dict[int, List[str]] = {n: [] for n in voice.verses}
186
+ bar_alters: Dict[str, int] = {}
187
+ in_tuplet = False
188
+ for i, entry in enumerate(entries):
189
+ if entry.tuplet_mark == "start":
190
+ count = next(k for k, e in enumerate(entries[i:]) if e.tuplet_mark == "stop") + 1
191
+ actual, normal = entry.tuplet
192
+ parts.append(f"({actual}:{normal}:{count}")
193
+ in_tuplet = True
194
+ elif entry.tuplet and not in_tuplet:
195
+ # tuplet isolata (una nota sola, es. una pausa di terzina)
196
+ actual, normal = entry.tuplet
197
+ parts.append(f"({actual}:{normal}:1")
198
+ text = _entry_text(entry, voice, bar_alters, signature)
199
+ if entry.tuplet_mark == "stop":
200
+ in_tuplet = False
201
+ joined = entry.beam in ("begin", "continue")
202
+ parts.append(text + ("" if joined else " "))
203
+ if entry.item is not None:
204
+ for n in voice.verses:
205
+ syllables[n].append(_lyric_syllable(entry, n))
206
+ return "".join(parts).rstrip(), syllables
207
+
208
+
209
+ def project_to_abc(project: "Song", only_audible: bool = True,
210
+ tracks: Optional[List["Part"]] = None, midi_dir: Optional[str] = None) -> str:
211
+ """Il progetto come brano ABC (X:1), una voce per pentagramma."""
212
+ from .timing import build_metrica_beat_map, build_tempo_beat_map
213
+
214
+ if tracks is None:
215
+ tracks = project.audible_tracks() if only_audible else project.tracks
216
+ tracks = [t for t in tracks if not t.is_audio]
217
+ events_by_track = {t.name: t.parsed_events(project.patterns, midi_dir=midi_dir, meter=project.meter()) for t in tracks}
218
+ key = key_fifths(project.key)
219
+ flats = bool(key and key[0] < 0)
220
+ signature = key_alters(key[0] if key else 0)
221
+
222
+ grid = None
223
+ for _attempt in range(2):
224
+ parsed = {t.name: _track_items(events_by_track[t.name], t.instrument, flats, grid) for t in tracks}
225
+ tempo_map = [(_frac(b, grid), bpm) for b, bpm in
226
+ build_tempo_beat_map(project, tracks=tracks, events_by_track=events_by_track)]
227
+ end = max([i.end for items, _ in parsed.values() for i in items] + [Fraction(0)])
228
+ measures = _measures(build_metrica_beat_map(project), end, getattr(project, "pickup", 0))
229
+ times = [m[0] for m in measures] + [t for t, _ in tempo_map]
230
+ for items, pedals in parsed.values():
231
+ times += [i.start for i in items] + [i.end for i in items] + [t for t, _ in pedals]
232
+ if _grid_divisions(times):
233
+ break
234
+ grid = QUANT_GRID
235
+
236
+ layout = _repeat_layout(measures, [r for t in tracks for r in _track_repeats(events_by_track[t.name], grid)],
237
+ [parsed[t.name][0] for t in tracks])
238
+ voices: List[_Voice] = []
239
+ score: List[str] = []
240
+ for n, track in enumerate(tracks, 1):
241
+ items, _pedals = parsed[track.name] # il pedale non e' nello standard ABC 2.1
242
+ _drop_repeated_harmonies(items)
243
+ instrument = track.instrument
244
+ drums = instrument.is_percussion
245
+ clefs = _clefs(track, items)
246
+ if drums:
247
+ items = _drum_items(items)
248
+ if len(clefs) == 2:
249
+ upper = [dataclasses.replace(i, pitches=[p for p in i.pitches if p.midi >= 60])
250
+ for i in items if any(p.midi >= 60 for p in i.pitches)]
251
+ lower = [dataclasses.replace(i, pitches=[p for p in i.pitches if p.midi < 60], harmony=None,
252
+ lyric=i.lyric if not any(p.midi >= 60 for p in i.pitches) else None)
253
+ for i in items if any(p.midi < 60 for p in i.pitches)]
254
+ staff_items = [upper, lower]
255
+ else:
256
+ staff_items = [items]
257
+
258
+ directions: List[_Direction] = []
259
+ if n == 1:
260
+ directions += [_Direction(t, f"[Q:1/4={bpm}]") for t, bpm in tempo_map if t > 0]
261
+ directions += [_Direction(i.start, chord_symbol(*i.harmony)) for i in items if i.harmony]
262
+ directions += [_Direction(t, chord_symbol(symbol, bass))
263
+ for t, symbol, bass in _track_harmonies(events_by_track[track.name], grid)]
264
+ directions += [_Direction(t, '"^' + words.replace('"', "'") + '"')
265
+ for t, words in _track_texts(events_by_track[track.name], grid)]
266
+ directions += [_Direction(t, {"segno": "!segno!", "coda": "!coda!"}.get(name)
267
+ or '"^' + NAVIGATION_WORDS.get(name, name) + '"')
268
+ for t, name in _track_navigation(events_by_track[track.name], grid)]
269
+ if not drums:
270
+ first_voice = min((i.voice for i in items), default=1)
271
+ directions += [_Direction(t, f"!{mark}!") for t, mark in
272
+ _dynamic_changes(_monophonic([i for i in items if i.voice == first_voice]))]
273
+ merged: Dict[Fraction, str] = {}
274
+ for d in sorted(directions, key=lambda d: (d.time, not d.xml.startswith("["))):
275
+ merged[d.time] = merged.get(d.time, "") + d.xml
276
+ directions = [_Direction(t, text) for t, text in sorted(merged.items())]
277
+
278
+ staves = []
279
+ for s, s_items in enumerate(staff_items):
280
+ by_voice: Dict[int, List[_Item]] = {}
281
+ for item in s_items:
282
+ by_voice.setdefault(item.voice, []).append(item)
283
+ sign, _line, octave_change = clefs[s]
284
+ ids = []
285
+ for k, (_v, v_items) in enumerate(sorted(by_voice.items()) or [(1, [])]):
286
+ v_items = _monophonic(v_items)
287
+ _mark_lyrics(v_items)
288
+ vid = f"T{n}" + ("" if s == 0 else "b") + ("" if k == 0 else f"v{k + 1}")
289
+ name = track.name if s == 0 and k == 0 else ""
290
+ voice = _Voice(vid, name, _clef_text(sign, octave_change), instrument.gm_program,
291
+ drums, v_items, k == 0, -12 * octave_change)
292
+ if s == 0 and k == 0:
293
+ voice.directions = directions
294
+ voices.append(voice)
295
+ ids.append(vid)
296
+ staves.append(ids[0] if len(ids) == 1 else "(" + " ".join(ids) + ")")
297
+ score.append(staves[0] if len(staves) == 1 else "{" + " | ".join(staves) + "}")
298
+
299
+ first_sig = f"{measures[0][2]}/{measures[0][3]}"
300
+ bpm = tempo_map[0][1] if tempo_map and tempo_map[0][0] == 0 else round(project.tempo_bpm)
301
+ out = ["X:1", f"T:{getattr(project, 'title', '') or project.name}"]
302
+ if getattr(project, "composer", ""):
303
+ out.append(f"C:{project.composer}")
304
+ if getattr(project, "lyricist", ""):
305
+ out.append(f"C:{project.lyricist} (lyrics)")
306
+ out += [f"M:{first_sig}", "L:1/8", f"Q:1/4={bpm}"]
307
+ key_changes = dict(song_key_changes(project))
308
+ if len(voices) > 1:
309
+ out.append("%%score " + " ".join(score))
310
+ for voice in voices:
311
+ line = f"V:{voice.vid} clef={voice.clef}"
312
+ if voice.name:
313
+ line += ' name="' + voice.name.replace('"', "'") + '"'
314
+ out.append(line)
315
+ out.append(f"K:{key_name(project.key)}")
316
+
317
+ for voice in voices:
318
+ out.append(f"V:{voice.vid}")
319
+ out.append(f"%%MIDI channel {DRUM_CHANNEL}" if voice.drums else f"%%MIDI program {voice.program}")
320
+ line: List[str] = []
321
+ syllables: Dict[int, List[str]] = {n: [] for n in voice.verses}
322
+ previous_sig = (measures[0][2], measures[0][3])
323
+ voice_signature = signature
324
+ for number, (m_index, marks) in enumerate(layout, 1):
325
+ m_start, m_len, num, den = measures[m_index]
326
+ changes_key = m_start in key_changes and number > 1 and not voice.drums
327
+ if changes_key:
328
+ voice_signature = key_alters(key_fifths(key_changes[m_start])[0])
329
+ entries = _staff_entries(voice.items, voice.directions, m_start, m_len, num, den)
330
+ text, bar_syllables = _measure_text(entries, voice, voice_signature)
331
+ if (num, den) != previous_sig:
332
+ text = f"[M:{num}/{den}] " + text
333
+ previous_sig = (num, den)
334
+ if changes_key:
335
+ text = f"[K:{key_name(key_changes[m_start])}] " + text
336
+ if marks.ending_start:
337
+ text = f"[{marks.ending_start} " + text
338
+ if number == 1 and marks.forward:
339
+ text = "|: " + text
340
+ last = number == len(layout)
341
+ following = layout[number][1] if not last else None
342
+ if marks.backward:
343
+ bar = "::" if following is not None and following.forward else ":|"
344
+ elif following is not None and following.forward:
345
+ bar = "|:"
346
+ else:
347
+ bar = "|]" if last else "|"
348
+ line.append(text + " " + bar)
349
+ for n in voice.verses:
350
+ syllables[n] += bar_syllables[n]
351
+ if number % BARS_PER_LINE == 0 or last:
352
+ out.append(" ".join(line))
353
+ # una riga w: per strofa, sempre (anche vuota): per lo standard
354
+ # la successiva si allinea dopo le note di questa
355
+ for n in voice.verses:
356
+ words = "".join(syllables[n]).split(" ")
357
+ while words and words[-1] in ("*", ""):
358
+ words.pop()
359
+ out.append(("w: " + " ".join(words)).rstrip())
360
+ line, syllables = [], {n: [] for n in voice.verses}
361
+ return "\n".join(out) + "\n"
362
+
363
+
364
+ def export_project_to_abc(project: "Song", path: str, only_audible: bool = True,
365
+ tracks: Optional[List["Part"]] = None, midi_dir: Optional[str] = None) -> str:
366
+ """Scrive in path il brano ABC del progetto (vedi project_to_abc): le
367
+ stesse tracce dell'esportazione MIDI e MusicXML."""
368
+ with open(path, "w", encoding="utf-8") as f:
369
+ f.write(project_to_abc(project, only_audible=only_audible, tracks=tracks, midi_dir=midi_dir))
370
+ return path