Refactor GUI and GitMIDI into separate files

This commit is contained in:
Gergely Polonkai 2016-06-10 00:31:40 +02:00
parent 20290a882f
commit 07001dbf91
4 changed files with 790 additions and 775 deletions

View File

@ -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, dont 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
View File

374
git_sound/gitmidi.py Normal file
View File

@ -0,0 +1,374 @@
# -*- coding: utf-8
"""
The GitMIDI class that converts a Git repositorys 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, dont 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
View 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)