Last active
July 4, 2024 22:54
-
-
Save lyjia/cf98303eefb8730266d38c3fa02af17f to your computer and use it in GitHub Desktop.
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 ruby | |
# | |
# This script extracts cue points ("locators" in Ableton's parlance) from an Ableton Live set to a more-easily readable | |
# format, like cuesheet .cue files. | |
# | |
# The 'thor' gem is used for command-line argument processing, so you will need to install it by running: | |
# | |
# gem install thor | |
# | |
# Example usage: | |
# | |
# ruby extract_from_live_set.rb cue ".\my live set\my live set.als" --audiofile "my live set export.wav" --savewith --parsetitlefield | |
# | |
# For a list of all options run the following: | |
# | |
# ruby extract_from_live_set.rb help | |
# -or- | |
# ruby extract_from_live_set.rb help cue | |
# | |
# (C) 2022 Lyjia, all rights reserved. This script is licensed under the terms of GPL-3, as specified here: | |
# https://www.gnu.org/licenses/gpl-3.0.en.html | |
# | |
# Updated 6-Dec-2022 | |
require "zlib" | |
require 'rexml/document' | |
include REXML | |
require "thor" | |
require 'fileutils' | |
############################################################################### | |
# Util | |
############################################################################### | |
SAMPLE_RATE = 44100 | |
FRAMES_PER_SEC = 75 #https://en.wikipedia.org/wiki/Cue_sheet_(computing) | |
def log(str) | |
$stderr.puts(str) | |
end | |
def get_folder_paths(alsfilepath) | |
return File.dirname(alsfilepath), File.basename(alsfilepath, File.extname(alsfilepath)) | |
end | |
############################################################################### | |
# Subroutines | |
############################################################################### | |
# Parse the locators in an ableton XML file | |
def get_locators_from_ableton_xml(xmlfilename) | |
xmldoc = Document.new(File.new(xmlfilename)) | |
xmlroot = xmldoc.root | |
log "Reading live set created with " + xmlroot.attributes["Creator"] | |
tempoxpath = "/Ableton/LiveSet/MasterTrack/DeviceChain/Mixer/Tempo/Manual" | |
tempo = xmlroot.elements[tempoxpath].attributes["Value"].to_f | |
log "Tempo read as #{tempo} BPM" | |
# nodes we want appear to be at Ableton/LiveSet/locators | |
xpath = "/Ableton/LiveSet/Locators/Locators" | |
# <Locators> | |
# <Locators> | |
# <Locator Id="0"> | |
# <LomId Value="0" /> | |
# <Time Value="0" /> | |
# <Name Value="Themba feat. Thakzin - Sound of Freedom (Extended Mix)" /> | |
# <Annotation Value="" /> | |
# <IsSongStart Value="false" /> | |
# </Locator> | |
locators = xmlroot.elements[xpath] | |
log "Found #{locators.count / 2} locators..." | |
cuepoints = {} | |
locators.each do |el| | |
next if el.class == REXML::Text | |
timecode = el.elements['Time'].attributes["Value"] | |
name = el.elements['Name'].attributes["Value"] | |
cuepoints[timecode] = { name: name } | |
log (" --> #{timecode} - #{name}") | |
end | |
return cuepoints, tempo | |
end | |
# convert ableton locators timecodes to exact seconds/frames | |
def convert_locators_to_cuepoints(cuepoints, tempo) | |
samples_per_min = SAMPLE_RATE * 60 | |
samples_per_beat = samples_per_min / tempo | |
log "At #{tempo} BPM, one beat has #{samples_per_beat} samples." | |
cuepoints.each_key do |beat| | |
sample = (beat.to_i * samples_per_beat).round | |
minute = (sample / SAMPLE_RATE / 60).floor | |
second = (sample / SAMPLE_RATE).floor - (minute * 60) | |
ratio = (sample % SAMPLE_RATE).to_f / SAMPLE_RATE | |
frame = (ratio * FRAMES_PER_SEC).floor #get remainder and scale proportionately to frame via FRAMES_PER_SEC | |
msecs = (ratio * 1000).floor | |
cuepoints[beat][:minute] = minute | |
cuepoints[beat][:second] = second | |
cuepoints[beat][:frame] = frame | |
cuepoints[beat][:millisecond] = msecs | |
cuepoints[beat][:sample] = sample | |
end | |
return cuepoints | |
end | |
def extract_cuepoints(alsfilepath, options) | |
if !alsfilepath.nil? && File.exist?(alsfilepath) | |
log "Using <#{alsfilepath}>..." | |
else | |
log "File at <#{alsfilepath}> does not exist. It should point to the Ableton .als set file." | |
end | |
xmlfilename = extract_xml(alsfilepath, options) | |
locators, tempo = get_locators_from_ableton_xml(xmlfilename) | |
unless options[:nocleanup] | |
xmlabspath = File.absolute_path(xmlfilename) | |
File.delete xmlabspath rescue log("ERROR! Could not delete #{xmlabspath} for some reason! You will have to delete it yourself!") | |
end | |
return convert_locators_to_cuepoints(locators, tempo) | |
end | |
def parse_title_field(name) | |
name.split(" - ") | |
end | |
def create_cuesheet_from_cuepoints(cuepoints, options) | |
# REM GENRE Electronica | |
# REM DATE 1998 | |
# PERFORMER "Faithless" | |
# TITLE "Live in Berlin" | |
# FILE "Faithless - Live in Berlin.mp3" MP3 | |
# TRACK 01 AUDIO | |
# TITLE "Reverence" | |
# PERFORMER "Faithless" | |
# INDEX 01 00:00:00 | |
# TRACK 02 AUDIO | |
# TITLE "She's My Baby" | |
# PERFORMER "Faithless" | |
# INDEX 01 06:42:00 | |
performer = options[:artist] | |
title = options[:title] | |
file = options[:audiofile] | |
output = <<~CUE.chomp | |
PERFORMER "#{performer}" | |
TITLE "#{title}" | |
CUE | |
if file | |
output << "\nFILE \"#{file}\" WAVE" | |
end | |
track = 0 | |
cuepoints.each do |beat, hash| | |
minute = hash[:minute] | |
second = hash[:second] | |
frame = hash[:frame] | |
name = hash[:name] | |
cstimecode = "%02d:%02d:%02d" % [minute, second, frame] | |
buf = "\n\s\sTRACK %02d AUDIO" % (track += 1) | |
if options[:parsetitlefield] | |
performer, title = parse_title_field(name) | |
buf << "\n\s\s\s\sPERFORMER \"#{performer}\"" | |
buf << "\n\s\s\s\sTITLE \"#{title}\"" | |
else | |
buf << "\n\s\s\s\sPERFORMER \"\"" | |
buf << "\n\s\s\s\sTITLE \"#{name}\"" | |
end | |
buf << "\n\s\s\s\sINDEX 01 #{cstimecode}" | |
output << buf | |
end | |
return output | |
end | |
def create_chapters_from_cuepoints(cuepoints, options) | |
output = [] | |
track = 0 | |
cuepoints.each do |beat, hash| | |
hour = Integer( hash[:minute] ) / 60 | |
minute = hash[:minute] % 60 | |
second = hash[:second] | |
name = hash[:name] | |
cstimecode = "%02d:%02d:%02d" % [hour, minute, second] | |
buf = "" | |
if options[:parsetitlefield] | |
performer, title = parse_title_field(name) | |
buf << "#{cstimecode} #{performer} - #{title}" | |
else | |
buf << "#{cstimecode} #{name}" | |
end | |
output << buf | |
end | |
return output.join("\n") | |
end | |
def create_tracklist_from_cuepoints(cuepoints, options) | |
output = [] | |
i = 0 | |
cuepoints.each do |beat, hash| | |
i += 1 | |
line = {} | |
name = hash[:name] | |
if options[:tracknumbers] | |
line[:track] = i | |
end | |
if options[:parsetitlefield] | |
line[:performer], line[:title] = parse_title_field(name) | |
else | |
line[:title] = name | |
end | |
if options[:seperator] | |
out = [] | |
out << line[:track] if line[:track] | |
out << line[:performer] if line[:performer] | |
out << line[:title] | |
output << out.join(options[:seperator]) | |
else | |
out = "" | |
out << "#{line[:track]}." if line[:track] | |
out << "#{line[:performer]} - " if line[:performer] | |
out << line[:title] | |
output << out | |
end | |
end | |
return output | |
end | |
############################################################################### | |
# Main | |
############################################################################### | |
def extract_xml(alsfilepath, options) | |
# https://stackoverflow.com/questions/856891/unzip-zip-tar-tag-gz-files-with-ruby | |
alsdirpath, alsbasename = get_folder_paths(alsfilepath) | |
xmldata = Zlib::GzipReader.open(alsfilepath).read | |
newfilename = File.join(alsdirpath, "#{alsbasename}.xml") | |
File.open(newfilename, "wb") do |f| | |
f.write xmldata | |
end | |
log "XML data file <#{newfilename}> created!" | |
newfilename | |
end | |
def extract_cuesheet(alsfilepath, options) | |
cuepoints = extract_cuepoints(alsfilepath, options) | |
output = create_cuesheet_from_cuepoints(cuepoints, options) | |
if outfile = options[:outfile] | |
File.open(outfile, "w") do |f| | |
f.write output | |
end | |
elsif options[:audiofile] && File.exist?(options[:audiofile]) && options[:savewith] | |
wavdirname, wavbasename = get_folder_paths(options[:audiofile]) | |
newfilename = File.join(wavdirname, "#{wavbasename}.cue") | |
File.open(newfilename, "w") do |f| | |
f.write output | |
end | |
else | |
puts output | |
end | |
end | |
def extract_tracklist(alsfilepath, options) | |
cuepoints = extract_cuepoints(alsfilepath, options) | |
output = create_tracklist_from_cuepoints(cuepoints, options) | |
if outfile = options[:outfile] | |
File.open(outfile, "w") do |f| | |
f.write output | |
end | |
else | |
puts output | |
end | |
end | |
def extract_chapters(alsfilepath, options) | |
cuepoints = extract_cuepoints(alsfilepath, options) | |
output = create_chapters_from_cuepoints(cuepoints, options) | |
if outfile = options[:outfile] | |
File.open(outfile, "w") do |f| | |
f.write output | |
end | |
else | |
puts output | |
end | |
end | |
############################################################################### | |
# CLI options handling | |
############################################################################### | |
class MyCLI < Thor | |
option :outfile, type: :string, aliases: "-o", desc: "Filename for cuesheet output. (Defaults to STDOUT)" | |
option :audiofile, type: :string, aliases: "-w", desc: "The exported audiofile of the given .als file, to be included in the cuesheet" | |
option :nocleanup, type: :boolean, aliases: "-c", desc: "Delete the intermediary XML file produced.", default: false | |
option :parsetitlefield, type: :boolean, desc: "Attempt to parse track names into PERFORMER and TITLE fields. Otherise the locator name is used in TITLE only." | |
option :savewith, type: :boolean, aliases: "-s", desc: "Save a CUE file next to the audiofile, with the same name ending in .cue. (cuesheet mode only)" | |
option :title, type: :string, desc: "Value to use for TITLE field. (cuesheet mode only)" | |
option :artist, type: :string, desc: "Value to use for PERFORMER field. (cuesheet mode only)" | |
option :seperator, type: :string, aliases: "-sep", desc: "Output tracklist in SEPERATOR-separated format. (like CSV or TSV) (tracklist mode only)" | |
option :tracknumbers, type: :boolean, aliases: "-t", desc: "Include track number in tracklist. (tracklist mode only)", default: false | |
desc "cuesheet FILE [options]", "Extracts cuesheet-type data from an Albeton Live set." | |
def cuesheet(file) | |
extract_cuesheet(file, options) | |
end | |
desc "tracklist FILE [options]", "Extract a text-formatted tracklist from an Ableton Live set." | |
def tracklist(file) | |
extract_tracklist(file, options) | |
end | |
desc "xml FILE [options]", "Extract Locator XML data from an Ableton Live set." | |
def xml(file) | |
extract_xml(file, options) | |
end | |
desc "chapters FILE [options]", "Extract Youtube Chapters from an Ableton Live set." | |
def chapters(file) | |
extract_chapters(file, options) | |
end | |
def self.exit_on_failure? | |
false | |
end | |
end | |
MyCLI.start(ARGV) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment