about summary refs log tree commit homepage
diff options
context:
space:
mode:
authorEric Wong <normalperson@yhbt.net>2013-08-25 11:40:19 +0000
committerEric Wong <normalperson@yhbt.net>2013-08-25 11:40:19 +0000
commit0f9bfb784b7bbccf86b7d7d241982ba4e703efd4 (patch)
treeee8cdbf659457a82ac5d03df77fae8bc00728e68
parent157b75aa71bb2d9e7140cd72f29451fa234a902a (diff)
downloaddtas-0f9bfb784b7bbccf86b7d7d241982ba4e703efd4.tar.gz
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).
-rw-r--r--lib/dtas/player.rb21
-rw-r--r--lib/dtas/process.rb4
-rw-r--r--lib/dtas/source/av.rb97
-rw-r--r--lib/dtas/source/file.rb8
-rw-r--r--lib/dtas/source/sox.rb16
-rw-r--r--test/test_source_av.rb114
6 files changed, 251 insertions, 9 deletions
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 <normalperson@yhbt.net>
+# 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 <normalperson@yhbt.net>
+# 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