File size: 5,433 Bytes
d4d0bc7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c2dd476
 
 
d4d0bc7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
#!/usr/bin/env python3
"""
train_classifier.py — Train sklearn RandomForest classifier on labelled data.

Usage:
    python3 scripts/train_classifier.py

Reads all JSON files from training_data/ directory.
Saves model to training_data/models/instrument_classifier.pkl
"""

import json
import os
import sys
from pathlib import Path

# Allow running from project root
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))

# Data dir is overridable (MAESTRO_TRAINING_DIR) so tests can point at an
# isolated fixture dir instead of the repo's accumulated training_data/.
TRAINING_DIR = Path(os.environ.get("MAESTRO_TRAINING_DIR", PROJECT_ROOT / "training_data"))
MODEL_DIR = TRAINING_DIR / "models"
MODEL_PATH = MODEL_DIR / "instrument_classifier.pkl"
LABEL_ENCODER_PATH = MODEL_DIR / "label_encoder.pkl"

# Feature keys (must match ml_classifier.py)
FEATURE_KEYS = [
    "spectral_centroid_hz",
    "spectral_rolloff_hz",
    "spectral_flatness",
    "harmonic_ratio",
    "zero_crossing_rate",
    "percussive_ratio",
    "dynamic_range_db",
    "inharmonicity_score",
    "tempo_bpm",
    "pitch_std_hz",
    "attack_sharpness",
]


def extract_feature_vector(features: dict) -> list:
    """Extract a flat feature vector from a features dict."""
    vec = []
    for key in FEATURE_KEYS:
        vec.append(float(features.get(key, 0.0) or 0.0))

    # mel_band_stats sub-keys
    mel = features.get("mel_band_stats", {}) or {}
    for sub in ["mel_sub", "mel_bass", "mel_low_mid", "mel_high_mid", "mel_high"]:
        vec.append(float(mel.get(sub, 0.0) or 0.0))

    # frequency_bands_db sub-keys
    bands = features.get("frequency_bands_db", {}) or {}
    for sub in ["sub_bass_db", "bass_db", "low_mid_db", "high_mid_db", "presence_db", "air_db"]:
        vec.append(float(bands.get(sub, 0.0) or 0.0))

    # mfcc_means — first 13 coefficients
    mfcc = features.get("mfcc_means", []) or []
    for i in range(13):
        vec.append(float(mfcc[i]) if i < len(mfcc) else 0.0)

    # spectral_contrast — first 7 bands
    sc = features.get("spectral_contrast", []) or []
    for i in range(7):
        vec.append(float(sc[i]) if i < len(sc) else 0.0)

    return vec


def load_training_data():
    """Load all JSON training records from training_data/ directory."""
    X = []
    y = []
    skipped = 0

    for json_file in TRAINING_DIR.glob("*.json"):
        try:
            with open(json_file) as f:
                records = json.load(f)

            # Support both list of records and single record
            if isinstance(records, dict):
                records = [records]

            for record in records:
                try:
                    features = record.get("features", {})
                    analysis = record.get("analysis", {})
                    instruments = analysis.get("instruments", [])

                    if not instruments:
                        skipped += 1
                        continue

                    label = instruments[0].get("model")
                    if not label:
                        skipped += 1
                        continue

                    vec = extract_feature_vector(features)
                    X.append(vec)
                    y.append(label)

                except Exception as e:
                    skipped += 1
                    continue

        except Exception as e:
            print(f"Warning: Could not read {json_file}: {e}")
            continue

    if skipped > 0:
        print(f"Skipped {skipped} records (missing features or labels)")

    return X, y


def main():
    print("Loading training data...")
    X, y = load_training_data()

    n_samples = len(X)
    print(f"Found {n_samples} labelled samples")

    if n_samples < 5:
        print(f"Insufficient data ({n_samples} samples). Need at least 5 to train.")
        sys.exit(1)

    import numpy as np
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.model_selection import cross_val_score
    from sklearn.pipeline import Pipeline
    from sklearn.preprocessing import LabelEncoder, StandardScaler
    import joblib

    X_arr = np.array(X)
    le = LabelEncoder()
    y_enc = le.fit_transform(y)

    n_classes = len(le.classes_)
    print(f"Labels: {n_classes} unique instruments")

    pipeline = Pipeline([
        ("scaler", StandardScaler()),
        ("clf", RandomForestClassifier(n_estimators=100, random_state=42)),
    ])

    # Cross-validation (cv=3 if enough samples)
    cv = min(3, n_samples // n_classes) if n_samples >= 3 else 2
    cv = max(cv, 2)  # at least 2

    if n_samples >= cv * n_classes:
        try:
            scores = cross_val_score(pipeline, X_arr, y_enc, cv=cv, scoring="accuracy")
            print(f"Cross-validation accuracy (cv={cv}): {scores.mean():.3f} ± {scores.std():.3f}")
        except Exception as e:
            print(f"Cross-validation skipped: {e}")
    else:
        print("Not enough samples for cross-validation, skipping.")

    # Train on full data
    print("Training final model on all data...")
    pipeline.fit(X_arr, y_enc)

    # Save model
    MODEL_DIR.mkdir(parents=True, exist_ok=True)
    joblib.dump(pipeline, MODEL_PATH)
    joblib.dump(le, LABEL_ENCODER_PATH)

    print(f"Model saved to: {MODEL_PATH}")
    print(f"Label encoder saved to: {LABEL_ENCODER_PATH}")
    print("Training complete.")


if __name__ == "__main__":
    main()