diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-07-11 19:02:14 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-07-11 19:02:14 +0900 |
| commit | ca35520338057512a71acd42bb116ffbb82cc192 (patch) | |
| tree | 9faa2428aeb71b62497f21ceec93b99ef7f2a5ac /src/mml_composemusic_mcp/synthesizer.py | |
| parent | 7dca1616b8c891f5e140c7267323d1fcdde6741a (diff) | |
feat: implement Famicom-style MML composer MCP server
- Add FastMCP server with compose_mml tool
- Support ppmck and pyxel MML syntax
- Add lexer and parsers for both formats
- Add NES APU-style synthesizer (pulse/triangle/noise) to WAV
- Add MML templates (basic/melody/chord/drum/empty) for both modes
- Add tests for lexer, parser, and server
- Support stdio, http, sse, streamable-http transports
- Update README, pyproject.toml, and .gitignore
Diffstat (limited to 'src/mml_composemusic_mcp/synthesizer.py')
| -rw-r--r-- | src/mml_composemusic_mcp/synthesizer.py | 301 |
1 files changed, 301 insertions, 0 deletions
diff --git a/src/mml_composemusic_mcp/synthesizer.py b/src/mml_composemusic_mcp/synthesizer.py new file mode 100644 index 0000000..42a3c11 --- /dev/null +++ b/src/mml_composemusic_mcp/synthesizer.py @@ -0,0 +1,301 @@ +"""NES APU-style synthesizer: NoteSequence -> WAV.""" + +import wave +from math import floor +from pathlib import Path + +import numpy as np + +from .ir import ( + DutyEvent, + ErrorCode, + ErrorDetail, + NoteEvent, + NoteSequence, + RestEvent, + TempoEvent, + VolumeEvent, +) + +CPU_CLOCK = 1_789_772.5 +SAMPLE_RATE_DEFAULT = 44100 + + +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: + # NES 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 + + +class ChannelSynthesizer: + def __init__( + self, channel_type: str, sample_rate: int = SAMPLE_RATE_DEFAULT + ) -> None: + self.channel_type = channel_type + self.sample_rate = sample_rate + self.wave = np.zeros(0, dtype=np.float64) + + def render( + self, + events: list, + total_ticks: int, + bpm: int, + mode: str, + ) -> np.ndarray: + seconds = tick_to_second(total_ticks, bpm) + length = int(seconds * 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 start_sample >= length or end_sample <= start_sample: + continue + if isinstance(event, RestEvent): + continue + self._render_note( + start_sample, + end_sample, + event, + last_velocity, + last_duty, + mode, + ) + return self.wave + + def _render_note( + self, + start: int, + end: int, + event: NoteEvent, + velocity: int, + duty: int, + mode: str, + ) -> None: + duration_samples = end - start + if duration_samples <= 0: + return + 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 + 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 + + +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 synthesize( + note_sequence: NoteSequence, + mode: str, + sample_rate: int = SAMPLE_RATE_DEFAULT, + 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()) + 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 + + +def write_wav(path: Path, wave_data: np.ndarray, sample_rate: int) -> list[ErrorDetail]: + errors: list[ErrorDetail] = [] + try: + path.parent.mkdir(parents=True, exist_ok=True) + pcm = np.int16(np.clip(wave_data * 32767, -32768, 32767)) + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(sample_rate) + wav.writeframes(pcm.tobytes()) + except Exception as exc: + errors.append( + ErrorDetail( + code=ErrorCode.SYSTEM_WAV_WRITE_FAILED, + line=0, + column=0, + message=f"WAVファイルの出力に失敗しました: {exc}", + severity="error", + 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 + 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] + summary.append( + { + "channel": name, + "note_count": len(notes), + "octave_range": [min(octaves, default=4), max(octaves, default=4)], + "duration_ticks": total_ticks, + } + ) + return summary |
