[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
[Prev in Thread] |
Current Thread |
[Next in Thread] |
- [PATCH] dtas-partstats: initial implementation,
Eric Wong <=