Download app.py from qwrt/Test: direct link, hf CLI and curl.
- Browser
- Download file 36.6 kB
-
https://huggingface.co/spaces/qwrt/Test/resolve/main/app.py
- Command line
-
hf download hf://spaces/qwrt/Test/app.py
-
curl -L -o app.py https://huggingface.co/spaces/qwrt/Test/resolve/main/app.py
36.6 kB
| """ | |
| 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 | |
| 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: | |
| 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() | |
| 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) | |