From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: X-Spam-Checker-Version: SpamAssassin 3.3.2 (2011-06-06) on dcvr.yhbt.net X-Spam-Level: X-Spam-ASN: X-Spam-Status: No, score=-2.9 required=3.0 tests=ALL_TRUSTED,AWL,BAYES_00, URIBL_BLOCKED shortcircuit=no autolearn=unavailable version=3.3.2 X-Original-To: spew@80x24.org Received: from localhost (dcvr.yhbt.net [127.0.0.1]) by dcvr.yhbt.net (Postfix) with ESMTP id DD78E63384D for ; Sun, 5 Jul 2015 02:21:17 +0000 (UTC) From: Eric Wong To: spew@80x24.org Subject: [PATCH 3/3] huge revamp Date: Sun, 5 Jul 2015 02:21:17 +0000 Message-Id: <1436062877-12475-3-git-send-email-e@80x24.org> In-Reply-To: <1436062877-12475-1-git-send-email-e@80x24.org> References: <1436062877-12475-1-git-send-email-e@80x24.org> List-Id: --- bin/dtas-script | 63 +++++++++++++++ examples/script.sample.rb | 65 +++++++++++++++ examples/tfx.sample.yml | 27 ------- lib/dtas/parse_freq.rb | 14 ++++ lib/dtas/script.rb | 201 ++++++++++++++++++++++++++++++++++++++++++++++ lib/dtas/tfx.rb | 72 ++++------------- test/test_script.rb | 53 ++++++++++++ test/test_tfx.rb | 69 ++++------------ 8 files changed, 424 insertions(+), 140 deletions(-) create mode 100755 bin/dtas-script create mode 100644 examples/script.sample.rb delete mode 100644 examples/tfx.sample.yml create mode 100644 lib/dtas/parse_freq.rb create mode 100644 lib/dtas/script.rb create mode 100644 test/test_script.rb diff --git a/bin/dtas-script b/bin/dtas-script new file mode 100755 index 0000000..05778cf --- /dev/null +++ b/bin/dtas-script @@ -0,0 +1,63 @@ +#!/usr/bin/env ruby +# Copyright (C) 2015 all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require 'dtas/script' +require 'optionparser' +$stdout.sync = $stderr.sync = false +usage = "#$0 SCRIPT.rb [OPTIONS] [trim START [LENGTH]]" +outfmt = {} +out = $stdout +dry_run = false +OptionParser.new('', 24, ' ') do |op| + op.banner = usage + op.on('-p', '--sox-pipe', 'alias for `-t sox -') { outfmt[:type] = 'sox' } + op.on('-t', '--type TYPE') { |type| outfmt[:type] = type } + op.on('-r', '--rate RATE') { |rate| + include DTAS::ParseFreq + outfmt[:rate] = parse_freq(rate) + } + op.on('-c', '--channels CHANNELS', Integer) { |c| outfmt[:channels] = c } + op.on('-b', '--bits BITS', Integer) { |bits| outfmt[:bits] = bits } + op.on('-o', '--output FILE') do |file| + out = File.open(file, 'w') + out.sync = false + file =~ /\.(\w+)\z/ and outfmt[:type] ||= $1 + end + op.on('-n', '--dry-run', 'only print commands, do not run them') do + dry_run = true + end + op.on('-h', '--help') do + puts(op.to_s) + exit + end + op.parse!(ARGV) +end + +s = DTAS::Script.new +script = ARGV.shift or abort usage + +# trim may be the first arg +case arg = ARGV.shift +when 'trim' + require 'dtas/parse_time' + include DTAS::ParseTime + tbeg = ARGV.shift or abort usage + tbeg = parse_time(tbeg) + case ARGV[0] + when nil # ok, play until the end + when /\A=?[\d\.:]+\z/ + tlen = ARGV.shift + absolute = tlen.sub!(/\A=/, '') # 44:00 =44:55 + tlen = parse_time(tlen) + tlen -= tbeg if absolute + else + abort usage + end +end + +# ARGV now contains other effects we add at the end... + +s.instance_eval(File.read(script), script) +format = s.format +outfmt.each { |k, v| format.__send__("#{k}=", v) } +s.emit(out, format, tbeg, tlen) diff --git a/examples/script.sample.rb b/examples/script.sample.rb new file mode 100644 index 0000000..2031938 --- /dev/null +++ b/examples/script.sample.rb @@ -0,0 +1,65 @@ +#!/usr/bin/env dtas-script +# To the extent possible under law, Eric Wong has waived all copyright and +# related or neighboring rights to this example. +# Note: be sure to update test/test_lang.rb if you change this, +# test_lang.rb relies on this. + +# declare the primary backing file (which may be :null and a format for +# a silent backing file) + +input 'foo.flac' + +# The above maps channel 1 and 2 of `omnis.flac' to channels 3 and 4 for +# further processing. `:bypass' is a shortcut + +# instead of updating ENV directly, `env' is locally scoped +env.update(SOX_OPTS: "#{ENV['SOX_OPTS']} -R") + +# rumble filter for the entire file +at(0) { sox %w(highpass 20) } +# at(0) { %w(sox highpass 20) } # just an array is fine, too + +# the following commands are equivalent, start the effect at +# 52 seconds and have it last for 1 second: +at(52..53) { sox %w(gain -6) } +at(52, 1) { sh 'sox $SOXIN $SOXOUT $TRIMFX gain -6' } +# TODO +# at(52, 1) { sh 'sox', :SOXIN, :SOXOUT, :TRIMFX, 'gain', '-6' ) # safer + +# as are the following (for little endian machines) +at(52, 1) { eca %w(-eadb:-6) } +at(52, 1) { + sh 'sox $SOXIN $SOX2ECA $TRIMFX | ' \ + 'ecasound $ECAFMT -i stdin -o stdout -eadb:-6 | ' \ + 'sox $ECA2SOX - $SOXOUT' +} + +# TODO +at(55..60) do + # Linkwitz-Riley filters (highpass 400 highpass 400 lowpass 460 lowpass 460) + # to isolate a region and apply limiter to it. + freq(400..480) { sox %w(ladspa -lr tap_limiter -5 4) } + + # use a LR lowpass at everything below 400 Hz + freq(-400) { sox %w(compand 6:0.01,0.05 6:-4,-6,0,-6) } + + # ditto for anything above 4000Hz + freq(4000) { sox %w(compand 6:0.01,0.05 6:-4,-6,0,-6) } + + # shortcuts also work + freq('4k') { sox %w(compand 6:0.01,0.05 6:-4,-6,0,-6) } +end if false + +# Anything may be limited to a single or a group of channels +ch(1) { sox %w(delay 0.001) } +ch(1, 2) { sox %w(delay 0.001) } + +ch(1..3) { sox %w(delay 0.01) } + +# the final mix down into stereo +at(0) do + sox 'remix 1v0.5,3v0.5 2v0.5,4v0.5' +end + +# SOX2ECA='-tf32 -c$CHANNELS -r$RATE' +# ECAFMT='-f32_le,$CHANNELS,$RATE diff --git a/examples/tfx.sample.yml b/examples/tfx.sample.yml deleted file mode 100644 index b8add0b..0000000 --- a/examples/tfx.sample.yml +++ /dev/null @@ -1,27 +0,0 @@ -# To the extent possible under law, Eric Wong has waived all copyright and -# related or neighboring rights to this example. -# Note: be sure to update test/test_trimfx.rb if you change this, -# test_trimfx.rb relies on this. ---- -infile: foo.flac -env: !omap - PATH: $PATH - SOX_OPTS: $SOX_OPTS -R - I2: second.flac - I3: third.flac -comments: - ARTIST: John Smith - ALBUM: Hello World - YEAR: 2013 -track_start: 1 -effects: -# the following commands are equivalent -- trim 52 =53 sh sox $SOXIN $SOXOUT $TRIMFX gain -6 -- trim 52 1 sox gain -6 # shorthand - -# as are the following (for little endian machines) -- trim 52 1 eca -eadb:-6 -- trim 52 1 sh sox $SOXIN $SOX2ECA $TRIMFX | ecasound $ECAFMT - -i stdin -o stdout -eadb:-6 | sox $ECA2SOX - $SOXOUT -# SOX2ECA='-tf32 -c$CHANNELS -r$RATE' -# ECAFMT='-f32_le,$CHANNELS,$RATE diff --git a/lib/dtas/parse_freq.rb b/lib/dtas/parse_freq.rb new file mode 100644 index 0000000..b079eae --- /dev/null +++ b/lib/dtas/parse_freq.rb @@ -0,0 +1,14 @@ +# Copyright (C) 2015 all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +module DTAS::ParseFreq + def parse_freq(val) + case val + when String + val = val.dup + mult = val.sub!(/k\z/, '') ? 1000 : 1 + (val.to_f * mult).to_i + else + val.to_i + end + end +end diff --git a/lib/dtas/script.rb b/lib/dtas/script.rb new file mode 100644 index 0000000..4446c66 --- /dev/null +++ b/lib/dtas/script.rb @@ -0,0 +1,201 @@ +# Copyright (C) 2015 all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) + +require_relative '../dtas' +require_relative 'epoch' +require_relative 'tfx' +require_relative 'parse_time' +require_relative 'parse_freq' + +class DTAS::Script + # env hash + attr_reader :env + + # for dtas-script use only + def format + @format ||= make_format + end + + # runs the effects described in the given block at the specified time + def at(tbeg, tlen = nil, fade: nil, &block) + case tbeg + when Range # seconds + tlen and err('at(start..end) takes only one Range') + r = tbeg + r.exclude_end? and err('at(start..end) cannot exclude end') + + tbeg = parse_time(r.first) + tend = r.last + case tend + when String # account for '=POSITION' + tlen = parse_tlen(tend, tbeg, 'at(start..end)') + else + tlen = parse_time(tend) - tbeg + end + when Numeric # seconds + usage = 'at(START_TIME, [ END_POSITION])' + tlen = parse_tlen(tlen, tbeg, usage) + when String + if tbeg.include?(' ') + usage = 'at("START_POSITION END_POSITION")' + tlen and err(usage) + tmp = tbeg.split(/\s+/) + tmp.size == 2 or err(usage) + + tbeg = parse_time(tmp[0]) + tlen = parse_tlen(tmp[1], tbeg, usage) + else + usage = 'at("START_POSITION", "END_POSITION")' + tbeg = parse_time(tbeg) + tlen = parse_tlen(tlen, tbeg, usage) + end + else + err('at(start..end) OR at("START_POSITION END_POSITION")') + end + + check_time_overlaps(tbeg, tlen) + continue(:at, tbeg, tlen, fade, block) + end # at + + def sox(effects); cmd(:sox, effects); end + def eca(effects); cmd(:eca, effects); end + def sh(args); cmd(:sh, args); end + + def ch(channel, *rest, &block) + channels = [ channel, *channels ].inject([]) do |m,c| + case c + when Integer + m << c + else # Range or Array + m.concat(Array(c)) + end + end + err("ch(CHANNELS): no channels specified") unless channels + continue(:ch, channels, block) + end + + # returns 'file' + def input(file, infmt = nil) + @inputs.empty? or err('only one input currently supported') + case file + when String + @inputs << file + infmt and err('format must not be specified with input file') + file + when Numeric + # input(length, rate: 48000, channels: 2) + infmt ||= {} + infmt[:rate] ||= 44100 + infmt[:channels] ||= 2 + unknown = infmt.keys - [ :rate, :channels ] + unknown.empty? or err("unknown keys in format: #{unknown.inspect}") + + ret = [ length, infmt ] + @inputs << ret + ret + when Hash # TODO + # input 'cards.flac' => :bypass, + # 'omnis.flac' => { 1 => 3, 2 => 4 }, + # The above maps channel 1 and 2 of `omnis.flac' to channels 3 and 4 for + # further processing. `:bypass' is a shortcut for {1 => 1, 2 => 2, ... } + else + err('input(file)') + end + end + + def emit(io, outfmt, tbeg = 0, tlen = nil) + p [ io, ouitfmt ] + end + +private + include DTAS::ParseTime + + def cmd(type, args) + tfx = nil + chan = nil # all channels + @scope.reverse_each do |s| + case s[0] + when :at + tbeg, tlen, fade = s[1], s[2], s[3] + tfx ||= [ type, tbeg, tlen, fade, args ] + break if chan + when :ch + chan ||= s[1] + break if tfx + end + end + tfx ||= [ type, 0, nil, nil, args ] + @tfx << [ chan, tfx ] + end + + def continue(*args) + @scope << args + begin + return args.last.call + ensure + @scope.pop + end + end + + def err(msg) + raise ArgumentError, "usage: #{msg}" + end + + def parse_tlen(tlen, tbeg, usage) + case tlen + when String + absolute = tlen.sub!(/\A=/, '') # 44:00 =44:55 + tlen = parse_time(tlen) + tlen -= tbeg if absolute + when nil, Numeric + # OK + else + err(usage) + end + tlen + end + + def check_time_overlaps(tbeg, tlen) + @scope.each do |s| + next if s[0] != :at + + tbeg < s[1] and + err("nested at scope starts before existing scope #{s[1]}") + + if tlen && s[2] + tend = tbeg + tlen + send = s[1] + s[2] + tend > send and + err("nested at scope ends after existing scope #{s[2]}") + end + end + end + + def make_format + input = @inputs[0] + case input + when String + DTAS::Format.from_file(env_s, input) + else # [ length, format_hash ] + # stringify keys + h = {} + input[1].each { |k,v| h[k.to_s.freeze] = v } + DTAS::Format.load(h) + end + end + + def env_s + h = {} + @env.each { |k,v| h[k.to_s.freeze] = v } + h + end + + def initialize + @scope = [] + @at = [] + @inputs = [] + @tfx = [] + @env = {} + @format = nil # lazy, wait for input + end +end diff --git a/lib/dtas/tfx.rb b/lib/dtas/tfx.rb index 607d26b..f9870aa 100644 --- a/lib/dtas/tfx.rb +++ b/lib/dtas/tfx.rb @@ -1,42 +1,26 @@ # Copyright (C) 2013-2015 all contributors # License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) require_relative '../dtas' -require_relative 'parse_time' require_relative 'format' require 'shellwords' -# this will represent a trim section inside -splitfx for applying -# effects to only a part of the output class DTAS::TFX - include DTAS::ParseTime - attr_reader :tbeg # samples attr_reader :tlen # samples attr_reader :cmd - def initialize(args, format = DTAS::Format.new) - @format = format - args = args.dup - case args.shift + def initialize(type, tbeg, tlen, args = nil) + @type = type + @tbeg = tbeg + case type when :pad # [ :pad, start_time, end_time ] - @tbeg = args.shift - @tlen = args.shift - @tbeg - when "trim" - parse_trim!(args) - when "all" - @tbeg = 0 - @tlen = nil - else - raise ArgumentError, "#{args.inspect} not understood" - end - case tmp = args.shift - when "sh" then @cmd = args - when "sox" then tfx_sox(args) - when "eca" then tfx_eca(args) - when nil - @cmd = [] + @tlen = tlen ? tlen - @tbeg : nil + raise ArgumentError, 'args must not be specified for pad' if args + when :sox, :eca, :sh + @tlen = tlen + @args = args else - raise ArgumentError, "unknown effect type: #{tmp}" + raise ArgumentError, "#{type.inspect} not understood" end end @@ -52,34 +36,6 @@ class DTAS::TFX @cmd.concat(%w(| sox $ECA2SOX - $SOXOUT)) end - def to_sox_arg - if @tbeg && @tlen - %W(trim #{@tbeg}s #{@tlen}s) - elsif @tbeg - return [] if @tbeg == 0 - %W(trim #{@tbeg}s) - else - [] - end - end - - # tries to interpret "trim" time args the same way the sox trim effect does - # This takes _time_ arguments only, not sample counts; - # otherwise, deviations from sox are considered bugs in dtas - def parse_trim!(args) - beg = parse_time(args.shift) - if args[0] =~ /\A=?[\d\.]+\z/ - len = args.shift - is_stop_time = len.sub!(/\A=/, "") ? true : false - len = parse_time(len) - len = len - beg if is_stop_time - @tlen = (len * @format.rate).round - else - @tlen = nil - end - @tbeg = (beg * @format.rate).round - end - def <=>(other) @tbeg <=> other.tbeg end @@ -145,7 +101,7 @@ class DTAS::TFX # like schedule, but fills in the gaps with pass-through (no-op) TFX objs # This does not change the number of epochs. - def self.expand(ary, total_samples) + def self.expand(ary, total_samples = nil) rv = [] schedule(ary).each_with_index do |sary, epoch| tip = 0 @@ -153,14 +109,14 @@ class DTAS::TFX while tfx = sary.shift if tfx.tbeg > tip # fill in the previous gap - nfx = new([:pad, tip, tfx.tbeg]) + nfx = new(:pad, tip, tfx.tbeg) dst << nfx dst << tfx tip = tfx.tbeg + tfx.tlen end end - if tip < total_samples # fill until the last chunk - nfx = new([:pad, tip, total_samples]) + if total_samples && tip < total_samples # fill until the last chunk + nfx = new(:pad, tip, total_samples) dst << nfx end end diff --git a/test/test_script.rb b/test/test_script.rb new file mode 100644 index 0000000..ab225e2 --- /dev/null +++ b/test/test_script.rb @@ -0,0 +1,53 @@ +# Copyright (C) 2013-2015 all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'helper' +require 'dtas/script' + +class TestScript < Testcase + def setup + @s = DTAS::Script.new + end + + def test_ch + t = self + @s.instance_eval do + ch(1) { sox %w(gain -1) } + end + tfx = @s.instance_variable_get(:@tfx) + assert_equal [[[1], [:sox, 0, nil, nil, %w(gain -1)]]], tfx + + @s.instance_eval do + at(6..66) do + ch(2) { sox %w(gain -9) } + end + end + + assert_equal 2, tfx.size + assert_equal [[2], [:sox, 6, 60, nil, %w(gain -9)]], tfx[1] + end + + def test_at + check = inner = nil + t = self + @s.instance_eval do + at(5..6) do + t.assert_equal 1, t.scope.size + t.assert_equal [:at, 5, 1], t.scope[0][0..2] + check = 'ok' + + at(5.5..5.7) do + t.assert_equal 2, t.scope.size + inner = 'ok' + end + end + t.assert_equal 0, t.scope.size + t.assert_equal 'ok', check, 'block called' + t.assert_equal 'ok', inner, 'inner block called' + end + assert_equal 0, scope.size + end + + def scope + @s.instance_variable_get(:@scope) + end +end diff --git a/test/test_tfx.rb b/test/test_tfx.rb index 59aa697..c261c5e 100644 --- a/test/test_tfx.rb +++ b/test/test_tfx.rb @@ -5,77 +5,36 @@ require 'dtas/tfx' require 'dtas/format' require 'yaml' +# internal class for dtas/script class TestTFX < Testcase - def rate - 44100 - end - - def test_example - ex = YAML.load(File.read("examples/tfx.sample.yml")) - effects = [] - ex["effects"].each do |line| - words = Shellwords.split(line) - case words[0] - when "trim" - tfx = DTAS::TFX.new(words) - assert_equal 52 * rate, tfx.tbeg - assert_equal rate, tfx.tlen - effects << tfx - end - end - assert_equal 4, effects.size - end - - def test_all - tfx = DTAS::TFX.new(%w(all)) - assert_equal 0, tfx.tbeg - assert_nil tfx.tlen - assert_equal [], tfx.to_sox_arg - end - - def test_time - tfx = DTAS::TFX.new(%w(trim 2:30 3.1)) - assert_equal 150 * rate, tfx.tbeg - assert_equal((3.1 * rate).round, tfx.tlen) - end - - def test_to_sox_arg - tfx = DTAS::TFX.new(%w(trim 1 0.5)) - assert_equal %w(trim 44100s 22050s), tfx.to_sox_arg - - tfx = DTAS::TFX.new(%w(trim 1 sox vol -1dB)) - assert_equal %w(trim 44100s), tfx.to_sox_arg - end - - def test_tfx_effects - tfx = DTAS::TFX.new(%w(trim 1 sox vol -1dB)) - assert_equal %w(sox $SOXIN $SOXOUT $TRIMFX vol -1dB), tfx.cmd + def rate(ary) + ary.map { |x| x * 44100 } end def test_schedule_simple fx = [ - DTAS::TFX.new(%w(trim 1 0.3)), - DTAS::TFX.new(%w(trim 2 0.2)), - DTAS::TFX.new(%w(trim 0.5 0.5)), + DTAS::TFX.new(:sox, 1, 0.3), + DTAS::TFX.new(:sox, 2, 0.2), + DTAS::TFX.new(:sox, 0.5, 0.5), ].shuffle ary = DTAS::TFX.schedule(fx) assert_operator 1, :==, ary.size - assert_equal [ 22050, 44100, 88200 ], ary[0].map(&:tbeg) - assert_equal [ 22050, 13230, 8820 ], ary[0].map(&:tlen) + assert_equal [ 22050, 44100, 88200 ], rate(ary[0].map(&:tbeg)) + assert_equal [ 22050, 13230, 8820 ], rate(ary[0].map(&:tlen)) end def test_schedule_overlaps fx = [ - DTAS::TFX.new(%w(trim 1 0.3 sox)), - DTAS::TFX.new(%w(trim 1.1 0.2 sox)), - DTAS::TFX.new(%w(trim 0.5 0.5 sox)), + DTAS::TFX.new(:sox, 1, 0.3), + DTAS::TFX.new(:sox, 1.1, 0.2), + DTAS::TFX.new(:sox, 0.5, 0.5), ] ary = DTAS::TFX.schedule(fx) assert_equal 2, ary.size - assert_equal [ 22050, 44100 ], ary[0].map(&:tbeg) - assert_equal [ 48510 ], ary[1].map(&:tbeg) + assert_equal [ 22050, 44100 ], rate(ary[0].map(&:tbeg)) + assert_equal [ 48510 ], rate(ary[1].map(&:tbeg)).map(&:round) # :x - ex = DTAS::TFX.expand(fx, 10 * rate) + ex = DTAS::TFX.expand(fx, 10) assert_equal 2, ex.size assert_equal 0, ex[0][0].tbeg assert_equal 3, ex[0].size -- EW