Skip to content

Instantly share code, notes, and snippets.

@lyjia
Last active July 4, 2024 22:54
Show Gist options
  • Save lyjia/cf98303eefb8730266d38c3fa02af17f to your computer and use it in GitHub Desktop.
Save lyjia/cf98303eefb8730266d38c3fa02af17f to your computer and use it in GitHub Desktop.
#!/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