diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-07-12 18:28:50 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-07-12 18:28:50 +0900 |
| commit | 3d06bee12a26bc55cd73b1494e4d008a8098f6f5 (patch) | |
| tree | dd13a8f3fcd9d6dbf74d6406bfaa8894895af0ce /src/mml_composemusic_mcp/synthesizer.py | |
| parent | bf36a773918959eeb6ffe7dae2fb8b0874438eb4 (diff) | |
feat: MML拡張合成とNTSC 2A03準拠のIR v2音源を実装
- エンベロープ、LFO、ビブラート、グライドのグローバル定義表を追加
- ppmckおよびPyxelの拡張コマンドを音声合成へ反映
- 2A03のPulseデューティ列、Triangle DAC、Noise LFSR、Sweepを実装
- APUタイマーに基づく周波数量子化と非線形ミキサを追加
- 途中テンポ変更、拡張コマンドの検証、トラック外定義に対応
- APU規格、拡張合成、IR v2のテストを追加
- プロジェクト独自のppmck拡張仕様と対応範囲を文書化
Diffstat (limited to 'src/mml_composemusic_mcp/synthesizer.py')
| -rw-r--r-- | src/mml_composemusic_mcp/synthesizer.py | 693 |
1 files changed, 489 insertions, 204 deletions
diff --git a/src/mml_composemusic_mcp/synthesizer.py b/src/mml_composemusic_mcp/synthesizer.py index b314aa5..ae98267 100644 --- a/src/mml_composemusic_mcp/synthesizer.py +++ b/src/mml_composemusic_mcp/synthesizer.py @@ -1,64 +1,255 @@ -"""Retro APU-style synthesizer: NoteSequence -> WAV.""" +"""Deterministic, minimal NTSC 2A03 synthesizer: NoteSequence -> WAV.""" import wave -from math import floor +from dataclasses import dataclass from pathlib import Path import numpy as np from .ir import ( + DetuneEvent, + DutyEnvelopeEvent, DutyEvent, + EnvelopeEvent, ErrorCode, ErrorDetail, ErrorPhase, + GlideEvent, + LfoEvent, + NoteEnvEvent, NoteEvent, NoteSequence, + PitchEnvEvent, + RelativeVolumeEvent, RestEvent, + SweepEvent, TempoEvent, + VibratoEvent, + VolumeEnvelopeEvent, VolumeEvent, ) CPU_CLOCK = 1_789_772.5 SAMPLE_RATE_DEFAULT = 44100 +ENVELOPE_STEP_TICKS = 48 +NOISE_PERIODS = ( + 4, + 8, + 16, + 32, + 64, + 96, + 128, + 160, + 202, + 254, + 380, + 508, + 762, + 1016, + 2034, + 4068, +) +PULSE_SEQUENCES = ( + (0, 1, 0, 0, 0, 0, 0, 0), + (0, 1, 1, 0, 0, 0, 0, 0), + (0, 1, 1, 1, 1, 0, 0, 0), + (1, 0, 0, 1, 1, 1, 1, 1), +) +TRIANGLE_SEQUENCE = ( + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8, + 7, + 6, + 5, + 4, + 3, + 2, + 1, + 0, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, +) + + +def midi_to_freq(note_number: float) -> float: + return 440.0 * 2.0 ** ((note_number - 69.0) / 12.0) + + +def frequency_to_timer(frequency: float, channel_type: str) -> int: + divider = 16.0 if channel_type == "pulse" else 32.0 + if frequency <= 0: + return 0x7FF + return max(0, min(0x7FF, round(CPU_CLOCK / (divider * frequency) - 1.0))) + + +def timer_to_frequency(timer: int, channel_type: str) -> float: + divider = 16.0 if channel_type == "pulse" else 32.0 + return CPU_CLOCK / (divider * (timer + 1)) + + +def noise_period_to_rate( + period: int, mode: int = 0, sample_rate: int = SAMPLE_RATE_DEFAULT +) -> float: + del mode, sample_rate + return CPU_CLOCK / NOISE_PERIODS[period & 0x0F] + + +def apu_mix( + pulse1: np.ndarray, pulse2: np.ndarray, triangle: np.ndarray, noise: np.ndarray +) -> np.ndarray: + pulse_sum = pulse1 + pulse2 + pulse_out = np.zeros_like(pulse_sum, dtype=np.float64) + mask = pulse_sum > 0 + pulse_out[mask] = 95.88 / (8128.0 / pulse_sum[mask] + 100.0) + tnd_input = triangle / 8227.0 + noise / 12241.0 + tnd_out = np.zeros_like(tnd_input, dtype=np.float64) + mask = tnd_input > 0 + tnd_out[mask] = 159.79 / (1.0 / tnd_input[mask] + 100.0) + return pulse_out + tnd_out + + +@dataclass +class TempoMap: + events: list[tuple[int, int]] + ticks_per_quarter: int = 192 + + def tick_to_second(self, tick: float) -> float: + elapsed = 0.0 + previous_tick = 0 + bpm = self.events[0][1] + for event_tick, event_bpm in self.events[1:]: + if tick <= event_tick: + break + elapsed += ( + (event_tick - previous_tick) * 60.0 / (bpm * self.ticks_per_quarter) + ) + previous_tick, bpm = event_tick, event_bpm + return elapsed + (tick - previous_tick) * 60.0 / (bpm * self.ticks_per_quarter) + + def seconds_to_ticks(self, seconds: np.ndarray) -> np.ndarray: + result = np.empty_like(seconds) + segment_seconds = 0.0 + for index, (start_tick, bpm) in enumerate(self.events): + end_tick = ( + self.events[index + 1][0] if index + 1 < len(self.events) else None + ) + end_seconds = ( + ( + segment_seconds + + (end_tick - start_tick) * 60.0 / (bpm * self.ticks_per_quarter) + ) + if end_tick is not None + else np.inf + ) + mask = (seconds >= segment_seconds) & (seconds < end_seconds) + result[mask] = ( + start_tick + + (seconds[mask] - segment_seconds) + * bpm + * self.ticks_per_quarter + / 60.0 + ) + segment_seconds = end_seconds + return result + + +def build_tempo_map(note_sequence: NoteSequence) -> TempoMap: + changes = {0: note_sequence.bpm} + for channel in note_sequence.channels.values(): + for event in channel.events: + if isinstance(event, TempoEvent): + changes[event.tick_position] = event.bpm + return TempoMap(sorted(changes.items()), note_sequence.ticks_per_quarter) + + +def tick_to_second(tick: int, bpm: int, ticks_per_quarter: int = 192) -> float: + return tick * 60.0 / (bpm * ticks_per_quarter) -def midi_to_freq(note_number: int) -> float: - return 440.0 * 2.0 ** ((note_number - 69) / 12.0) - - -def noise_period_to_rate(period: int, mode: int, sample_rate: int) -> float: - # Retro noise: rate = CPU_CLOCK / (NTSC divider for period) - # Approximate using pyxel mapping described in design - dividers = [ - 4, - 8, - 16, - 32, - 64, - 96, - 128, - 160, - 202, - 254, - 380, - 508, - 762, - 1016, - 2034, - 4068, - ] - div = dividers[period & 0x0F] - # Long mode (0) vs short mode (1) affects LFSR length, not frequency divider - return CPU_CLOCK / div +def _sequence_value( + definition: dict | None, elapsed_ticks: float, default: float +) -> float: + if not definition: + return default + head = list(definition.get("points", [])) + loop = list(definition.get("loop_points", [])) + values = head + loop + if not values: + return default + index = max(0, int(elapsed_ticks // ENVELOPE_STEP_TICKS)) + if index < len(values): + return values[index] + if loop: + return loop[(index - len(head)) % len(loop)] + return values[-1] + + +def _triangle_lfo(elapsed: float, delay: float, period: float, depth: float) -> float: + if elapsed < delay or period <= 0: + return 0.0 + phase = ((elapsed - delay) / period) % 1.0 + return (1.0 - 4.0 * abs(phase - 0.5)) * depth + + +def _definition(table: dict, slot: object) -> dict | None: + return table.get(slot) or table.get(str(slot)) + + +def _pyxel_envelope(points: list[dict], elapsed: float, initial: float) -> float: + value = initial * 127.0 / 15.0 + remaining = elapsed + for point in points: + target = point["target_volume"] + duration = point["duration_ticks"] + if duration <= 0: + value = target + continue + if remaining <= duration: + return round( + (value + (target - value) * remaining / duration) * 15.0 / 127.0 + ) + value = target + remaining -= duration + return round(value * 15.0 / 127.0) class ChannelSynthesizer: def __init__( - self, channel_type: str, sample_rate: int = SAMPLE_RATE_DEFAULT + self, + channel_type: str, + sample_rate: int = SAMPLE_RATE_DEFAULT, + channel_name: str = "Pulse1", + noise_mode: int = 0, ) -> None: self.channel_type = channel_type + self.channel_name = channel_name + self.noise_mode = 1 if noise_mode else 0 self.sample_rate = sample_rate self.wave = np.zeros(0, dtype=np.float64) + self._triangle_phase = 0.0 + self._noise_lfsr = 1 + self._noise_accumulator = 0.0 def render( self, @@ -66,162 +257,236 @@ class ChannelSynthesizer: total_ticks: int, bpm: int, mode: str, + definitions: dict | None = None, + tempo_map: TempoMap | None = None, ) -> np.ndarray: - seconds = tick_to_second(total_ticks, bpm) - length = int(seconds * self.sample_rate) + definitions = definitions or {} + tempo_map = tempo_map or TempoMap([(0, bpm)]) + length = int(tempo_map.tick_to_second(total_ticks) * self.sample_rate) self.wave = np.zeros(length, dtype=np.float64) - last_velocity = 15 if self.channel_type != "triangle" else 15 - last_duty = 2 - for event in events: - if isinstance(event, VolumeEvent): - last_velocity = event.value - continue - if isinstance(event, DutyEvent): - last_duty = event.value - continue - if isinstance(event, TempoEvent): - bpm = event.bpm - continue - if not isinstance(event, (NoteEvent, RestEvent)): - continue - start_sample = int( - tick_to_second(event.tick_position, bpm) * self.sample_rate - ) - end_tick = event.tick_position + event.duration - end_sample = min( - int(tick_to_second(end_tick, bpm) * self.sample_rate), length + if length == 0: + return self.wave + seconds = np.arange(length, dtype=np.float64) / self.sample_rate + ticks = tempo_map.seconds_to_ticks(seconds) + notes = [e for e in events if isinstance(e, NoteEvent)] + controls = sorted( + ( + e + for e in events + if not isinstance(e, (NoteEvent, RestEvent, TempoEvent)) + ), + key=lambda e: e.tick_position, + ) + for note in notes: + mask = (ticks >= note.tick_position) & ( + ticks < note.tick_position + note.duration ) - if start_sample >= length or end_sample <= start_sample: - continue - if isinstance(event, RestEvent): + if not np.any(mask): continue - self._render_note( - start_sample, - end_sample, - event, - last_velocity, - last_duty, - mode, - ) + local_ticks = ticks[mask] - note.tick_position + indices = np.flatnonzero(mask) + self._render_note(indices, local_ticks, note, controls, definitions, mode) return self.wave def _render_note( self, - start: int, - end: int, - event: NoteEvent, - velocity: int, - duty: int, + indices: np.ndarray, + local_ticks: np.ndarray, + note: NoteEvent, + controls: list, + definitions: dict, mode: str, ) -> None: - duration_samples = end - start - if duration_samples <= 0: - return + active = [e for e in controls if e.tick_position <= note.tick_position] + volume = ( + note.velocity + if mode == "ppmck" + else round(note.velocity * 15 / 127) + if note.velocity > 15 + else note.velocity + ) + duty = note.duty + detune = note.detune_cents + selections: dict[str, object | None] = { + "vol": None, + "duty": None, + "pitch": None, + "note": None, + "lfo": None, + "env": None, + "vib": None, + "glide": None, + } + sweep: SweepEvent | None = None + for event in active: + if isinstance(event, VolumeEvent): + volume = event.value + elif isinstance(event, RelativeVolumeEvent): + # Semantic analysis already stores the resulting velocity on + # each note; this event additionally cancels @v selection. + selections["vol"] = None + elif isinstance(event, DutyEvent): + duty = event.value + selections["duty"] = None + elif isinstance(event, DetuneEvent): + detune = event.value + elif isinstance(event, SweepEvent): + sweep = event + elif isinstance(event, VolumeEnvelopeEvent) and not event.is_definition: + selections["vol"] = event.slot + elif isinstance(event, DutyEnvelopeEvent) and not event.is_definition: + selections["duty"] = event.slot + elif isinstance(event, PitchEnvEvent) and not event.is_definition: + selections["pitch"] = None if event.is_off else event.slot + elif isinstance(event, NoteEnvEvent) and not event.is_definition: + selections["note"] = None if event.is_off else event.slot + elif isinstance(event, LfoEvent) and not event.is_definition: + selections["lfo"] = None if event.is_off else event.slot + elif isinstance(event, EnvelopeEvent): + selections["env"] = None if event.slot == 0 else event.slot + elif isinstance(event, VibratoEvent): + selections["vib"] = None if event.slot == 0 else event.slot + elif isinstance(event, GlideEvent): + selections["glide"] = None if event.slot == 0 else event.slot + vol_values = np.full(len(indices), float(volume)) + duty_values = np.full(len(indices), int(duty)) + cents = np.full(len(indices), float(detune)) + for i, tick in enumerate(local_ticks): + if selections["vol"] is not None: + vol_values[i] = _sequence_value( + _definition( + definitions.get("volume_envelopes", {}), selections["vol"] + ), + tick, + volume, + ) + if selections["duty"] is not None: + duty_values[i] = int( + _sequence_value( + _definition( + definitions.get("duty_envelopes", {}), selections["duty"] + ), + tick, + duty, + ) + ) + cents[i] += 100.0 * _sequence_value( + _definition(definitions.get("note_envelopes", {}), selections["note"]), + tick, + 0, + ) + cents[i] += _sequence_value( + _definition( + definitions.get("pitch_envelopes", {}), selections["pitch"] + ), + tick, + 0, + ) + lfo = _definition(definitions.get("lfos", {}), selections["lfo"]) + if lfo: + cents[i] += _triangle_lfo( + tick, lfo["delay"], lfo["speed"], lfo["depth"] + ) + vib = _definition(definitions.get("vibratos", {}), selections["vib"]) + if vib: + cents[i] += _triangle_lfo( + tick, + vib.get("delay_ticks", 0), + vib.get("period_ticks", 0), + vib.get("depth_cents", 0), + ) + gli = _definition(definitions.get("glides", {}), selections["glide"]) + if gli: + duration = gli.get("duration_ticks", 0) + cents[i] += ( + gli.get("initial_offset_cents", 0) * max(0.0, 1.0 - tick / duration) + if duration + else 0 + ) + env = _definition(definitions.get("envelopes", {}), selections["env"]) + if env: + vol_values[i] = _pyxel_envelope(env.get("points", []), tick, volume) if self.channel_type == "noise": - self._render_noise(start, end, event, velocity, mode) - return - if self.channel_type == "triangle": - self._render_triangle(start, end, event, velocity, mode) - return - self._render_pulse(start, end, event, velocity, duty) - - def _render_pulse( - self, - start: int, - end: int, - event: NoteEvent, - velocity: int, - duty: int, - ) -> None: - freq = midi_to_freq(event.note_number) - if freq <= 0: - return - phase_inc = freq / self.sample_rate - duty_cycle = {0: 0.125, 1: 0.25, 2: 0.5, 3: 0.75}.get(duty, 0.5) - samples = np.arange(end - start) - phase = (samples * phase_inc) % 1.0 - waveform = np.where(phase < duty_cycle, 1.0, -1.0) - amp = velocity / 15.0 - gate = max(0.0, min(1.0, event.gate_time)) - if gate < 1.0: - gate_sample = int((end - start) * gate) - if gate_sample < len(waveform): - waveform[gate_sample:] = 0.0 - self.wave[start:end] += waveform * amp - - def _render_triangle( - self, - start: int, - end: int, - event: NoteEvent, - velocity: int, - mode: str, - ) -> None: - freq = midi_to_freq(event.note_number) - if freq <= 0: - return - phase_inc = freq / self.sample_rate - samples = np.arange(end - start) - phase = (samples * phase_inc) % 1.0 - # 32-step triangle approximated by abs(phase*2-1) - waveform = 2.0 * np.abs(phase * 2.0 - 1.0) - 1.0 - if mode == "pyxel": - amp = velocity / 15.0 - else: - amp = 1.0 - gate = max(0.0, min(1.0, event.gate_time)) - if gate < 1.0: - gate_sample = int((end - start) * gate) - if gate_sample < len(waveform): - waveform[gate_sample:] = 0.0 - self.wave[start:end] += waveform * amp - - def _render_noise( - self, - start: int, - end: int, - event: NoteEvent, - velocity: int, - mode: str, - ) -> None: - if mode == "pyxel": - period = max(0, min(15, 15 - floor(event.note_number / 8))) - mode_flag = 1 if period >= 13 else 0 + self._render_noise(indices, vol_values, note) + elif self.channel_type == "triangle": + self._render_triangle(indices, note, cents) else: - period = 8 - mode_flag = 0 - rate = noise_period_to_rate(period, mode_flag, self.sample_rate) - phase_inc = rate / self.sample_rate - samples = np.arange(end - start) - phase = (samples * phase_inc) % 1.0 - diff = np.diff(phase, prepend=0) - transitions = diff < 0 - # LFSR-like pseudo-random toggling on each period rollover - state = np.zeros(len(samples), dtype=np.int32) - lfsr = 1 - for i in range(len(samples)): - if transitions[i]: - # feedback bit - if mode_flag: - bit = (lfsr ^ (lfsr >> 6)) & 1 - lfsr = ((lfsr >> 1) | (bit << 14)) & 0x7FFF - else: - bit = (lfsr ^ (lfsr >> 1)) & 1 - lfsr = ((lfsr >> 1) | (bit << 14)) & 0x7FFF - state[i] = lfsr & 1 - waveform = np.where(state, 1.0, -1.0) - amp = velocity / 15.0 - gate = max(0.0, min(1.0, event.gate_time)) - if gate < 1.0: - gate_sample = int((end - start) * gate) - if gate_sample < len(waveform): - waveform[gate_sample:] = 0.0 - self.wave[start:end] += waveform * amp + self._render_pulse(indices, note, cents, vol_values, duty_values, sweep) + def _render_pulse(self, indices, note, cents, volumes, duties, sweep): + phase = 0.0 + sweep_elapsed = 0.0 + timer = frequency_to_timer( + midi_to_freq(note.note_number + cents[0] / 100), "pulse" + ) + for j, output_index in enumerate(indices): + if sweep and sweep.speed > 0 and sweep.depth != 0: + sweep_elapsed += 1 / self.sample_rate + interval = (sweep.speed + 1) / 120.0 + if sweep_elapsed >= interval: + shift = abs(sweep.depth) + delta = timer >> shift + if sweep.depth < 0: + timer -= delta + (1 if self.channel_name == "Pulse1" else 0) + else: + timer += delta + sweep_elapsed -= interval + else: + timer = frequency_to_timer( + midi_to_freq(note.note_number + cents[j] / 100), "pulse" + ) + target = ( + timer + (timer >> abs(sweep.depth)) + if sweep and sweep.depth > 0 + else timer + ) + if timer < 8 or target > 0x7FF or timer > 0x7FF: + self.wave[output_index] = 0 + continue + phase = ( + phase + timer_to_frequency(timer, "pulse") * 8 / self.sample_rate + ) % 8 + self.wave[output_index] = PULSE_SEQUENCES[int(duties[j]) & 3][ + int(phase) + ] * max(0, min(15, volumes[j])) + gate_index = int(len(indices) * max(0, min(1, note.gate_time))) + self.wave[indices[gate_index:]] = 0 -def tick_to_second(tick: int, bpm: int, ticks_per_quarter: int = 192) -> float: - quarter_seconds = 60.0 / bpm - return tick * (quarter_seconds / ticks_per_quarter) + def _render_triangle(self, indices, note, cents): + phase = self._triangle_phase + for j, output_index in enumerate(indices): + timer = frequency_to_timer( + midi_to_freq(note.note_number + cents[j] / 100), "triangle" + ) + phase = ( + phase + timer_to_frequency(timer, "triangle") * 32 / self.sample_rate + ) % 32 + self.wave[output_index] = TRIANGLE_SEQUENCE[int(phase)] + self._triangle_phase = phase + # The 2A03 holds the current DAC value when gated, rather than forcing zero. + gate_index = int(len(indices) * max(0, min(1, note.gate_time))) + if 0 < gate_index < len(indices): + self.wave[indices[gate_index:]] = self.wave[indices[gate_index - 1]] + + def _render_noise(self, indices, volumes, note): + period_index = ( + 8 if note.note_number == 0 else max(0, min(15, 15 - note.note_number // 8)) + ) + cycles_per_sample = CPU_CLOCK / (NOISE_PERIODS[period_index] * self.sample_rate) + accumulator = self._noise_accumulator + lfsr = self._noise_lfsr + for j, output_index in enumerate(indices): + accumulator += cycles_per_sample + for _ in range(int(accumulator)): + tap = 6 if self.noise_mode else 1 + feedback = (lfsr & 1) ^ ((lfsr >> tap) & 1) + lfsr = (lfsr >> 1) | (feedback << 14) + accumulator %= 1.0 + self.wave[output_index] = 0 if lfsr & 1 else max(0, min(15, volumes[j])) + self._noise_accumulator = accumulator + self._noise_lfsr = lfsr + gate_index = int(len(indices) * max(0, min(1, note.gate_time))) + self.wave[indices[gate_index:]] = 0 def synthesize( @@ -231,23 +496,35 @@ def synthesize( normalize: bool = True, ) -> tuple[np.ndarray, float, list[ErrorDetail]]: errors: list[ErrorDetail] = [] - total_ticks = max(ch.total_ticks for ch in note_sequence.channels.values()) + total_ticks = max( + (ch.total_ticks for ch in note_sequence.channels.values()), default=0 + ) if total_ticks <= 0: return np.zeros(0, dtype=np.float64), 0.0, errors - channels = [] - for _name, ch in note_sequence.channels.items(): - synth = ChannelSynthesizer(ch.channel_type, sample_rate) - wave = synth.render(ch.events, total_ticks, note_sequence.bpm, mode) - channels.append(wave) - max_len = max(len(w) for w in channels) - padded = [np.pad(w, (0, max_len - len(w)), mode="constant") for w in channels] - mixed = np.mean(padded, axis=0) - peak = np.max(np.abs(mixed)) - if normalize and peak > 0: - mixed = mixed / peak - mixed = np.clip(mixed, -1.0, 1.0) - duration = max_len / sample_rate - return mixed, duration, errors + tempo_map = build_tempo_map(note_sequence) + rendered: dict[str, np.ndarray] = {} + for name, channel in note_sequence.channels.items(): + rendered[name] = ChannelSynthesizer( + channel.channel_type, sample_rate, name + ).render( + channel.events, + total_ticks, + note_sequence.bpm, + mode, + note_sequence.definitions, + tempo_map, + ) + zero = np.zeros(max(map(len, rendered.values())), dtype=np.float64) + mixed = apu_mix( + rendered.get("Pulse1", zero), + rendered.get("Pulse2", zero), + rendered.get("Triangle", zero), + rendered.get("Noise", zero), + ) + if normalize and mixed.size and np.max(np.abs(mixed)) > 0: + mixed /= np.max(np.abs(mixed)) + mixed = np.clip(mixed, -1, 1) + return mixed, len(mixed) / sample_rate, errors def write_wav(path: Path, wave_data: np.ndarray, sample_rate: int) -> list[ErrorDetail]: @@ -269,29 +546,37 @@ def write_wav(path: Path, wave_data: np.ndarray, sample_rate: int) -> list[Error column=0, message=f"WAVファイルの出力に失敗しました: {exc}", severity="error", - hint="しばらく待ってから再度お試しください。", + hint="出力先を確認してください。", ) ) return errors def build_channel_summary(note_sequence: NoteSequence | dict) -> list[dict]: - if isinstance(note_sequence, dict): - channels = note_sequence["channels"] - else: - channels = note_sequence.channels + channels = ( + note_sequence["channels"] + if isinstance(note_sequence, dict) + else note_sequence.channels + ) summary = [] - for name, ch in channels.items(): - events = ch["events"] if isinstance(ch, dict) else ch.events - total_ticks = ch["total_ticks"] if isinstance(ch, dict) else ch.total_ticks - notes = [e for e in events if isinstance(e, NoteEvent)] - if not notes: - notes = [ - e for e in events if isinstance(e, dict) and e.get("type") == "note" - ] - octaves = [e["note_number"] // 12 - 1 for e in notes] - else: - octaves = [e.note_number // 12 - 1 for e in notes] + for name, channel in channels.items(): + events = channel["events"] if isinstance(channel, dict) else channel.events + total_ticks = ( + channel["total_ticks"] if isinstance(channel, dict) else channel.total_ticks + ) + notes = [ + e + for e in events + if ( + isinstance(e, NoteEvent) + or isinstance(e, dict) + and e.get("type") == "note" + ) + ] + numbers = [ + e["note_number"] if isinstance(e, dict) else e.note_number for e in notes + ] + octaves = [n // 12 - 1 for n in numbers] summary.append( { "channel": name, |
