from dataclasses import dataclass
import hashlib
import math
import numpy as np
import torch
import logging
import itertools

# TODO using a rest at the start of inference might not align with training data


def midi2wavtool(midi, split_on_desc_anchor=False):
    res = []
    for i, note in enumerate(midi):
        if note.get("descriptionAnchor", False) and split_on_desc_anchor:
            return {"notes": res}, midi2wavtool(midi[i:])
        wnote = {
            "pitch": note["note"],
            "start": note["onBeat"],
            "end": note["offBeat"],
            "velocity": note.get("onVelocity", 127) / 127,
            "lifted": True,
        }
        if "dists" in note:
            wnote["dists"] = note["dists"]
        res.append(wnote)
    return ({"notes": res},) if split_on_desc_anchor else {"notes": res}


def filter_wavtool_nulls(xs):
    return (
        x
        for x in xs
        if x["start"] is not None and x["end"] is not None and x["pitch"] is not None
    )


def wavtool2midi(wavtool):
    return [
        {
            "onBeat": note["start"],
            "offBeat": note["end"],
            "onVelocity": int(note["velocity"] * 127),
            "offVelocity": int(note["velocity"] * 127),
            "sourceTrack": 0,
            "note": note["pitch"],
        }
        for note in sorted(
            filter_wavtool_nulls(wavtool["notes"]),
            key=lambda note: (note["start"], note["pitch"]),
        )
    ]


def wavtoolm2midi(wavtool):
    return [
        {
            "onBeat": note["start"],
            "offBeat": note["end"],
            "onVelocity": note["velocity"],
            "note": note["pitch"],
        }
        for note in sorted(wavtool, key=lambda note: (note["start"], note["pitch"]))
    ]


def wavtoolmm2midi(wavtool):
    return [
        {
            "onBeat": note["start"],
            "offBeat": note["end"],
            "onVelocity": int(note["velocity"] * 127),
            "note": int(note["pitch"]),
        }
        for note in sorted(wavtool, key=lambda note: (note["start"], note["pitch"]))
    ]


def quantized_midi_to_indexable_bytes(quantized_midi):
    def int2varint(x):
        res = []
        while x >= 0x80:
            res.append((x & 0x7F) | 0x80)
            x >>= 7
        res.append(x)
        return res

    if len(quantized_midi) == 0:
        return b""
    res = []
    t = quantized_midi[0][2]
    group = []
    for note in quantized_midi:
        t_next = note[2]
        if t_next > t:
            for note2 in sorted(group, key=lambda x: x[0]):
                res.extend(int2varint(max(0, note2[0]) + 3))
                res.extend(int2varint(max(0, note2[3] - t) + 3))
            group.clear()
            res.append(1)
            res.extend(int2varint(t_next - t + 3))
            t = t_next
        group.append(note)
    res.append(2)  # separator
    return bytes(res)


def rec_sum(x):
    if isinstance(x, (int, float)):
        return x
    if isinstance(x, dict):
        return sum(rec_sum(v) for v in x.values())
    return sum(rec_sum(v) for v in x)


def normalize_dist(dist):
    def rec_div(x, d):
        if isinstance(x, (int, float)):
            return x / d
        if isinstance(x, dict):
            return {k: rec_div(v, d) for k, v in x.items()}
        return [rec_div(v, d) for v in x]

    s = max(rec_sum(dist.values()), 1e-8)
    return rec_div(dist, s)


@dataclass
class Symbol:
    index: int


@dataclass
class Control(Symbol):
    name: str

    def __repr__(self):
        return "{" + self.name + "}"


@dataclass
class Rest(Symbol):
    def __repr__(self):
        return "{R}"


@dataclass
class Pitch(Symbol):
    value: int
    velocity: int

    def __repr__(self):
        # convert midi note number self.value to string of the form {note}{octave}, e.g. C4
        if self.value >= 1000:
            pitch_str = "drum"
        else:
            pitch_str = [
                "C",
                "C#",
                "D",
                "D#",
                "E",
                "F",
                "F#",
                "G",
                "G#",
                "A",
                "A#",
                "B",
            ][self.value % 12] + str(self.value // 12)
        return "{" + f"{pitch_str} {self.value} ({self.velocity})" + "}"


@dataclass
class Duration(Symbol):
    value: int  # in quantize_divisions
    fraction: str  # in fractional beats, e.g. 1/4

    def __repr__(self):
        return "{L" + self.fraction + "}"


@dataclass
class Polyphony(Symbol):
    value: int

    def __repr__(self):
        return "{P" + str(self.value) + "}"


@dataclass
class Key(Symbol):
    value: int

    def __repr__(self):
        return "{K" + str(self.value) + "}"


class VocabError(ValueError):
    pass


def int_or_none(x):
    if x is None:
        return None
    return int(x)


class Vocab:
    @classmethod
    def from_config(cls, config):
        quantize = int(config["quantize_divisions"])
        return cls(
            int(config["pitch_min"]),
            int(config["pitch_max"]),
            int_or_none(config["drum_min"]),
            int_or_none(config["drum_max"]),
            int(config["velocity_min"]),
            int(config["velocity_max"]),
            int(config["velocity_quantize"]),
            int(config["length_min"]),
            int(config["length_max"]),
            int(config["polyphony_max"]),
            int(config["embed_length_max"]),
            int(config["embed_polyphony_max"]),
            quantize,
        )

    def __init__(
        self,
        pitch_min,
        pitch_max,
        drum_min,
        drum_max,
        velocity_min,
        velocity_max,
        velocity_quantize,
        duration_min,  # in quantize_divisions
        duration_max,  # in quantize_divisions
        polyphony_max,
        embed_length_max,  # in quantize_divisions
        embed_polyphony_max,
        quantize_divisions,
    ):
        super().__init__()
        self.pitch_min = pitch_min
        self.pitch_max = pitch_max
        self.drum_min = drum_min
        self.drum_max = drum_max
        self.velocity_min = velocity_min
        self.velocity_max = velocity_max
        self.velocity_quantize = velocity_quantize
        self.duration_min = duration_min
        self.duration_max = duration_max
        self.polyphony_max = polyphony_max
        self.embed_length_max = embed_length_max
        self.embed_polyphony_max = embed_polyphony_max
        self.quantize_divisions = quantize_divisions

        assert (self.drum_min is None) == (self.drum_max is None)
        if self.drum_min is not None:
            assert self.drum_min <= self.drum_max
        assert self.pitch_min <= self.pitch_max
        assert self.velocity_min <= self.velocity_max
        assert self.duration_min <= self.duration_max

        self.pad = Control(0, "pad")
        self.rest = Rest(1)
        self.begin = Control(2, "begin")
        self.end = Control(3, "end")
        self.new_track = Control(4, "new_track")
        self.description_anchor = Control(5, "description_anchor")
        self.symbols = [
            self.pad,
            self.rest,
            self.begin,
            self.end,
            self.new_track,
            self.description_anchor,
        ]
        self.control_greatest_index = len(self.symbols) - 1

        self.pitches = dict()
        self.pitch_least_index = len(self.symbols)
        for i in itertools.chain(
            range(pitch_min, pitch_max + 1),
            range(1000 + drum_min, 1000 + drum_max + 1) if drum_min is not None else (),
        ):
            pitch_vels = dict()
            for j in range(velocity_min, velocity_max + 1, velocity_quantize):
                pitch = Pitch(len(self.symbols), i, j)
                self.symbols.append(pitch)
                pitch_vels[j] = pitch
            if velocity_max % velocity_quantize != 0:
                pitch = Pitch(len(self.symbols), i, velocity_max)
                self.symbols.append(pitch)
                pitch_vels[velocity_max] = pitch
            self.pitches[i] = pitch_vels
        self.pitch_greatest_index = len(self.symbols) - 1

        self.durations = dict()
        self.durations_least_index = len(self.symbols)
        for i in range(duration_min, duration_max + 1):
            gcd = math.gcd(i, quantize_divisions)
            duration = Duration(
                len(self.symbols),
                i,
                str(i // gcd)
                + (
                    ("/" + str(quantize_divisions // gcd))
                    if gcd != quantize_divisions
                    else ""
                ),
            )
            self.symbols.append(duration)
            self.durations[i] = duration
        self.durations_greatest_index = len(self.symbols) - 1

        self.polyphony = dict()
        self.polyphony_least_index = len(self.symbols)
        for i in range(2, polyphony_max + 1):
            polyphony = Polyphony(len(self.symbols), i)
            self.symbols.append(polyphony)
            self.polyphony[i] = polyphony
        self.polyphony_greatest_index = len(self.symbols) - 1

        self.keys = dict()
        self.keys_least_index = len(self.symbols)
        for i in range(24):
            key = Key(len(self.symbols), i)
            self.symbols.append(key)
            self.keys[i] = key
        self.keys_greatest_index = len(self.symbols) - 1

        self.N = len(self.symbols)

    def is_duration(self, idx):
        return self.durations_least_index <= idx <= self.durations_greatest_index

    def is_rest(self, idx):
        return idx == self.rest.index

    def is_begin(self, idx):
        return idx == self.begin.index

    def is_new_track(self, idx):
        return idx == self.new_track.index

    def is_description_anchor(self, idx):
        return idx == self.description_anchor.index

    def is_pitch(self, idx):
        return self.pitch_least_index <= idx <= self.pitch_greatest_index

    def quantize_velocity(self, velocity):
        return min(
            self.velocity_max,
            max(
                self.velocity_min,
                int(self.velocity_quantize * round(velocity / self.velocity_quantize)),
            ),
        )

    def get_pitch(self, midi_pitch, midi_velocity):
        return self.pitches[midi_pitch][self.quantize_velocity(midi_velocity)]

    def is_polyphony(self, idx):
        return self.polyphony_least_index <= idx <= self.polyphony_greatest_index

    def is_key(self, idx):
        return self.keys_least_index <= idx <= self.keys_greatest_index

    def get_tensor_subbeat(self, x, next=False):
        offset = 4 if next else 0
        return (
            x[..., offset + 1] * 4 * self.quantize_divisions
            + x[..., offset + 2] * self.quantize_divisions
            + x[..., offset + 3]
        )

    def embedding_time_wrapped(self, x):
        x = x.numpy() if isinstance(x, torch.Tensor) else x
        return np.any(
            x[..., 1] * 4 * self.quantize_divisions
            + x[..., 2] * self.quantize_divisions
            + x[..., 3]
            > x[..., 5] * 4 * self.quantize_divisions
            + x[..., 6] * self.quantize_divisions
            + x[..., 7]
        ).item()

    def subbeat_to_tensor(self, subbeat, tensor=None):
        bar = (subbeat % self.embed_length_max) // (4 * self.quantize_divisions)
        beat = (subbeat // self.quantize_divisions) % 4
        tick = subbeat % self.quantize_divisions
        if tensor is None:
            return torch.tensor([bar, beat, tick], dtype=torch.long)
        tensor[0] = bar
        tensor[1] = beat
        tensor[2] = tick
        return tensor

    def fast_estimate_length(self, example, accompany=None, include_header=True):
        if accompany is None:
            accompany_len = 0
        else:
            accompany_len = (
                self.fast_estimate_length(accompany, include_header=False) + 1
            )
        example = self.quantize_midi(example)
        count = 4 if include_header else 1
        if len(example) == 0:
            return count + accompany_len

        time = example[0][2]
        polyphony = 0
        for note in example:
            if note[0] > 1000:
                if note[0] - 1000 < self.drum_min or note[0] - 1000 > self.drum_max:
                    raise VocabError(
                        f"fast_estimate_length: drum {note[0]} out of vocab range"
                    )
            else:
                if note[0] < self.pitch_min or note[0] > self.pitch_max:
                    raise VocabError(
                        f"fast_estimate_length: pitch {note[0]} out of vocab range"
                    )
            if note[3] > self.embed_length_max:
                raise VocabError(
                    f"fast_estimate_length: note end {note[3]} out of embed range"
                )
            if note[3] - note[2] > self.duration_max:
                raise VocabError(
                    f"fast_estimate_length: duration {note[3] - note[2]} out of vocab range"
                )
            if note[2] > time:
                count += 3 if polyphony > 1 else 2
                polyphony = 0
                time = note[2]
            polyphony += 1
            if polyphony > self.polyphony_max:
                raise VocabError(
                    f"fast_estimate_length: polyphony {polyphony} out of vocab range"
                )
            count += 2
        return count + accompany_len

    # quantizes and removes any overlapping or zero-length notes
    # when strict is True, throws if:
    #   any pitch is out of vocab range
    #   any length is greater than vocab range
    # when allow_anchor is True, output may include description-anchored notes
    # returns [[pitch, velocity, qstart, qend, anchor], ...]
    def quantize_midi(self, notes, strict=True, allow_anchor=False):
        def quantize(x):
            return round(x * self.quantize_divisions)

        active_notes = dict()  # pitch -> obj
        quantized_notes = []  # [pitch, velocity, qstart, qend]
        time = 0
        for note in sorted(notes, key=lambda x: (x["onBeat"], -x["offBeat"])):
            pitch = note["note"]
            velocity = note["onVelocity"]
            qstart = quantize(note["onBeat"])
            qlength = quantize(note["offBeat"] - note["onBeat"])
            anchor = allow_anchor and note.get("descriptionAnchor", False)
            if qstart < 0 or qlength < 0:
                raise VocabError("negative qstart or qlength")
            # special rule for drums: round length 0 up to 1
            if (
                self.drum_min is not None
                and pitch >= 1000 + self.drum_min
                and pitch <= 1000 + self.drum_max
                and qlength == 0
            ):
                qlength = 1
            if (
                strict
                and self.velocity_min != self.velocity_max
                and (velocity < self.velocity_min or velocity > self.velocity_max)
            ):
                raise VocabError(f"velocity {velocity} out of vocab range")
            velocity = self.quantize_velocity(velocity)
            if not (
                (pitch >= self.pitch_min and pitch <= self.pitch_max)
                or (
                    self.drum_min is not None
                    and pitch >= 1000 + self.drum_min
                    and pitch <= 1000 + self.drum_max
                )
            ):
                if strict:
                    raise VocabError(f"pitch {pitch} out of vocab range")
                continue
            if qlength > self.duration_max:
                if strict:
                    raise VocabError(f"duration {qlength} out of vocab range")
                continue
            if qlength <= 0:
                continue
            if qstart > time:
                time = qstart
                for active_pitch, rec in list(active_notes.items()):
                    if rec[3] <= time:
                        del active_notes[active_pitch]
            if pitch in active_notes:
                if active_notes[pitch][2] == qstart:
                    active_notes[pitch][1] = velocity
                    active_notes[pitch][3] = qstart + qlength
                    continue
                active_notes[pitch][3] = qstart
                del active_notes[pitch]
            rec = [pitch, velocity, qstart, qstart + qlength, anchor]
            quantized_notes.append(rec)
            active_notes[pitch] = rec

        if len(notes) > 0 and len(quantized_notes) == 0:
            logging.debug("quantization removed all notes")

        return quantized_notes

    # midi -> tensor[j]
    def midi_to_symbols(self, notes, strict=True, allow_anchor=False):
        quantized_notes = self.quantize_midi(
            notes, strict=strict, allow_anchor=allow_anchor
        )

        if len(quantized_notes) == 0:
            return None

        res = []
        time = 0
        group = []

        def flush():
            nonlocal group
            sorted_group = list(sorted(group, key=lambda x: x[0]))
            wrote_anchor = False
            if len(sorted_group) > 1:
                if strict and len(sorted_group) > self.polyphony_max:
                    raise VocabError(
                        f"polyphony {len(sorted_group)} out of vocab range, group is: {sorted_group}"
                    )
                if sorted_group[0][4]:
                    res.append(self.description_anchor.index)
                    wrote_anchor = True
                sorted_group = sorted_group[: self.polyphony_max]
                res.append(self.polyphony[len(sorted_group)].index)
            for note in sorted_group:
                if not wrote_anchor and note[4]:
                    if len(sorted_group) > 1:
                        logging.warning(
                            "description anchor not first in polyphony group"
                        )
                    res.append(self.description_anchor.index)
                res.append(self.pitches[note[0]][note[1]].index)
                res.append(self.durations[note[3] - note[2]].index)
            group.clear()

        for note in quantized_notes:
            qstart = note[2]
            if qstart > time:
                flush()
                rest_duration = qstart - time
                if rest_duration > self.duration_max:
                    raise VocabError(
                        f"rest duration {rest_duration} out of vocab range"
                    )
                res.append(self.rest.index)
                res.append(self.durations[rest_duration].index)
                time = qstart
            group.append(note)
        flush()

        return torch.tensor(res, dtype=torch.long)

    # midi -> tensor[j, (symbol, *context)]
    # key: optional key signature 0..23 (major then minor keys)
    # accompany: optional accompaniment midi, will be prepended to symbolc representation
    def midi_to_tensor(
        self,
        notes,
        key=None,
        accompany=None,
        rest_end=False,
        strict=False,
        place_anchor=True,
    ):
        out = self.append_symbols(None, self.header_symbols(key=key))

        if accompany is not None:
            accompany_symbols = self.midi_to_symbols(accompany, strict=strict)
            if accompany_symbols is not None:
                out = self.append_symbols(
                    out,
                    accompany_symbols.unsqueeze(0),
                    strict=strict,
                )

        symbols = self.midi_to_symbols(notes, strict=strict, allow_anchor=True)

        out = self.append_symbols(
            out,
            torch.tensor([[self.new_track.index]], dtype=torch.long),
        )

        # fix next subbeat and polyphony of new_track symbol
        self.subbeat_to_tensor(0, tensor=out[0, -1, 5:8])
        out[0, -1, 8] = 0

        # argument to torch.cat below
        symconcat = []

        # append description anchor if needed
        if place_anchor:
            already_anchored = symbols is not None and torch.any(
                symbols == self.description_anchor.index
            )
            if not already_anchored:
                symconcat.append(
                    torch.tensor([self.description_anchor.index], dtype=torch.long)
                )

        if symbols is not None:
            symconcat.append(symbols)

        if (
            rest_end
            and symbols is not None
            and symbols.shape[0] >= 2
            and self.is_pitch(symbols[-2].item())
            and self.is_duration(symbols[-1].item())
        ):
            symconcat.append(torch.tensor([self.rest.index], dtype=torch.long))

        if not rest_end:
            symconcat.append(torch.tensor([self.end.index], dtype=torch.long))

        if len(symconcat) == 0:
            return out[0]

        return self.append_symbols(
            out, torch.cat(symconcat, dim=-1).unsqueeze(0), strict=strict
        )[0]

    def header_symbols(self, key=None):
        header = [self.begin.index]
        if key is not None:
            header.append(self.keys[key].index)
        return torch.tensor(header, dtype=torch.long).unsqueeze(0)

    # tensor[j, (symbol, *context)] -> midi, key
    def tensor_to_midi(
        self, x, start_subbeat=0, strict=True, return_accompany=False, dists=None
    ):
        # read header if there is one
        start_idx = 0
        key = None
        while start_idx < x.shape[0] and not (
            self.is_pitch(x[start_idx, 0])
            or self.is_polyphony(x[start_idx, 0])
            or self.is_rest(x[start_idx, 0])
            or self.is_description_anchor(x[start_idx, 0])
        ):
            if self.is_key(x[start_idx, 0]):
                assert not strict or key is None, "multiple keys"
                key = self.symbols[x[start_idx, 0].item()].value
            start_idx += 1

        assert not strict or start_idx > 0, "no header"
        if start_idx > 0:
            assert not strict or (
                x[0, 0].item() == self.begin.index
            ), "header does not begin with begin symbol"

        if start_idx == x.shape[0]:
            return [], key

        res = []
        embed_time = self.get_tensor_subbeat(x[start_idx])
        time = start_subbeat
        expected_polyphony = 0
        got_polyphony = 0
        active_note_ends = dict()  # pitch -> end subbeat
        desc_anchor_next = False
        desc_anchor_count = 0

        last_dists_obj = None
        for i in range(start_idx, x.shape[0]):
            sym = x[i, 0].item()
            lastsym = x[i - 1, 0].item() if i > 0 else -1

            if sym == self.pad.index:
                continue

            assert not strict or embed_time == self.get_tensor_subbeat(
                x[i]
            ), f"current embed time mismatch at {i}"
            assert (
                not strict or len(active_note_ends) == x[i, 4].item()
            ), f"current embed polyphony mismatch at {i}"

            if self.is_duration(sym):
                duration = self.symbols[sym].value
                if dists is not None and i > x.shape[0] - dists.shape[0]:
                    duration_dist = {
                        d.value
                        / self.quantize_divisions: dists[
                            i - x.shape[0] + dists.shape[0], d.index
                        ].item()
                        for d in self.durations.values()
                    }
                    normalize_dist(duration_dist)
                else:
                    duration_dist = None
                if self.is_rest(lastsym):
                    time += duration
                    embed_time += duration
                    assert (
                        not strict or expected_polyphony == got_polyphony
                    ), f"expected polyphony mismatch at {i}"
                    got_polyphony = 0
                    expected_polyphony = 1
                    for pitch, end in list(active_note_ends.items()):
                        if end <= time:
                            del active_note_ends[pitch]
                    if last_dists_obj is not None and duration_dist is not None:
                        last_dists_obj["restDuration"] = duration_dist
                elif self.is_pitch(lastsym):
                    pitch = self.symbols[lastsym]
                    note_obj = {
                        "note": pitch.value,
                        "onVelocity": pitch.velocity,
                        "onBeat": time / self.quantize_divisions,
                        "offBeat": (time + duration) / self.quantize_divisions,
                    }
                    if desc_anchor_next:
                        desc_anchor_next = False
                        note_obj["descriptionAnchor"] = True
                    if dists is not None and i > x.shape[0] - dists.shape[0]:
                        pitch_dist = {
                            p: {
                                value.velocity: dists[
                                    i - x.shape[0] + dists.shape[0] - 1, value.index
                                ].item()
                                for value in v.values()
                            }
                            for p, v in self.pitches.items()
                        }
                        pitch_dist["rest"] = dists[
                            i - x.shape[0] + dists.shape[0] - 1, self.rest.index
                        ].item()
                        normalize_dist(pitch_dist)
                        dists_obj = {}
                        dists_obj["pitch"] = pitch_dist
                        dists_obj["duration"] = duration_dist
                        note_obj["dists"] = dists_obj
                        last_dists_obj = dists_obj
                    res.append(note_obj)
                    got_polyphony += 1
                    if expected_polyphony == 0:
                        expected_polyphony = 1  # tracks can start with a note or rest
                    active_note_ends[pitch.value] = max(
                        active_note_ends.get(pitch.value, 0), time + duration
                    )
                elif strict:
                    raise ValueError("unexpected duration symbol")
            elif self.is_polyphony(sym):
                expected_polyphony = self.symbols[sym].value
            elif self.is_new_track(sym):
                assert not strict or expected_polyphony == got_polyphony, "polyphony"
                if return_accompany:
                    return res, key
                res = []
                embed_time = self.get_tensor_subbeat(x[i], next=True)
                time = start_subbeat
                expected_polyphony = 0
                got_polyphony = 0
                desc_anchor_next = False
                active_note_ends.clear()
            elif self.is_description_anchor(sym):
                desc_anchor_next = True
                desc_anchor_count += 1
                assert (
                    not strict or desc_anchor_count == 1
                ), "multiple description anchors"
            assert not strict or embed_time == self.get_tensor_subbeat(
                x[i], next=True
            ), f"next embed time mismatch at {i}"
            assert (
                not strict or len(active_note_ends) == x[i, 8].item()
            ), f"next embed polyphony mismatch at {i}"

        return res, key

    def tensor_to_midi_using_embeds(self, x):
        res = []
        tracks = []
        desc_anchor_next = False

        for i in range(x.shape[0]):
            sym = x[i, 0].item()
            lastsym = x[i - 1, 0].item() if i > 0 else -1
            if self.is_new_track(sym):
                tracks.append(res)
                res = []
                desc_anchor_next = False

            if self.is_duration(sym) and self.is_pitch(lastsym):
                duration = self.symbols[sym].value
                lastsymobj = self.symbols[lastsym]
                time = self.get_tensor_subbeat(x[i]).item()
                noteobj = {
                    "note": lastsymobj.value,
                    "onVelocity": lastsymobj.velocity,
                    "onBeat": time / self.quantize_divisions,
                    "offBeat": (time + duration) / self.quantize_divisions,
                }
                if desc_anchor_next:
                    desc_anchor_next = False
                    noteobj["descriptionAnchor"] = True
                res.append(noteobj)
            elif self.is_description_anchor(sym):
                desc_anchor_next = True

        if len(res) > 0:
            tracks.append(res)

        return tracks

    # context = (cur_bar, cur_beat, cur_subbeat, cur_polyphony, next_bar, next_beat, next_subbeat, next_polyphony)
    # ys: tensor[batch, seq, (symbol, *context)] or None
    # sym: tensor[batch, j])
    # inserting_subbeats: tensor[batch]
    # returns tensor[batch, seq + j, (symbol, *context)]
    # if seq_len is not None, the operation is in-place and seq_len is the length of the original sequence
    def append_symbols(
        self,
        ys,
        syms,
        inserting_subbeats=None,
        inserting_polyphonys=None,
        strict=True,
        seq_len=None,
    ):
        # fast indexing
        syms = syms.numpy() if isinstance(syms, torch.Tensor) else syms

        assert len(syms.shape) == 2, "syms must be tensor[batch, sym]"
        bsz, new = syms.shape
        if ys is None:
            assert seq_len is None
            old = 0
        else:
            old = ys.shape[1] if seq_len is None else seq_len
        symcon_len = 9
        if seq_len is None:
            new_ys_torch = torch.zeros(bsz, old + new, symcon_len, dtype=torch.long)
        else:
            assert ys.shape[1] >= old + new
            new_ys_torch = ys
        new_ys = new_ys_torch.numpy() if isinstance(new_ys_torch, torch.Tensor) else ys
        if ys is not None:
            ys = ys.numpy() if isinstance(ys, torch.Tensor) else ys
            assert (
                len(ys.shape) == 3 and ys.shape[0] == bsz and ys.shape[-1] == symcon_len
            ), "ys must be tensor[batch, seq, (symbol, *context)]"
            if inserting_subbeats is None:
                inserting_subbeats = self.get_tensor_subbeat(
                    ys[:, old - 1], next=True
                ).copy()
            if inserting_polyphonys is None:
                inserting_polyphonys = ys[:, old - 1, 8].copy()
            if seq_len is None:
                new_ys[:, :old] = ys
        new_ys[:, old : old + syms.shape[1], 0] = syms
        if inserting_subbeats is None:
            inserting_subbeats = np.zeros(bsz, dtype=np.int64)
        if inserting_polyphonys is None:
            inserting_polyphonys = np.zeros(bsz, dtype=np.int64)
        for i in range(bsz):
            # search backwards to find end times of currently active notes
            note_end_times = []
            if inserting_polyphonys[i] > 0:
                p = inserting_polyphonys[i].item()
                t = inserting_subbeats[i].item()
                for j in range(2, old + 1):
                    sym = ys[i, old - j, 0].item()
                    nextsym = ys[i, old - j + 1, 0].item()
                    if self.is_pitch(sym) and self.is_duration(nextsym):
                        start_subbeat = self.get_tensor_subbeat(ys[i, old - j]).item()
                        end_subbeat = start_subbeat + self.symbols[nextsym].value
                        if end_subbeat > t:
                            note_end_times.append(end_subbeat)
                            p -= 1
                            if p == 0:
                                break
                note_end_times.sort()

            # insert new symbols
            for j in range(new):
                sym = syms[i, j].item()
                lastsym = new_ys[i, old + j - 1, 0].item() if old + j > 0 else -1

                self.subbeat_to_tensor(
                    inserting_subbeats[i], tensor=new_ys[i, old + j, 1:4]
                )
                new_ys[i, old + j, 4] = min(
                    inserting_polyphonys[i], self.embed_polyphony_max
                )

                if self.is_duration(sym):
                    duration = self.symbols[sym].value
                    if self.is_rest(lastsym):
                        # increment inserting_subbeats
                        inserting_subbeats[i] += duration
                        if inserting_subbeats[i] >= self.embed_length_max:
                            if strict:
                                raise VocabError(
                                    f"subbeat {inserting_subbeats[i]} out of embedding range"
                                )
                            else:
                                inserting_subbeats[i] %= self.embed_length_max
                        # update insert polyphony
                        while (
                            len(note_end_times) > 0
                            and note_end_times[0] <= inserting_subbeats[i]
                        ):
                            note_end_times.pop(0)
                            inserting_polyphonys[i] -= 1
                    elif self.is_pitch(lastsym):
                        note_end_times.append(inserting_subbeats[i].item() + duration)
                        note_end_times.sort()
                        inserting_polyphonys[i] += 1
                        if inserting_polyphonys[i] > self.embed_polyphony_max:
                            if strict:
                                raise VocabError(
                                    f"polyphony {inserting_polyphonys[i]} out of embedding range"
                                )

                self.subbeat_to_tensor(
                    inserting_subbeats[i], tensor=new_ys[i, old + j, 5:8]
                )
                new_ys[i, old + j, 8] = min(
                    inserting_polyphonys[i], self.embed_polyphony_max
                )

        return new_ys_torch

    # x: tensor[seq, (symbol, *context)] or tensor[seq]
    def repr_tensor(self, x):
        quantize_digits = int(math.ceil(math.log10(self.quantize_divisions)))
        bar_digits = int(
            math.ceil(
                math.log10(self.embed_length_max // (4 * self.quantize_divisions))
            )
        )
        poly_digits = int(math.ceil(math.log10(self.embed_polyphony_max)))

        def repr_time(t):
            return f"{t[0]:0{bar_digits}}.{t[1]}.{t[2]:0{quantize_digits}}"

        res = []
        for i in range(x.shape[0]):
            if x.ndim > 1:
                f = "*" if isinstance(self.symbols[x[i, 0].item()], Control) else " "
                res.append(
                    f"{f}{repr_time(x[i, 1:4])} {repr_time(x[i, 5:8])} p{x[i, 4].item():0{poly_digits}} pn{x[i, 8].item():0{poly_digits}} {self.symbols[x[i, 0].item()]}"
                )
            else:
                res.append(str(self.symbols[x[i].item()]))
        return "\n".join(res)

    # x: tensor[seq, (symbol, *context)] -> bytes
    def hash_tensor(self, x):
        return hashlib.sha256(x.flatten().numpy().tobytes()).digest()[:16]
