From 8ebf63afe8c9af13868376e7618c840517618076 Mon Sep 17 00:00:00 2001 From: Eric Wong Date: Sat, 7 Sep 2013 12:18:45 +0000 Subject: add dtas-splitfx - .cuesheets + make(1) This is lacking tests and documentation, but it works from a old trivial sample I had from a recording I previously split using plain POSIX shell splitfx is like make(1) for splitting and minor audio editing. It also allows any number of effects. --- bin/dtas-splitfx | 39 ++++++ examples/README | 3 + examples/splitfx.sample.yml | 17 +++ lib/dtas/splitfx.rb | 280 ++++++++++++++++++++++++++++++++++++++++++++ test/test_splitfx.rb | 14 +++ 5 files changed, 353 insertions(+) create mode 100755 bin/dtas-splitfx create mode 100644 examples/README create mode 100644 examples/splitfx.sample.yml create mode 100644 lib/dtas/splitfx.rb create mode 100644 test/test_splitfx.rb diff --git a/bin/dtas-splitfx b/bin/dtas-splitfx new file mode 100755 index 0000000..2d66c0d --- /dev/null +++ b/bin/dtas-splitfx @@ -0,0 +1,39 @@ +#!/usr/bin/env ruby +# Copyright (C) 2013, Eric Wong and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require 'yaml' +require 'optparse' +require 'dtas/splitfx' +usage = "#$0 [-n|--dry-run][-j [JOBS]] SPLITFX_FILE.yml [TARGET]" +overrides = {} # FIXME: not tested +dryrun = false +jobs = 1 +op = OptionParser.new('', 24, ' ') do |opts| + opts.banner = usage + opts.on('-n', '--dry-run') { dryrun = true } + opts.on('-j', '--jobs [JOBS]', Integer) { |val| jobs = val } + opts.parse!(ARGV) +end + +args = [] +ARGV.each do |arg| + case arg + when %r{\A(\w+)=(.*)\z} + key, val = $1, $2 + # only one that makes sense is infile=another_file + overrides[key] = YAML.load(val) + when %r{\A(\w+)\.(\w+)=(.*)\z} + # comments.ARTIST='blah' + top, key, val = $1, $2, $3 + hsh = overrides[top] ||= {} + hsh[key] = val + else + args << arg + end +end + +file = args.shift or abort usage +target = args.shift || "flac" +splitfx = DTAS::SplitFX.new +splitfx.import(YAML.load(File.read(file)), overrides) +splitfx.run(target, jobs, dryrun) diff --git a/examples/README b/examples/README new file mode 100644 index 0000000..c87947a --- /dev/null +++ b/examples/README @@ -0,0 +1,3 @@ +All files in this example directory (including this one) are CC0: +To the extent possible under law, Eric Wong has waived all copyright and +related or neighboring rights to these examples. diff --git a/examples/splitfx.sample.yml b/examples/splitfx.sample.yml new file mode 100644 index 0000000..c4655ff --- /dev/null +++ b/examples/splitfx.sample.yml @@ -0,0 +1,17 @@ +# To the extent possible under law, Eric Wong has waived all copyright and +# related or neighboring rights to this example. +--- +infile: foo.flac +env: + PATH: /usr/local/bin:/usr/bin:/bin + SOX_OPTS: -R +comments: + ARTIST: John Smith + ALBUM: Hello World + YEAR: 2013 +track_start: 1 # 0 for pregap/intro tracks +cdda_align: true +tracks: + - t 0:04 "track one" + - t 0:05 "track two" + - stop 1:00 diff --git a/lib/dtas/splitfx.rb b/lib/dtas/splitfx.rb new file mode 100644 index 0000000..83ac190 --- /dev/null +++ b/lib/dtas/splitfx.rb @@ -0,0 +1,280 @@ +# Copyright (C) 2013, Eric Wong and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +# Unlike the stuff for dtas-player, dtas-splitfx is fairly tied to sox +# (but we may still pipe to ecasound or anything else) +require_relative '../dtas' +require_relative 'format' +require_relative 'process' +require_relative 'xs' +require 'tempfile' +class DTAS::SplitFX # :nodoc: + CMD = 'sox "$INFILE" $COMMENTS $OUTFMT "$TRACKNUMBER.$SUFFIX" '\ + '$TRIMFX $RATEFX $DITHERFX' + include DTAS::Process + include DTAS::XS + + class T < Struct.new(:env, :comments, :tstart, :fade_in, :fade_out) + def commit(next_track_samples) + tlen = next_track_samples - tstart + trimfx = "trim #{tstart}s #{tlen}s" + if fade_in + trimfx << " #{fade_in}" + end + if fade_out + tmp = fade_out.dup + fade_out_len = tmp.pop or + raise ArgumentError, "fade_out needs a time value" + fade_type = tmp.pop # may be nil + fade = " fade #{fade_type} 0 #{tlen}s #{fade_out_len}" + trimfx << fade + end + env["TRIMFX"] = trimfx + end + end + + # vars: + # $CHANNELS (input) + # $BITS_PER_SAMPLE (input) + def initialize + @env = {} + @comments = {} + @track_first = 1 + @track_zpad = true + @t2s = method(:t2s) + @infile = nil + @targets = {} + @tracks = [] + @infmt = nil # wait until input is assigned + end + + def _bool(hash, key) + val = hash[key] + case val + when false, true then yield val + when nil # ignore + else + raise TypeError, "'#{key}' must be boolean (true or false)" + end + end + + def import(hash, overrides = {}) + # merge overrides from the command-line + overrides.each do |k,v| + case v + when Hash then hash[k] = (hash[k] || {}).merge(v) + else + hash[k] = v + end + end + + hash = hash.merge(overrides) + case v = hash["track_zpad"] + when Integer then @track_zpad = val + else + _bool(hash, "track_zpad") { |val| @track_zpad = val } + end + + _bool(hash, "cdda_align") { |val| @t2s = method(val ? :t2s : :t2s_cdda) } + + case v = hash["track_first"] + when Integer then @track_first = v + when nil + else + raise TypeError, "'track_first' must be an integer" + end + + %w(comments env targets).each do |key| + case val = hash[key] + when Hash then instance_variable_get("@#{key}").merge!(val) + when nil + else + raise TypeError, "'#{key}' must be a hash" + end + end + + @targets.each_value do |thsh| + case tfmt = thsh["format"] + when Hash + thsh["format"] = DTAS::Format.load(tfmt) unless tfmt.empty? + end + end + + load_input!(hash) + load_tracks!(hash) + end + + def load_input!(hash) + @infile = hash["infile"] or raise ArgumentError, "'infile' not specified" + if infmt = hash["infmt"] # rarely needed + @infmt = DTAS::Format.load(infmt) + else # likely + @infmt = DTAS::Format.new + @infmt.channels = qx(@env, %W(soxi -c #@infile)).to_i + @infmt.rate = qx(@env, %W(soxi -r #@infile)).to_i + # we don't care for type + end + end + + def generic_target(target = "flac") + fmt = { "type" => target } + { command: CMD, format: DTAS::Format.load(fmt) } + end + + def spawn(target, t, dryrun = false) + target = @targets[target] || generic_target(target) + outfmt = target[:format] + env = outfmt.to_env + + # set very high quality resampling if using 24-bit or higher output + if outfmt.rate != @infmt.rate + if outfmt.bits + # set very-high resampling quality for 24-bit outputs + quality = "-v" if outfmt.bits >= 24 + else + # assume output bits matches input bits + quality = "-v" if @infmt.bits >= 24 + end + env["RATEFX"] = "rate #{quality} #{outfmt.rate}" + end + + # add noise-shaped dither for 16-bit (sox manual seems to recommend this) + outfmt.bits && outfmt.bits <= 16 and env["DITHERFX"] = "dither -s" + comments = Tempfile.new(%W(dtas-splitfx-#{t.comments["TRACKNUMBER"]} .txt)) + comments.sync = true + t.comments.each do |k,v| + env[k] = v.to_s + comments.puts("#{k}=#{v}") + end + env["COMMENTS"] = "--comment-file=#{comments.path}" + env["INFILE"] = @infile + env["OUTFMT"] = xs(outfmt.to_sox_arg) + env["SUFFIX"] = outfmt.type + env.merge!(t.env) + + command = target[:command] + tmp = Shellwords.split(command).map do |arg| + qx(env, "printf %s \"#{arg}\"") + end + echo = "echo #{xs(tmp)}" + if dryrun + command = echo + else + system(echo) + end + [ dtas_spawn(env, command, {}), comments ] + end + + def load_tracks!(hash) + tracks = hash["tracks"] or raise ArgumentError, "'tracks' not specified" + tracks.each { |line| parse_track(Shellwords.split(line)) } + + fmt = "%d" + case @track_zpad + when true + max = @track_first - 1 + @tracks.size + fmt = "%0#{max.to_s.size}d" + when Integer + fmt = "%0#{@track_zpad}d" + else + fmt = "%d" + end + nr = @track_first + @tracks.each do |t| + t.comments["TRACKNUMBER"] = sprintf(fmt, nr) + nr += 1 + end + end + + # argv: + # [ 't', '0:05', 'track one', 'fade_in=t 4', '.comment=blah' ] + # [ 'stop', '1:00' ] + def parse_track(argv) + case cmd = argv.shift + when "t" + start_time = argv.shift + title = argv.shift + t = T.new + t.tstart = @t2s.call(start_time) + t.comments = @comments.dup + t.comments["TITLE"] = title + t.env = @env.dup + + argv.each do |arg| + case arg + when %r{\Afade_in=(.+)\z} + # generate fade-in effect + # $1 = "t 4" => "fade t 4 0 0" + t.fade_in = "fade #$1 0 0" + when %r{\Afade_out=(.+)\z} # $1 = "t 4" or just "4" + t.fade_out = $1.split(/\s+/) + when %r{\A\.(\w+)=(.+)\z} then t.comments[$1] = $2 + else + raise ArgumentError, "unrecognized arg(s): #{xs(argv)}" + end + end + + prev = @tracks.last and prev.commit(t.tstart) + @tracks << t + when "stop" + stop_time = argv.shift + argv.empty? or raise ArgumentError, "stop does not take extra args" + samples = @t2s.call(stop_time) + prev = @tracks.last and prev.commit(samples) + else + raise ArgumentError, "unknown command: #{xs(Array(cmd))}" + end + end + + # like t2s, but align to CDDA sectors (75 frames per second) + def t2s_cdda(time) + time = time.dup + frac = 0 + + # fractions of a second, convert to samples based on sample rate + # taking into account CDDA alignment + if time.sub!(/\.(\d+)\z/, "") + s = "0.#$1".to_f * @infmt.rate / 75 + frac = s.to_i * 75 + end + + # feed the rest to the normal function + t2s(time) + frac + end + + def t2s(time) + @infmt.hhmmss_to_samples(time) + end + + def run(target, jobs = 1, dryrun = false) + fails = [] + tracks = @tracks.dup + pids = {} + jobs ||= tracks.size # jobs == nil => everything at once + jobs.times.each do + t = tracks.shift or break + pid, tmp = spawn(target, t, dryrun) + pids[pid] = [ t, tmp ] + end + + while pids.size > 0 + pid, status = Process.waitpid2(-1) + done = pids.delete(pid) + if status.success? + if t = tracks.shift + pid, tmp = spawn(target, t, dryrun) + pids[pid] = [ t, tmp ] + end + puts "DONE #{done[0].inspect}" if $DEBUG + done[1].close! + else + fails << [ t, status ] + end + end + + return true if fails.empty? && tracks.empty? + fails.each do |(_t,s)| + warn "FAIL #{s.inspect} #{_t.inspect}" + end + false + end +end diff --git a/test/test_splitfx.rb b/test/test_splitfx.rb new file mode 100644 index 0000000..1d36c54 --- /dev/null +++ b/test/test_splitfx.rb @@ -0,0 +1,14 @@ +# Copyright (C) 2013, Eric Wong and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require './test/helper' +require 'dtas/splitfx' + +class TestSplitfx < Testcase + def test_cdda + sfx = DTAS::SplitFX.new + sfx.instance_eval do + @infmt = DTAS::Format.load("rate"=>44100) + end + assert_equal 118554000, sfx.t2s_cdda('44:48.3') + end +end -- cgit v1.2.3-24-ge0c7