concertina_helper.cli
1import argparse 2from pathlib import Path 3from signal import signal, SIGPIPE, SIG_DFL 4from enum import Enum 5from collections.abc import Callable, Iterable 6 7from pyabc2 import Tune 8 9from .layouts.layout_loader import ( 10 list_layout_names, load_bisonoric_layout_by_path, load_bisonoric_layout_by_name) 11from .layouts.bisonoric import BisonoricLayout 12from .notes_on_layout import NotesOnLayout 13from .note_generators import notes_from_tune, notes_from_pitches 14from .penalties import ( 15 PenaltyFunction, 16 penalize_bellows_change, 17 penalize_finger_in_same_column, 18 penalize_pull_at_start_of_measure, 19 penalize_outer_fingers) 20from .type_defs import Direction, PitchToStr, Annotation 21from .output_utils import condense 22 23 24class _OutputFormat(Enum): 25 def __init__( 26 self, 27 doc: str, 28 button_down_f: PitchToStr | None = None, 29 button_up_f: PitchToStr | None = None, 30 direction_f: Callable[[Direction], str] | None = None 31 ): 32 self.doc = doc 33 self.button_down_f = button_down_f 34 self.button_up_f = button_up_f 35 self.direction_f = direction_f 36 UNICODE = ( 37 'uses "○" and "●" to represent button state', 38 lambda pitch: '● ', 39 lambda pitch: '○ ', 40 lambda direction: ( 41 f'-> {direction.name} <-' 42 if direction == Direction.PUSH 43 else f'<- {direction.name} ->') 44 ) 45 ASCII = ( 46 'uses "." and "@" to represent button state', 47 lambda pitch: '@', 48 lambda pitch: '.', 49 lambda direction: direction.name 50 ) 51 LONG = ( 52 'spells out the names of pressed buttons', 53 lambda pitch: str(pitch).ljust(4), 54 lambda pitch: '--- ', 55 lambda direction: direction.name 56 ) 57 COMPACT = ( 58 'multiple fingerings represented in single grid' 59 ) 60 61 62def _format_enum(enum: Iterable) -> str: 63 return ' / '.join(f'"{opt.name}" {opt.doc}' for opt in enum) # type: ignore 64 65 66def _parse_and_print_fingerings() -> None: 67 ''' 68 Parses command line arguments, finds optimal fingering for tune, and prints. 69 ''' 70 # Ignore broken pipes, so piping output to "head" will not error. 71 # https://stackoverflow.com/a/30091579 72 signal(SIGPIPE, SIG_DFL) 73 74 parser = argparse.ArgumentParser( 75 formatter_class=argparse.ArgumentDefaultsHelpFormatter, 76 description=''' 77Given a file containing ABC notation, 78and a concertina type, 79prints possible fingerings. 80''') 81 parser.add_argument( 82 'input', type=Path, 83 help='Input file: Parsed either as a list of pitches, one per line, ' 84 'or as ABC, if the first lines starts with "X:".') 85 parser.add_argument( 86 '--output_format', choices=[f.name for f in _OutputFormat], 87 default=_OutputFormat.LONG.name, 88 help='Output format. ' + _format_enum(_OutputFormat)) 89 90 layout_group = parser.add_argument_group( 91 'Layout options', 92 'Supply your own layout, or use a predefined one, optionally transposed\n') 93 layout_source_group = layout_group.add_mutually_exclusive_group(required=True) 94 layout_source_group.add_argument( 95 '--layout_path', type=Path, metavar='PATH', 96 help='Path of YAML file with concertina layout') 97 layout_source_group.add_argument( 98 '--layout_name', choices=list_layout_names(), 99 help='Name of concertina layout') 100 layout_group.add_argument( 101 '--layout_transpose', default=0, type=int, metavar='SEMITONES', 102 help='Semitones to transpose the layout; Negative transposes down') 103 104 cost_group = parser.add_argument_group( 105 'Cost options', 106 'Configure the relative costs of different transitions between fingerings\n') 107 for name in globals(): 108 if name.startswith('penalize_'): 109 param_name = name.replace('penalize_', '') + '_cost' 110 cost_group.add_argument( 111 f'--{param_name}', type=float, 112 metavar='N', default=1, 113 help=globals()[name].__doc__) 114 cost_group.add_argument( 115 '--show_all', action='store_true', 116 help='Ignore cost options and just show all possible fingerings') 117 118 args = parser.parse_args() 119 120 input_text = args.input.read_text() 121 notes = ( 122 notes_from_tune(Tune(input_text)) 123 if input_text.startswith('X:') else 124 notes_from_pitches(input_text.split('\n')) 125 ) 126 127 layout = ( 128 load_bisonoric_layout_by_path(args.layout_path) 129 if args.layout_path else 130 load_bisonoric_layout_by_name(args.layout_name) 131 ).transpose(args.layout_transpose) 132 133 penalty_functions = [] if args.show_all else [ 134 penalize_bellows_change(args.bellows_change_cost), 135 penalize_finger_in_same_column(args.finger_in_same_column_cost), 136 penalize_pull_at_start_of_measure(args.pull_at_start_of_measure_cost), 137 penalize_outer_fingers(args.outer_fingers_cost) 138 ] 139 output_format = _OutputFormat[args.output_format] 140 141 print_fingerings( 142 notes, layout, 143 button_down_f=output_format.button_down_f, 144 button_up_f=output_format.button_up_f, 145 direction_f=output_format.direction_f, 146 penalty_functions=penalty_functions) 147 148 149def print_fingerings( 150 notes: Iterable[Annotation], 151 layout: BisonoricLayout, 152 button_down_f: PitchToStr | None = lambda _: '@', 153 button_up_f: PitchToStr | None = lambda _: '.', 154 direction_f: Callable[[Direction], str] | None = lambda direction: direction.name, 155 penalty_functions: Iterable[PenaltyFunction] = [] 156) -> None: 157 ''' 158 The core of the CLI functionality. 159 - `notes`: A sequence of annotated pitches. 160 - `layout`: A bisonoric layout, either built-in or supplied by user. 161 - `button_down_f`, `button_up_f`, `direction_f`: 162 Functions that determine output style. 163 - `penalty_functions`: Heuristic functions that define what makes a good fingering. 164 If empty, all fingerings will be printed. 165 ''' 166 n_l = NotesOnLayout(notes, layout) 167 168 if penalty_functions: 169 best = n_l.get_best_fingerings(penalty_functions) 170 if direction_f is None: 171 # TODO: split on measures? 172 print(condense(best)) 173 else: 174 assert ( 175 button_down_f is not None 176 and button_up_f is not None 177 and direction_f is not None), 'Either set all or none' 178 for annotated_fingering in best: 179 print(annotated_fingering.format( 180 button_down_f=button_down_f, 181 button_up_f=button_up_f, 182 direction_f=direction_f)) 183 else: 184 if direction_f is None: 185 raise ValueError('Display functions required to show all fingerings') 186 assert ( 187 button_down_f is not None 188 and button_up_f is not None 189 and direction_f is not None), 'Either set all or none' 190 for annotation, annotated_fingering_set in n_l.get_all_fingerings(): 191 if not annotated_fingering_set: 192 a = annotation 193 print(f'No fingerings for {a.pitch} in measure {a.measure}') 194 continue 195 for annotated_fingering in annotated_fingering_set: 196 print(annotated_fingering.format( 197 button_down_f=button_down_f, 198 button_up_f=button_up_f, 199 direction_f=direction_f))
def
print_fingerings( notes: collections.abc.Iterable[concertina_helper.type_defs.Annotation], layout: concertina_helper.layouts.bisonoric.BisonoricLayout, button_down_f: collections.abc.Callable[[concertina_helper.type_defs.Pitch], str] | None = <function <lambda>>, button_up_f: collections.abc.Callable[[concertina_helper.type_defs.Pitch], str] | None = <function <lambda>>, direction_f: collections.abc.Callable[[concertina_helper.type_defs.Direction], str] | None = <function <lambda>>, penalty_functions: collections.abc.Iterable[collections.abc.Callable[[concertina_helper.layouts.bisonoric.AnnotatedBisonoricFingering, concertina_helper.layouts.bisonoric.AnnotatedBisonoricFingering], float]] = []) -> None:
150def print_fingerings( 151 notes: Iterable[Annotation], 152 layout: BisonoricLayout, 153 button_down_f: PitchToStr | None = lambda _: '@', 154 button_up_f: PitchToStr | None = lambda _: '.', 155 direction_f: Callable[[Direction], str] | None = lambda direction: direction.name, 156 penalty_functions: Iterable[PenaltyFunction] = [] 157) -> None: 158 ''' 159 The core of the CLI functionality. 160 - `notes`: A sequence of annotated pitches. 161 - `layout`: A bisonoric layout, either built-in or supplied by user. 162 - `button_down_f`, `button_up_f`, `direction_f`: 163 Functions that determine output style. 164 - `penalty_functions`: Heuristic functions that define what makes a good fingering. 165 If empty, all fingerings will be printed. 166 ''' 167 n_l = NotesOnLayout(notes, layout) 168 169 if penalty_functions: 170 best = n_l.get_best_fingerings(penalty_functions) 171 if direction_f is None: 172 # TODO: split on measures? 173 print(condense(best)) 174 else: 175 assert ( 176 button_down_f is not None 177 and button_up_f is not None 178 and direction_f is not None), 'Either set all or none' 179 for annotated_fingering in best: 180 print(annotated_fingering.format( 181 button_down_f=button_down_f, 182 button_up_f=button_up_f, 183 direction_f=direction_f)) 184 else: 185 if direction_f is None: 186 raise ValueError('Display functions required to show all fingerings') 187 assert ( 188 button_down_f is not None 189 and button_up_f is not None 190 and direction_f is not None), 'Either set all or none' 191 for annotation, annotated_fingering_set in n_l.get_all_fingerings(): 192 if not annotated_fingering_set: 193 a = annotation 194 print(f'No fingerings for {a.pitch} in measure {a.measure}') 195 continue 196 for annotated_fingering in annotated_fingering_set: 197 print(annotated_fingering.format( 198 button_down_f=button_down_f, 199 button_up_f=button_up_f, 200 direction_f=direction_f))
The core of the CLI functionality.
notes
: A sequence of annotated pitches.layout
: A bisonoric layout, either built-in or supplied by user.button_down_f
,button_up_f
,direction_f
: Functions that determine output style.penalty_functions
: Heuristic functions that define what makes a good fingering. If empty, all fingerings will be printed.