def test_irregular_beats_per_frame_2() -> None: chart = """ b=1 ①□□□ □□□□ □□□□ □□□□ -- □□□□ □①□□ □□□□ □□□□ -- b=2.75 □□□□ □□□□ □□①□ □□□□ -- □□□□ □□□□ □□□□ □□□① -- """ expected = [ TapNote(BeatsTime("0.00"), NotePosition(0, 0)), TapNote(BeatsTime("1.00"), NotePosition(1, 1)), TapNote(BeatsTime("2.00"), NotePosition(2, 2)), TapNote(BeatsTime("4.75"), NotePosition(3, 3)), ] compare_chart_notes(chart, expected)
def append_chart_line(self, raw_line: RawMemo2ChartLine) -> None: if (len( raw_line.position.encode("shift-jis-2004", errors="surrogateescape")) != 4 * self.bytes_per_panel): raise SyntaxError( f"Invalid chart line for #bpp={self.bytes_per_panel} : {raw_line}" ) if raw_line.timing is not None and self.bytes_per_panel == 2: if any( len( e.string.encode("shift-jis-2004", errors="surrogateescape")) % 2 != 0 for e in raw_line.timing if isinstance(e, NoteCluster)): raise SyntaxError( f"Invalid chart line for #bpp=2 : {raw_line}") if not raw_line.timing: line = Memo2ChartLine(raw_line.position, None) else: # split notes bar: List[Union[str, Stop, BPM]] = [] for raw_event in raw_line.timing: if isinstance(raw_event, NoteCluster): bar.extend(self._split_chart_line(raw_event.string)) else: bar.append(raw_event) # extract timing info bar_length = sum(1 for e in bar if isinstance(e, str)) symbol_duration = BeatsTime(1, bar_length) in_bar_beat = BeatsTime(0) for event in bar: if isinstance(event, str): in_bar_beat += symbol_duration elif isinstance(event, BPM): self.timing_events.append( BPMEvent(time=self._current_beat() + in_bar_beat, BPM=event.value)) elif isinstance(event, Stop): time = self._current_beat() + in_bar_beat if time != 0: raise ValueError( "Chart contains a pause that's not happening at the " "very first beat, these are not supported by jubeatools" ) self.offset += event.duration bar_notes = [e for e in bar if isinstance(e, str)] line = Memo2ChartLine(raw_line.position, bar_notes) self.current_chart_lines.append(line) if len(self.current_chart_lines) == 4: self._push_frame()
def beat_time( draw: DrawFunc, min_section: Optional[int] = None, max_section: Optional[int] = None, min_numerator: Optional[int] = None, max_numerator: Optional[int] = None, denominator_strat: st.SearchStrategy[int] = st.sampled_from( [4, 8, 16, 3, 5]), ) -> BeatsTime: denominator = draw(denominator_strat) if min_section is not None: min_value = denominator * 4 * min_section else: min_value = 0 if min_numerator is not None: min_value = max(min_value, min_numerator) if max_section is not None: max_value = denominator * 4 * max_section else: max_value = None if max_numerator is not None: if max_value is not None: max_value = min(max_numerator, max_value) else: max_value = max_numerator numerator = draw(st.integers(min_value=min_value, max_value=max_value)) return BeatsTime(numerator, denominator)
def _dump_memo1_chart( difficulty: str, chart: Chart, metadata: Metadata, timing: Timing, circle_free: bool = False, ) -> StringIO: _raise_if_unfit_for_memo1(chart, timing, circle_free) sections = create_sections_from_chart(Memo1DumpedSection, chart, difficulty, timing, metadata, circle_free) # Jubeat Analyser format command sections[BeatsTime(0)].commands["memo1"] = None # Actual output to file file = StringIO() file.write(f"// Converted using jubeatools {__version__}\n") file.write(f"// https://github.com/Stepland/jubeatools\n\n") file.write("\n\n".join( section.render(circle_free) for _, section in sections.items())) return file
def _iter_frames( self, ) -> Iterator[Tuple[Mapping[str, BeatsTime], Memo2Frame]]: """iterate over tuples of (currently_defined_symbols, frame)""" local_symbols = {} frame_starting_beat = BeatsTime(0) for i, frame in enumerate(self.frames): if frame.timing_part: frame_starting_beat = sum( (f.duration for f in self.frames[:i]), start=BeatsTime(0)) local_symbols = { symbol: frame_starting_beat + bar_index + BeatsTime(symbol_index, len(bar)) for bar_index, bar in enumerate(frame.timing_part) for symbol_index, symbol in enumerate(bar) if symbol not in EMPTY_BEAT_SYMBOLS } yield local_symbols, frame
class _JubeatAnalyerDumpedSection: current_beat: BeatsTime length: BeatsTime = BeatsTime(4) commands: Commands = field(default_factory=Commands) # type: ignore symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict) symbols: Dict[BeatsTime, str] = field(default_factory=dict) notes: List[Union[TapNote, LongNote, LongNoteEnd]] = field(default_factory=list)
def __init__(self) -> None: self.music: Optional[str] = None self.symbols = deepcopy(SYMBOL_TO_BEATS_TIME) self.section_starting_beat = BeatsTime(0) self.current_tempo = Decimal(120) self.timing_events: List[BPMEvent] = [] self.offset = 0 self.beats_per_section = BeatsTime(4) self.bytes_per_panel = 2 self.level = Decimal(1) self.difficulty: Optional[str] = None self.title: Optional[str] = None self.artist: Optional[str] = None self.jacket: Optional[str] = None self.preview_start: Optional[int] = None self.hold_by_arrow = False self.circle_free = False
def _iter_blocs( self, ) -> Iterator[Tuple[BeatsTime, MonoColumnLoadedSection, List[List[str]]]]: section_starting_beat = BeatsTime(0) for section in self.sections: for bloc in section.blocs(): yield section_starting_beat, section, bloc section_starting_beat += section.length
def test_circle_free() -> None: chart = """ #holdbyarrow=1 #circlefree=1 □□□□ □□□□ □□□□ >□□① -- □□□□ □□□□ □□□□ □□□13 -- """ expected = [ LongNote(BeatsTime(0), NotePosition(3, 3), BeatsTime(7), NotePosition(0, 3)) ] compare_chart_notes(chart, expected)
def _iter_notes_without_longs(self) -> Iterator[TapNote]: section_starting_beat = BeatsTime(0) for section in self.sections: for bloc, y, x in product(section.blocs(), range(4), range(4)): symbol = bloc[y][x] if symbol in section.symbols: symbol_time = section.symbols[symbol] note_time = section_starting_beat + symbol_time position = NotePosition(x, y) yield TapNote(note_time, position) section_starting_beat += section.length
def test_simple_section() -> None: chart = """ ①□□□ □⑤□□ □□⑨□ □□□⑬ ------- """ expected = [ TapNote(time=BeatsTime(i), position=NotePosition(i, i)) for i in range(4) ] compare_chart_notes(chart, expected)
def test_that_a_single_long_note_roundtrips(note: LongNote) -> None: timing = Timing( events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) ) chart = Chart(level=Decimal(0), timing=timing, notes=[note]) metadata = Metadata("", "", Path(""), Path("")) string_io = _dump_mono_column_chart("", chart, metadata, timing) chart_text = string_io.getvalue() parser = MonoColumnParser() for line in chart_text.split("\n"): parser.load_line(line) actual = set(parser.notes()) assert set([note]) == actual
def test_long_notes_simple_solution_no_warning() -> None: chart = """ #holdbyarrow=1 □□□□ >①①< □□□□ □□□□ -- □□□□ □①①□ □□□□ □□□□ -- """ expected = [ LongNote(BeatsTime(0), NotePosition(x, y), BeatsTime(4), NotePosition(tx, ty)) for (x, y), (tx, ty) in [ ((1, 1), (0, 1)), ((2, 1), (3, 1)), ] ] compare_chart_notes(chart, expected)
def test_long_notes() -> None: chart = """ #holdbyarrow=1 ①□□< □□□□ □□□□ □□□□ -- ①□□□ □□□□ □□□□ □□□□ -- """ expected = [ LongNote( time=BeatsTime(0), position=NotePosition(0, 0), duration=BeatsTime(4), tail_tip=NotePosition(3, 0), ) ] compare_chart_notes(chart, expected)
def test_long_notes_ambiguous_case() -> None: chart = """ #holdbyarrow=1 ①①<< □□□□ □□□□ □□□□ -- ①①□□ □□□□ □□□□ □□□□ -- """ expected = [ LongNote(BeatsTime(0), NotePosition(x, y), BeatsTime(4), NotePosition(tx, ty)) for (x, y), (tx, ty) in [ ((0, 0), (2, 0)), ((1, 0), (3, 0)), ] ] with pytest.warns(UserWarning): compare_chart_notes(chart, expected)
def test_long_notes_complex_case() -> None: chart = """ #holdbyarrow=1 □□□□ □□∨□ □∨□□ >①①① -- □□□□ □□□□ □□□□ □①①① -- """ expected = [ LongNote(BeatsTime(0), NotePosition(x, y), BeatsTime(4), NotePosition(tx, ty)) for (x, y), (tx, ty) in [ ((1, 3), (1, 2)), ((2, 3), (2, 1)), ((3, 3), (0, 3)), ] ] compare_chart_notes(chart, expected)
def _iter_frames( self, ) -> Iterator[Tuple[Mapping[str, BeatsTime], MemoFrame, BeatsTime, MemoLoadedSection]]: """iterate over tuples of currently_defined_symbols, frame_starting_beat, frame, section_starting_beat, section""" local_symbols: Dict[str, BeatsTime] = {} section_starting_beat = BeatsTime(0) for section in self.sections: frame_starting_beat = BeatsTime(0) for i, frame in enumerate(section.frames): if frame.timing_part: frame_starting_beat = sum( (f.duration for f in section.frames[:i]), start=BeatsTime(0)) local_symbols = { symbol: BeatsTime("1/4") * i + frame_starting_beat for i, symbol in enumerate(collapse(frame.timing_part)) if symbol not in EMPTY_BEAT_SYMBOLS } currently_defined_symbols = ChainMap(local_symbols, section.symbols) yield currently_defined_symbols, frame, section_starting_beat, section section_starting_beat += section.length
def _iter_frames( self, ) -> Iterator[Tuple[Mapping[str, BeatsTime], Memo1Frame, BeatsTime, Memo1LoadedSection]]: """iterate over tuples of currently_defined_symbols, frame, section_starting_beat, section""" local_symbols = {} section_starting_beat = BeatsTime(0) for section in self.sections: frame_starting_beat = BeatsTime(0) for i, frame in enumerate(section.frames): if frame.timing_part: frame_starting_beat = sum( (f.duration for f in section.frames[:i]), start=BeatsTime(0)) local_symbols = { symbol: BeatsTime(symbol_index, len(bar)) + bar_index + frame_starting_beat for bar_index, bar in enumerate(frame.timing_part) for symbol_index, symbol in enumerate(bar) if symbol not in EMPTY_BEAT_SYMBOLS } yield local_symbols, frame, section_starting_beat, section section_starting_beat += section.length
def test_that_a_set_of_tap_notes_roundtrip(notes: Set[TapNote]) -> None: timing = Timing( events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) ) chart = Chart( level=Decimal(0), timing=timing, notes=sorted(notes, key=lambda n: (n.time, n.position)), ) metadata = Metadata("", "", Path(""), Path("")) string_io = _dump_mono_column_chart("", chart, metadata, timing) chart_text = string_io.getvalue() parser = MonoColumnParser() for line in chart_text.split("\n"): parser.load_line(line) actual = set(parser.notes()) assert notes == actual
def test_that_notes_roundtrip(notes: List[Union[TapNote, LongNote]]) -> None: timing = Timing(events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)) chart = Chart( level=Decimal(0), timing=timing, notes=sorted(notes, key=lambda n: (n.time, n.position)), ) metadata = Metadata("", "", Path(""), Path("")) string_io = _dump_memo2_chart("", chart, metadata, timing) chart_text = string_io.getvalue() parser = Memo2Parser() for line in chart_text.split("\n"): parser.load_line(line) parser.finish_last_few_notes() actual = set(parser.notes()) assert notes == actual
def test_half_width_symbols() -> None: chart = """ b=7 *⑲:4.5 *21:5 *25:6 口⑪①口 ①⑮⑤⑪ ⑤口口⑮ ⑨口口⑨ 21口口⑲ 25口口25 口⑲21口 25口口25 ---------- """ expected = [ TapNote(BeatsTime(t), NotePosition(x, y)) for t, x, y in [ ("0.0", 2, 0), ("0.0", 0, 1), ("1.0", 2, 1), ("1.0", 0, 2), ("2.0", 0, 3), ("2.0", 3, 3), ("2.5", 1, 0), ("2.5", 3, 1), ("3.5", 1, 1), ("3.5", 3, 2), ("4.5", 1, 2), ("4.5", 3, 0), ("5.0", 0, 0), ("5.0", 2, 2), ("6.0", 0, 1), ("6.0", 3, 1), ("6.0", 0, 3), ("6.0", 3, 3), ] ] compare_chart_notes(chart, expected)
def test_symbol_definition() -> None: chart = """ *A:2 //⑨と同タイミング *B:2.125 *C:2.25 //⑩と同じ *D:2.375 *E:2.5 //⑪と同じ *F:2.625 *G:2.75 //⑫と同じ *H:2.875 *I:3 //⑬と同じ *J:3.125 *K:3.25 //⑭と同じ *L:3.375 ABCD L③□E K⑦□F JIHG -- """ expected = [ TapNote(BeatsTime(t), NotePosition(x, y)) for t, x, y in [ ("4/8", 1, 1), ("12/8", 1, 2), ("16/8", 0, 0), ("17/8", 1, 0), ("18/8", 2, 0), ("19/8", 3, 0), ("20/8", 3, 1), ("21/8", 3, 2), ("22/8", 3, 3), ("23/8", 2, 3), ("24/8", 1, 3), ("25/8", 0, 3), ("26/8", 0, 2), ("27/8", 0, 1), ] ] compare_chart_notes(chart, expected)
def notes( draw: DrawFunc, collisions: bool = False, note_strat: st.SearchStrategy[Union[TapNote, LongNote]] = st.one_of( tap_note(), long_note()), beat_time_strat: st.SearchStrategy[BeatsTime] = beat_time(max_section=3), ) -> Set[Union[TapNote, LongNote]]: raw_notes: Set[Union[TapNote, LongNote]] = draw(st.sets(note_strat, max_size=32)) if collisions: return raw_notes else: last_notes: Dict[NotePosition, Optional[BeatsTime]] = { NotePosition(x, y): None for y, x in product(range(4), range(4)) } notes: Set[Union[TapNote, LongNote]] = set() for note in sorted(raw_notes, key=lambda n: (n.time, n.position)): last_note_time = last_notes[note.position] if last_note_time is None: new_time = draw(beat_time_strat) else: numerator = draw( st.integers(min_value=1, max_value=last_note_time.denominator * 4)) distance = BeatsTime(numerator, last_note_time.denominator) new_time = last_note_time + distance if isinstance(note, LongNote): notes.add( LongNote( time=new_time, position=note.position, duration=note.duration, tail_tip=note.tail_tip, )) last_notes[note.position] = new_time + note.duration else: notes.add(TapNote(time=new_time, position=note.position)) last_notes[note.position] = new_time return notes
def timing_info( draw: DrawFunc, with_bpm_changes: bool = True, bpm_strat: st.SearchStrategy[Decimal] = bpms(), beat_zero_offset_strat: st.SearchStrategy[Decimal] = st.decimals( min_value=0, max_value=20, places=3), time_strat: st.SearchStrategy[BeatsTime] = beat_time(min_section=1, max_section=10), ) -> Timing: first_bpm = draw(bpm_strat) first_event = BPMEvent(BeatsTime(0), first_bpm) events = [first_event] if with_bpm_changes: raw_bpm_changes = st.lists(bpm_changes(bpm_strat, time_strat), unique_by=get_bpm_change_time) sorted_bpm_changes = raw_bpm_changes.map( lambda l: sorted(l, key=get_bpm_change_time)) other_events = draw(sorted_bpm_changes) events += other_events beat_zero_offset = draw(beat_zero_offset_strat) return Timing(events=events, beat_zero_offset=beat_zero_offset)
def _dump_memo_chart( difficulty: str, chart: Chart, metadata: Metadata, timing: Timing, circle_free: bool = False, ) -> StringIO: _raise_if_unfit_for_memo(chart, timing, circle_free) sections = create_sections_from_chart(MemoDumpedSection, chart, difficulty, timing, metadata, circle_free) # Jubeat Analyser format command sections[BeatsTime(0)].commands["memo"] = None # Define extra symbols existing_symbols: Dict[BeatsTime, str] = {} extra_symbols = iter(DEFAULT_EXTRA_SYMBOLS) for section_start, section in sections.items(): # intentionally not a copy : at the end of this loop every section # holds a reference to a dict containing every defined symbol section.symbols = existing_symbols for note in section.notes: time_in_section = note.time - section_start if (time_in_section % Fraction(1, 4) != 0 and time_in_section not in existing_symbols): new_symbol = next(extra_symbols) section.symbol_definitions[time_in_section] = new_symbol existing_symbols[time_in_section] = new_symbol # Actual output to file file = StringIO() file.write(f"// Converted using jubeatools {__version__}\n") file.write(f"// https://github.com/Stepland/jubeatools\n\n") file.write("\n\n".join( section.render(circle_free) for _, section in sections.items())) return file
def test_compound_section() -> None: chart = """ □①①□ □⑩⑪□ ④⑧⑨⑤ ③⑥⑦③ ⑯⑫⑬⑯ □□□□ □□□□ ⑭□□⑭ ------------- 2 """ expected = [ TapNote(time=BeatsTime("1/4") * (t - 1), position=NotePosition(x, y)) for t, x, y in [ (1, 1, 0), (1, 2, 0), (3, 0, 3), (3, 3, 3), (4, 0, 2), (5, 3, 2), (6, 1, 3), (7, 2, 3), (8, 1, 2), (9, 2, 2), (10, 1, 1), (11, 2, 1), (12, 1, 0), (13, 2, 0), (14, 0, 3), (14, 3, 3), (16, 0, 0), (16, 3, 0), ] ] compare_chart_notes(chart, expected)
def duration(self) -> BeatsTime: # This is wrong for the last frame in a section if the section has a # length in beats that's not an integer return BeatsTime(len(self.timing_part))
def _frames_duration(self) -> BeatsTime: return sum((frame.duration for frame in self.current_frames), start=BeatsTime(0))
def _dump_memo2_chart( difficulty: str, chart: Chart, metadata: Metadata, timing: Timing, circle_free: bool = False, ) -> StringIO: _raise_if_unfit_for_memo2(chart, timing, circle_free) def make_section(b: BeatsTime) -> Memo2Section: return Memo2Section() sections = SortedDefaultDict(make_section) timing_events = sorted(timing.events, key=lambda e: e.time) notes = SortedKeyList(set(chart.notes), key=lambda n: n.time) for note in chart.notes: if isinstance(note, LongNote): notes.add(LongNoteEnd(note.time + note.duration, note.position)) all_events = SortedKeyList(timing_events + notes, key=lambda n: n.time) last_event = all_events[-1] last_measure = last_event.time // 4 for i in range(last_measure + 1): beat = BeatsTime(4) * i sections.add_key(beat) # Timing events sections[BeatsTime(0)].events.append( StopEvent(BeatsTime(0), timing.beat_zero_offset) ) for event in timing_events: section_beat = event.time - (event.time % 4) sections[section_beat].events.append(event) # Fill sections with notes for key, next_key in windowed(chain(sections.keys(), [None]), 2): assert key is not None sections[key].notes = list( notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False)) ) # Actual output to file file = StringIO() file.write(f"// Converted using jubeatools {__version__}\n") file.write(f"// https://github.com/Stepland/jubeatools\n\n") # Header file.write(dump_command("lev", Decimal(chart.level)) + "\n") file.write(dump_command("dif", DIFFICULTY_NUMBER.get(difficulty, 1)) + "\n") if metadata.audio is not None: file.write(dump_command("m", metadata.audio) + "\n") if metadata.title is not None: file.write(dump_command("title", metadata.title) + "\n") if metadata.artist is not None: file.write(dump_command("artist", metadata.artist) + "\n") if metadata.cover is not None: file.write(dump_command("jacket", metadata.cover) + "\n") if metadata.preview is not None: file.write(dump_command("prevpos", int(metadata.preview.start * 1000)) + "\n") if any(isinstance(note, LongNote) for note in chart.notes): file.write(dump_command("holdbyarrow", 1) + "\n") if circle_free: file.write(dump_command("circlefree", 1) + "\n") file.write(dump_command("memo2") + "\n") file.write("\n") # Notes file.write( "\n\n".join(section.render(circle_free) for _, section in sections.items()) ) return file
def _dump_notes(self, circle_free: bool = False) -> Iterator[str]: # Split notes and events into bars notes_by_bar: Dict[int, List[AnyNote]] = defaultdict(list) for note in self.notes: time_in_section = note.time % BeatsTime(4) bar_index = int(time_in_section) notes_by_bar[bar_index].append(note) events_by_bar: Dict[int, List[Union[BPMEvent, StopEvent]]] = defaultdict(list) for event in self.events: time_in_section = event.time % BeatsTime(4) bar_index = int(time_in_section) events_by_bar[bar_index].append(event) # Pre-render timing bars bars: Dict[int, List[str]] = defaultdict(list) chosen_symbols: Dict[BeatsTime, str] = {} symbols_iterator = iter(NOTE_SYMBOLS) for bar_index in range(4): notes = notes_by_bar.get(bar_index, []) events = events_by_bar.get(bar_index, []) bar_length = lcm( *( [note.time.denominator for note in notes] + [event.time.denominator for event in events] ) ) if bar_length < 3: bar_length = 4 bar_dict: Dict[int, BarEvent] = defaultdict(BarEvent) for note in notes: time_in_section = note.time % BeatsTime(4) time_in_bar = note.time % Fraction(1) time_index = time_in_bar.numerator * ( bar_length // time_in_bar.denominator ) if time_index not in bar_dict: symbol = next(symbols_iterator) chosen_symbols[time_in_section] = symbol bar_dict[time_index].note = symbol for event in events: time_in_bar = event.time % Fraction(1) time_index = time_in_bar.numerator * ( bar_length // time_in_bar.denominator ) if isinstance(event, StopEvent): bar_dict[time_index].stops.append(event) elif isinstance(event, BPMEvent): bar_dict[time_index].bpms.append(event) bar = [] for i in range(bar_length): bar_event = bar_dict.get(i, BarEvent()) for stop in bar_event.stops: bar.append(f"[{int(stop.duration * 1000)}]") for bpm in bar_event.bpms: bar.append(f"({bpm.BPM})") bar.append(bar_event.note or EMPTY_BEAT_SYMBOL) bars[bar_index] = bar # Create frame by bar frames_by_bar: Dict[int, List[Frame]] = defaultdict(list) for bar_index in range(4): bar = bars.get(bar_index, []) frame = Frame() frame.bars[bar_index] = bar for note in notes_by_bar[bar_index]: time_in_section = note.time % BeatsTime(4) symbol = chosen_symbols[time_in_section] if isinstance(note, TapNote): if note.position in frame.positions: frames_by_bar[bar_index].append(frame) frame = Frame() frame.positions[note.position] = symbol elif isinstance(note, LongNote): needed_positions = set(note.positions_covered()) if needed_positions & frame.positions.keys(): frames_by_bar[bar_index].append(frame) frame = Frame() direction = note.tail_direction() arrow = DIRECTION_TO_ARROW[direction] line = DIRECTION_TO_LINE[direction] for is_first, is_last, pos in mark_ends(note.positions_covered()): if is_first: frame.positions[pos] = symbol elif is_last: frame.positions[pos] = arrow else: frame.positions[pos] = line elif isinstance(note, LongNoteEnd): if note.position in frame.positions: frames_by_bar[bar_index].append(frame) frame = Frame() if circle_free and symbol in NOTE_TO_CIRCLE_FREE_SYMBOL: symbol = NOTE_TO_CIRCLE_FREE_SYMBOL[symbol] frame.positions[note.position] = symbol frames_by_bar[bar_index].append(frame) # Merge bar-specific frames is possible final_frames: List[Frame] = [] for bar_index in range(4): frames = frames_by_bar[bar_index] # Merge if : # - No split in current bar (only one frame) # - There is a previous frame # - The previous frame is not a split frame (it holds a bar) # - The previous and current bars are all in the same 4-bar group # - The note positions in the previous frame do not clash with the current frame if ( len(frames) == 1 and final_frames and final_frames[-1].bars and max(final_frames[-1].bars.keys()) // 4 == min(frames[0].bars.keys()) // 4 and ( not (final_frames[-1].positions.keys() & frames[0].positions.keys()) ) ): final_frames[-1].bars.update(frames[0].bars) final_frames[-1].positions.update(frames[0].positions) else: final_frames.extend(frames) dumped_frames = map(lambda f: f.dump(), final_frames) yield from collapse(intersperse("", dumped_frames))