mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
Clean release snapshot
This commit is contained in:
+307
@@ -0,0 +1,307 @@
|
||||
"""Generate MIDI and render audio from a Guitar Pro file."""
|
||||
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.gp2midi")
|
||||
|
||||
import guitarpro
|
||||
from midiutil import MIDIFile
|
||||
|
||||
GP_TICKS_PER_QUARTER = 960
|
||||
|
||||
# Standard tuning MIDI values (GP string order: 1=high, 6=low)
|
||||
STANDARD_6 = [64, 59, 55, 50, 45, 40] # e B G D A E
|
||||
STANDARD_4 = [43, 38, 33, 28] # G D A E (bass)
|
||||
|
||||
|
||||
def gp_to_midi(gp_path: str, output_midi: str, track_indices: list[int] | None = None,
|
||||
force_standard_tuning: bool = False) -> str:
|
||||
"""Convert Guitar Pro file to MIDI.
|
||||
|
||||
Args:
|
||||
gp_path: Path to .gp5/.gp4/.gp3 file
|
||||
output_midi: Output .mid file path
|
||||
track_indices: Which tracks to include (None = all non-percussion)
|
||||
force_standard_tuning: If True, use E standard tuning for all instruments
|
||||
(keeps fret numbers, changes the pitch of open strings)
|
||||
|
||||
Returns:
|
||||
Path to the MIDI file
|
||||
"""
|
||||
song = guitarpro.parse(gp_path)
|
||||
|
||||
if track_indices is None:
|
||||
track_indices = list(range(len(song.tracks)))
|
||||
|
||||
midi = MIDIFile(
|
||||
len(track_indices),
|
||||
ticks_per_quarternote=GP_TICKS_PER_QUARTER,
|
||||
)
|
||||
|
||||
for midi_track_idx, gp_track_idx in enumerate(track_indices):
|
||||
track = song.tracks[gp_track_idx]
|
||||
is_perc = track.isPercussionTrack
|
||||
|
||||
# MIDI channel: percussion must be 9, others avoid 9
|
||||
if is_perc:
|
||||
channel = 9
|
||||
else:
|
||||
channel = midi_track_idx if midi_track_idx < 9 else midi_track_idx + 1
|
||||
channel = min(channel, 15)
|
||||
|
||||
midi.addTrackName(midi_track_idx, 0, track.name)
|
||||
midi.addTempo(midi_track_idx, 0, song.tempo)
|
||||
|
||||
# Instrument and volume from GP channel data
|
||||
gp_ch = track.channel
|
||||
if gp_ch and not is_perc:
|
||||
midi.addProgramChange(midi_track_idx, channel, 0, gp_ch.instrument)
|
||||
elif not is_perc:
|
||||
midi.addProgramChange(midi_track_idx, channel, 0, 29) # overdriven guitar
|
||||
|
||||
# Volume (CC7) and pan (CC10)
|
||||
if gp_ch:
|
||||
vol = min(127, gp_ch.volume)
|
||||
pan = min(127, gp_ch.balance)
|
||||
midi.addControllerEvent(midi_track_idx, channel, 0, 7, vol)
|
||||
midi.addControllerEvent(midi_track_idx, channel, 0, 10, pan)
|
||||
|
||||
# Tempo changes
|
||||
tempo_added = set()
|
||||
for measure in track.measures:
|
||||
for voice in measure.voices:
|
||||
for beat in voice.beats:
|
||||
if beat.effect and beat.effect.mixTableChange:
|
||||
mtc = beat.effect.mixTableChange
|
||||
if mtc.tempo and mtc.tempo.value > 0:
|
||||
tick_time = beat.start / GP_TICKS_PER_QUARTER
|
||||
if tick_time not in tempo_added:
|
||||
midi.addTempo(midi_track_idx, tick_time, mtc.tempo.value)
|
||||
tempo_added.add(tick_time)
|
||||
|
||||
# Notes
|
||||
for measure in track.measures:
|
||||
for voice in measure.voices:
|
||||
for beat in voice.beats:
|
||||
if not beat.notes:
|
||||
continue
|
||||
|
||||
beat_time = beat.start / GP_TICKS_PER_QUARTER
|
||||
|
||||
dur_quarters = 4.0 / beat.duration.value
|
||||
if beat.duration.isDotted:
|
||||
dur_quarters *= 1.5
|
||||
if beat.duration.tuplet.enters > 0 and beat.duration.tuplet.times > 0:
|
||||
dur_quarters *= beat.duration.tuplet.times / beat.duration.tuplet.enters
|
||||
|
||||
for note in beat.notes:
|
||||
if note.type == guitarpro.NoteType.rest:
|
||||
continue
|
||||
|
||||
if force_standard_tuning and not is_perc:
|
||||
num_strings = len(track.strings)
|
||||
std = STANDARD_4 if num_strings == 4 else STANDARD_6
|
||||
string_midi = std[note.string - 1] if note.string - 1 < len(std) else track.strings[note.string - 1].value
|
||||
else:
|
||||
string_midi = track.strings[note.string - 1].value
|
||||
pitch = string_midi + note.value
|
||||
|
||||
if note.type == guitarpro.NoteType.dead:
|
||||
dur_q = 0.05
|
||||
else:
|
||||
dur_q = dur_quarters
|
||||
|
||||
velocity = note.velocity
|
||||
if note.effect.ghostNote:
|
||||
velocity = max(20, velocity // 2)
|
||||
|
||||
# Skip invalid notes that would cause midiutil to crash
|
||||
if dur_q <= 0:
|
||||
dur_q = 0.05
|
||||
if pitch < 0 or pitch > 127:
|
||||
continue
|
||||
if velocity <= 0:
|
||||
velocity = 1
|
||||
|
||||
midi.addNote(
|
||||
midi_track_idx, channel,
|
||||
pitch, beat_time, dur_q, velocity,
|
||||
)
|
||||
|
||||
with open(output_midi, "wb") as f:
|
||||
try:
|
||||
midi.writeFile(f)
|
||||
except IndexError:
|
||||
# midiutil can crash with "pop from empty list" on malformed note events
|
||||
# Retry with deinterleave disabled
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
midi.close()
|
||||
midi.writeFile(f)
|
||||
|
||||
return output_midi
|
||||
|
||||
|
||||
def _find_soundfont() -> str | None:
|
||||
"""Locate a .sf2 soundfont for MIDI rendering.
|
||||
|
||||
Precedence:
|
||||
1. ``SLOPSMITH_SOUNDFONT`` env var (user override / desktop-app-supplied)
|
||||
2. Bundled ``<RESOURCESPATH>/soundfonts/*.sf2`` (Electron desktop builds)
|
||||
3. Common system locations per OS.
|
||||
"""
|
||||
override = os.environ.get("SLOPSMITH_SOUNDFONT")
|
||||
if override:
|
||||
if os.path.isfile(override):
|
||||
return override
|
||||
log.warning("SLOPSMITH_SOUNDFONT is set to %r but that file does not exist; falling back to other sources", override)
|
||||
|
||||
resources = os.environ.get("RESOURCESPATH")
|
||||
if resources:
|
||||
matches = sorted(glob.glob(os.path.join(resources, "soundfonts", "*.sf2")))
|
||||
if matches:
|
||||
return matches[0]
|
||||
|
||||
candidates: list[str] = []
|
||||
if sys.platform.startswith("linux"):
|
||||
candidates += [
|
||||
"/usr/share/soundfonts/FluidR3_GM.sf2",
|
||||
"/usr/share/soundfonts/FluidR3_GS.sf2",
|
||||
"/usr/share/soundfonts/default.sf2",
|
||||
"/usr/share/sounds/sf2/FluidR3_GM.sf2",
|
||||
"/usr/share/sounds/sf2/default-GM.sf2",
|
||||
]
|
||||
elif sys.platform == "darwin":
|
||||
candidates += [
|
||||
"/opt/homebrew/share/sounds/sf2/FluidR3_GM.sf2",
|
||||
"/opt/homebrew/share/soundfonts/FluidR3_GM.sf2",
|
||||
"/usr/local/share/sounds/sf2/FluidR3_GM.sf2",
|
||||
"/usr/local/share/soundfonts/FluidR3_GM.sf2",
|
||||
]
|
||||
elif sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata:
|
||||
# "Slopsmith" matches slopsmith-desktop's Electron productName
|
||||
# (app.getPath('userData') resolves to %APPDATA%\Slopsmith on Windows).
|
||||
for pattern in (
|
||||
os.path.join(appdata, "Slopsmith", "soundfonts", "*.sf2"),
|
||||
os.path.join(appdata, "SoundFonts", "*.sf2"),
|
||||
):
|
||||
candidates += sorted(glob.glob(pattern))
|
||||
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _soundfont_install_hint() -> str:
|
||||
if sys.platform.startswith("linux"):
|
||||
return (
|
||||
"Install a soundfont:\n"
|
||||
" Arch/Manjaro: sudo pacman -S soundfont-fluid\n"
|
||||
" Debian/Ubuntu: sudo apt install fluid-soundfont-gm\n"
|
||||
" Fedora: sudo dnf install fluid-soundfont-gm"
|
||||
)
|
||||
if sys.platform == "darwin":
|
||||
# Homebrew's fluid-synth formula doesn't bundle a soundfont; the user
|
||||
# needs to fetch one separately (confirmed 2026-04 against the
|
||||
# upstream formula).
|
||||
return (
|
||||
"Download a soundfont (e.g. GeneralUser GS from schristiancollins.com "
|
||||
"or FluidR3_GM from musical-artifacts.com) and either place the .sf2 "
|
||||
"file in /usr/local/share/sounds/sf2/ (Intel) or "
|
||||
"/opt/homebrew/share/sounds/sf2/ (Apple Silicon), or set the "
|
||||
"SLOPSMITH_SOUNDFONT environment variable to its full path."
|
||||
)
|
||||
if sys.platform == "win32":
|
||||
return (
|
||||
"Download a soundfont (e.g. GeneralUser GS from schristiancollins.com or "
|
||||
"FluidR3_GM from musical-artifacts.com) and either place the .sf2 file in "
|
||||
"%APPDATA%\\Slopsmith\\soundfonts\\ or set the SLOPSMITH_SOUNDFONT "
|
||||
"environment variable to its full path."
|
||||
)
|
||||
return "Set SLOPSMITH_SOUNDFONT to the full path of a .sf2 file."
|
||||
|
||||
|
||||
def _fluidsynth_install_hint() -> str:
|
||||
if sys.platform.startswith("linux"):
|
||||
return (
|
||||
"Install fluidsynth:\n"
|
||||
" Arch/Manjaro: sudo pacman -S fluidsynth\n"
|
||||
" Debian/Ubuntu: sudo apt install fluidsynth\n"
|
||||
" Fedora: sudo dnf install fluidsynth"
|
||||
)
|
||||
if sys.platform == "darwin":
|
||||
return "Install fluidsynth with Homebrew: brew install fluid-synth"
|
||||
if sys.platform == "win32":
|
||||
return (
|
||||
"Install fluidsynth (https://github.com/FluidSynth/fluidsynth/releases) and "
|
||||
"ensure fluidsynth.exe is on your PATH."
|
||||
)
|
||||
return "Install fluidsynth and ensure it is on PATH."
|
||||
|
||||
|
||||
def render_midi_to_audio(midi_path: str, output_path: str) -> str:
|
||||
"""Render MIDI to OGG audio using fluidsynth."""
|
||||
soundfont = _find_soundfont()
|
||||
if not soundfont:
|
||||
raise RuntimeError(
|
||||
"No soundfont found. " + _soundfont_install_hint()
|
||||
)
|
||||
|
||||
wav_path = output_path + ".wav"
|
||||
ogg_path = output_path + ".ogg"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["fluidsynth", "-ni", "-T", "wav", "-F", wav_path, "-r", "44100", soundfont, midi_path],
|
||||
capture_output=True, text=True, timeout=600,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError("fluidsynth not found. " + _fluidsynth_install_hint()) from e
|
||||
|
||||
if result.returncode != 0 or not os.path.exists(wav_path):
|
||||
raise RuntimeError(f"fluidsynth failed: {result.stderr[-300:]}")
|
||||
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", wav_path, "-q:a", "6", ogg_path],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
if result.returncode == 0 and os.path.exists(ogg_path):
|
||||
os.remove(wav_path)
|
||||
return ogg_path
|
||||
|
||||
return wav_path
|
||||
|
||||
|
||||
def gp_to_audio(gp_path: str, output_path: str,
|
||||
track_indices: list[int] | None = None,
|
||||
force_standard_tuning: bool = False) -> str:
|
||||
"""Convert Guitar Pro file directly to audio.
|
||||
|
||||
Args:
|
||||
gp_path: Path to .gp5 file
|
||||
output_path: Output audio file path (without extension)
|
||||
track_indices: Which tracks (None = all including drums)
|
||||
force_standard_tuning: Force E standard tuning (keeps frets, changes pitch)
|
||||
|
||||
Returns:
|
||||
Path to the audio file
|
||||
"""
|
||||
tmp_midi = tempfile.mktemp(suffix=".mid", prefix="rs_midi_")
|
||||
try:
|
||||
tuning_label = " (E Standard)" if force_standard_tuning else ""
|
||||
log.info("Generating MIDI from %s%s", Path(gp_path).name, tuning_label)
|
||||
gp_to_midi(gp_path, tmp_midi, track_indices, force_standard_tuning)
|
||||
log.info("Rendering audio with FluidSynth...")
|
||||
return render_midi_to_audio(tmp_midi, output_path)
|
||||
finally:
|
||||
if os.path.exists(tmp_midi):
|
||||
os.remove(tmp_midi)
|
||||
Reference in New Issue
Block a user