about summary refs log tree commit homepage
path: root/lib/dtas
diff options
context:
space:
mode:
authorEric Wong <normalperson@yhbt.net>2013-09-07 12:18:45 +0000
committerEric Wong <normalperson@yhbt.net>2013-09-07 12:21:07 +0000
commit8ebf63afe8c9af13868376e7618c840517618076 (patch)
treed6c6b8a73f67b1e9463c682bb53c39dfa21ff68b /lib/dtas
parent5fe09ee3e080a2592e89d28f4157879d871485e9 (diff)
downloaddtas-8ebf63afe8c9af13868376e7618c840517618076.tar.gz
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.
Diffstat (limited to 'lib/dtas')
-rw-r--r--lib/dtas/splitfx.rb280
1 files changed, 280 insertions, 0 deletions
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 <normalperson@yhbt.net> 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