File size: 21,683 Bytes
2a6e44d
 
 
 
a2ff005
 
 
2a6e44d
4612a25
2a6e44d
4612a25
0e7ae00
2a6e44d
caa38e4
2a6e44d
 
 
 
 
 
 
 
a2ff005
2a6e44d
 
6d970f0
a2ff005
 
 
 
 
 
 
 
 
6d970f0
2a6e44d
64567d1
2a6e44d
 
 
caa38e4
2a6e44d
a2ff005
2a6e44d
a2ff005
 
4612a25
a2ff005
4612a25
 
0e7ae00
2a6e44d
a2ff005
0e7ae00
a2ff005
 
67c32d2
4612a25
2a6e44d
a2ff005
2a6e44d
 
 
 
 
 
 
a2ff005
2a6e44d
67c32d2
2a6e44d
 
67c32d2
 
2a6e44d
a2ff005
2a6e44d
 
a2ff005
2a6e44d
 
 
 
a2ff005
2a6e44d
a2ff005
 
 
 
2a6e44d
4612a25
2a6e44d
 
4612a25
a2ff005
 
 
 
2a6e44d
 
a2ff005
4612a25
67c32d2
2a6e44d
 
 
 
 
67c32d2
 
a2ff005
 
 
 
 
 
 
 
 
 
2a6e44d
a2ff005
2a6e44d
a2ff005
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67c32d2
6d970f0
2a6e44d
 
a2ff005
 
 
 
 
67c32d2
a2ff005
 
2a6e44d
a2ff005
2a6e44d
 
 
a2ff005
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d970f0
2a6e44d
64567d1
a2ff005
64567d1
a2ff005
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64567d1
a2ff005
 
 
 
 
 
 
 
 
 
 
 
 
 
64567d1
a2ff005
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64567d1
a2ff005
 
64567d1
a2ff005
64567d1
2a6e44d
 
a2ff005
2a6e44d
a2ff005
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67c32d2
0e7ae00
 
2a6e44d
 
67c32d2
 
a2ff005
 
 
 
 
0e7ae00
 
 
 
 
4612a25
67c32d2
 
 
64567d1
2a6e44d
a2ff005
 
 
0e7ae00
a2ff005
2a6e44d
 
 
0e7ae00
2a6e44d
 
 
caa38e4
a2ff005
67c32d2
2a6e44d
 
 
a2ff005
67c32d2
a2ff005
2a6e44d
 
a2ff005
2a6e44d
 
 
 
 
a2ff005
2a6e44d
67c32d2
a2ff005
2a6e44d
 
 
a2ff005
2a6e44d
 
 
 
 
 
 
 
 
 
 
a2ff005
2a6e44d
 
 
 
 
 
 
a2ff005
2a6e44d
 
 
 
a2ff005
67c32d2
2a6e44d
a2ff005
64567d1
a2ff005
 
 
 
67c32d2
 
a2ff005
67c32d2
a2ff005
64567d1
a2ff005
 
f6674c5
67c32d2
2a6e44d
a2ff005
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e7ae00
a2ff005
 
2a6e44d
 
a2ff005
 
67c32d2
2a6e44d
67c32d2
a2ff005
 
 
 
2a6e44d
a2ff005
2a6e44d
 
a2ff005
 
2a6e44d
 
a2ff005
 
 
2a6e44d
a2ff005
2a6e44d
a2ff005
2a6e44d
a2ff005
2a6e44d
a2ff005
 
64567d1
 
a2ff005
 
 
 
 
 
64567d1
a2ff005
 
 
 
 
 
 
 
 
 
64567d1
a2ff005
 
 
 
 
 
 
 
 
 
64567d1
a2ff005
 
 
 
 
 
 
 
 
 
64567d1
a2ff005
2a6e44d
a2ff005
2a6e44d
a2ff005
 
62985b6
 
0e7ae00
d0a24c7
2a6e44d
 
0e7ae00
 
 
6d970f0
67c32d2
2a6e44d
a2ff005
2a6e44d
a2ff005
2a6e44d
a2ff005
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a6e44d
67c32d2
2a6e44d
 
 
a2ff005
2a6e44d
67c32d2
a2ff005
 
 
 
2a6e44d
a2ff005
 
2a6e44d
a2ff005
 
2a6e44d
a2ff005
2a6e44d
4612a25
a2ff005
67c32d2
a2ff005
 
 
 
 
 
64567d1
2a6e44d
 
67c32d2
a2ff005
 
 
2a6e44d
 
 
a2ff005
 
 
 
 
6d970f0
 
 
2a6e44d
a2ff005
 
 
2a6e44d
67c32d2
2a6e44d
a2ff005
 
 
2a6e44d
6d970f0
 
 
a2ff005
2a6e44d
a2ff005
793d592
a2ff005
 
 
 
 
 
 
 
 
 
 
 
6d970f0
67c32d2
2a6e44d
 
6d970f0
4612a25
a2ff005
 
 
 
 
 
 
 
2a6e44d
 
 
 
 
 
 
 
 
 
a2ff005
 
 
 
 
 
 
 
 
 
 
 
2a6e44d
6d970f0
67c32d2
a2ff005
2a6e44d
 
 
 
 
 
 
 
 
 
a2ff005
 
 
 
6d970f0
 
 
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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
import os
import io
import tempfile
import datetime
import textwrap

import numpy as np
import torch
import librosa
import gradio as gr

from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
from reportlab.lib.utils import ImageReader
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont

from transformers import (
    WhisperProcessor,
    AutoModelForSpeechSeq2Seq,
    AutoFeatureExtractor,
    AutoModel,
)
from transformers import pipeline as hf_pipeline

# --- SciPy / librosa compatibility patch (hann -> windows.hann) ----------
try:
    import scipy.signal as _sg
    from scipy.signal import windows as _win

    if not hasattr(_sg, "hann"):
        _sg.hann = _win.hann
except Exception:
    _sg = None

# ---------------------------------------------------------
# FONTS
# ---------------------------------------------------------
pdfmetrics.registerFont(TTFont("PlayfairBold", "PlayfairDisplay-Bold.ttf"))
pdfmetrics.registerFont(TTFont("Geneva", "Geneva.ttf"))

# ---------------------------------------------------------
# COLORS & CONFIG
# ---------------------------------------------------------
ACCENT = colors.HexColor("#8b5cf6")   # violet accent
PRIMARY = colors.HexColor("#3b0c3f")  # eggplant
LIGHT_GRAY = colors.HexColor("#e6e6e6")
GOLD = colors.HexColor("#f4c542")     # deeper gold for better contrast
WHITE = colors.white
BLACK = colors.black

ENGINE_URL = "https://www.tourdefierce.vip/ai-music-detector"
LOGO_FILE = "logo.jpg"

ASR_MODEL = "openai/whisper-small"        # best free-tier Whisper
CLF_MODEL = "microsoft/wavlm-base-plus-sv"


# ---------------------------------------------------------
# LOAD MODELS
# ---------------------------------------------------------
processor = WhisperProcessor.from_pretrained(ASR_MODEL)
asr_model = AutoModelForSpeechSeq2Seq.from_pretrained(ASR_MODEL)
asr_pipe = hf_pipeline(
    "automatic-speech-recognition",
    model=asr_model,
    tokenizer=processor.tokenizer,
    feature_extractor=processor.feature_extractor,
)

clf_processor = AutoFeatureExtractor.from_pretrained(CLF_MODEL)
clf_model = AutoModel.from_pretrained(CLF_MODEL)


# ---------------------------------------------------------
# DSP / ANALYSIS UTILITIES
# ---------------------------------------------------------
def compute_autotune_index(y, sr):
    """Heuristic autotune index: low pitch variance -> more 'quantized' -> higher score."""
    f0, voiced, _ = librosa.pyin(
        y,
        sr=sr,
        fmin=librosa.note_to_hz("C2"),
        fmax=librosa.note_to_hz("C6"),
    )

    if f0 is None:
        return 0.0

    f0 = f0[voiced > 0.5]

    if len(f0) < 10:
        return 0.0

    log_f0 = np.log(f0)
    std = np.std(log_f0)

    # Very smooth / quantized singing => lower std
    max_std = 0.25
    score = 1 - np.clip(std / max_std, 0, 1)
    return float(score * 100.0)


def extract_embeddings(y, sr):
    inp = clf_processor(y, sampling_rate=sr, return_tensors="pt")
    with torch.no_grad():
        out = clf_model(**inp).last_hidden_state.mean(dim=1).squeeze()
    return out.cpu().numpy()


def calculate_ai_probability(emb, y, sr, autotune_idx):
    """
    Heuristic AI probability in [0, 1].

    Uses:
    - Embedding norm
    - Dynamic range
    - Autotune index
    """
    # Embedding norm (rough style/complexity proxy)
    norm = np.linalg.norm(emb)
    norm_min, norm_max = 40, 140
    norm_scaled = np.clip((norm - norm_min) / (norm_max - norm_min), 0, 1)

    # Dynamic range: very flat dynamics can hint at synthetic / over-processed audio
    S = np.abs(librosa.stft(y))
    rms = librosa.feature.rms(S=S)[0]
    dyn_range = np.percentile(rms, 95) - np.percentile(rms, 5)
    dyn_scaled = 1.0 - np.clip((dyn_range - 0.02) / 0.1, 0, 1)  # flatter -> closer to 1

    # Autotune contribution
    at_scaled = autotune_idx / 100.0

    # Weighted combination
    raw = 0.4 * norm_scaled + 0.3 * dyn_scaled + 0.3 * at_scaled

    # Squash to [0.05, 0.99] so we never hit absolute 0/100
    ai_prob = float(np.clip(raw * 0.95 + 0.05, 0.05, 0.99))
    return ai_prob


def detect_key(y, sr):
    chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
    chroma_mean = chroma.mean(axis=1)
    key_index = int(np.argmax(chroma_mean))

    KEYS = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]
    root = KEYS[key_index]

    maj_energy = chroma_mean[(key_index + 4) % 12] + chroma_mean[(key_index + 7) % 12]
    min_energy = chroma_mean[(key_index + 3) % 12] + chroma_mean[(key_index + 7) % 12]

    return f"{root} major" if maj_energy >= min_energy else f"{root} minor"


def detect_bpm(y, sr):
    onset_env = librosa.onset.onset_strength(y=y, sr=sr)
    tempo = librosa.beat.tempo(onset_envelope=onset_env, sr=sr)
    if tempo is None or len(tempo) == 0:
        return 0.0
    return float(tempo[0])


def estimate_voice_type(y, sr):
    """Very rough tessitura-based suggestion."""
    f0, voiced, _ = librosa.pyin(
        y,
        sr=sr,
        fmin=librosa.note_to_hz("C2"),
        fmax=librosa.note_to_hz("C6"),
    )

    if f0 is None or np.sum(voiced) < 5:
        return "Unable to estimate voice type from this clip."

    f0 = f0[voiced > 0.5]
    median_hz = np.median(f0)
    median_note = librosa.hz_to_note(median_hz)

    # Very coarse buckets
    if median_hz < librosa.note_to_hz("G3"):
        base = "lower voice (baritone / alto range)"
    elif median_hz < librosa.note_to_hz("C4"):
        base = "mid voice (baritenor / mezzo range)"
    else:
        base = "high voice (tenor or soprano range)"

    return f"Given the tessitura, this song is best suited for a {base}."


def compute_production_polish(y, sr):
    """0-100: how polished / produced the track sounds."""
    S = np.abs(librosa.stft(y))
    rms = librosa.feature.rms(S=S)[0]

    dyn_range = np.percentile(rms, 95) - np.percentile(rms, 5)
    dyn_score = 1.0 - np.clip((dyn_range - 0.015) / 0.12, 0, 1)

    flatness = np.mean(librosa.feature.spectral_flatness(S=S))
    flat_score = np.clip((flatness - 0.1) / 0.4, 0, 1)

    polish = 0.6 * dyn_score + 0.4 * flat_score
    return float(polish * 100.0)


def compute_shade_score(ai_percent, autotune_idx, polish_idx):
    """
    Shade Meter 0–100:
    - 60% AI likelihood
    - 25% autotune index
    - 15% production polish
    """
    shade = 0.6 * ai_percent + 0.25 * autotune_idx + 0.15 * polish_idx
    return float(np.clip(shade, 0, 100))


# ---------------------------------------------------------
# TEXT HELPERS
# ---------------------------------------------------------
def wrap_paragraph(text, width=90):
    lines = []
    for para in text.splitlines():
        if not para.strip():
            lines.append("")
            continue
        lines.extend(textwrap.wrap(para, width=width))
    return lines


def build_scientific_analysis(ai_pct, human_pct, autotune_idx, shade, key_sig, bpm, polish_idx):
    lines = []
    lines.append("Overview")
    lines.append(
        f"This clip was analyzed using a hybrid signal-processing and deep-learning stack. "
        f"Based on embedding statistics, dynamic range, spectral behavior, and pitch stability, "
        f"the system estimates a {ai_pct:.1f}% probability that the source material is AI-generated, "
        f"and a {human_pct:.1f}% probability that it is primarily human-performed."
    )
    lines.append("")
    lines.append("Pitch & Autotune")
    lines.append(
        f"Fundamental frequency tracking suggests an autotune index of {autotune_idx:.1f}/100. "
        f"Lower scores indicate more organic pitch variance, while higher scores indicate quantized or "
        f"grid-snapped intonation."
    )
    lines.append("")
    lines.append("Rhythm & Tempo")
    lines.append(
        f"Tempo estimation places this performance at approximately {bpm:.1f} beats per minute. "
        f"The detected tempo is derived from onset strength peaks and may vary slightly with different sections "
        f"of the recording."
    )
    lines.append("")
    lines.append("Timbre & Production")
    lines.append(
        f"Timbre and dynamics analysis yields a production polish score of {polish_idx:.1f}/100. "
        f"Higher scores correspond to compressed, consistently loud, and spectrally uniform material, "
        f"often associated with heavily produced or synthetic audio."
    )
    lines.append("")
    lines.append("Musical Context")
    lines.append(
        f"Harmonic analysis indicates that the material centers around {key_sig}. "
        f"This key estimate is based on chroma energy distribution over the length of the clip."
    )
    return "\n".join(lines)


def build_clapback(ai_pct, human_pct, autotune_idx, shade, key_sig, bpm, voice_text):
    tone_lines = []
    tone_lines.append("CLAPBACK SUMMARY")
    tone_lines.append("")
    if ai_pct >= 75:
        tone_lines.append(
            f"This track is giving **full robot fantasy** with an AI likelihood of {ai_pct:.1f}%. "
            f"If there was a human involved, they were probably just pressing 'render.'"
        )
    elif ai_pct >= 40:
        tone_lines.append(
            f"This performance lives in the uncanny valley with an AI likelihood of {ai_pct:.1f}%. "
            f"Some human in there, but the machines are definitely helping."
        )
    else:
        tone_lines.append(
            f"With only {ai_pct:.1f}% AI likelihood, this one is serving mostly human realness. "
            f"Congrats: your soul is still in the mix."
        )

    tone_lines.append("")
    if autotune_idx >= 70:
        tone_lines.append(
            f"Autotune index {autotune_idx:.1f}/100: every note is so locked to the grid it should pay rent there."
        )
    elif autotune_idx >= 35:
        tone_lines.append(
            f"Autotune index {autotune_idx:.1f}/100: tasteful correction, but we definitely hear the safety net."
        )
    else:
        tone_lines.append(
            f"Autotune index {autotune_idx:.1f}/100: pitch is flying mostly solo — brave, messy, and very human."
        )

    tone_lines.append("")
    tone_lines.append(
        f"Shade Meter score: {shade:.1f}/100. "
        f"Zero would mean unplugged, unprocessed, angel-on-a-stool vibes. "
        f"You're sitting at {shade:.1f}, which means there's at least a mild breeze of manufactured perfection "
        f"blowing through this mix."
    )

    tone_lines.append("")
    tone_lines.append(
        f"Musically, the track hangs out around {key_sig} at about {bpm:.1f} BPM, so if you’re clapping back on TikTok, "
        f"now you know what tempo to drag them in."
    )

    tone_lines.append("")
    tone_lines.append(f"Voice-tessitura take: {voice_text}")

    return "\n".join(tone_lines)


# ---------------------------------------------------------
# PDF GENERATION
# ---------------------------------------------------------
def scale_color(val, invert=False):
    """
    For score boxes:
    - green: good
    - gold: medium
    - red: high risk
    """
    if invert:
        # invert: low is good
        if val <= 25:
            return colors.green
        if val <= 75:
            return GOLD
        return colors.red
    else:
        if val >= 75:
            return colors.green
        if val >= 25:
            return GOLD
        return colors.red


def make_pdf(
    ai_score,
    human_score,
    atune,
    shade,
    key_sig,
    bpm,
    transcript,
    scientific_text,
    clapback_text,
    clip_title,
    polish_idx,
):
    buffer = io.BytesIO()
    c = canvas.Canvas(buffer, pagesize=letter)
    W, H = letter

    # Background
    c.setFillColor(WHITE)
    c.rect(0, 0, W, H, fill=1)

    # Logo
    try:
        c.drawImage(LOGO_FILE, 40, H - 120, width=90, height=90)
    except Exception:
        pass

    # Branding
    c.setFillColor(PRIMARY)
    c.setFont("PlayfairBold", 32)
    c.drawString(150, H - 60, "Tour de Fierce")

    c.setFillColor(ACCENT)
    c.setFont("Geneva", 14)
    c.drawString(150, H - 82, "Audio Clapback Report™")

    # Timestamp & clip
    c.setFillColor(BLACK)
    c.setFont("Geneva", 10)
    c.drawString(150, H - 98, f"Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}")
    c.setFont("Geneva", 12)
    c.drawString(40, H - 145, f"Clip analyzed: {clip_title}")

    # QR to engine
    try:
        import qrcode

        qr = qrcode.make(ENGINE_URL)
        buf = io.BytesIO()
        qr.save(buf, format="PNG")
        buf.seek(0)
        c.drawImage(ImageReader(buf), W - 120, H - 140, width=80, height=80)
    except Exception:
        pass

    # Divider line
    c.setStrokeColor(LIGHT_GRAY)
    c.line(40, H - 165, W - 40, H - 165)

    # ---------------------- SCORE BOXES ----------------------
    c.setFillColor(scale_color(ai_score, invert=True))
    c.rect(40, H - 260, 150, 80, fill=1)
    c.setFillColor(WHITE)
    c.setFont("Geneva", 11)
    c.drawString(55, H - 195, "AI Likelihood")
    c.setFont("PlayfairBold", 26)
    c.drawString(55, H - 220, f"{ai_score:.1f}%")

    c.setFillColor(scale_color(human_score))
    c.rect(210, H - 260, 150, 80, fill=1)
    c.setFillColor(WHITE)
    c.setFont("Geneva", 11)
    c.drawString(225, H - 195, "Human Likelihood")
    c.setFont("PlayfairBold", 26)
    c.drawString(225, H - 220, f"{human_score:.1f}%")

    c.setFillColor(scale_color(atune, invert=True))
    c.rect(380, H - 260, 150, 80, fill=1)
    c.setFillColor(WHITE)
    c.setFont("Geneva", 11)
    c.drawString(395, H - 195, "Autotune Index")
    c.setFont("PlayfairBold", 26)
    c.drawString(395, H - 220, f"{atune:.1f}/100")

    # ---------------------- SHADE METER ----------------------
    c.setFillColor(BLACK)
    c.setFont("Geneva", 12)
    c.drawString(40, H - 295, "Shade Meter")

    # capsule bar background (below the title so it doesn't overlap)
    bar_y = H - 310
    bar_height = 14
    bar_width = 490

    c.setFillColor(LIGHT_GRAY)
    c.roundRect(40, bar_y, bar_width, bar_height, 7, fill=1)

    # fill proportional to shade score
    c.setFillColor(ACCENT)
    fill_w = (shade / 100.0) * bar_width
    c.roundRect(40, bar_y, fill_w, bar_height, 7, fill=1)

    c.setFillColor(BLACK)
    c.setFont("Geneva", 10)
    c.drawString(540, bar_y + 1, f"{shade:.1f}/100")

    # explanatory blurb
    shade_blurb = (
        "The Shade Meter provides a comprehensive analysis of the uploaded file, representing exactly "
        "how much shade you are entitled to direct toward the source of the clip. The ideal score is 0%, "
        "indicating real, acoustic instruments and un-pitch-corrected vocals. Moderate scores may reflect "
        "MIDI instruments or noticeably processed vocals. A 100 is the ultimate shade parade, with 100% "
        "confidence that the clip was generated by an AI system."
    )
    c.setFont("Geneva", 9)
    ytxt = H - 330
    for line in wrap_paragraph(shade_blurb, width=95):
        c.drawString(40, ytxt, line)
        ytxt -= 11

    # ---------------------- MUSICALITY -----------------------
    ytxt -= 5
    c.setFont("PlayfairBold", 18)
    c.setFillColor(PRIMARY)
    c.drawString(40, ytxt, "Musicality Analysis")
    ytxt -= 18

    c.setFont("Geneva", 11)
    c.setFillColor(BLACK)
    c.drawString(40, ytxt, f"Key Signature: {key_sig}")
    ytxt -= 14
    c.drawString(40, ytxt, f"Tempo (BPM): {bpm:.1f}")
    ytxt -= 20

    # ----------------- TECHNICAL FORENSIC ANALYSIS -----------------
    c.setFont("PlayfairBold", 18)
    c.setFillColor(PRIMARY)
    c.drawString(40, ytxt, "Technical Forensic Analysis")
    ytxt -= 18

    c.setFont("Geneva", 10)
    c.setFillColor(BLACK)
    for line in wrap_paragraph(scientific_text, width=95):
        if ytxt < 60:
            c.showPage()
            W2, H2 = letter
            c.setFont("Geneva", 10)
            ytxt = H2 - 60
        c.drawString(40, ytxt, line)
        ytxt -= 11

    # ----------------- CLAPBACK SECTION -----------------
    ytxt -= 10
    c.setFont("PlayfairBold", 18)
    c.setFillColor(PRIMARY)
    if ytxt < 60:
        c.showPage()
        W2, H2 = letter
        ytxt = H2 - 60
    c.drawString(40, ytxt, "Clapback Shade Report")
    ytxt -= 18

    c.setFont("Geneva", 10)
    c.setFillColor(BLACK)
    for line in wrap_paragraph(clapback_text, width=95):
        if ytxt < 60:
            c.showPage()
            W2, H2 = letter
            c.setFont("Geneva", 10)
            ytxt = H2 - 60
        c.drawString(40, ytxt, line)
        ytxt -= 11

    # ----------------- TRANSCRIPT -----------------
    ytxt -= 10
    c.setFont("PlayfairBold", 18)
    c.setFillColor(PRIMARY)
    if ytxt < 60:
        c.showPage()
        W2, H2 = letter
        ytxt = H2 - 60
    c.drawString(40, ytxt, "Transcript")
    ytxt -= 18

    c.setFont("Geneva", 9)
    c.setFillColor(BLACK)
    for line in wrap_paragraph(transcript, width=100):
        if ytxt < 50:
            c.showPage()
            W2, H2 = letter
            c.setFont("Geneva", 9)
            ytxt = H2 - 60
        c.drawString(40, ytxt, line)
        ytxt -= 10

    # footer on last page
    c.setStrokeColor(LIGHT_GRAY)
    c.line(40, 40, W - 40, 40)
    c.setFont("Geneva", 9)
    c.drawString(40, 28, "© 2025 Tour de Fierce — All Shade, No Shame.")
    c.drawString(300, 28, "www.tourdefierce.vip")

    c.save()
    buffer.seek(0)

    fname = f"clapback-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}.pdf"
    tmp = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{fname}")
    tmp.write(buffer.getvalue())
    tmp.close()
    return tmp.name


# ---------------------------------------------------------
# MAIN ANALYSIS PIPELINE
# ---------------------------------------------------------
def run_analysis(audio_file):
    if not audio_file:
        return (
            "No audio file uploaded.",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            None,
        )

    # load
    y, sr = librosa.load(audio_file, sr=16000, mono=True)

    # transcription
    try:
        text = asr_pipe({"array": y, "sampling_rate": sr})["text"]
    except Exception:
        text = "[Transcription unavailable]"

    # core metrics
    autotune_idx = compute_autotune_index(y, sr)
    polish_idx = compute_production_polish(y, sr)

    emb = extract_embeddings(y, sr)
    ai_prob = calculate_ai_probability(emb, y, sr, autotune_idx)
    human_prob = 1.0 - ai_prob

    ai_pct = ai_prob * 100.0
    human_pct = human_prob * 100.0

    shade = compute_shade_score(ai_pct, autotune_idx, polish_idx)
    key_sig = detect_key(y, sr)
    bpm = detect_bpm(y, sr)
    voice_text = estimate_voice_type(y, sr)

    scientific_text = build_scientific_analysis(
        ai_pct, human_pct, autotune_idx, shade, key_sig, bpm, polish_idx
    )
    clapback_text = build_clapback(
        ai_pct, human_pct, autotune_idx, shade, key_sig, bpm, voice_text
    )

    clip_title = os.path.basename(audio_file)

    pdf_path = make_pdf(
        ai_pct,
        human_pct,
        autotune_idx,
        shade,
        key_sig,
        bpm,
        text,
        scientific_text,
        clapback_text,
        clip_title,
        polish_idx,
    )

    return (
        text,
        f"{ai_pct:.1f}%",
        f"{human_pct:.1f}%",
        f"{autotune_idx:.1f}",
        f"{shade:.1f}",
        key_sig,
        f"{bpm:.1f}",
        voice_text,
        scientific_text,
        clapback_text,
        pdf_path,
    )


# --------------------------------------------------------------
# UI
# --------------------------------------------------------------
with gr.Blocks() as demo:
    gr.HTML(
        """
        <div style='text-align:center; padding:20px;'>
            <h1 style='font-size:36px; font-weight:800;'>
                👋 Tour de Fierce Audio Clapback Engine™
            </h1>
            <p style='color:#ccc;'>
                AI Detector • Autotune Detector • Key & BPM • Forensic Reporting
            </p>
        </div>
        """
    )

    with gr.Row():
        audio_in = gr.Audio(type="filepath", label="Upload audio")
        run_btn = gr.Button("Run Clapback 👏", variant="primary")

    with gr.Row():
        transcript = gr.Textbox(
            label="Transcript",
            interactive=False,
            lines=5,
            show_label=True,
        )

    with gr.Row():
        ai_out = gr.Textbox(label="AI Likelihood", interactive=False)
        human_out = gr.Textbox(label="Human Likelihood", interactive=False)
        atune_out = gr.Textbox(label="Autotune Index", interactive=False)

    with gr.Row():
        shade_out = gr.Textbox(label="Shade Meter", interactive=False)
        key_out = gr.Textbox(label="Key Signature", interactive=False)
        bpm_out = gr.Textbox(label="Tempo (BPM)", interactive=False)
        voice_out = gr.Textbox(label="Suggested Voice Type", interactive=False)

    with gr.Row():
        forensic_out = gr.Textbox(
            label="Technical Forensic Analysis",
            interactive=False,
            lines=12,
        )
        clapback_out = gr.Textbox(
            label="Clapback Shade Report",
            interactive=False,
            lines=12,
        )

    pdf_download = gr.File(label="Download Report")

    run_btn.click(
        fn=run_analysis,
        inputs=audio_in,
        outputs=[
            transcript,
            ai_out,
            human_out,
            atune_out,
            shade_out,
            key_out,
            bpm_out,
            voice_out,
            forensic_out,
            clapback_out,
            pdf_download,
        ],
    )

demo.launch()