File size: 36,635 Bytes
842396d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
"""
Melodimodell 67M – Hugging Face Space

Modellen arbetar på byte-nivå: varje token är en byte i UTF-8-texten, precis som när
du skickar "prompt": list(text.encode()) till llama-server. Här körs samma GGUF-fil
direkt i Space:t med llama-cpp-python, så ingen separat server behövs.

Notformat: [oktav][notnamn][volym][notlängd][tidstämpel], t.ex. 4G5d21
  • varje rad = 1 sekund, tidstämpel 0–59 i steg om 1/60 s
  • notnamn C I D J E F K G L A H B = C C# D D# E F F# G G# A A# B
  • volym 0–10, notlängd a–i = 1, 2, 4 … 256 × 33 ms
  • "*" skiljer låtar åt, så "*\n" betyder att en ny låt börjar

Miljövariabler (Settings → Variables and secrets i Space:t):
  MODEL_REPO, MODEL_FILE  vilken GGUF-fil som laddas ner
  MODEL_PATH              lokal GGUF-fil i stället för nedladdning
  N_CTX                   kontextlängd (standard 8192, som -c 8192)
  MAX_SECONDS             tak för reglaget "Sekunder ny musik"
  N_THREADS               antal CPU-trådar (annars CPU-kvoten, högst 4, eller 2)
  SOUNDFONT               sökväg till en .sf2-fil för FluidSynth
"""

import atexit
import codecs
import glob
import inspect
import os
import queue
import random
import re
import shutil
import subprocess
import tempfile
import threading
import time
import traceback
import wave
from collections import defaultdict
from functools import lru_cache
from pathlib import Path
from statistics import median

try:  # finns på ZeroGPU-Spaces och ska importeras före gradio
    import spaces
except ImportError:
    spaces = None

import gradio as gr
import mido
import numpy as np
from matplotlib.collections import PolyCollection
from matplotlib.figure import Figure
from matplotlib.patches import Patch

# ------------------------------------------------------------
#  Modell och körning
# ------------------------------------------------------------
MODEL_REPO = os.environ.get("MODEL_REPO", "qwrt/Melodimodell-67M")
MODEL_FILE = os.environ.get("MODEL_FILE", "midi-67m-f16.gguf")
MODEL_PATH = os.environ.get("MODEL_PATH", "")
N_CTX = int(os.environ.get("N_CTX", "8192"))
MAX_SECONDS = int(os.environ.get("MAX_SECONDS", "120"))
KEEP_FRACTION = 0.5  # andel av kontexten som behålls när den blir full

# ------------------------------------------------------------
#  Notformatet (samma konstanter som i dina konverteringsskript)
# ------------------------------------------------------------
STAMP_SPAN_MS = 1_000
TAKTER_PER_STAMP = 60
TACKT_MS = STAMP_SPAN_MS / TAKTER_PER_STAMP
BASE_DUR_MS = 33
VELOCITY_SCALE = 12.8
TEMPO_US_PER_BEAT = 500_000
TICKS_PER_BEAT = 480

NOTE_MAP = {
    "C": 0, "I": 1, "D": 2, "J": 3, "E": 4, "F": 5,
    "K": 6, "G": 7, "L": 8, "A": 9, "H": 10, "B": 11,
}
MIDI_TO_NOTE = {v: k for k, v in NOTE_MAP.items()}
DURATION_MAP = {"a": 1, "b": 2, "c": 4, "d": 8, "e": 16, "f": 32, "g": 64, "h": 128, "i": 256}
TOKEN_RE = re.compile(r"(\d+)([A-L])(\d+)([a-i])(\d+)")

# MIDI -> text, samma val som i convert_midi_to_custom_string.py
FILTER_DRUMS = True
FILTER_NON_PIANO = False
MAX_CHORD_NOTES = 10
MIN_VOLUME = 0
MAX_OCTAVE = 6

EXAMPLE_PROMPT = """*
4D4d0 4D4c18 4D4d27 4D4c45 4D4d54
4D4c12 4D4c21 4D4c30 4D4c39 4D4d48
4D4c6 4D4d15 4D4c33 4D4d42 4D4c59
4D4c9 4D4c18 4D4c27 4D4d36 2D4f36 1D4f36 4D4c54
4D4d3 4D4c21 4D4d30 4H4d30 2D4d30 1D4d30 4D4c48 4D4c57 4A4d57 2D4d57 1D4d57
3A4c6 4A4d10 4C4c15 4D4d24 3A4d24 3F4d24 3D4d24 2D4d24 4D4d42 3A4d42 3F4d42 3D4c42 2D4c42 3D4d51 2D4d51 4D4c59 3A4c59 3F4c59
4E4c9 4C4c9 3A4c9 3C4c9 2C4c9 4F4d18 4D4d18 3H4d18 2H4d18 1H4d18 4F4d36 4D4d36 3H4d36 2H4c36 1H4c36 2H4d45 1H4d45 4F4c54 4D4c54 3H4c54
4G4c3 4D4c3 2H4c3 1H4c3 4E4d12 4C4d12 3A4d12 2A4d12 1A4d12 4E4d30 4C4d30 3A4d30 2A4c30 1A4c30 2A4d39 1A4d39 4D4c48 3A4c48 4C4c57 3G4c57 2A4c57 1A4c57
4C4c6 3A4c6 3D4d6 2D4d6 4D4d15 3A4d15 3D4c24 2D4c24 3D4d33 2D4d33 3A4c42 4C4c51 3D4c51 2D4c51
4D4d0 3H4d0 3F4d0 2H4d0 1H4d0 4D4d18 3H4d18 3F4d18 2H4c18 1H4c18 2H4d27 1H4d27 4D4c36 3H4c36 4E4c45 3H4c45 2H4c45 1H4c45 4F4d54 4C4d54 3A4d54 2H4d54 1H4d54
4F4d12 4C4d12 3A4d12 2H4c12 1H4c12 2H4d21 1H4d21 4F4c30 4C4c30 4G4c39 4C4c39 2H4c39 1H4c39 4E4d48 4C4d48 3A4d48 2A4d48 1A4d48
4E4d6 4C4d6 3A4d6 2A4c6 1A4c6 2A4d15 1A4d15 4D4c24 3A4c24 4C4c33 3G4c33 2A4c33 1A4c33 4D4d42 3A4d42 3F4d42 3D4d42 2D4d42 3D4c59 2D4c59
3D4d9 2D4d9 3A4c18 4C4c27 3D4c27 2D4c27 4D4d36 3A4d36 3F4d36 3D4d36 2D4d36 4D4d54 3A4d54 3F4d54 3D4c54 2D4c54
3D4d3 2D4d3 4D4c12 3A4c12 4F4c21 3A4c21 3D4c21 2D4c21 4G4d30 4D4d30 3H4d30 2H4d30 1H4d30 4G4d48 4D4d48 3H4d48 2H4c48 1H4c48 2H4d57 1H4d57
4G4c6 4D4c6 4A4c15 4D4c15 2H4c15 1H4c15 4H4d24 4G4d24 4D4d24 2G4d24 1G4d24 4H4d42 4G4d42 4D4d42 2G4c42 1G4c42 2G4d51 1G4d51 4A4c59 4F4c59
4G4c9 4E4c9 2G4c9 1G4c9 4A4c18 4F4c18 3D4d18 2D4d18 4D4d27 3D4c36 2D4c36 3D4d45 2D4d45 4D4c54"""


# ------------------------------------------------------------
#  Text -> noter -> MIDI
# ------------------------------------------------------------
def normalize_prompt(text):
    """Enhetliga radbrytningar, bara ASCII och ett mellanslag mellan tokens."""
    text = (text or "").replace("\r\n", "\n").replace("\r", "\n")
    text = text.encode("ascii", "ignore").decode("ascii")
    text = "\n".join(" ".join(line.split()) for line in text.split("\n")).lstrip("\n")
    return text if text.strip() else "*\n"


def parse_notes(text, prompt_len=0):
    """Tolkar texten som parse_custom gör, men hoppar över trasiga tokens i stället för att krascha.

    Returnerar ([(start_ms, pitch, velocity, duration_ms, från_prompten)], antal_ogiltiga).
    """
    text = text.replace("*", " ")  # samma längd, så teckenpositionerna stämmer
    pos = len(text) - len(text.lstrip())  # som .strip(): inledande tomrader räknas inte
    events, invalid = [], 0
    for stamp, line in enumerate(text[pos:].split("\n")):
        for m in re.finditer(r"\S+", line):
            tok = TOKEN_RE.fullmatch(m.group())
            if not tok:
                invalid += 1
                continue
            octave, letter, vol, dur, takt = tok.groups()
            pitch = NOTE_MAP[letter] + (int(octave) + 1) * 12
            velocity = min(127, int(round(int(vol) * VELOCITY_SCALE)))
            if pitch > 127:
                invalid += 1
                continue
            if velocity == 0:  # volym 0 hörs inte
                continue
            start_ms = stamp * STAMP_SPAN_MS + int(takt) * TACKT_MS
            events.append((start_ms, pitch, velocity, DURATION_MAP[dur] * BASE_DUR_MS,
                           pos + m.start() < prompt_len))
        pos += len(line) + 1
    return events, invalid


def build_midi(events, out_path):
    """Ett spår med piano. Överlappande noter på samma tangent kapas, så att en
    note_off aldrig stänger av en senare not, och note_off kommer före note_on."""
    per_pitch = defaultdict(list)
    for start, pitch, velocity, duration, *_ in events:
        per_pitch[pitch].append((start, start + duration, velocity))

    messages = []
    for pitch, notes in per_pitch.items():
        notes.sort()
        for i, (start, end, velocity) in enumerate(notes):
            if i + 1 < len(notes):
                end = min(end, notes[i + 1][0])
            if end - start < 1:  # samma tangent två gånger på samma tid
                continue
            messages.append((start, 1, pitch, velocity))
            messages.append((end, 0, pitch, 0))
    messages.sort(key=lambda m: (m[0], m[1]))

    mid = mido.MidiFile(ticks_per_beat=TICKS_PER_BEAT)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=TEMPO_US_PER_BEAT, time=0))
    track.append(mido.Message("program_change", program=0, time=0))
    last_tick = 0
    for ms, is_on, pitch, velocity in messages:
        tick = int(round(mido.second2tick(ms / 1000, TICKS_PER_BEAT, TEMPO_US_PER_BEAT)))
        track.append(mido.Message("note_on" if is_on else "note_off", note=pitch,
                                  velocity=velocity, time=max(0, tick - last_tick)))
        last_tick = max(last_tick, tick)
    mid.save(str(out_path))


# ------------------------------------------------------------
#  MIDI -> text (din convert_midi_to_custom_string, något förenklad)
# ------------------------------------------------------------
def midi_to_events(path):
    """MIDI -> [(start_ms, pitch, velocity, duration_ms)]. mido räknar om ticks till
    sekunder och tar hänsyn till alla tempobyten."""
    mid = mido.MidiFile(path, clip=True)
    programs, active, events, now = {}, {}, [], 0.0
    for msg in mid:  # alla spår sammanslagna, msg.time = sekunder sedan förra meddelandet
        now += msg.time
        if msg.type == "program_change":
            programs[msg.channel] = msg.program
            continue
        if msg.type not in ("note_on", "note_off"):
            continue
        if FILTER_DRUMS and msg.channel == 9:
            continue
        if FILTER_NON_PIANO and programs.get(msg.channel, 0) > 7:
            continue
        key = (msg.note, msg.channel)
        if key in active:  # note_off, eller ett nytt anslag på en tangent som redan låter
            start, velocity = active.pop(key)
            events.append((round(start * 1000, 3), msg.note, velocity, (now - start) * 1000))
        if msg.type == "note_on" and msg.velocity > 0:
            active[key] = (now, msg.velocity)
    for (note, _), (start, velocity) in active.items():
        events.append((round(start * 1000, 3), note, velocity, (now - start) * 1000))
    # Avrundningen tar bort flyttalsbrus från summeringen ovan, annars kan en not
    # på exakt 9000 ms hamna på 8999.9999 ms, alltså sista takten på raden innan.
    return sorted(events, key=lambda e: e[0])


def remove_duplicate_notes(events):
    best = {}
    for ev in events:
        key = (round(ev[0], 1), ev[1])
        if key not in best or ev[2] > best[key][2]:
            best[key] = ev
    return sorted(best.values(), key=lambda e: e[0])


def filter_large_chords(events, max_simultaneous):
    groups = defaultdict(list)
    for ev in events:
        groups[round(ev[0], 1)].append(ev)
    kept = []
    for group in groups.values():
        if len(group) > max_simultaneous:
            middle = median(e[1] for e in group)
            group = sorted(group, key=lambda e: abs(e[1] - middle))[:max_simultaneous]
        kept.extend(group)
    return sorted(kept, key=lambda e: e[0])


def duration_to_letter(duration_ms):
    units = duration_ms / BASE_DUR_MS
    return min(DURATION_MAP, key=lambda letter: abs(units - DURATION_MAP[letter]))


def events_to_text(events):
    stamps = defaultdict(list)
    for start_ms, pitch, velocity, duration_ms in events:
        octave = pitch // 12 - 1
        volume = max(0, min(10, int(round(velocity / VELOCITY_SCALE))))
        if octave < 0 or octave > MAX_OCTAVE or volume <= MIN_VOLUME:
            continue
        stamp = int(start_ms // STAMP_SPAN_MS)
        takt = max(0, min(59, int(round((start_ms - stamp * STAMP_SPAN_MS) / TACKT_MS))))
        token = f"{octave}{MIDI_TO_NOTE[pitch % 12]}{volume}{duration_to_letter(duration_ms)}{takt}"
        if token not in stamps[stamp]:
            stamps[stamp].append(token)
    if not stamps:
        return ""
    return "\n".join(" ".join(stamps.get(i, [])) for i in range(max(stamps) + 1))


def midi_to_text(path):
    events = remove_duplicate_notes(midi_to_events(path))
    events = [e for e in events if e[1] >= 12]  # negativ oktav bort
    if MAX_CHORD_NOTES > 0:
        events = filter_large_chords(events, MAX_CHORD_NOTES)
    return events_to_text(events)


# ------------------------------------------------------------
#  Ljud: FluidSynth om det finns (packages.txt), annars en enkel syntes
# ------------------------------------------------------------
AUDIO_SR = 22050
FLUIDSYNTH = shutil.which("fluidsynth")


def _find_soundfont():
    candidates = [
        os.environ.get("SOUNDFONT", ""),
        "/usr/share/sounds/sf2/FluidR3_GM.sf2",
        "/usr/share/soundfonts/FluidR3_GM.sf2",
        "/usr/share/sounds/sf2/default-GM.sf2",
        "/usr/share/soundfonts/default.sf2",
    ]
    candidates += sorted(glob.glob("/usr/share/sounds/sf2/*.sf2"))
    candidates += sorted(glob.glob("/usr/share/soundfonts/*.sf2"))
    return next((p for p in candidates if p and os.path.isfile(p)), None)


SOUNDFONT = _find_soundfont()


def _render_fluidsynth(midi_path):
    raw_path = Path(midi_path).with_suffix(".f32")
    cmd = [FLUIDSYNTH, "-ni", "-g", "0.6", "-r", str(AUDIO_SR), "-T", "raw", "-O", "float",
           "-F", str(raw_path), SOUNDFONT, str(midi_path)]
    subprocess.run(cmd, check=True, timeout=300,
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    samples = np.fromfile(raw_path, dtype="<f4")
    raw_path.unlink(missing_ok=True)
    if samples.size < 2:
        raise RuntimeError("FluidSynth gav inget ljud")
    return samples[: samples.size // 2 * 2].reshape(-1, 2)  # stereo


@lru_cache(maxsize=256)
def _piano_tone(pitch, duration_ms):
    """En enkel pianolik ton: några övertoner som klingar av, mjuk attack och släpp."""
    f0 = 440.0 * 2 ** ((pitch - 69) / 12)
    hold = duration_ms / 1000
    n = int((hold + 0.3) * AUDIO_SR)
    t = np.arange(n, dtype=np.float32) / AUDIO_SR
    tau = float(np.clip(1.4 * (261.6 / f0) ** 0.5, 0.25, 3.0))  # mörka toner klingar längre
    tone = np.zeros(n, dtype=np.float32)
    for k, amp in enumerate((1.0, 0.5, 0.28, 0.14, 0.07), start=1):
        if k * f0 >= AUDIO_SR / 2:
            break
        tone += amp * np.sin(2 * np.pi * k * f0 * t) * np.exp(-t * k / tau)
    envelope = np.minimum(1.0, t / 0.005)
    released = t > hold
    envelope[released] *= np.exp(-(t[released] - hold) / 0.08)
    return (tone * envelope).astype(np.float32)


def _render_simple(events):
    end_s = max(e[0] + e[3] for e in events) / 1000 + 0.5
    buf = np.zeros(int(end_s * AUDIO_SR) + 1, dtype=np.float32)
    for start, pitch, velocity, duration, *_ in events:
        tone = _piano_tone(pitch, int(duration))
        i = int(start / 1000 * AUDIO_SR)
        j = min(len(buf), i + len(tone))
        buf[i:j] += tone[: j - i] * (velocity / 127)
    return buf


def _write_wav(path, samples):
    peak = float(np.max(np.abs(samples))) if samples.size else 0.0
    if peak > 0:
        samples = samples * (0.89 / peak)
    pcm = (samples * 32767).astype("<i2")
    with wave.open(str(path), "wb") as w:
        w.setnchannels(1 if pcm.ndim == 1 else pcm.shape[1])
        w.setsampwidth(2)
        w.setframerate(AUDIO_SR)
        w.writeframes(np.ascontiguousarray(pcm).tobytes())


def render_audio(midi_path, events, wav_path):
    samples = None
    if FLUIDSYNTH and SOUNDFONT:
        try:
            samples = _render_fluidsynth(midi_path)
        except Exception:
            traceback.print_exc()
    if samples is None:
        samples = _render_simple(events)
    _write_wav(wav_path, samples)
    return str(wav_path)


# ------------------------------------------------------------
#  Pianorulle
# ------------------------------------------------------------
PROMPT_RGB = (0.55, 0.61, 0.71)  # filtgrå: det du gav modellen
NEW_RGB = (0.91, 0.64, 0.24)  # mässing: det modellen skrev
MUTED = "#8a8f98"


def piano_roll(events):
    fig = Figure(figsize=(10, 3.8), dpi=110)
    fig.patch.set_alpha(0)
    ax = fig.add_subplot(111)
    ax.set_facecolor((0, 0, 0, 0))
    if not events:
        ax.text(0.5, 0.5, "Inga noter att visa", ha="center", va="center",
                color=MUTED, transform=ax.transAxes)
        ax.set_axis_off()
        return fig

    verts, colors = [], []
    for start, pitch, velocity, duration, from_prompt in events:
        x0, x1 = start / 1000, (start + duration) / 1000
        verts.append(((x0, pitch - 0.42), (x1, pitch - 0.42), (x1, pitch + 0.42), (x0, pitch + 0.42)))
        colors.append((*(PROMPT_RGB if from_prompt else NEW_RGB), 0.35 + 0.65 * velocity / 127))
    ax.add_collection(PolyCollection(verts, facecolors=colors, edgecolors="none"))

    low = min(e[1] for e in events) - 2
    high = max(e[1] for e in events) + 2
    ax.set_xlim(0, max(1.0, max(e[0] + e[3] for e in events) / 1000))
    ax.set_ylim(low, high)
    c_notes = [p for p in range(low, high + 1) if p % 12 == 0]
    ax.set_yticks(c_notes)
    ax.set_yticklabels([f"{p // 12 - 1}C" for p in c_notes])  # samma skrivsätt som formatet
    ax.set_xlabel("Sekunder", color=MUTED)
    ax.tick_params(colors=MUTED, labelsize=8)
    for side, spine in ax.spines.items():
        spine.set_visible(side == "bottom")
        spine.set_color(MUTED)
    ax.grid(axis="y", color=MUTED, alpha=0.15, linewidth=0.6)

    new_starts = [e[0] for e in events if not e[4]]
    handles = []
    if len(new_starts) < len(events):
        handles.append(Patch(color=PROMPT_RGB, label="Prompt"))
    if new_starts:
        handles.append(Patch(color=NEW_RGB, label="Ny musik"))
        if len(new_starts) < len(events):
            ax.axvline(min(new_starts) / 1000, color=MUTED, linewidth=1, linestyle=(0, (3, 3)))
    ax.legend(handles=handles, loc="upper right", frameon=False, fontsize=8, labelcolor=MUTED)
    fig.subplots_adjust(left=0.06, right=0.99, top=0.96, bottom=0.14)
    return fig


# ------------------------------------------------------------
#  Modellen
# ------------------------------------------------------------
def _cpu_count():
    """Antal trådar till llama.cpp och varifrån siffran kommer.

    Gratis-Spaces har 2 vCPU, men os.cpu_count() och sched_getaffinity visar ofta hela
    värddatorn (t.ex. 16). Fler trådar än kärnor gör llama.cpp mycket långsammare, så
    om CPU-kvoten inte går att läsa används 2. Sätt N_THREADS för större hårdvara.
    """
    if os.environ.get("N_THREADS"):
        return max(1, int(os.environ["N_THREADS"])), "N_THREADS"
    try:  # cgroup v2
        quota, period = Path("/sys/fs/cgroup/cpu.max").read_text().split()[:2]
        if quota != "max":
            return max(1, min(4, round(int(quota) / int(period)))), "CPU-kvot"
    except (OSError, ValueError):
        pass
    try:  # cgroup v1
        quota = int(Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").read_text())
        period = int(Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read_text())
        if quota > 0 and period > 0:
            return max(1, min(4, round(quota / period))), "CPU-kvot"
    except (OSError, ValueError):
        pass
    return 2, "standard, CPU-kvoten gick inte att läsa"


def load_model():
    from llama_cpp import Llama

    path = MODEL_PATH
    if not path:
        from huggingface_hub import hf_hub_download

        path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE)
    threads, source = _cpu_count()
    print(f"Laddar {path} med n_ctx={N_CTX} och {threads} trådar ({source})", flush=True)
    return Llama(model_path=path, n_ctx=N_CTX, n_batch=512, n_threads=threads,
                 n_threads_batch=threads, verbose=os.environ.get("LLAMA_VERBOSE") == "1")


# ZeroGPU, som är gratisalternativet för Gradio-Spaces, startar bara appar som har
# minst en funktion med @spaces.GPU. Modellen körs på CPU, så funktionen nedan anropas
# aldrig och drar ingen GPU-tid. På vanlig CPU-hårdvara och lokalt gör den ingenting.
if spaces is not None:
    @spaces.GPU
    def _zerogpu_placeholder():
        return None


LLM, LOAD_ERROR = None, ""
try:
    LLM = load_model()
except Exception as exc:  # visa felet i gränssnittet i stället för att krascha
    LOAD_ERROR = f"{type(exc).__name__}: {exc}"
    traceback.print_exc()
CTX_LIMIT = LLM.n_ctx() if LLM is not None else N_CTX


# ------------------------------------------------------------
#  Generering. All kontakt med modellen sker i en och samma tråd; Gradio-
#  funktionen läser bara tokens från en kö och uppdaterar gränssnittet.
# ------------------------------------------------------------
JOBS = queue.Queue()
_SESSION_STOPS = {}  # session -> stop-event för Stoppa-knappen
_LATEST = {"stop": None}  # stop-event för det senast startade jobbet


def _slide(ctx):
    """Behåll slutet av kontexten och börja helst vid en ny rad (= hel sekund)."""
    keep = max(1, int(CTX_LIMIT * KEEP_FRACTION))
    if len(ctx) <= keep:
        return ctx
    tail = ctx[-keep:]
    for sep in (10, 32):  # "\n", annars " "
        if sep in tail:
            return tail[tail.index(sep) + 1:]
    return tail


def _run_job(prompt, opts, out_q, stop_ev):
    reason = "eos"
    try:
        if stop_ev.is_set():
            reason = "stopped"
            return
        out_q.put(("started", None))
        ctx = list(prompt.encode("utf-8"))  # samma som "prompt": list(text.encode())
        if len(ctx) > CTX_LIMIT - 256:
            ctx = _slide(ctx)
        # Slutar prompten mitt på en rad avslutar det första "\n" bara den raden.
        newlines_left = opts["seconds"] + (0 if prompt.endswith("\n") else 1)
        eos = LLM.token_eos()
        window = 0
        while True:
            LLM.set_seed((opts["seed"] + window) % 0xFFFFFFFF)
            window += 1
            stream = LLM.generate(
                list(ctx),  # kopia: ctx byggs på nedan medan generatorn körs
                temp=opts["temperature"],
                top_k=opts["top_k"],
                top_p=opts["top_p"],
                min_p=opts["min_p"],
                repeat_penalty=opts["repeat_penalty"],
            )
            context_full = False
            try:
                for tok in stream:
                    if stop_ev.is_set():
                        reason = "stopped"
                        return
                    if tok == eos or not 0 <= tok < 256:
                        reason = "eos"
                        return
                    if tok == 42 and opts["stop_at_star"]:  # "*": nästa låt börjar
                        reason = "star"
                        return
                    ctx.append(tok)
                    out_q.put(("tok", tok))
                    if tok == 10:
                        newlines_left -= 1
                        if newlines_left <= 0:
                            reason = "done"
                            return
                    if len(ctx) >= CTX_LIMIT:
                        context_full = True
                        break
            finally:
                stream.close()
            if not context_full:
                return
            # Kontexten är full: modellen får se slutet och skriver vidare.
            # Hela texten finns ändå kvar hos Gradio-funktionen.
            ctx = _slide(ctx)
            out_q.put(("slide", None))
    except Exception as exc:
        traceback.print_exc()
        reason = "error"
        out_q.put(("error", f"{type(exc).__name__}: {exc}"))
    finally:
        out_q.put(("done", reason))


_BUSY = threading.Lock()  # hålls medan modellen genererar


def _model_loop():
    while True:
        job = JOBS.get()
        with _BUSY:
            _run_job(*job)


threading.Thread(target=_model_loop, name="llm", daemon=True).start()


@atexit.register
def _close_model():
    """Frigör modellen innan Python river ner sina moduler. Annars försöker
    llama-cpp-python göra det för sent och skriver ut ett ofarligt men
    förvirrande TypeError när Space:t stängs av eller startas om."""
    if LLM is None:
        return
    if _LATEST["stop"] is not None:
        _LATEST["stop"].set()
    if _BUSY.acquire(timeout=5):  # vänta tills en ev. pågående generering har stannat
        try:
            LLM.close()
        except Exception:
            pass

OUT_ROOT = Path(tempfile.gettempdir()) / "melodimodell"


def _new_output_dir():
    OUT_ROOT.mkdir(parents=True, exist_ok=True)
    now = time.time()
    for old in OUT_ROOT.iterdir():  # städa bort resultat äldre än en timme
        try:
            if now - old.stat().st_mtime > 3600:
                shutil.rmtree(old, ignore_errors=True)
        except OSError:
            pass
    return Path(tempfile.mkdtemp(dir=OUT_ROOT))


def _session_key(request):
    return getattr(request, "session_hash", None) or "anon"


def _new_seconds(gen_text, prompt):
    return max(0, gen_text.count("\n") - (0 if prompt.endswith("\n") else 1))


def _drop_partial_token(text):
    """Tar bort en ev. halvskriven not i slutet."""
    if not text or text[-1] in " \n":
        return text
    return text[: max(text.rfind(" "), text.rfind("\n")) + 1]


FINAL_TEXT = {
    "done": "Klart.",
    "star": "Klart. Modellen avslutade låten.",
    "eos": "Klart. Modellen slutade skriva.",
    "stopped": "Stoppad.",
}


def generate_music(prompt, seconds, temperature, top_k, top_p, min_p,
                   repeat_penalty, seed, stop_at_star, request: gr.Request = None):
    if LLM is None:
        yield (prompt, f"Modellen kunde inte laddas ({LOAD_ERROR}). Läs loggen under Logs.",
               None, None, None)
        return

    prompt = normalize_prompt(prompt)
    try:
        seed = int(seed)
    except (TypeError, ValueError):
        seed = -1
    if seed < 0:
        seed = random.randint(0, 2**31 - 1)
    opts = {
        "seconds": int(seconds),
        "temperature": float(temperature),
        "top_k": int(top_k),
        "top_p": float(top_p),
        "min_p": float(min_p),
        "repeat_penalty": float(repeat_penalty),
        "seed": seed,
        "stop_at_star": bool(stop_at_star),
    }

    # Gradio kör bara en generering åt gången (concurrency_limit=1). Hänger ett
    # gammalt jobb kvar, t.ex. från en flik som stängdes mitt i, avbryts det här.
    if _LATEST["stop"] is not None:
        _LATEST["stop"].set()
    stop_ev = threading.Event()
    _LATEST["stop"] = stop_ev
    key = _session_key(request)
    _SESSION_STOPS[key] = stop_ev

    out_q = queue.Queue()
    JOBS.put((prompt, opts, out_q, stop_ev))

    decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
    pieces, reason, error, slides = [], None, "", 0
    t_start, last_ui = None, 0.0
    try:
        yield prompt, "Väntar på modellen …", None, None, None
        while reason is None:
            items = []
            try:
                items.append(out_q.get(timeout=0.25))
                while True:
                    items.append(out_q.get_nowait())
            except queue.Empty:
                pass
            for kind, value in items:
                if kind == "tok":
                    pieces.append(decoder.decode(bytes([value])))
                elif kind == "started":
                    t_start = time.time()
                elif kind == "slide":
                    slides += 1
                elif kind == "error":
                    error = value
                elif kind == "done":
                    reason = value
            if reason is None and t_start is not None and time.time() - last_ui > 0.3:
                last_ui = time.time()
                gen_text = "".join(pieces)
                rate = len(gen_text) / max(1e-6, last_ui - t_start)
                yield (prompt + gen_text,
                       f"Genererar … {_new_seconds(gen_text, prompt)} av {opts['seconds']} s "
                       f"ny musik ({rate:.0f} tecken per sekund).",
                       None, None, None)
    finally:
        stop_ev.set()
        if _SESSION_STOPS.get(key) is stop_ev:
            del _SESSION_STOPS[key]

    elapsed = time.time() - (t_start or time.time())
    gen_text = "".join(pieces)
    if reason != "done":
        gen_text = _drop_partial_token(gen_text)
    full_text = prompt + gen_text
    print(f"[generering] seed={seed} orsak={reason} nya_tecken={len(gen_text)} "
          f"tid={elapsed:.1f}s fönsterflytt={slides}", flush=True)

    head = f"Fel under genereringen: {error}." if reason == "error" else FINAL_TEXT.get(reason, "Klart.")
    try:
        events, invalid = parse_notes(full_text, prompt_len=len(prompt))
        out_dir = _new_output_dir()
        stem = f"melodi-seed{seed}"
        txt_path = out_dir / f"{stem}.txt"
        txt_path.write_text(full_text, encoding="utf-8")
        files, audio_path = [str(txt_path)], None
        if events:
            midi_path = out_dir / f"{stem}.mid"
            build_midi(events, midi_path)
            files.insert(0, str(midi_path))
            try:
                audio_path = render_audio(midi_path, events, out_dir / f"{stem}.wav")
            except Exception:
                traceback.print_exc()
        total_s = max((e[0] + e[3] for e in events), default=0) / 1000
        parts = [head, f"{_new_seconds(gen_text, prompt)} s ny musik på {elapsed:.0f} s. "
                       f"Hela låten är {total_s:.0f} s lång med {len(events)} noter. Seed {seed}."]
        if slides:
            parts.append(f"Kontexten blev full och flyttades fram {slides} "
                         f"{'gång' if slides == 1 else 'gånger'}.")
        if invalid:
            parts.append(f"{invalid} ogiltiga tokens hoppades över.")
        yield full_text, " ".join(parts), audio_path, piano_roll(events), files
    except Exception as exc:
        traceback.print_exc()
        yield full_text, f"{head} Kunde inte skapa MIDI eller ljud: {type(exc).__name__}: {exc}", None, None, None


def stop_generation(request: gr.Request = None):
    stop_ev = _SESSION_STOPS.get(_session_key(request))
    if stop_ev is not None:
        stop_ev.set()


def use_result_as_prompt(result, prompt):
    return result if (result or "").strip() else prompt


def midi_to_prompt(midi_file, seconds, prompt, status):
    path = midi_file if isinstance(midi_file, str) else getattr(midi_file, "name", None)
    if not path:
        return prompt, status
    try:
        text = midi_to_text(path)
    except Exception as exc:
        raise gr.Error(f"Kunde inte läsa MIDI-filen ({type(exc).__name__}: {exc}). Prova en annan fil.")
    lines = text.split("\n")
    first = next((i for i, line in enumerate(lines) if line.strip()), None)
    if first is None:
        raise gr.Error("Hittade inga noter att använda. Trummor och toner över oktav 6 tas bort.")
    chunk = lines[first:first + int(seconds)]
    new_prompt = "*\n" + "\n".join(chunk) + "\n"
    notes = sum(len(line.split()) for line in chunk)
    message = (f"Prompten är de första {len(chunk)} sekunderna av {Path(path).name}: "
               f"{notes} noter, {len(new_prompt)} tecken.")
    if len(new_prompt) > CTX_LIMIT - 256:
        message += " Den är längre än modellens kontext, så modellen ser bara slutet av den."
    return new_prompt, message


# ------------------------------------------------------------
#  Gränssnitt
# ------------------------------------------------------------
HEADER_MD = """
# Melodimodell 67M
En liten språkmodell som skriver musik som text, ett tecken i taget. Ge den en början och lyssna
på hur den fortsätter. Du kan skriva själv, ladda upp en MIDI-fil eller börja från noll.
"""

FORMAT_MD = """
Varje rad är en sekund. En not skrivs som `[oktav][not][volym][längd][tid]`, till exempel `4G5d21`:

| Del | Värden | I `4G5d21` |
|---|---|---|
| Oktav | 0–6, där `4C` är mittens C | `4` |
| Not | `C I D J E F K G L A H B` = C, C♯, D, D♯, E, F, F♯, G, G♯, A, A♯, B | `G` |
| Volym | 1–10 | `5` |
| Längd | `a`–`i` = 1, 2, 4 … 256 × 33 ms | `d` = 264 ms |
| Tid | 0–59, var i sekunden noten börjar, i steg om 1/60 s | `21` = 350 ms |

En rad med bara `*` betyder att en ny låt börjar. Därför börjar prompten med `*`.
"""

THEME = gr.themes.Soft(
    primary_hue=gr.themes.colors.amber,
    neutral_hue=gr.themes.colors.slate,
    font=[gr.themes.GoogleFont("Instrument Sans"), "ui-sans-serif", "system-ui", "sans-serif"],
    font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
)

CSS = """
#prompt textarea, #result textarea {
    font-family: var(--font-mono);
    font-size: 13px;
    line-height: 1.6;
}
"""


def _split_style_kwargs(**style):
    """Beroende på Gradio-version tas title/theme/css emot av gr.Blocks() eller av launch()."""
    blocks_params = inspect.signature(gr.Blocks.__init__).parameters
    launch_params = inspect.signature(gr.Blocks.launch).parameters
    blocks_kw = {k: v for k, v in style.items() if k in blocks_params}
    launch_kw = {k: v for k, v in style.items() if k not in blocks_kw and k in launch_params}
    return blocks_kw, launch_kw


BLOCKS_KW, LAUNCH_KW = _split_style_kwargs(title="Melodimodell 67M", theme=THEME, css=CSS)

with gr.Blocks(**BLOCKS_KW) as demo:
    gr.Markdown(HEADER_MD)
    if LOAD_ERROR:
        gr.Markdown(f"**Modellen kunde inte laddas:** `{LOAD_ERROR}`. "
                    "Kontrollera MODEL_REPO och MODEL_FILE och läs loggen under Logs.")

    with gr.Row(equal_height=False):
        with gr.Column(scale=5):
            prompt_box = gr.Textbox(
                label="Början på låten",
                info="Modellen fortsätter där texten slutar. En rad är en sekund.",
                value=EXAMPLE_PROMPT, lines=12, max_lines=12, elem_id="prompt",
            )
            with gr.Row():
                example_btn = gr.Button("Ladda exemplet", size="sm")
                scratch_btn = gr.Button("Börja från noll", size="sm")
            with gr.Accordion("Börja från en MIDI-fil", open=False):
                midi_in = gr.File(label="MIDI-fil", file_types=[".mid", ".midi"], type="filepath")
                midi_secs = gr.Slider(minimum=1, maximum=60, value=10, step=1,
                                      label="Sekunder från filen")
            seconds_in = gr.Slider(minimum=5, maximum=MAX_SECONDS, value=30, step=5,
                                   label="Sekunder ny musik")
            with gr.Accordion("Sampling", open=False):
                temperature_in = gr.Slider(minimum=0.1, maximum=1.5, value=0.9, step=0.05,
                                           label="Temperatur")
                with gr.Row():
                    top_k_in = gr.Slider(minimum=0, maximum=64, value=0, step=1,
                                         label="top_k", info="0 = av")
                    top_p_in = gr.Slider(minimum=0.1, maximum=1.0, value=1.0, step=0.01,
                                         label="top_p")
                with gr.Row():
                    min_p_in = gr.Slider(minimum=0.0, maximum=0.5, value=0.0, step=0.01,
                                         label="min_p")
                    repeat_in = gr.Slider(minimum=1.0, maximum=1.3, value=1.0, step=0.01,
                                          label="repeat_penalty")
                with gr.Row():
                    seed_in = gr.Number(value=-1, precision=0, label="Seed",
                                        info="-1 ger en ny slumpad seed varje gång")
                    star_in = gr.Checkbox(value=True, label="Sluta när låten tar slut (*)")
            with gr.Row():
                gen_btn = gr.Button("Generera musik", variant="primary")
                stop_btn = gr.Button("Stoppa", variant="stop")

        with gr.Column(scale=6):
            status_box = gr.Textbox(label="Status", interactive=False, lines=2, max_lines=4)
            audio_out = gr.Audio(label="Ljud", type="filepath", interactive=False)
            roll_out = gr.Plot(label="Pianorulle")
            files_out = gr.File(label="MIDI och text", file_count="multiple", interactive=False)
            result_box = gr.Textbox(label="Resultat", lines=12, max_lines=12,
                                    interactive=False, elem_id="result")
            continue_btn = gr.Button("Fortsätt från resultatet")

    with gr.Accordion("Om notformatet", open=False):
        gr.Markdown(FORMAT_MD)

    gen_inputs = [prompt_box, seconds_in, temperature_in, top_k_in, top_p_in,
                  min_p_in, repeat_in, seed_in, star_in]
    gen_outputs = [result_box, status_box, audio_out, roll_out, files_out]

    gen_btn.click(generate_music, gen_inputs, gen_outputs,
                  concurrency_limit=1, concurrency_id="model")
    continue_btn.click(use_result_as_prompt, [result_box, prompt_box], prompt_box).then(
        generate_music, gen_inputs, gen_outputs, concurrency_limit=1, concurrency_id="model")
    stop_btn.click(stop_generation, None, None)
    example_btn.click(lambda: EXAMPLE_PROMPT, None, prompt_box)
    scratch_btn.click(lambda: "*\n", None, prompt_box)
    midi_inputs = [midi_in, midi_secs, prompt_box, status_box]
    midi_in.change(midi_to_prompt, midi_inputs, [prompt_box, status_box])
    midi_secs.change(midi_to_prompt, midi_inputs, [prompt_box, status_box])

demo.queue(max_size=20)

if __name__ == "__main__":
    demo.launch(**LAUNCH_KW)