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))