Created
February 13, 2017 03:13
-
-
Save n-hansen/ece68353531596ff8ea80575d7e2b433 to your computer and use it in GitHub Desktop.
Command line utility for merging playlists into a single file with interval markers.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# intervals.py - turn an itunes playlist into a single track with interval markers | |
import os | |
import plistlib | |
import urllib.request | |
from pydub import AudioSegment | |
from pydub.generators import Sine | |
from numpy import linspace | |
import argparse | |
OUTPUT_FORMAT = "mp3" | |
OUTPUT_PARAMETERS = ["-q:a", "0"] | |
IGNORED_PLAYLISTS = ["Library", | |
"Music", | |
"Movies", | |
"TV Shows", | |
"Podcasts", | |
"iTunes\xa0U", | |
"Audiobooks", | |
"Purchased"] | |
class Library: | |
def __init__(self,filepath): | |
with open(filepath, 'rb') as f: | |
lib = plistlib.load(f) | |
self.songs = {int(k): v["Location"] for (k,v) in lib["Tracks"].items() if "Location" in v} | |
self.playlists = {pl["Name"]:pl for pl in lib["Playlists"] if pl["Name"] not in IGNORED_PLAYLISTS} | |
def get_playlist_songs(self,pl_name): | |
if pl_name not in self.playlists: | |
return [] | |
def get_song(song_id): | |
path = self.songs[song_id] | |
extn = path.split('.')[-1] | |
with urllib.request.urlopen(path) as song_file: | |
return AudioSegment.from_file(song_file, format=extn) | |
return [get_song(x["Track ID"]) for x in self.playlists[pl_name]["Playlist Items"]] | |
class SFX: | |
def __init__(self,volume=0.0,low=261.6,hi=392,steps=2,length=450,spacing=90,fade=0.03): | |
pulse_length = (length-(steps-1)*spacing)/steps | |
fade_length = int(pulse_length * fade) | |
pulse_length = int(pulse_length) | |
self.sounds = [Sine(f).to_audio_segment(pulse_length,volume).fade_in(fade_length).fade_out(fade_length) | |
for f in linspace(low,hi,steps)] | |
self.spacing=spacing | |
def up(self): | |
acc = self.sounds[0] | |
for s in self.sounds[1:]: | |
if self.spacing <= 0: | |
acc = acc.append(s,crossfade=(-self.spacing)) | |
else: | |
acc = acc + AudioSegment.silent(duration=self.spacing) + s | |
return acc | |
def down(self): | |
acc = self.sounds[-1] | |
for s in reversed(self.sounds[0:-1]): | |
if self.spacing <= 0: | |
acc = acc.append(s,crossfade=(-self.spacing)) | |
else: | |
acc = acc + AudioSegment.silent(duration=self.spacing) + s | |
return acc | |
def add_sound_at(sound1,sound2,time,fade,gain=-50): | |
front = sound1[:time].fade(to_gain=gain,end=time,duration=fade) | |
middle = sound1[time:(time+len(sound2))].apply_gain(gain).overlay(sound2) | |
back = sound1[(time+len(sound2)):].fade(from_gain=gain,start=0,duration=fade) | |
return (front + middle + back) | |
def add_intervals(audio,warmup,a_len,b_len,cooldown,count,fade=180): | |
two_note = SFX(volume=audio.max_dBFS,steps=2) | |
three_note = SFX(volume=audio.max_dBFS,steps=3) | |
ten_note = SFX(volume=audio.max_dBFS,steps=10,length=800,spacing=-30) | |
end_warmup = three_note.up() | |
a2b = two_note.down() | |
b2a = two_note.up() | |
start_cooldown = three_note.down() | |
done = ten_note.down() | |
cursor = warmup | |
audio = add_sound_at(audio,end_warmup,cursor,fade) | |
for i in range(count): | |
cursor = cursor + a_len | |
audio = add_sound_at(audio,a2b,cursor,fade) | |
cursor = cursor + b_len | |
if i < (count-1): | |
audio = add_sound_at(audio,b2a,cursor,fade) | |
audio = add_sound_at(audio,start_cooldown,cursor,fade) | |
audio = add_sound_at(audio,done,cursor+cooldown,fade) | |
return audio | |
def main(): | |
parser = argparse.ArgumentParser() | |
parser.add_argument("playlist", | |
help="Name of the iTunes playlist to use.") | |
parser.add_argument("warmup", | |
help="Length (in seconds) of the warm-up interval.", | |
type=float) | |
parser.add_argument("a", | |
help="Length (in seconds) of the A interval.", | |
type=float) | |
parser.add_argument("b", | |
help="Length (in seconds) of the B interval.", | |
type=float) | |
parser.add_argument("cooldown", | |
help="Length (in seconds) of the cool-down interval.", | |
type=float) | |
parser.add_argument("intervals", | |
help="Number of intervals (not including warm-up and cool-down).", | |
type=int) | |
parser.add_argument("-o","--outfile", | |
help="Output file.", | |
default="out.mp3") | |
parser.add_argument("-l","--library", | |
help="Path to iTunes Music Library.xml", | |
default=os.path.expanduser("~/Music/iTunes/iTunes Music Library.xml")) | |
args = parser.parse_args() | |
lib = Library(args.library) | |
if args.playlist not in lib.playlists: | |
print("Could not find the requested playlist in the iTunes library file.") | |
return | |
base_audio_track = sum(lib.get_playlist_songs(args.playlist)) | |
final_audio_track = add_intervals(base_audio_track, | |
int(1000*args.warmup), | |
int(1000*args.a), | |
int(1000*args.b), | |
int(1000*args.cooldown), | |
args.intervals) | |
final_audio_track.export(args.outfile, | |
format=OUTPUT_FORMAT, | |
parameters=OUTPUT_PARAMETERS, | |
tags={"title":args.playlist}) | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment