""" 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="= 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(" 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)