From 0f9bfb784b7bbccf86b7d7d241982ba4e703efd4 Mon Sep 17 00:00:00 2001 From: Eric Wong Date: Sun, 25 Aug 2013 11:40:19 +0000 Subject: preliminary support for avconv/avprobe from libav avconv is capable of outputting to the .sox format, greatly simplifying our life as it enables us to easily apply sox effects on a per-source file basis. dtas-sourceedit and the "source" protocol commands will need to change to support internal priorities (like sink). --- lib/dtas/player.rb | 21 ++++++++- lib/dtas/process.rb | 4 ++ lib/dtas/source/av.rb | 97 ++++++++++++++++++++++++++++++++++++++++ lib/dtas/source/file.rb | 8 ++++ lib/dtas/source/sox.rb | 16 ++++--- test/test_source_av.rb | 114 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 lib/dtas/source/av.rb create mode 100644 test/test_source_av.rb diff --git a/lib/dtas/player.rb b/lib/dtas/player.rb index 57976c2..99c7400 100644 --- a/lib/dtas/player.rb +++ b/lib/dtas/player.rb @@ -6,6 +6,7 @@ require 'shellwords' require_relative '../dtas' require_relative 'source' require_relative 'source/sox' +require_relative 'source/av' require_relative 'source/cmd' require_relative 'sink' require_relative 'unix_server' @@ -297,6 +298,21 @@ class DTAS::Player # :nodoc: end end + def try_file(*args) + [ DTAS::Source::Sox, DTAS::Source::Av ].each do |klass| + rv = klass.try(*args) and return rv + end + + # keep going down the list until we find something + while source_spec = @queue.shift + [ DTAS::Source::Sox, DTAS::Source::Av ].each do |klass| + rv = klass.try(*source_spec) and return rv + end + end + echo "idle" + nil + end + def next_source(source_spec) @current = nil if source_spec @@ -305,16 +321,17 @@ class DTAS::Player # :nodoc: case source_spec when String - @current = DTAS::Source::Sox.new(source_spec) + @current = try_file(source_spec) echo(%W(file #{@current.infile})) when Array - @current = DTAS::Source::Sox.new(*source_spec) + @current = try_file(*source_spec) echo(%W(file #{@current.infile} #{@current.offset_samples}s)) else @current = DTAS::Source::Cmd.new(source_spec["command"]) echo(%W(command #{@current.command_string})) end + # FIXME, support Av overrides if DTAS::Source::Sox === @current @current.command = @srccmd if @srccmd @current.env = @srcenv.dup unless @srcenv.empty? diff --git a/lib/dtas/process.rb b/lib/dtas/process.rb index 2806fbf..2d7dcb0 100644 --- a/lib/dtas/process.rb +++ b/lib/dtas/process.rb @@ -84,4 +84,8 @@ module DTAS::Process # :nodoc: return res if status.success? raise RuntimeError, "`#{xs(cmd)}' failed: #{status.inspect}" end + + # XXX only for DTAS::Source::{Sox,Av}.try + module_function :qx + module_function :xs end diff --git a/lib/dtas/source/av.rb b/lib/dtas/source/av.rb new file mode 100644 index 0000000..804a66b --- /dev/null +++ b/lib/dtas/source/av.rb @@ -0,0 +1,97 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative '../../dtas' +require_relative '../source' +require_relative '../replaygain' + +# this is usually one input file +class DTAS::Source::Av # :nodoc: + require_relative 'file' + + include DTAS::Source::File + + AV_DEFAULTS = COMMAND_DEFAULTS.merge( + "command" => + 'avconv -v error $SSPOS -i "$INFILE" -f sox - | sox -p $SOXFMT - $RGFX', + "comments" => nil, + ) + + attr_reader :precision # always 32 + attr_reader :format + + def self.try(infile, offset = nil) + err = "" + DTAS::Process.qx(%W(avprobe #{infile}), err: err) + return if err =~ /Unable to find a suitable output format for/ + new(infile, offset) + rescue + end + + def initialize(infile, offset = nil) + command_init(AV_DEFAULTS) + source_file_init(infile, offset) + @precision = 32 # this still goes through sox, which is 32-bit + do_avprobe + end + + def do_avprobe + @duration = nil + @format = DTAS::Format.new + @format.bits = @precision + @comments = {} + err = "" + s = qx(%W(avprobe -show_streams -show_format #@infile), err: err) + s.scan(%r{^\[STREAM\]\n(.*?)\n\[/STREAM\]\n}m) do |_| + stream = $1 + # XXX what to do about multiple streams? + if stream =~ /^codec_type=audio$/ + duration = channels = rate = nil + stream =~ /^duration=([\d\.]+)\s*$/m and duration = $1.to_f + stream =~ /^channels=(\d)\s*$/m and channels = $1.to_i + stream =~ /^sample_rate=([\d\.]+)\s*$/m and rate = $1.to_i + if channels > 0 && rate > 0 + @duration = duration + @format.channels = channels + @format.rate = rate + end + end + end + s.scan(%r{^\[FORMAT\]\n(.*?)\n\[/FORMAT\]\n}m) do |_| + f = $1 + f =~ /^duration=([\d\.]+)\s*$/m and @duration ||= $1.to_f + # TODO: multi-line/multi-value/repeated tags + f.gsub!(/^TAG:([^=]+)=(.*)$/i) { |_| @comments[$1.upcase] = $2 } + end + end + + def sspos(offset) + offset =~ /\A(\d+)s\z/ or return "-ss #{offset}" + samples = $1.to_f + sprintf("-ss %0.9g", samples / @format.rate) + end + + def spawn(format, rg_state, opts) + raise "BUG: #{self.inspect}#spawn called twice" if @to_io + e = @format.to_env + e["INFILE"] = @infile + + # make sure these are visible to the "current" command... + @env["SSPOS"] = @offset ? sspos(@offset) : nil + @env["RGFX"] = rg_state.effect(self) || nil + e.merge!(@rg.to_env) if @rg + + @pid = dtas_spawn(e.merge!(@env), command_string, opts) + end + + + # This is the number of samples according to the samples in the source + # file itself, not the decoded output + def samples + @samples ||= (@duration * @format.rate).round + end + + def to_hsh + to_hash.delete_if { |k,v| v == AV_DEFAULTS[k] } + end +end diff --git a/lib/dtas/source/file.rb b/lib/dtas/source/file.rb index 472cb3d..a66308b 100644 --- a/lib/dtas/source/file.rb +++ b/lib/dtas/source/file.rb @@ -11,9 +11,11 @@ module DTAS::Source::File # :nodoc: attr_reader :infile attr_reader :offset require_relative 'common' # dtas/source/common + require_relative 'mp3gain' include DTAS::Command include DTAS::Process include DTAS::Source::Common + include DTAS::Source::Mp3gain FILE_SIVS = %w(infile comments command env) @@ -60,4 +62,10 @@ module DTAS::Source::File # :nodoc: rv["samples"] = samples rv end + + def replaygain + @rg ||= DTAS::ReplayGain.new(comments) || + DTAS::ReplayGain.new(mp3gain_comments) + end + end diff --git a/lib/dtas/source/sox.rb b/lib/dtas/source/sox.rb index 64ce095..954c1a5 100644 --- a/lib/dtas/source/sox.rb +++ b/lib/dtas/source/sox.rb @@ -8,16 +8,23 @@ require_relative '../replaygain' # this is usually one input file class DTAS::Source::Sox # :nodoc: require_relative 'file' - require_relative 'mp3gain' include DTAS::Source::File - include DTAS::Source::Mp3gain SOX_DEFAULTS = COMMAND_DEFAULTS.merge( "command" => 'exec sox "$INFILE" $SOXFMT - $TRIMFX $RGFX', "comments" => nil, ) + + def self.try(infile, offset = nil) + err = "" + DTAS::Process.qx(%W(soxi #{infile}), err: err) + return if err =~ /soxi FAIL formats:/ + new(infile, offset) + rescue + end + def initialize(infile, offset = nil) command_init(SOX_DEFAULTS) source_file_init(infile, offset) @@ -79,11 +86,6 @@ class DTAS::Source::Sox # :nodoc: tmp end - def replaygain - @rg = DTAS::ReplayGain.new(comments) || - DTAS::ReplayGain.new(mp3gain_comments) - end - def spawn(format, rg_state, opts) raise "BUG: #{self.inspect}#spawn called twice" if @to_io e = format.to_env diff --git a/test/test_source_av.rb b/test/test_source_av.rb new file mode 100644 index 0000000..2104b7e --- /dev/null +++ b/test/test_source_av.rb @@ -0,0 +1,114 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require './test/helper' +require 'dtas/source/av' +require 'tempfile' + +class TestSourceAv < Minitest::Unit::TestCase + def teardown + @tempfiles.each { |tmp| tmp.close! } + end + + def setup + @tempfiles = [] + end + + def x(cmd) + system(*cmd) + assert $?.success?, cmd.inspect + end + + def new_file(suffix) + tmp = Tempfile.new(%W(tmp .#{suffix})) + @tempfiles << tmp + cmd = %W(sox -r 44100 -b 16 -c 2 -n #{tmp.path} trim 0 1) + return tmp if system(*cmd) + nil + end + + def test_flac + return if `which metaflac`.strip.size == 0 + tmp = new_file('flac') or return + + x(%W(metaflac --set-tag=FOO=BAR #{tmp.path})) + x(%W(metaflac --add-replay-gain #{tmp.path})) + source = DTAS::Source::Av.new(tmp.path) + assert_equal source.comments["FOO"], "BAR", source.inspect + rg = source.replaygain + assert_kind_of DTAS::ReplayGain, rg + assert_in_delta 0.0, rg.track_peak.to_f, 0.00000001 + assert_in_delta 0.0, rg.album_peak.to_f, 0.00000001 + assert_operator rg.album_gain.to_f, :>, 1 + assert_operator rg.track_gain.to_f, :>, 1 + end + + def test_mp3gain + return if `which mp3gain`.strip.size == 0 + a = new_file('mp3') or return + b = new_file('mp3') or return + + # redirect stdout to /dev/null temporarily, mp3gain is noisy + File.open("/dev/null", "w") do |null| + old_out = $stdout.dup + $stdout.reopen(null) + begin + x(%W(mp3gain -q #{a.path} #{b.path})) + ensure + $stdout.reopen(old_out) + old_out.close + end + end + + source = DTAS::Source::Av.new(a.path) + rg = source.replaygain + assert_kind_of DTAS::ReplayGain, rg + assert_in_delta 0.0, rg.track_peak.to_f, 0.00000001 + assert_in_delta 0.0, rg.album_peak.to_f, 0.00000001 + assert_operator rg.album_gain.to_f, :>, 1 + assert_operator rg.track_gain.to_f, :>, 1 + end + + def test_offset + tmp = new_file('flac') or return + source = DTAS::Source::Av.new(*%W(#{tmp.path} 5s)) + assert_equal 5, source.offset_samples + + source = DTAS::Source::Av.new(*%W(#{tmp.path} 1:00:00.5)) + expect = 1 * 60 * 60 * 44100 + (44100/2) + assert_equal expect, source.offset_samples + + source = DTAS::Source::Av.new(*%W(#{tmp.path} 1:10.5)) + expect = 1 * 60 * 44100 + (10 * 44100) + (44100/2) + assert_equal expect, source.offset_samples + + source = DTAS::Source::Av.new(*%W(#{tmp.path} 10.03)) + expect = (10 * 44100) + (44100 * 3/100.0) + assert_equal expect, source.offset_samples + end + + def test_offset_us + tmp = new_file('flac') or return + source = DTAS::Source::Av.new(*%W(#{tmp.path} 441s)) + assert_equal 10000.0, source.offset_us + + source = DTAS::Source::Av.new(*%W(#{tmp.path} 22050s)) + assert_equal 500000.0, source.offset_us + + source = DTAS::Source::Av.new(tmp.path, '1') + assert_equal 1000000.0, source.offset_us + end + + def test_format_from_file + Tempfile.open(%w(tmp .wav)) do |tmp| + # generate an empty file with 1s of audio + cmd = %W(sox -r 96000 -b 24 -c 2 -n #{tmp.path} trim 0 1) + system(*cmd) + assert $?.success?, "#{cmd.inspect} failed: #$?" + fmt = DTAS::Source::Av.new(tmp.path).format + assert_equal 96000, fmt.rate + assert_equal 2, fmt.channels + tmp.unlink + end + end +end -- cgit v1.2.3-24-ge0c7