dtas-all
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[PATCH] dtas-partstats: initial implementation


From: Eric Wong
Subject: [PATCH] dtas-partstats: initial implementation
Date: Wed, 9 Oct 2013 09:13:30 +0000
User-agent: Mutt/1.5.21 (2010-09-15)

dtas-partstats divides large audio files into small partitions (10
seconds by default) and runs the "stats" effect of sox(1) against each
partition.

Currently it emits space-delimited output, but configurable output
options (including Sequel/SQLite) support is coming.

The intended use of this tool is for quickly finding the loudest
portions of a given recording without the need for a graphical viewer.
This can be useful for selectively applying (and testing the results of)
dynamic range compression filters.

Use with sort(1) in a pipeline is recommended in this scenario
(but again, Sequel support is coming).
---
 bin/dtas-partstats    |  39 +++++++++++
 lib/dtas/partstats.rb | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 226 insertions(+)
 create mode 100755 bin/dtas-partstats
 create mode 100644 lib/dtas/partstats.rb

diff --git a/bin/dtas-partstats b/bin/dtas-partstats
new file mode 100755
index 0000000..e29ec73
--- /dev/null
+++ b/bin/dtas-partstats
@@ -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)
+# TODO
+# - option parsing: sox effects, stats effect options
+# - support piping out to external processes
+# - configurable output formatting
+# - Sequel/SQLite support
+require 'dtas/partstats'
+infile = ARGV[0] or abort "usage: #$0 INFILE"
+ps = DTAS::PartStats.new(infile)
+opts = {
+  jobs: `nproc 2>/dev/null || echo 2`.to_i
+}
+stats = ps.run(opts)
+
+headers = ps.key_idx.to_a
+headers = headers.sort_by! { |(n,i)| i }.map! { |(n,_)| n }
+width = ps.key_width
+print "    time "
+puts(headers.map do |h|
+  cols = width[h]
+  sprintf("% #{(cols * 6)+cols-1}s", h.tr(' ','_'))
+end.join(" | "))
+
+stats.each do |row|
+  trim_part = row.shift
+  print "#{trim_part.hhmmss} "
+  puts(row.map do |group|
+    group.map do |f|
+      case f
+      when Float
+        sprintf("% 6.2f", f)
+      else
+        sprintf("% 6s", f)
+      end
+    end.join(" ")
+  end.join(" | "))
+end
diff --git a/lib/dtas/partstats.rb b/lib/dtas/partstats.rb
new file mode 100644
index 0000000..fa1b255
--- /dev/null
+++ b/lib/dtas/partstats.rb
@@ -0,0 +1,187 @@
+# -*- encoding: binary -*-
+# 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-partstats is fairly tied to sox
+require_relative '../dtas'
+require_relative 'xs'
+require_relative 'process'
+require_relative 'sigevent'
+
+class DTAS::PartStats
+  CMD = 'sox "$INFILE" -n $TRIMFX $SOXFX stats $STATSOPTS'
+  include DTAS::Process
+  attr_reader :key_idx
+  attr_reader :key_width
+
+  class TrimPart < Struct.new(:tbeg, :tlen, :rate)
+    def sec
+      tbeg / rate
+    end
+
+    def hhmmss
+      Time.at(sec).strftime("%H:%M:%S")
+    end
+  end
+
+  def initialize(infile)
+    @infile = infile
+    %w(samples rate channels).each do |iv|
+      sw = iv[0] # -s, -r, -c
+      i = qx(%W(soxi -#{sw} address@hidden)).to_i
+      raise ArgumentError, "invalid #{iv}: #{i}" if i <= 0
+      instance_variable_set("@#{iv}", i)
+    end
+
+    # "Pk lev dB" => 1, "RMS lev dB" => 2, ...
+    @key_nr = 0
+    @key_idx = Hash.new { |h,k| h[k] = (@key_nr += 1) }
+    @key_width = {}
+  end
+
+  def partitions(chunk_sec)
+    n = 0
+    part_samples = chunk_sec * @rate
+    rv = []
+    begin
+      rv << TrimPart.new(n, part_samples, @rate)
+      n += part_samples
+    end while n < @samples
+    rv
+  end
+
+  def spawn(trim_part, opts)
+    rd, wr = IO.pipe
+    env = opts[:env]
+    env = env ? env.dup : {}
+    env["INFILE"] = @infile
+    env["TRIMFX"] = "trim #{trim_part.tbeg}s #{trim_part.tlen}s"
+    opts = { pgroup: true, close_others: true, err: wr }
+    pid = begin
+      Process.spawn(env, CMD, opts)
+    rescue Errno::EINTR # Ruby bug?
+      retry
+    end
+    wr.close
+    [ pid, rd ]
+  end
+
+  def run(opts = {})
+    sev = DTAS::Sigevent.new
+    trap(:CHLD) { sev.signal }
+    jobs = opts[:jobs] || 2
+    pids = {}
+    rset = {}
+    stats = []
+    fails = []
+    do_spawn = lambda do |trim_part|
+      pid, rpipe = spawn(trim_part, opts)
+      rset[rpipe] = [ trim_part, "" ]
+      pids[pid] = [ trim_part, rpipe ]
+    end
+
+    parts = partitions(opts[:chunk_length] || 10)
+    jobs.times do
+      trim_part = parts.shift or break
+      do_spawn.call(trim_part)
+    end
+
+    rset[sev] = true
+
+    while pids.size > 0
+      r = IO.select(rset.keys) or next
+      r[0].each do |rd|
+        if DTAS::Sigevent === rd
+          rd.readable_iter do |_,_|
+            begin
+              pid, status = Process.waitpid2(-1, Process::WNOHANG)
+              pid or break
+              done = pids.delete(pid)
+              done_part = done[0]
+              if status.success?
+                trim_part = parts.shift and do_spawn.call(trim_part)
+                puts "DONE #{done_part}" if $DEBUG
+              else
+                fails << [ done_part, status ]
+              end
+            rescue Errno::ECHILD
+              break
+            end while true
+          end
+        else
+          # spurious wakeup should not happen on local pipes,
+          # so readpartial should be safe
+          trim_part, buf = rset[rd]
+          begin
+            buf << rd.readpartial(666)
+          rescue EOFError
+            rset.delete(rd)
+            rd.close
+            parse_stats(stats, trim_part, buf)
+          end
+        end
+      end
+    end
+
+    return stats if fails.empty? && parts.empty?
+    fails.each do |(trim_part,status)|
+      warn "FAIL #{status.inspect} #{trim_part}"
+    end
+    false
+  ensure
+    sev.close
+  end
+
+# "sox INFILE -n stats" example output
+=begin
+             Overall     Left      Right
+DC offset   0.001074  0.000938  0.001074
+Min level  -0.997711 -0.997711 -0.997711
+Max level   0.997681  0.997681  0.997681
+Pk lev dB      -0.02     -0.02     -0.02
+RMS lev dB    -10.38     -9.90    -10.92
+RMS Pk dB      -4.62     -4.62     -5.10
+RMS Tr dB     -87.25    -86.58    -87.25
+Crest factor       -      3.12      3.51
+Flat factor    19.41     19.66     18.89
+Pk count        117k      156k     77.4k
+Bit-depth      16/16     16/16     16/16
+Num samples    17.2M
+Length s     389.373
+Scale max   1.000000
+Window s       0.050
+
+becomes:
+  [
+    TrimPart,
+    [ -0.02, -0.02, -0.02 ], # Pk lev dB
+    [ -10.38, -9.90, -10.92 ], # RMS lev dB
+    ...
+  ]
+=end
+
+  def parse_stats(stats, trim_part, buf)
+    trim_row = [ trim_part ]
+    buf.split(/\n/).each do |line|
+      do_map = true
+      case line
+      when /\A(\S+ \S+ dB)\s/, /\A(Crest factor)\s+-\s/
+        nshift = 3
+      when /\A(Flat factor)\s/
+        nshift = 2
+      when /\A(Pk count)\s/
+        nshift = 2
+        do_map = false
+      else
+        next
+      end
+      key = $1
+      key.freeze
+      key_idx = @key_idx[key]
+      parts = line.split(/\s+/)
+      nshift.times { parts.shift } # remove stuff we don't need
+      @key_width[key] = parts.size
+      trim_row[key_idx] = do_map ? parts.map!(&:to_f) : parts
+    end
+    stats[trim_part.tbeg / trim_part.tlen] = trim_row
+  end
+end
-- 
1.8.4




reply via email to

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