git-sound/git_sound/gitmidi.py

387 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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 time import sleep
from io import BytesIO
from midiutil.MidiFile import MIDIFile
from git import Repo, Tree
from git.objects.blob import Blob
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:
element = elements.pop(0)
if isinstance(tree[element], Tree):
tree = tree[element]
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 = BytesIO()
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
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 = sum([int(c, 16) for c in str(sha)]) % 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 self.__max_beat_len is not None and \
self.__max_beat_len != 0 and \
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:
current_commit += 1
if callback:
callback(commit_count, current_commit)
if self.__verbose:
print("{}/{}".format(current_commit, commit_count))
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, 'wb') as midi_file:
self.__mem_file.seek(0)
midi_file.write(self.__mem_file.getbuffer())
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)
if self.__verbose:
print("{}/{}".format(current, log_length))
# 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