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)
Exemple #2
0
    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()
Exemple #3
0
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)
Exemple #4
0
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
Exemple #5
0
 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
Exemple #6
0
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)
Exemple #7
0
 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
Exemple #8
0
 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)
Exemple #10
0
 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)
Exemple #12
0
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)
Exemple #17
0
 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
Exemple #18
0
 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
Exemple #19
0
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
Exemple #20
0
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)
Exemple #23
0
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
Exemple #24
0
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)
Exemple #25
0
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)
Exemple #27
0
 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))
Exemple #28
0
 def _frames_duration(self) -> BeatsTime:
     return sum((frame.duration for frame in self.current_frames),
                start=BeatsTime(0))
Exemple #29
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
Exemple #30
0
    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))