問題一覧 > 通常問題

No.2403 "Eight" Bridges of Königsberg

レベル : / 実行時間制限 : 1ケース 2.000秒 / メモリ制限 : 512 MB / 標準ジャッジ問題
タグ : / 解いたユーザー数 92
作問者 : 👑 獅子座じゃない人 / テスター : 👑 amentorimaru
ProblemId : 9818 / yukicoder contest 400 (順位表) / 自分の提出
問題文最終更新日: 2026-03-28 17:20:30
yukicoder contest 400の他の問題:

解説

模範解答

F 4
.
E 4
.
D 4
.
A 3
.
R
.
Bb 3
.
A 3
.
D 4
.

R
.
F 4
.
E 4
F 4
E 4
.
G 4
.
F 4
G 4
F 4
.
G 4
G# 4

A 4
.
G 4
.
F 4
.
F 3
.
R
.
A 3
.
G 3
A 3
G 3
.

Bb 3
.
A 3
Bb 3
A 3
.
Bb 3
B 3
C 4
.
C# 4
D 4
E 4
.
F 4
.

解法例
耳コピのコツ

はじめに、普通に耳コピを行ってこの問題を解く場合のコツを軽く説明します。

まず、音程とリズムの両方を一度に当てようと思わず、先にどちらかを確定させてからもう片方に取り掛かるほうがやりやすいと writer は考えています。writer は自分に音感がそこまであるとは思っていないので、より当てやすいリズムのほうを先に確定させてから音程を聴きとることが多いです。

Domino や DAW (Digital Audio Workstation) を使用できる方や楽器を演奏できる方は、耳コピしたメロディと原曲のメロディをときどき聴き比べるとよいです。一発で当てようと思わず、間違っていることに後から気付いてもよいくらいのラフさで打ち込んでみるとやりやすいと writer は考えています。

また、実は sample.mp3 と problem.mp3 はメインリード以外のトラックが全く同じです。そして、sample.mp3 の音程は全て公開されているため、problem.mp3 の調性を知ったうえで耳コピに取り掛かることができます。
具体的には、sample.mp3 と problem.mp3 はともにニ短調(D minor)です。sample.mp3 の調がニ短調であることはサンプル出力からも読み取れ、problem.mp3 も D, E, F, G, A, B, C# が特に頻出であると予想できます。
そして、調性がわかれば最初の音や最後の音の候補をより少なくすることができ、「絶対音感はないが相対音感はある」という人でもより耳コピしやすくなる可能性があります。

WaveTone の使用

Windows の PC を使用している場合は、無償ソフトである WaveTone を使用するのも手です。

詳しい使い方の説明は省きますが、適切な設定をすれば以下のように通常のスペクトログラムよりもメロディが視覚的にわかりやすい形で解析結果を得ることができます。

Python による解析

Python や MATLAB などを用いた解析により、答えを得ることもできます。

以下に、Python の標準ライブラリおよび NumPy, pydub, SciPy を使用した Python プログラムのソースコードを示します。このプログラムは、sample.mp3 と problem.mp3 に対して正しい答えを返します。
なお、pydub は実行環境の FFmpeg に依存するので、このプログラムを実行するためには FFmpeg をインストールしておく必要があります。たとえば、Ubuntu であれば sudo apt install ffmpeg でインストールすることができます。
また、このプログラムを Python 3.13 以降で実行する場合、numpy, pydub, scipy とともに audioop-lts をインストールする必要があります。

多くの曲でメインボーカルやメインリードは Side よりも Mid に配置されていることが多く、この problem.mp3 もそうです。そこで、このプログラムでは L と R の平均をとって音源の Mid 成分を抽出したモノラル音源を load_mid_mono 関数で取得しています。

また、多くの曲でメインボーカルやメインリードの成分は低域にほとんどないため、ハイパスフィルタなどによってローカットしてしまっても問題ありません。このプログラムでは、highpass_filter 関数でローカットを行っています。

さらに、このプログラムでは make_cells 関数で音源をそれぞれの区間に分割した後、区間の後半のみを残し前半を捨てています。これは、音量や音高などが安定していない音の立ち上がりを無視するためです。

理想的な矩形波は奇数倍音のみを持ち、第 $i$ 倍音 ($i$ は奇数) の振幅は基音の $1/i$ 倍となります。もちろん電子音楽における square wave は理想的な矩形波とは異なりますが、奇数倍音に関する特徴が大きくかけ離れているわけではありません。
そこで、このプログラムの best_note_and_score 関数では、候補となる音高およびそれを基音としたときの第 $3$ 倍音, 第 $5$ 倍音, ..., 第 $15$ 倍音にあたる周波数成分の大きさをもとにスコアを計算して最もスコアの高い音高を答えとすることで、オクターブの間違いなどを防いでいます。

ここまででこの問題の sample.mp3 と problem.mp3 については音価以外を正しく得ることができますが、休符判定を入れないとこの問題を AC することはできません。
休符となる位置では得られる音高のスコアはより低いと予想でき、sample.mp3 でスコアをデバッグ出力してみると実際にその傾向が読み取れます。
そこで、このプログラムの transcribe 関数では、スコアが実験的に得た THRESHOLD 未満であれば休符と判定するようにしています。

もし、休符判定のための THRESHOLD 自体を推定したい場合は、各区間に対するスコアのクラスタリング(1 次元 2-means)を行うことなどが考えられます。

なお、Librosa などを使用してさらに高度な解析を行うのも手です。

from collections.abc import Sequence
from dataclasses import dataclass
import argparse

import numpy as np
from pydub import AudioSegment
from scipy import signal

START_TIME = 1.0
BPM = 120.0
BEATS_PER_BAR = 4
BARS = 4
SUBDIVISION = 4  # 16 分音符
N_CELLS = BARS * BEATS_PER_BAR * SUBDIVISION  # 64 区間
CELL_LEN = 60.0 / BPM / SUBDIVISION  # 1 区間あたり 1/8 秒

LOWCUT_FREQ = 300.0
NOTE_MIN = 45  # `A 1` から
NOTE_MAX = 88  # `E 6` まで
N_HARMONICS = 15
FREQ_TOL_SEMITONES = 0.15

# 休符判定の閾値
#
# この閾値自体をちゃんと推定したい場合、
# 各区間に対するスコアのクラスタリング(1 次元 2-means)
# などの方法が考えられる
THRESHOLD = 4.0

PITCH_NAMES = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B"]


def load_mid_mono(path: str) -> tuple[np.ndarray, int]:
    """
    音源の Mid 成分を抽出し、モノラルで得る。
    """

    seg = AudioSegment.from_file(path)
    samples = np.array(seg.get_array_of_samples(), dtype=np.float32)
    if seg.channels == 2:
        samples = samples.reshape(-1, 2)
        mono = 0.5 * (samples[:, 0] + samples[:, 1])  # L と R の平均で Mid を得る
    elif seg.channels == 1:
        mono = samples
    else:
        raise ValueError("音源のチャンネル数が 2 より大きいです。")

    scale = float(1 << (8 * seg.sample_width - 1))
    mono /= scale
    return mono, seg.frame_rate


def highpass_filter(
    x: np.ndarray, sampling_rate: int, cutoff_freq: float
) -> np.ndarray:
    """
    ハイパスフィルタ

    ローカットに使用する。
    """

    sos = signal.butter(
        3, cutoff_freq, btype="highpass", fs=sampling_rate, output="sos"
    )
    return signal.sosfiltfilt(sos, x)


def midi_note_number_to_freq(note_number: int) -> float:
    """
    MIDI のノート番号から周波数に変換する。
    """

    return 440.0 * (2.0 ** ((note_number - 69) / 12.0))


def midi_note_number_to_problem_out(note_number: int) -> str:
    """
    MIDI のノート番号から問題の出力形式に変換する。
    """

    pitch_num = note_number % 12
    problem_out_octave = note_number // 12 - 2
    return f"{PITCH_NAMES[pitch_num]} {problem_out_octave}"


def next_pow_of_2(n: int) -> int:
    """
    n 以上の最小の 2 べき
    """

    if n.bit_count() == 1:
        return n
    return 1 << n.bit_length()


def make_cells(x: np.ndarray, sampling_rate: int) -> list[np.ndarray]:
    """
    64 個の区間を生成する。
    """

    total_len = BARS * BEATS_PER_BAR * 60.0 / BPM
    a = int(round(START_TIME * sampling_rate))
    b = int(round((START_TIME + total_len) * sampling_rate))
    x = x[a:b]

    bounds = np.round(np.linspace(0, len(x), N_CELLS + 1)).astype(int)
    return [x[bounds[i] : bounds[i + 1]] for i in range(N_CELLS)]


def best_note_and_score(cell: np.ndarray, sampling_rate: int) -> tuple[int, float]:
    """
    NOTE_MIN から NOTE_MAX まで走査し、最もスコアの高い音高を採用する。

    メインリードが square wave であることを利用し、
    基音から第 15 倍音までの奇数倍音の大きさをもとにスコアを得る。
    """

    # 区間の後半のみを解析したほうが、メインリードの音高を正しく推定しやすい
    cell = cell[len(cell) // 2 :]

    window = np.hanning(len(cell))
    nfft = next_pow_of_2(len(cell) * 4)
    spec = np.abs(np.fft.rfft(cell * window, n=nfft))
    freqs = np.fft.rfftfreq(nfft, 1.0 / sampling_rate)
    logspec = np.log1p(spec)

    best_note = NOTE_MIN
    best_score = -1.0
    for note in range(NOTE_MIN, NOTE_MAX + 1):
        f0 = midi_note_number_to_freq(note)
        score = 0.0
        for h in range(1, N_HARMONICS + 2, 2):
            f = f0 * h
            if f > freqs[-1]:
                break
            width = f * (2.0 ** (FREQ_TOL_SEMITONES / 12.0) - 1.0)
            mask = (freqs >= f - width) & (freqs <= f + width)
            if not np.any(mask):
                continue
            score += float(logspec[mask].max()) / h
        if score > best_score:
            best_score = score
            best_note = note

    return best_note, best_score


def compress_with_dots(lines: Sequence[str]) -> list[str]:
    out: list[str] = []
    prev = None
    for line in lines:
        if prev is not None and line == prev:
            out.append(".")
        else:
            out.append(line)
            prev = line
    return out


def transcribe(path: str) -> list[str]:
    x, sampling_rate = load_mid_mono(path)
    x = highpass_filter(x, sampling_rate, LOWCUT_FREQ)
    cells = make_cells(x, sampling_rate)

    best_notes: list[int] = []
    best_scores: list[float] = []
    for cell in cells:
        note, score = best_note_and_score(cell, sampling_rate)
        best_notes.append(note)
        best_scores.append(score)

    lines: list[str] = []
    for note, score in zip(best_notes, best_scores):
        if score < THRESHOLD:
            lines.append("R")
        else:
            lines.append(midi_note_number_to_problem_out(note))

    return compress_with_dots(lines)


@dataclass(frozen=True)
class CommandLineArgs:
    path: str


def main() -> int:
    parser = argparse.ArgumentParser(description="耳コピプログラムの例")
    parser.add_argument("path", help="sample.mp3 または problem.mp3 のパス")

    args = CommandLineArgs(**vars(parser.parse_args()))

    lines = transcribe(args.path)
    print("\n".join(lines))

    return 0


if __name__ == "__main__":
    main()

提出するには、Twitter 、GitHub、 Googleもしくは右上の雲マークをクリックしてアカウントを作成してください。