dtas-all
[Top][All Lists]
Advanced

[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




reply via email to

[Prev in Thread] Current Thread [Next in Thread]