[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[PATCH 8/8] add dtas-splitfx - .cuesheets + make(1)
From: |
Eric Wong |
Subject: |
[PATCH 8/8] add dtas-splitfx - .cuesheets + make(1) |
Date: |
Sat, 7 Sep 2013 20:03:27 +0000 |
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 <address@hidden> 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 <address@hidden> 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 address@hidden)).to_i
+ @infmt.rate = qx(@env, %W(soxi -r address@hidden)).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 = "address@hidden"
+ 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 <address@hidden> 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
--
1.8.4
- [PATCH 0/8] latest changes in dtas.git (dtas-splitfx), Eric Wong, 2013/09/07
- [PATCH 4/8] test/*: compatibility class for both minitest 4 and 5, Eric Wong, 2013/09/07
- [PATCH 8/8] add dtas-splitfx - .cuesheets + make(1),
Eric Wong <=
- [PATCH 1/8] source/sox: correctly extend xs for try_to_fail_harder, Eric Wong, 2013/09/07
- [PATCH 5/8] test/helper: delay at_exit registration for tmpfifo, Eric Wong, 2013/09/07
- [PATCH 3/8] test/player_integration: thread-safety fix, Eric Wong, 2013/09/07
- [PATCH 2/8] test/*.rb: test/unit compatibility, Eric Wong, 2013/09/07
- [PATCH 6/8] dtas-console: show paused track when paused, Eric Wong, 2013/09/07
- [PATCH 7/8] test/helper: fix var shadowing, Eric Wong, 2013/09/07