File size: 4,658 Bytes
ddef6a2
91a6faa
 
ddef6a2
91a6faa
 
 
 
 
 
 
 
 
 
 
 
ddef6a2
 
91a6faa
 
ddef6a2
 
91a6faa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddef6a2
91a6faa
 
 
 
 
 
ddef6a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91a6faa
 
 
 
 
ddef6a2
 
91a6faa
ddef6a2
 
91a6faa
ddef6a2
 
 
 
 
 
91a6faa
 
ddef6a2
 
 
 
 
 
91a6faa
 
 
 
 
 
 
 
 
 
 
 
ddef6a2
91a6faa
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
"""key / tempo / meter detection. pure librosa dsp — no ML."""
import librosa
import numpy as np
import soundfile as sf

# krumhansl-schmuckler key profiles
# major and minor correlation vectors for pitch class distribution
MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09,
                          2.52, 5.19, 2.39, 3.66, 2.29, 2.88])

MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53,
                          2.54, 4.75, 3.98, 2.69, 3.34, 3.17])

PITCH_CLASSES = ['C', 'C#', 'D', 'D#', 'E', 'F',
                 'F#', 'G', 'G#', 'A', 'A#', 'B']

# all the analysis here works on envelopes and chroma; 22k mono is plenty
ANALYSIS_SR = 22050


def _key_from_audio(track, sr):
    """chroma-based key detection using krumhansl-schmuckler profiles"""
    chroma = librosa.feature.chroma_cqt(y=track, sr=sr)
    pitch_dist = np.mean(chroma, axis=1)

    # normalize
    pitch_dist = (pitch_dist - pitch_dist.mean()) / (pitch_dist.std() + 1e-8)

    best_corr = -2
    best_key = 'C major'

    for shift in range(12):
        rolled = np.roll(pitch_dist, -shift)

        # check major
        major_norm = (MAJOR_PROFILE - MAJOR_PROFILE.mean()) / MAJOR_PROFILE.std()
        corr_major = np.corrcoef(rolled, major_norm)[0, 1]
        if corr_major > best_corr:
            best_corr = corr_major
            best_key = f'{PITCH_CLASSES[shift]} major'

        # check minor
        minor_norm = (MINOR_PROFILE - MINOR_PROFILE.mean()) / MINOR_PROFILE.std()
        corr_minor = np.corrcoef(rolled, minor_norm)[0, 1]
        if corr_minor > best_corr:
            best_corr = corr_minor
            best_key = f'{PITCH_CLASSES[shift]} minor'

    return best_key


def _scalar_tempo(tempo):
    # librosa sometimes returns an array
    if hasattr(tempo, '__len__'):
        return float(tempo[0])
    return float(tempo)


def _time_signature_from_beats(onset_env, beats):
    """
    estimate the meter from where the accents land: take the onset strength
    at each tracked beat and score how well a "downbeat every N beats" grid
    lines up with the loud ones, for N = 3 and 4. waltz time has to win
    clearly — ambiguous material is called 4/4, like most music.
    """
    if len(beats) < 12:
        return "4/4"

    strengths = onset_env[beats].astype(float)
    spread = strengths.std()
    if spread < 1e-8:
        return "4/4"
    strengths = (strengths - strengths.mean()) / spread

    def accent_score(meter):
        # best phase alignment of the downbeat grid
        return max(float(np.mean(strengths[offset::meter]))
                   for offset in range(meter))

    three, four = accent_score(3), accent_score(4)
    if three > 0 and three > four * 1.25:
        return "3/4"
    return "4/4"


def find_key(path):
    track, sr = librosa.load(path, sr=ANALYSIS_SR, mono=True)
    return _key_from_audio(track, sr)


def get_tempo(path):
    track, sr = librosa.load(path, sr=ANALYSIS_SR, mono=True)
    tempo, _ = librosa.beat.beat_track(y=track, sr=sr)
    return _scalar_tempo(tempo)


def get_time_signature(path):
    track, sr = librosa.load(path, sr=ANALYSIS_SR, mono=True)
    onset_env = librosa.onset.onset_strength(y=track, sr=sr)
    _, beats = librosa.beat.beat_track(onset_envelope=onset_env, sr=sr)
    return _time_signature_from_beats(onset_env, beats)


def get_duration(path):
    return librosa.get_duration(path=path)


def fingerprint(path):
    """one-stop analysis: key, bpm, meter, duration — single decode pass."""
    track, sr = librosa.load(path, sr=ANALYSIS_SR, mono=True)

    onset_env = librosa.onset.onset_strength(y=track, sr=sr)
    tempo, beats = librosa.beat.beat_track(onset_envelope=onset_env, sr=sr)

    # source-file metadata without a second full decode
    try:
        meta = sf.info(path)
        native_sr, channels = meta.samplerate, meta.channels
    except Exception:
        native_sr, channels = sr, 1

    return {
        'key': _key_from_audio(track, sr),
        'bpm': round(_scalar_tempo(tempo), 1),
        'time_signature': _time_signature_from_beats(onset_env, beats),
        'duration': round(len(track) / sr, 2),
        'sample_rate': native_sr,
        'channels': channels,
    }


if __name__ == '__main__':
    import sys
    if len(sys.argv) < 2:
        print('usage: python analyze.py <audio_file>')
        sys.exit(1)

    info = fingerprint(sys.argv[1])
    print(f"key: {info['key']}")
    print(f"bpm: {info['bpm']}")
    print(f"time signature: {info['time_signature']} (estimated)")
    print(f"duration: {info['duration']}s")
    print(f"sample rate: {info['sample_rate']}Hz")
    print(f"channels: {info['channels']}")