Refactor GUI and GitMIDI into separate files
This commit is contained in:
parent
20290a882f
commit
07001dbf91
896
git-sound.py
896
git-sound.py
@ -11,32 +11,15 @@ import sys
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pygame
|
from git_sound.gui import GitSoundWindow
|
||||||
import pygame.mixer
|
GUI_AVAILABLE = True
|
||||||
PYGAME_AVAILABLE = True
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
PYGAME_AVAILABLE = False
|
GUI_AVAILABLE = False
|
||||||
|
|
||||||
try:
|
|
||||||
import gi
|
|
||||||
gi.require_version('Gtk', '3.0')
|
|
||||||
|
|
||||||
from gi.repository import Gtk
|
|
||||||
from gi.repository import GLib
|
|
||||||
GTK_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
GTK_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
from midiutil.MidiFile import MIDIFile
|
|
||||||
from StringIO import StringIO
|
|
||||||
from time import sleep
|
|
||||||
from git import Repo
|
|
||||||
from git.objects.blob import Blob
|
|
||||||
from git.exc import InvalidGitRepositoryError
|
from git.exc import InvalidGitRepositoryError
|
||||||
|
|
||||||
|
from git_sound.gitmidi import GitMIDI
|
||||||
|
|
||||||
SCALES = {
|
SCALES = {
|
||||||
'c-major': ('C Major', [60, 62, 64, 65, 67, 69, 71]),
|
'c-major': ('C Major', [60, 62, 64, 65, 67, 69, 71]),
|
||||||
'a-harmonic-minor': ('A Harmonic Minor', [68, 69, 71, 72, 74, 76, 77]),
|
'a-harmonic-minor': ('A Harmonic Minor', [68, 69, 71, 72, 74, 76, 77]),
|
||||||
@ -116,756 +99,119 @@ PROGRAMS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_file_sha(commit, file_name):
|
parser = argparse.ArgumentParser(description='Voice of a Repo',
|
||||||
"""
|
epilog='Use the special value list for ' +
|
||||||
Get the SHA1 ID of a file by its name, in the given commit.
|
'scale and program to list the ' +
|
||||||
"""
|
'available program combinations')
|
||||||
|
|
||||||
elements = file_name.split(os.sep)
|
parser.add_argument('repository', type=str, nargs='?', default='.')
|
||||||
tree = commit.tree
|
parser.add_argument('--branch',
|
||||||
|
type=str,
|
||||||
while True:
|
default='master',
|
||||||
try:
|
help="The branch to generate sound for [master]")
|
||||||
tree = tree[elements.pop(0)]
|
parser.add_argument('--file',
|
||||||
except (KeyError, IndexError):
|
type=str,
|
||||||
# The file has been deleted, return the hash of an empty file
|
default=None,
|
||||||
return 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
|
help="Save the generated MIDI sequence to this file")
|
||||||
|
parser.add_argument('--play',
|
||||||
if isinstance(tree, Blob):
|
action='store_true',
|
||||||
break
|
default=False,
|
||||||
|
help="Play the generated file (requires pygame with " +
|
||||||
return tree.hexsha
|
"MIDI support)")
|
||||||
|
parser.add_argument('--verbose',
|
||||||
|
action='store_true',
|
||||||
class GitSoundWindow(object):
|
default=False,
|
||||||
"""
|
help="Print messages during execution")
|
||||||
GIU class for git-sound.
|
parser.add_argument('--scale',
|
||||||
"""
|
type=str,
|
||||||
|
default=None,
|
||||||
def __init__(self):
|
help="Scale to use in the generated track")
|
||||||
self.builder = Gtk.Builder()
|
parser.add_argument('--program',
|
||||||
self.builder.add_from_file('git-sound.ui')
|
type=str,
|
||||||
|
default=None,
|
||||||
self.win = self.builder.get_object('main-window')
|
help="Program setting to use in the generated track")
|
||||||
self.play_button = self.builder.get_object('play-button')
|
parser.add_argument('--volume-range',
|
||||||
self.stop_button = self.builder.get_object('stop-button')
|
type=int,
|
||||||
self.vol_spin = self.builder.get_object('vol-spin')
|
default=100,
|
||||||
self.program_combo = self.builder.get_object('program-combo')
|
help="The volume range to use.")
|
||||||
self.progressbar = self.builder.get_object('generate-progress')
|
parser.add_argument('--skip',
|
||||||
self.branch_combo = self.builder.get_object('branch-combo')
|
type=int,
|
||||||
self.statusbar = self.builder.get_object('statusbar')
|
default=0,
|
||||||
self.pos_label = self.builder.get_object('position-label')
|
metavar='N',
|
||||||
self.skip_spin = self.builder.get_object('skip-spin')
|
help="Skip the first N commits " +
|
||||||
self.scale_combo = self.builder.get_object('scale-combo')
|
"(comes in handy if the repo started " +
|
||||||
self.chooser_button = self.builder.get_object('repo-chooser')
|
"with some huge commits)")
|
||||||
self.notelen_spin = self.builder.get_object('notelen-spin')
|
|
||||||
self.beatlen_spin = self.builder.get_object('beatlen-spin')
|
args = parser.parse_args()
|
||||||
self.generate_button = self.builder.get_object('generate-button')
|
|
||||||
self.save_button = self.builder.get_object('save-button')
|
if args.scale is None and args.program is None and GUI_AVAILABLE:
|
||||||
|
GitSoundWindow(PROGRAMS, SCALES).start()
|
||||||
self.gitmidi = None
|
|
||||||
|
sys.exit(0)
|
||||||
program_store = self.builder.get_object('program-list')
|
|
||||||
|
if args.scale is None and args.program != 'list':
|
||||||
for program_id, program in PROGRAMS.items():
|
print("Please specify a scale!")
|
||||||
program_store.append([program['name'], program_id])
|
|
||||||
|
sys.exit(1)
|
||||||
renderer = Gtk.CellRendererText()
|
|
||||||
self.program_combo.pack_start(renderer, True)
|
if args.program is None and args.scale != 'list':
|
||||||
self.program_combo.add_attribute(renderer, "text", 0)
|
print("Please specify a program!")
|
||||||
|
|
||||||
scale_store = self.builder.get_object('scale-list')
|
sys.exit(1)
|
||||||
|
|
||||||
for scale_id, scale in SCALES.items():
|
if args.scale == 'list':
|
||||||
scale_store.append([scale[0], scale_id])
|
for scale in SCALES.keys():
|
||||||
|
print(scale)
|
||||||
renderer = Gtk.CellRendererText()
|
|
||||||
self.scale_combo.pack_start(renderer, True)
|
sys.exit(0)
|
||||||
self.scale_combo.add_attribute(renderer, "text", 0)
|
|
||||||
|
if args.program == 'list':
|
||||||
self.builder.connect_signals({
|
for program in PROGRAMS.keys():
|
||||||
'read_branches': lambda button: self.read_branches(),
|
print(program)
|
||||||
'settings_changed': lambda button: self.settings_changed(),
|
|
||||||
'generate_repo': lambda button: self.generate_repo(),
|
sys.exit(0)
|
||||||
'play_midi': lambda button: self.play_midi(),
|
|
||||||
'stop_midi': lambda button: self.stop_midi(),
|
if args.scale not in SCALES:
|
||||||
'save_midi': lambda button: self.save_midi(),
|
print("{} is an unknown scale.".format(args.scale))
|
||||||
})
|
print("Use 'list' to list the available scales.")
|
||||||
|
|
||||||
self.win.connect("delete-event", Gtk.main_quit)
|
sys.exit(1)
|
||||||
|
|
||||||
def read_branches(self):
|
if args.program not in PROGRAMS:
|
||||||
"""
|
print("{} is an unknown program.".format(args.program))
|
||||||
Callback for the repository chooser. Upon change, this reads
|
print("Use 'list' to list the available programs.")
|
||||||
all the branches from the selected repository.
|
|
||||||
"""
|
sys.exit(1)
|
||||||
|
|
||||||
# Make sure the Play, Stop and Save buttons are disabled
|
try:
|
||||||
self.gitmidi = None
|
repo_midi = GitMIDI(repository=args.repository,
|
||||||
repo_path = self.chooser_button.get_file().get_path()
|
branch=args.branch,
|
||||||
self.branch_combo.remove_all()
|
verbose=args.verbose,
|
||||||
self.branch_combo.set_button_sensitivity(False)
|
scale=SCALES[args.scale][1],
|
||||||
self.set_buttons_sensitivity(disable_all=True)
|
program=PROGRAMS[args.program],
|
||||||
|
volume_range=args.volume_range,
|
||||||
try:
|
skip=args.skip)
|
||||||
repo = Repo(repo_path)
|
|
||||||
except InvalidGitRepositoryError:
|
except InvalidGitRepositoryError:
|
||||||
dialog = Gtk.MessageDialog(
|
print("{} is not a valid Git repository"
|
||||||
self.chooser_button.get_toplevel(),
|
.format(os.path.abspath(args.repository)))
|
||||||
Gtk.DialogFlags.MODAL,
|
|
||||||
Gtk.MessageType.ERROR,
|
sys.exit(1)
|
||||||
Gtk.ButtonsType.OK,
|
|
||||||
"{} is not a valid Git repository".format(
|
except IndexError:
|
||||||
repo_path))
|
print("Branch '{}' does not exist in this repo".format(args.branch))
|
||||||
|
|
||||||
dialog.connect('response',
|
sys.exit(1)
|
||||||
lambda dialog, response_id: dialog.destroy())
|
|
||||||
dialog.run()
|
repo_midi.gen_repo_data()
|
||||||
|
repo_midi.generate_midi()
|
||||||
return
|
repo_midi.write_mem()
|
||||||
|
|
||||||
self.set_status('Opened repository: {}'.format(repo_path))
|
if args.file:
|
||||||
self.branch_combo.set_button_sensitivity(True)
|
if args.verbose:
|
||||||
|
print("Saving file to {}".format(args.file))
|
||||||
for head in repo.heads:
|
|
||||||
self.branch_combo.append_text(head.name)
|
repo_midi.export_file(args.file)
|
||||||
|
|
||||||
def set_status(self, text):
|
if args.play:
|
||||||
"""
|
repo_midi.play()
|
||||||
Change the status bar text.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.statusbar.push(self.statusbar.get_context_id("git-sound"), text)
|
|
||||||
|
|
||||||
def settings_changed(self):
|
|
||||||
"""
|
|
||||||
Callback to use if anything MIDI-related is changed
|
|
||||||
(repository, branch, scale or program).
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.gitmidi = None
|
|
||||||
self.set_buttons_sensitivity()
|
|
||||||
self.stop_midi()
|
|
||||||
|
|
||||||
def set_buttons_sensitivity(self, disable_all=False):
|
|
||||||
"""
|
|
||||||
Set buttons sensitivity based on different conditions.
|
|
||||||
|
|
||||||
It checks if a repository and a branch is selected or if MIDI
|
|
||||||
data is already generated.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.stop_button.set_sensitive(False)
|
|
||||||
|
|
||||||
if disable_all:
|
|
||||||
self.generate_button.set_sensitive(False)
|
|
||||||
self.play_button.set_sensitive(False)
|
|
||||||
self.save_button.set_sensitive(False)
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.gitmidi is not None:
|
|
||||||
self.generate_button.set_sensitive(False)
|
|
||||||
self.play_button.set_sensitive(True)
|
|
||||||
self.save_button.set_sensitive(True)
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
branch_selected = self.branch_combo.get_active_text() is not None
|
|
||||||
program_selected = self.program_combo.get_active_id() is not None
|
|
||||||
scale_selected = self.scale_combo.get_active_id() is not None
|
|
||||||
|
|
||||||
if branch_selected and program_selected and scale_selected:
|
|
||||||
self.generate_button.set_sensitive(True)
|
|
||||||
self.play_button.set_sensitive(False)
|
|
||||||
self.save_button.set_sensitive(False)
|
|
||||||
|
|
||||||
def generate_repo(self):
|
|
||||||
"""
|
|
||||||
Generate repository data for MIDI data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
repo_path = self.chooser_button.get_file().get_path()
|
|
||||||
branch_selected = self.branch_combo.get_active_text()
|
|
||||||
program_selected = self.program_combo.get_active_id()
|
|
||||||
scale_selected = self.scale_combo.get_active_id()
|
|
||||||
skip = int(self.skip_spin.get_value())
|
|
||||||
vol_deviation = int(self.vol_spin.get_value())
|
|
||||||
notelen = self.notelen_spin.get_value()
|
|
||||||
beatlen = int(self.beatlen_spin.get_value()) or None
|
|
||||||
|
|
||||||
self.progressbar.set_fraction(0.0)
|
|
||||||
self.progressbar.pulse()
|
|
||||||
self.set_status("Reading commits")
|
|
||||||
self.gitmidi = GitMIDI(repository=repo_path,
|
|
||||||
branch=branch_selected,
|
|
||||||
verbose=False,
|
|
||||||
scale=SCALES[scale_selected][1],
|
|
||||||
program=PROGRAMS[program_selected],
|
|
||||||
volume_range=vol_deviation,
|
|
||||||
skip=skip,
|
|
||||||
note_duration=notelen,
|
|
||||||
max_beat_len=beatlen)
|
|
||||||
|
|
||||||
self.set_status("Generating beats")
|
|
||||||
self.gitmidi.gen_repo_data(callback=self.genrepo_cb)
|
|
||||||
self.set_status("Generating MIDI")
|
|
||||||
self.gitmidi.generate_midi(callback=self.genrepo_cb)
|
|
||||||
self.gitmidi.write_mem()
|
|
||||||
|
|
||||||
self.set_buttons_sensitivity(disable_all=False)
|
|
||||||
|
|
||||||
def genrepo_cb(self, max_count, current):
|
|
||||||
"""
|
|
||||||
Generate repository data. This is called when the user presses
|
|
||||||
the Generate button.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
if max_count is None or current is None:
|
|
||||||
self.progressbar.pulse()
|
|
||||||
else:
|
|
||||||
fraction = float(current) / float(max_count)
|
|
||||||
self.progressbar.set_fraction(fraction)
|
|
||||||
|
|
||||||
# Make sure the progress bar gets updated
|
|
||||||
Gtk.main_iteration_do(False)
|
|
||||||
Gtk.main_iteration_do(False)
|
|
||||||
|
|
||||||
def update_play_pos(self):
|
|
||||||
"""
|
|
||||||
Update playback position label.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.gitmidi is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
position = self.gitmidi.get_play_pos()
|
|
||||||
|
|
||||||
if position is None:
|
|
||||||
self.set_status("Stopped")
|
|
||||||
self.pos_label.set_text("0:00")
|
|
||||||
self.play_button.set_sensitive(True)
|
|
||||||
self.stop_button.set_sensitive(False)
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
position = int(position / 1000)
|
|
||||||
|
|
||||||
minutes = int(position / 60)
|
|
||||||
seconds = position - (minutes * 60)
|
|
||||||
|
|
||||||
self.pos_label.set_text("{}:{:02}".format(minutes, seconds))
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def play_midi(self):
|
|
||||||
"""
|
|
||||||
Start MIDI playback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.set_status(u"Playing…")
|
|
||||||
self.gitmidi.play(track=True)
|
|
||||||
GLib.timeout_add_seconds(1, self.update_play_pos)
|
|
||||||
self.play_button.set_sensitive(False)
|
|
||||||
self.stop_button.set_sensitive(True)
|
|
||||||
|
|
||||||
def stop_midi(self):
|
|
||||||
"""
|
|
||||||
Stop MIDI playback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.gitmidi is not None:
|
|
||||||
self.gitmidi.stop()
|
|
||||||
|
|
||||||
def __save(self, dialog, response_id):
|
|
||||||
"""
|
|
||||||
Do the actual MIDI saving after the user chose a file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if response_id == Gtk.ResponseType.OK:
|
|
||||||
save_file = dialog.get_file().get_path()
|
|
||||||
dialog.destroy()
|
|
||||||
self.gitmidi.export_file(save_file)
|
|
||||||
|
|
||||||
def save_midi(self):
|
|
||||||
"""
|
|
||||||
Save MIDI data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
dialog = Gtk.FileChooserDialog(
|
|
||||||
u"Save As…",
|
|
||||||
self.win,
|
|
||||||
Gtk.FileChooserAction.SAVE,
|
|
||||||
("Save", Gtk.ResponseType.OK))
|
|
||||||
dialog.set_do_overwrite_confirmation(True)
|
|
||||||
|
|
||||||
dialog.connect('response', self.__save)
|
|
||||||
dialog.run()
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""
|
|
||||||
Start the GUI.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.win.show_all()
|
|
||||||
Gtk.main()
|
|
||||||
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
class GitMIDI(MIDIFile):
|
|
||||||
"""
|
|
||||||
Class to hold repository data, and MIDI data based on that repository.
|
|
||||||
"""
|
|
||||||
|
|
||||||
LOG_CHANNEL = 0
|
|
||||||
FILE_CHANNEL = 1
|
|
||||||
|
|
||||||
def __setup_midi(self, track_title=None):
|
|
||||||
"""
|
|
||||||
Initialise the MIDI file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.__verbose:
|
|
||||||
print("Preparing MIDI track…")
|
|
||||||
|
|
||||||
if track_title is None:
|
|
||||||
# TODO: Change this to something that connects to the repo
|
|
||||||
self.addTrackName(0, 0, "Sample Track")
|
|
||||||
|
|
||||||
self.addTempo(0, 0, self.__tempo)
|
|
||||||
|
|
||||||
if self.__need_commits:
|
|
||||||
self.addProgramChange(0, self.LOG_CHANNEL,
|
|
||||||
0, self.__program['commit']['program'])
|
|
||||||
|
|
||||||
if self.__need_files:
|
|
||||||
self.addProgramChange(0, self.FILE_CHANNEL,
|
|
||||||
0, self.__program['file']['program'])
|
|
||||||
|
|
||||||
def __setup_repo(self):
|
|
||||||
"""
|
|
||||||
Setup repository and get the specified branch.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.__verbose:
|
|
||||||
print("Analyzing repository…")
|
|
||||||
|
|
||||||
repo = Repo(self.__repo_dir)
|
|
||||||
self.__branch_head = repo.heads[self.__branch].commit
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
repository=None,
|
|
||||||
branch=None,
|
|
||||||
verbose=None,
|
|
||||||
scale=None,
|
|
||||||
program=None,
|
|
||||||
volume_range=None,
|
|
||||||
skip=None,
|
|
||||||
note_duration=None,
|
|
||||||
max_beat_len=None,
|
|
||||||
tempo=None):
|
|
||||||
MIDIFile.__init__(self, 1)
|
|
||||||
|
|
||||||
self.__verbose = verbose or False
|
|
||||||
self.__written = False
|
|
||||||
self.__repo_dir = repository or '.'
|
|
||||||
self.__repo = None
|
|
||||||
self.__branch = branch or 'master'
|
|
||||||
self.__branch_head = None
|
|
||||||
self.__repo_data = None
|
|
||||||
self.__git_log = []
|
|
||||||
self.__mem_file = StringIO()
|
|
||||||
self.__scale = scale
|
|
||||||
self.__program = program
|
|
||||||
self.__volume_deviation = min(abs(63 - (volume_range or 107)), 63)
|
|
||||||
self.__pygame_inited = False
|
|
||||||
self.__playing = False
|
|
||||||
self.__skip = skip or 0
|
|
||||||
self.__note_duration = note_duration or 0.3
|
|
||||||
self.__max_beat_len = max_beat_len or 10
|
|
||||||
self.__tempo = tempo or 120
|
|
||||||
|
|
||||||
self.__need_commits = self.__program['commit']['program'] is not None
|
|
||||||
self.__need_files = self.__program['file']['program'] is not None
|
|
||||||
|
|
||||||
self.__setup_midi()
|
|
||||||
self.__setup_repo()
|
|
||||||
|
|
||||||
def gen_volume(self, deletions, insertions, modifier):
|
|
||||||
"""
|
|
||||||
Generate a volume based on the number of modified lines
|
|
||||||
(insertions - deletions).
|
|
||||||
|
|
||||||
deviation specifies the minimum and maximum volume (minimum is
|
|
||||||
the value of deviation, maximum is 127 - deviation).
|
|
||||||
"""
|
|
||||||
|
|
||||||
return max(
|
|
||||||
self.__volume_deviation,
|
|
||||||
min(127 - self.__volume_deviation,
|
|
||||||
63 - deletions + insertions + modifier))
|
|
||||||
|
|
||||||
def sha_to_note(self, sha):
|
|
||||||
"""
|
|
||||||
Calculate note based on an SHA1 hash
|
|
||||||
"""
|
|
||||||
|
|
||||||
note_num = reduce(lambda res, digit: res + int(digit, 16),
|
|
||||||
list(str(sha)), 0) % len(self.__scale)
|
|
||||||
|
|
||||||
return self.__scale[note_num]
|
|
||||||
|
|
||||||
def gen_beat(self, commit):
|
|
||||||
"""
|
|
||||||
Generate data for a beat based on a commit and its files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
stat = commit.stats
|
|
||||||
|
|
||||||
file_notes = []
|
|
||||||
file_count = 0
|
|
||||||
|
|
||||||
for file_name, file_stat in stat.files.items():
|
|
||||||
file_count += 1
|
|
||||||
|
|
||||||
if file_count > self.__max_beat_len:
|
|
||||||
break
|
|
||||||
|
|
||||||
volume_mod = self.__program['file'].get('volume', 0)
|
|
||||||
file_note = self.sha_to_note(get_file_sha(commit, file_name)) + \
|
|
||||||
self.__program['file']['octave'] * 12
|
|
||||||
file_volume = self.gen_volume(file_stat['deletions'],
|
|
||||||
file_stat['insertions'],
|
|
||||||
volume_mod)
|
|
||||||
|
|
||||||
file_notes.append({
|
|
||||||
'note': file_note,
|
|
||||||
'volume': file_volume,
|
|
||||||
})
|
|
||||||
|
|
||||||
volume_mod = self.__program['commit'].get('volume', 0)
|
|
||||||
|
|
||||||
commit_note = self.sha_to_note(commit.hexsha) + \
|
|
||||||
self.__program['commit']['octave'] * 12
|
|
||||||
commit_volume = self.gen_volume(stat.total['deletions'],
|
|
||||||
stat.total['insertions'],
|
|
||||||
volume_mod)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'commit_note': commit_note,
|
|
||||||
'commit_volume': commit_volume,
|
|
||||||
'file_notes': file_notes,
|
|
||||||
}
|
|
||||||
|
|
||||||
def gen_repo_data(self, force=False, callback=None):
|
|
||||||
"""
|
|
||||||
Populate __repo_data with the Git history data. If force is
|
|
||||||
False and the repo_data is already calculated, we do not do
|
|
||||||
anything.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.__repo_data and not force:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.__verbose:
|
|
||||||
print("Reading repository log…")
|
|
||||||
|
|
||||||
self.__repo_data = []
|
|
||||||
counter = 0
|
|
||||||
to_process = [self.__branch_head]
|
|
||||||
|
|
||||||
while len(to_process) > 0:
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
# TODO: Make this 500 configurable
|
|
||||||
if counter % 500 == 0 and self.__verbose:
|
|
||||||
print("Done with {} commits".format(counter))
|
|
||||||
|
|
||||||
current_commit = to_process.pop()
|
|
||||||
|
|
||||||
if callback is not None:
|
|
||||||
callback(None, None)
|
|
||||||
|
|
||||||
if current_commit not in self.__repo_data:
|
|
||||||
self.__repo_data.append(current_commit)
|
|
||||||
to_process += current_commit.parents
|
|
||||||
|
|
||||||
if self.__verbose:
|
|
||||||
print("{} commits found".format(counter))
|
|
||||||
print("Sorting commits…")
|
|
||||||
|
|
||||||
self.__repo_data.sort(key=lambda commit: commit.authored_date)
|
|
||||||
|
|
||||||
if self.__verbose:
|
|
||||||
print("Generating MIDI data…")
|
|
||||||
|
|
||||||
self.__git_log = []
|
|
||||||
current_commit = 0
|
|
||||||
commits_to_process = self.__repo_data[self.__skip:]
|
|
||||||
commit_count = len(commits_to_process)
|
|
||||||
|
|
||||||
for commit in commits_to_process:
|
|
||||||
if callback:
|
|
||||||
current_commit += 1
|
|
||||||
callback(commit_count, current_commit)
|
|
||||||
|
|
||||||
self.__git_log.append(self.gen_beat(commit))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def repo_data(self):
|
|
||||||
"""
|
|
||||||
Get repository data for MIDI generation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.__repo_data is None:
|
|
||||||
self.gen_repo_data(force=True)
|
|
||||||
|
|
||||||
return self.__repo_data
|
|
||||||
|
|
||||||
def write_mem(self):
|
|
||||||
"""
|
|
||||||
Write MIDI data to the memory file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.writeFile(self.__mem_file)
|
|
||||||
self.__written = True
|
|
||||||
|
|
||||||
def export_file(self, filename):
|
|
||||||
"""
|
|
||||||
Export MIDI data to a file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not self.__written:
|
|
||||||
self.write_mem()
|
|
||||||
|
|
||||||
with open(filename, 'w') as midi_file:
|
|
||||||
self.__mem_file.seek(0)
|
|
||||||
shutil.copyfileobj(self.__mem_file, midi_file)
|
|
||||||
|
|
||||||
def generate_midi(self, callback=None):
|
|
||||||
"""
|
|
||||||
Generate MIDI data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.__verbose:
|
|
||||||
print("Creating MIDI…")
|
|
||||||
|
|
||||||
track = 0
|
|
||||||
time = 0
|
|
||||||
log_channel = 0
|
|
||||||
decor_channel = 1
|
|
||||||
|
|
||||||
log_length = len(self.__git_log)
|
|
||||||
current = 0
|
|
||||||
|
|
||||||
# WRITE THE SEQUENCE
|
|
||||||
for section in self.__git_log:
|
|
||||||
current += 1
|
|
||||||
section_len = len(section['file_notes']) * self.__note_duration
|
|
||||||
|
|
||||||
if callback is not None:
|
|
||||||
callback(log_length, current)
|
|
||||||
|
|
||||||
# Add a long note
|
|
||||||
if self.__need_commits:
|
|
||||||
self.addNote(track, log_channel,
|
|
||||||
section['commit_note'], time,
|
|
||||||
section_len, section['commit_volume'])
|
|
||||||
|
|
||||||
if self.__need_files:
|
|
||||||
for i, file_note in enumerate(section['file_notes']):
|
|
||||||
self.addNote(track, decor_channel,
|
|
||||||
file_note['note'],
|
|
||||||
time + i * self.__note_duration,
|
|
||||||
self.__note_duration, file_note['volume'])
|
|
||||||
|
|
||||||
time += section_len
|
|
||||||
|
|
||||||
def __init_pygame(self):
|
|
||||||
"""
|
|
||||||
Initialise pygame.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not PYGAME_AVAILABLE or self.__pygame_inited:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Initialise pygame
|
|
||||||
pygame.init()
|
|
||||||
pygame.mixer.init()
|
|
||||||
|
|
||||||
def play(self, track=False):
|
|
||||||
"""
|
|
||||||
Start MIDI playback. If pygame is not available, don’t do anything.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not PYGAME_AVAILABLE:
|
|
||||||
return "pygame is not available, cannot start playback"
|
|
||||||
|
|
||||||
if self.__verbose:
|
|
||||||
print("Playing!")
|
|
||||||
|
|
||||||
self.__init_pygame()
|
|
||||||
|
|
||||||
self.__mem_file.seek(0)
|
|
||||||
pygame.mixer.music.load(self.__mem_file)
|
|
||||||
pygame.mixer.music.play()
|
|
||||||
self.__playing = True
|
|
||||||
|
|
||||||
if not track:
|
|
||||||
while pygame.mixer.music.get_busy():
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
self.__playing = False
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""
|
|
||||||
Stop MIDI playback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not PYGAME_AVAILABLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
pygame.mixer.music.stop()
|
|
||||||
|
|
||||||
def get_play_pos(self):
|
|
||||||
"""
|
|
||||||
Get current playback position from the mixer
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not self.__playing:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if pygame.mixer.music.get_busy():
|
|
||||||
return pygame.mixer.music.get_pos()
|
|
||||||
else:
|
|
||||||
self.__playing = False
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""
|
|
||||||
Main function, used if we are not imported.
|
|
||||||
"""
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='Voice of a Repo',
|
|
||||||
epilog='Use the special value list for ' +
|
|
||||||
'scale and program to list the ' +
|
|
||||||
'available program combinations')
|
|
||||||
|
|
||||||
parser.add_argument('repository', type=str, nargs='?', default='.')
|
|
||||||
parser.add_argument('--branch',
|
|
||||||
type=str,
|
|
||||||
default='master',
|
|
||||||
help="The branch to generate sound for [master]")
|
|
||||||
parser.add_argument('--file',
|
|
||||||
type=str,
|
|
||||||
default=None,
|
|
||||||
help="Save the generated MIDI sequence to this file")
|
|
||||||
parser.add_argument('--play',
|
|
||||||
action='store_true',
|
|
||||||
default=False,
|
|
||||||
help="Play the generated file (requires pygame with " +
|
|
||||||
"MIDI support)")
|
|
||||||
parser.add_argument('--verbose',
|
|
||||||
action='store_true',
|
|
||||||
default=False,
|
|
||||||
help="Print messages during execution")
|
|
||||||
parser.add_argument('--scale',
|
|
||||||
type=str,
|
|
||||||
default=None,
|
|
||||||
help="Scale to use in the generated track")
|
|
||||||
parser.add_argument('--program',
|
|
||||||
type=str,
|
|
||||||
default=None,
|
|
||||||
help="Program setting to use in the generated track")
|
|
||||||
parser.add_argument('--volume-range',
|
|
||||||
type=int,
|
|
||||||
default=100,
|
|
||||||
help="The volume range to use.")
|
|
||||||
parser.add_argument('--skip',
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
metavar='N',
|
|
||||||
help="Skip the first N commits " +
|
|
||||||
"(comes in handy if the repo started " +
|
|
||||||
"with some huge commits)")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.scale is None and args.program is None and GTK_AVAILABLE:
|
|
||||||
GitSoundWindow().start()
|
|
||||||
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if args.scale is None and args.program != 'list':
|
|
||||||
print("Please specify a scale!")
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.program is None and args.scale != 'list':
|
|
||||||
print("Please specify a program!")
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.scale == 'list':
|
|
||||||
for scale in SCALES.keys():
|
|
||||||
print(scale)
|
|
||||||
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if args.program == 'list':
|
|
||||||
for program in PROGRAMS.keys():
|
|
||||||
print(program)
|
|
||||||
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if args.scale not in SCALES:
|
|
||||||
print("{} is an unknown scale.".format(args.scale))
|
|
||||||
print("Use 'list' to list the available scales.")
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.program not in PROGRAMS:
|
|
||||||
print("{} is an unknown program.".format(args.program))
|
|
||||||
print("Use 'list' to list the available programs.")
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
repo_midi = GitMIDI(repository=args.repository,
|
|
||||||
branch=args.branch,
|
|
||||||
verbose=args.verbose,
|
|
||||||
scale=SCALES[args.scale][1],
|
|
||||||
program=PROGRAMS[args.program],
|
|
||||||
volume_range=args.volume_range,
|
|
||||||
skip=args.skip)
|
|
||||||
|
|
||||||
except InvalidGitRepositoryError:
|
|
||||||
print("{} is not a valid Git repository"
|
|
||||||
.format(os.path.abspath(args.repository)))
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
except IndexError:
|
|
||||||
print("Branch '{}' does not exist in this repo".format(args.branch))
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
repo_midi.gen_repo_data()
|
|
||||||
repo_midi.generate_midi()
|
|
||||||
repo_midi.write_mem()
|
|
||||||
|
|
||||||
if args.file:
|
|
||||||
if args.verbose:
|
|
||||||
print("Saving file to {}".format(args.file))
|
|
||||||
|
|
||||||
repo_midi.export_file(args.file)
|
|
||||||
|
|
||||||
if args.play:
|
|
||||||
repo_midi.play()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
0
git_sound/__init__.py
Normal file
0
git_sound/__init__.py
Normal file
374
git_sound/gitmidi.py
Normal file
374
git_sound/gitmidi.py
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
# -*- coding: utf-8
|
||||||
|
"""
|
||||||
|
The GitMIDI class that converts a Git repository’s log into MIDI music.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import print_function
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from midiutil.MidiFile import MIDIFile
|
||||||
|
from StringIO import StringIO
|
||||||
|
from git import Repo
|
||||||
|
from git.objects.blob import Blob
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pygame
|
||||||
|
import pygame.mixer
|
||||||
|
PYGAME_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PYGAME_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_sha(commit, file_name):
|
||||||
|
"""
|
||||||
|
Get the SHA1 ID of a file by its name, in the given commit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
elements = file_name.split(os.sep)
|
||||||
|
tree = commit.tree
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
tree = tree[elements.pop(0)]
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
# The file has been deleted, return the hash of an empty file
|
||||||
|
return 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
|
||||||
|
|
||||||
|
if isinstance(tree, Blob):
|
||||||
|
break
|
||||||
|
|
||||||
|
return tree.hexsha
|
||||||
|
|
||||||
|
|
||||||
|
class GitMIDI(MIDIFile):
|
||||||
|
"""
|
||||||
|
Class to hold repository data, and MIDI data based on that repository.
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOG_CHANNEL = 0
|
||||||
|
FILE_CHANNEL = 1
|
||||||
|
|
||||||
|
def __setup_midi(self, track_title=None):
|
||||||
|
"""
|
||||||
|
Initialise the MIDI file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.__verbose:
|
||||||
|
print("Preparing MIDI track…")
|
||||||
|
|
||||||
|
if track_title is None:
|
||||||
|
# TODO: Change this to something that connects to the repo
|
||||||
|
self.addTrackName(0, 0, "Sample Track")
|
||||||
|
|
||||||
|
self.addTempo(0, 0, self.__tempo)
|
||||||
|
|
||||||
|
if self.__need_commits:
|
||||||
|
self.addProgramChange(0, self.LOG_CHANNEL,
|
||||||
|
0, self.__program['commit']['program'])
|
||||||
|
|
||||||
|
if self.__need_files:
|
||||||
|
self.addProgramChange(0, self.FILE_CHANNEL,
|
||||||
|
0, self.__program['file']['program'])
|
||||||
|
|
||||||
|
def __setup_repo(self):
|
||||||
|
"""
|
||||||
|
Setup repository and get the specified branch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.__verbose:
|
||||||
|
print("Analyzing repository…")
|
||||||
|
|
||||||
|
repo = Repo(self.__repo_dir)
|
||||||
|
self.__branch_head = repo.heads[self.__branch].commit
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
repository=None,
|
||||||
|
branch=None,
|
||||||
|
verbose=None,
|
||||||
|
scale=None,
|
||||||
|
program=None,
|
||||||
|
volume_range=None,
|
||||||
|
skip=None,
|
||||||
|
note_duration=None,
|
||||||
|
max_beat_len=None,
|
||||||
|
tempo=None):
|
||||||
|
MIDIFile.__init__(self, 1)
|
||||||
|
|
||||||
|
self.__verbose = verbose or False
|
||||||
|
self.__written = False
|
||||||
|
self.__repo_dir = repository or '.'
|
||||||
|
self.__repo = None
|
||||||
|
self.__branch = branch or 'master'
|
||||||
|
self.__branch_head = None
|
||||||
|
self.__repo_data = None
|
||||||
|
self.__git_log = []
|
||||||
|
self.__mem_file = StringIO()
|
||||||
|
self.__scale = scale
|
||||||
|
self.__program = program
|
||||||
|
self.__volume_deviation = min(abs(63 - (volume_range or 107)), 63)
|
||||||
|
self.__pygame_inited = False
|
||||||
|
self.__playing = False
|
||||||
|
self.__skip = skip or 0
|
||||||
|
self.__note_duration = note_duration or 0.3
|
||||||
|
self.__max_beat_len = max_beat_len or 10
|
||||||
|
self.__tempo = tempo or 120
|
||||||
|
|
||||||
|
self.__need_commits = self.__program['commit']['program'] is not None
|
||||||
|
self.__need_files = self.__program['file']['program'] is not None
|
||||||
|
|
||||||
|
self.__setup_midi()
|
||||||
|
self.__setup_repo()
|
||||||
|
|
||||||
|
def gen_volume(self, deletions, insertions, modifier):
|
||||||
|
"""
|
||||||
|
Generate a volume based on the number of modified lines
|
||||||
|
(insertions - deletions).
|
||||||
|
|
||||||
|
deviation specifies the minimum and maximum volume (minimum is
|
||||||
|
the value of deviation, maximum is 127 - deviation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
return max(
|
||||||
|
self.__volume_deviation,
|
||||||
|
min(127 - self.__volume_deviation,
|
||||||
|
63 - deletions + insertions + modifier))
|
||||||
|
|
||||||
|
def sha_to_note(self, sha):
|
||||||
|
"""
|
||||||
|
Calculate note based on an SHA1 hash
|
||||||
|
"""
|
||||||
|
|
||||||
|
note_num = reduce(lambda res, digit: res + int(digit, 16),
|
||||||
|
list(str(sha)), 0) % len(self.__scale)
|
||||||
|
|
||||||
|
return self.__scale[note_num]
|
||||||
|
|
||||||
|
def gen_beat(self, commit):
|
||||||
|
"""
|
||||||
|
Generate data for a beat based on a commit and its files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
stat = commit.stats
|
||||||
|
|
||||||
|
file_notes = []
|
||||||
|
file_count = 0
|
||||||
|
|
||||||
|
for file_name, file_stat in stat.files.items():
|
||||||
|
file_count += 1
|
||||||
|
|
||||||
|
if file_count > self.__max_beat_len:
|
||||||
|
break
|
||||||
|
|
||||||
|
volume_mod = self.__program['file'].get('volume', 0)
|
||||||
|
file_note = self.sha_to_note(get_file_sha(commit, file_name)) + \
|
||||||
|
self.__program['file']['octave'] * 12
|
||||||
|
file_volume = self.gen_volume(file_stat['deletions'],
|
||||||
|
file_stat['insertions'],
|
||||||
|
volume_mod)
|
||||||
|
|
||||||
|
file_notes.append({
|
||||||
|
'note': file_note,
|
||||||
|
'volume': file_volume,
|
||||||
|
})
|
||||||
|
|
||||||
|
volume_mod = self.__program['commit'].get('volume', 0)
|
||||||
|
|
||||||
|
commit_note = self.sha_to_note(commit.hexsha) + \
|
||||||
|
self.__program['commit']['octave'] * 12
|
||||||
|
commit_volume = self.gen_volume(stat.total['deletions'],
|
||||||
|
stat.total['insertions'],
|
||||||
|
volume_mod)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'commit_note': commit_note,
|
||||||
|
'commit_volume': commit_volume,
|
||||||
|
'file_notes': file_notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
def gen_repo_data(self, force=False, callback=None):
|
||||||
|
"""
|
||||||
|
Populate __repo_data with the Git history data. If force is
|
||||||
|
False and the repo_data is already calculated, we do not do
|
||||||
|
anything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.__repo_data and not force:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.__verbose:
|
||||||
|
print("Reading repository log…")
|
||||||
|
|
||||||
|
self.__repo_data = []
|
||||||
|
counter = 0
|
||||||
|
to_process = [self.__branch_head]
|
||||||
|
|
||||||
|
while len(to_process) > 0:
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# TODO: Make this 500 configurable
|
||||||
|
if counter % 500 == 0 and self.__verbose:
|
||||||
|
print("Done with {} commits".format(counter))
|
||||||
|
|
||||||
|
current_commit = to_process.pop()
|
||||||
|
|
||||||
|
if callback is not None:
|
||||||
|
callback(None, None)
|
||||||
|
|
||||||
|
if current_commit not in self.__repo_data:
|
||||||
|
self.__repo_data.append(current_commit)
|
||||||
|
to_process += current_commit.parents
|
||||||
|
|
||||||
|
if self.__verbose:
|
||||||
|
print("{} commits found".format(counter))
|
||||||
|
print("Sorting commits…")
|
||||||
|
|
||||||
|
self.__repo_data.sort(key=lambda commit: commit.authored_date)
|
||||||
|
|
||||||
|
if self.__verbose:
|
||||||
|
print("Generating MIDI data…")
|
||||||
|
|
||||||
|
self.__git_log = []
|
||||||
|
current_commit = 0
|
||||||
|
commits_to_process = self.__repo_data[self.__skip:]
|
||||||
|
commit_count = len(commits_to_process)
|
||||||
|
|
||||||
|
for commit in commits_to_process:
|
||||||
|
if callback:
|
||||||
|
current_commit += 1
|
||||||
|
callback(commit_count, current_commit)
|
||||||
|
|
||||||
|
self.__git_log.append(self.gen_beat(commit))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repo_data(self):
|
||||||
|
"""
|
||||||
|
Get repository data for MIDI generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.__repo_data is None:
|
||||||
|
self.gen_repo_data(force=True)
|
||||||
|
|
||||||
|
return self.__repo_data
|
||||||
|
|
||||||
|
def write_mem(self):
|
||||||
|
"""
|
||||||
|
Write MIDI data to the memory file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.writeFile(self.__mem_file)
|
||||||
|
self.__written = True
|
||||||
|
|
||||||
|
def export_file(self, filename):
|
||||||
|
"""
|
||||||
|
Export MIDI data to a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.__written:
|
||||||
|
self.write_mem()
|
||||||
|
|
||||||
|
with open(filename, 'w') as midi_file:
|
||||||
|
self.__mem_file.seek(0)
|
||||||
|
shutil.copyfileobj(self.__mem_file, midi_file)
|
||||||
|
|
||||||
|
def generate_midi(self, callback=None):
|
||||||
|
"""
|
||||||
|
Generate MIDI data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.__verbose:
|
||||||
|
print("Creating MIDI…")
|
||||||
|
|
||||||
|
track = 0
|
||||||
|
time = 0
|
||||||
|
log_channel = 0
|
||||||
|
decor_channel = 1
|
||||||
|
|
||||||
|
log_length = len(self.__git_log)
|
||||||
|
current = 0
|
||||||
|
|
||||||
|
# WRITE THE SEQUENCE
|
||||||
|
for section in self.__git_log:
|
||||||
|
current += 1
|
||||||
|
section_len = len(section['file_notes']) * self.__note_duration
|
||||||
|
|
||||||
|
if callback is not None:
|
||||||
|
callback(log_length, current)
|
||||||
|
|
||||||
|
# Add a long note
|
||||||
|
if self.__need_commits:
|
||||||
|
self.addNote(track, log_channel,
|
||||||
|
section['commit_note'], time,
|
||||||
|
section_len, section['commit_volume'])
|
||||||
|
|
||||||
|
if self.__need_files:
|
||||||
|
for i, file_note in enumerate(section['file_notes']):
|
||||||
|
self.addNote(track, decor_channel,
|
||||||
|
file_note['note'],
|
||||||
|
time + i * self.__note_duration,
|
||||||
|
self.__note_duration, file_note['volume'])
|
||||||
|
|
||||||
|
time += section_len
|
||||||
|
|
||||||
|
def __init_pygame(self):
|
||||||
|
"""
|
||||||
|
Initialise pygame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not PYGAME_AVAILABLE or self.__pygame_inited:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Initialise pygame
|
||||||
|
pygame.init()
|
||||||
|
pygame.mixer.init()
|
||||||
|
|
||||||
|
def play(self, track=False):
|
||||||
|
"""
|
||||||
|
Start MIDI playback. If pygame is not available, don’t do anything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not PYGAME_AVAILABLE:
|
||||||
|
return "pygame is not available, cannot start playback"
|
||||||
|
|
||||||
|
if self.__verbose:
|
||||||
|
print("Playing!")
|
||||||
|
|
||||||
|
self.__init_pygame()
|
||||||
|
|
||||||
|
self.__mem_file.seek(0)
|
||||||
|
pygame.mixer.music.load(self.__mem_file)
|
||||||
|
pygame.mixer.music.play()
|
||||||
|
self.__playing = True
|
||||||
|
|
||||||
|
if not track:
|
||||||
|
while pygame.mixer.music.get_busy():
|
||||||
|
sleep(1)
|
||||||
|
|
||||||
|
self.__playing = False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""
|
||||||
|
Stop MIDI playback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not PYGAME_AVAILABLE:
|
||||||
|
return
|
||||||
|
|
||||||
|
pygame.mixer.music.stop()
|
||||||
|
|
||||||
|
def get_play_pos(self):
|
||||||
|
"""
|
||||||
|
Get current playback position from the mixer
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.__playing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if pygame.mixer.music.get_busy():
|
||||||
|
return pygame.mixer.music.get_pos()
|
||||||
|
else:
|
||||||
|
self.__playing = False
|
||||||
|
|
||||||
|
return None
|
295
git_sound/gui.py
Normal file
295
git_sound/gui.py
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
# -*- coding: utf-8
|
||||||
|
"""
|
||||||
|
GUI for git-sound
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import gi
|
||||||
|
gi.require_version('Gtk', '3.0')
|
||||||
|
|
||||||
|
from gi.repository import Gtk
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
from git.exc import InvalidGitRepositoryError
|
||||||
|
from git import Repo
|
||||||
|
|
||||||
|
from .gitmidi import GitMIDI
|
||||||
|
|
||||||
|
|
||||||
|
class GitSoundWindow(object):
|
||||||
|
"""
|
||||||
|
GIU class for git-sound.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, programs, scales):
|
||||||
|
self.__programs = programs
|
||||||
|
self.__scales = scales
|
||||||
|
|
||||||
|
self.builder = Gtk.Builder()
|
||||||
|
self.builder.add_from_file('git-sound.ui')
|
||||||
|
|
||||||
|
self.win = self.builder.get_object('main-window')
|
||||||
|
self.play_button = self.builder.get_object('play-button')
|
||||||
|
self.stop_button = self.builder.get_object('stop-button')
|
||||||
|
self.vol_spin = self.builder.get_object('vol-spin')
|
||||||
|
self.program_combo = self.builder.get_object('program-combo')
|
||||||
|
self.progressbar = self.builder.get_object('generate-progress')
|
||||||
|
self.branch_combo = self.builder.get_object('branch-combo')
|
||||||
|
self.statusbar = self.builder.get_object('statusbar')
|
||||||
|
self.pos_label = self.builder.get_object('position-label')
|
||||||
|
self.skip_spin = self.builder.get_object('skip-spin')
|
||||||
|
self.scale_combo = self.builder.get_object('scale-combo')
|
||||||
|
self.chooser_button = self.builder.get_object('repo-chooser')
|
||||||
|
self.notelen_spin = self.builder.get_object('notelen-spin')
|
||||||
|
self.beatlen_spin = self.builder.get_object('beatlen-spin')
|
||||||
|
self.generate_button = self.builder.get_object('generate-button')
|
||||||
|
self.save_button = self.builder.get_object('save-button')
|
||||||
|
|
||||||
|
self.gitmidi = None
|
||||||
|
|
||||||
|
program_store = self.builder.get_object('program-list')
|
||||||
|
|
||||||
|
for program_id, program in self.__programs.items():
|
||||||
|
program_store.append([program['name'], program_id])
|
||||||
|
|
||||||
|
renderer = Gtk.CellRendererText()
|
||||||
|
self.program_combo.pack_start(renderer, True)
|
||||||
|
self.program_combo.add_attribute(renderer, "text", 0)
|
||||||
|
|
||||||
|
scale_store = self.builder.get_object('scale-list')
|
||||||
|
|
||||||
|
for scale_id, scale in self.__scales.items():
|
||||||
|
scale_store.append([scale[0], scale_id])
|
||||||
|
|
||||||
|
renderer = Gtk.CellRendererText()
|
||||||
|
self.scale_combo.pack_start(renderer, True)
|
||||||
|
self.scale_combo.add_attribute(renderer, "text", 0)
|
||||||
|
|
||||||
|
self.builder.connect_signals({
|
||||||
|
'read_branches': lambda button: self.read_branches(),
|
||||||
|
'settings_changed': lambda button: self.settings_changed(),
|
||||||
|
'generate_repo': lambda button: self.generate_repo(),
|
||||||
|
'play_midi': lambda button: self.play_midi(),
|
||||||
|
'stop_midi': lambda button: self.stop_midi(),
|
||||||
|
'save_midi': lambda button: self.save_midi(),
|
||||||
|
})
|
||||||
|
|
||||||
|
self.win.connect("delete-event", Gtk.main_quit)
|
||||||
|
|
||||||
|
def read_branches(self):
|
||||||
|
"""
|
||||||
|
Callback for the repository chooser. Upon change, this reads
|
||||||
|
all the branches from the selected repository.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Make sure the Play, Stop and Save buttons are disabled
|
||||||
|
self.gitmidi = None
|
||||||
|
repo_path = self.chooser_button.get_file().get_path()
|
||||||
|
self.branch_combo.remove_all()
|
||||||
|
self.branch_combo.set_button_sensitivity(False)
|
||||||
|
self.set_buttons_sensitivity(disable_all=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo = Repo(repo_path)
|
||||||
|
except InvalidGitRepositoryError:
|
||||||
|
dialog = Gtk.MessageDialog(
|
||||||
|
self.chooser_button.get_toplevel(),
|
||||||
|
Gtk.DialogFlags.MODAL,
|
||||||
|
Gtk.MessageType.ERROR,
|
||||||
|
Gtk.ButtonsType.OK,
|
||||||
|
"{} is not a valid Git repository".format(
|
||||||
|
repo_path))
|
||||||
|
|
||||||
|
dialog.connect('response',
|
||||||
|
lambda dialog, response_id: dialog.destroy())
|
||||||
|
dialog.run()
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
self.set_status('Opened repository: {}'.format(repo_path))
|
||||||
|
self.branch_combo.set_button_sensitivity(True)
|
||||||
|
|
||||||
|
for head in repo.heads:
|
||||||
|
self.branch_combo.append_text(head.name)
|
||||||
|
|
||||||
|
def set_status(self, text):
|
||||||
|
"""
|
||||||
|
Change the status bar text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.statusbar.push(self.statusbar.get_context_id("git-sound"), text)
|
||||||
|
|
||||||
|
def settings_changed(self):
|
||||||
|
"""
|
||||||
|
Callback to use if anything MIDI-related is changed
|
||||||
|
(repository, branch, scale or program).
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.gitmidi = None
|
||||||
|
self.set_buttons_sensitivity()
|
||||||
|
self.stop_midi()
|
||||||
|
|
||||||
|
def set_buttons_sensitivity(self, disable_all=False):
|
||||||
|
"""
|
||||||
|
Set buttons sensitivity based on different conditions.
|
||||||
|
|
||||||
|
It checks if a repository and a branch is selected or if MIDI
|
||||||
|
data is already generated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.stop_button.set_sensitive(False)
|
||||||
|
|
||||||
|
if disable_all:
|
||||||
|
self.generate_button.set_sensitive(False)
|
||||||
|
self.play_button.set_sensitive(False)
|
||||||
|
self.save_button.set_sensitive(False)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.gitmidi is not None:
|
||||||
|
self.generate_button.set_sensitive(False)
|
||||||
|
self.play_button.set_sensitive(True)
|
||||||
|
self.save_button.set_sensitive(True)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
branch_selected = self.branch_combo.get_active_text() is not None
|
||||||
|
program_selected = self.program_combo.get_active_id() is not None
|
||||||
|
scale_selected = self.scale_combo.get_active_id() is not None
|
||||||
|
|
||||||
|
if branch_selected and program_selected and scale_selected:
|
||||||
|
self.generate_button.set_sensitive(True)
|
||||||
|
self.play_button.set_sensitive(False)
|
||||||
|
self.save_button.set_sensitive(False)
|
||||||
|
|
||||||
|
def generate_repo(self):
|
||||||
|
"""
|
||||||
|
Generate repository data for MIDI data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
repo_path = self.chooser_button.get_file().get_path()
|
||||||
|
branch_selected = self.branch_combo.get_active_text()
|
||||||
|
program_selected = self.program_combo.get_active_id()
|
||||||
|
scale_selected = self.scale_combo.get_active_id()
|
||||||
|
skip = int(self.skip_spin.get_value())
|
||||||
|
vol_deviation = int(self.vol_spin.get_value())
|
||||||
|
notelen = self.notelen_spin.get_value()
|
||||||
|
beatlen = int(self.beatlen_spin.get_value()) or None
|
||||||
|
|
||||||
|
self.progressbar.set_fraction(0.0)
|
||||||
|
self.progressbar.pulse()
|
||||||
|
self.set_status("Reading commits")
|
||||||
|
self.gitmidi = GitMIDI(repository=repo_path,
|
||||||
|
branch=branch_selected,
|
||||||
|
verbose=False,
|
||||||
|
scale=self.__scales[scale_selected][1],
|
||||||
|
program=self.__programs[program_selected],
|
||||||
|
volume_range=vol_deviation,
|
||||||
|
skip=skip,
|
||||||
|
note_duration=notelen,
|
||||||
|
max_beat_len=beatlen)
|
||||||
|
|
||||||
|
self.set_status("Generating beats")
|
||||||
|
self.gitmidi.gen_repo_data(callback=self.genrepo_cb)
|
||||||
|
self.set_status("Generating MIDI")
|
||||||
|
self.gitmidi.generate_midi(callback=self.genrepo_cb)
|
||||||
|
self.gitmidi.write_mem()
|
||||||
|
|
||||||
|
self.set_buttons_sensitivity(disable_all=False)
|
||||||
|
|
||||||
|
def genrepo_cb(self, max_count, current):
|
||||||
|
"""
|
||||||
|
Generate repository data. This is called when the user presses
|
||||||
|
the Generate button.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if max_count is None or current is None:
|
||||||
|
self.progressbar.pulse()
|
||||||
|
else:
|
||||||
|
fraction = float(current) / float(max_count)
|
||||||
|
self.progressbar.set_fraction(fraction)
|
||||||
|
|
||||||
|
# Make sure the progress bar gets updated
|
||||||
|
Gtk.main_iteration_do(False)
|
||||||
|
Gtk.main_iteration_do(False)
|
||||||
|
|
||||||
|
def update_play_pos(self):
|
||||||
|
"""
|
||||||
|
Update playback position label.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.gitmidi is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
position = self.gitmidi.get_play_pos()
|
||||||
|
|
||||||
|
if position is None:
|
||||||
|
self.set_status("Stopped")
|
||||||
|
self.pos_label.set_text("0:00")
|
||||||
|
self.play_button.set_sensitive(True)
|
||||||
|
self.stop_button.set_sensitive(False)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
position = int(position / 1000)
|
||||||
|
|
||||||
|
minutes = int(position / 60)
|
||||||
|
seconds = position - (minutes * 60)
|
||||||
|
|
||||||
|
self.pos_label.set_text("{}:{:02}".format(minutes, seconds))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def play_midi(self):
|
||||||
|
"""
|
||||||
|
Start MIDI playback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.set_status(u"Playing…")
|
||||||
|
self.gitmidi.play(track=True)
|
||||||
|
GLib.timeout_add_seconds(1, self.update_play_pos)
|
||||||
|
self.play_button.set_sensitive(False)
|
||||||
|
self.stop_button.set_sensitive(True)
|
||||||
|
|
||||||
|
def stop_midi(self):
|
||||||
|
"""
|
||||||
|
Stop MIDI playback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.gitmidi is not None:
|
||||||
|
self.gitmidi.stop()
|
||||||
|
|
||||||
|
def __save(self, dialog, response_id):
|
||||||
|
"""
|
||||||
|
Do the actual MIDI saving after the user chose a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if response_id == Gtk.ResponseType.OK:
|
||||||
|
save_file = dialog.get_file().get_path()
|
||||||
|
dialog.destroy()
|
||||||
|
self.gitmidi.export_file(save_file)
|
||||||
|
|
||||||
|
def save_midi(self):
|
||||||
|
"""
|
||||||
|
Save MIDI data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dialog = Gtk.FileChooserDialog(
|
||||||
|
u"Save As…",
|
||||||
|
self.win,
|
||||||
|
Gtk.FileChooserAction.SAVE,
|
||||||
|
("Save", Gtk.ResponseType.OK))
|
||||||
|
dialog.set_do_overwrite_confirmation(True)
|
||||||
|
|
||||||
|
dialog.connect('response', self.__save)
|
||||||
|
dialog.run()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""
|
||||||
|
Start the GUI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.win.show_all()
|
||||||
|
Gtk.main()
|
||||||
|
|
||||||
|
sys.exit(0)
|
Loading…
Reference in New Issue
Block a user