dtas 0.0.0 → 0.1.I
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Documentation/dtas-console.txt +15 -0
- data/Documentation/dtas-player_protocol.txt +5 -3
- data/Documentation/dtas-sourceedit.txt +14 -7
- data/GIT-VERSION-GEN +3 -3
- data/INSTALL +3 -3
- data/README +6 -5
- data/Rakefile +16 -7
- data/bin/dtas-console +48 -3
- data/bin/dtas-cueedit +1 -1
- data/bin/dtas-sinkedit +12 -28
- data/bin/dtas-sourceedit +15 -30
- data/lib/dtas.rb +1 -1
- data/lib/dtas/command.rb +0 -5
- data/lib/dtas/compat_onenine.rb +2 -2
- data/lib/dtas/disclaimer.rb +4 -3
- data/lib/dtas/edit_client.rb +48 -0
- data/lib/dtas/format.rb +2 -9
- data/lib/dtas/player.rb +64 -28
- data/lib/dtas/player/client_handler.rb +39 -20
- data/lib/dtas/process.rb +16 -15
- data/lib/dtas/replaygain.rb +19 -3
- data/lib/dtas/sink.rb +1 -2
- data/lib/dtas/source.rb +1 -141
- data/lib/dtas/source/av.rb +29 -0
- data/lib/dtas/source/av_ff_common.rb +127 -0
- data/lib/dtas/source/{command.rb → cmd.rb} +1 -1
- data/lib/dtas/source/ff.rb +30 -0
- data/lib/dtas/source/file.rb +94 -0
- data/lib/dtas/source/{mp3.rb → mp3gain.rb} +1 -1
- data/lib/dtas/source/sox.rb +114 -0
- data/lib/dtas/unix_client.rb +1 -9
- data/test/player_integration.rb +5 -17
- data/test/test_format.rb +10 -14
- data/test/test_format_change.rb +4 -8
- data/test/test_player_integration.rb +50 -62
- data/test/test_process.rb +33 -0
- data/test/test_rg_integration.rb +45 -35
- data/test/test_sink_pipe_size.rb +20 -0
- data/test/test_sink_tee_integration.rb +2 -4
- data/test/{test_source.rb → test_source_av.rb} +16 -16
- data/test/test_source_sox.rb +115 -0
- metadata +23 -12
- data/.rsync_doc +0 -3
data/lib/dtas/process.rb
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
|
4
4
|
require 'shellwords'
|
5
5
|
require 'io/wait'
|
6
|
+
require_relative '../dtas'
|
6
7
|
module DTAS::Process # :nodoc:
|
7
8
|
PIDS = {}
|
8
9
|
|
@@ -17,12 +18,6 @@ module DTAS::Process # :nodoc:
|
|
17
18
|
end while true
|
18
19
|
end
|
19
20
|
|
20
|
-
# a convienient way to display commands so it's easy to
|
21
|
-
# read, copy and paste to a shell
|
22
|
-
def xs(cmd)
|
23
|
-
cmd.map { |w| Shellwords.escape(w) }.join(' ')
|
24
|
-
end
|
25
|
-
|
26
21
|
# for long-running processes (sox/play/ecasound filters)
|
27
22
|
def dtas_spawn(env, cmd, opts)
|
28
23
|
opts = { close_others: true, pgroup: true }.merge!(opts)
|
@@ -43,31 +38,35 @@ module DTAS::Process # :nodoc:
|
|
43
38
|
|
44
39
|
# this is like backtick, but takes an array instead of a string
|
45
40
|
# This will also raise on errors
|
46
|
-
def qx(cmd, opts = {})
|
41
|
+
def qx(env, cmd = {}, opts = {})
|
42
|
+
unless Hash === env
|
43
|
+
cmd, opts = env, cmd
|
44
|
+
env = {}
|
45
|
+
end
|
47
46
|
r, w = IO.pipe
|
48
47
|
opts = opts.merge(out: w)
|
49
48
|
r.binmode
|
50
|
-
|
49
|
+
no_raise = opts.delete(:no_raise)
|
50
|
+
if err_str = opts.delete(:err_str)
|
51
51
|
re, we = IO.pipe
|
52
52
|
re.binmode
|
53
53
|
opts[:err] = we
|
54
54
|
end
|
55
55
|
pid = begin
|
56
|
-
Process.spawn(*cmd, opts)
|
56
|
+
Process.spawn(env, *cmd, opts)
|
57
57
|
rescue Errno::EINTR # Ruby bug?
|
58
58
|
retry
|
59
59
|
end
|
60
60
|
w.close
|
61
|
-
if
|
61
|
+
if err_str
|
62
62
|
we.close
|
63
63
|
res = ""
|
64
|
-
want = { r => res, re =>
|
64
|
+
want = { r => res, re => err_str }
|
65
65
|
begin
|
66
66
|
readable = IO.select(want.keys) or next
|
67
67
|
readable[0].each do |io|
|
68
|
-
bytes = io.nread
|
69
68
|
begin
|
70
|
-
want[io] << io.read_nonblock(
|
69
|
+
want[io] << io.read_nonblock(2000)
|
71
70
|
rescue Errno::EAGAIN
|
72
71
|
# spurious wakeup, bytes may be zero
|
73
72
|
rescue EOFError
|
@@ -77,11 +76,13 @@ module DTAS::Process # :nodoc:
|
|
77
76
|
end until want.empty?
|
78
77
|
re.close
|
79
78
|
else
|
80
|
-
res = r.read
|
79
|
+
res = r.read # read until EOF
|
81
80
|
end
|
82
81
|
r.close
|
83
82
|
_, status = Process.waitpid2(pid)
|
84
83
|
return res if status.success?
|
85
|
-
|
84
|
+
return status if no_raise
|
85
|
+
raise RuntimeError,
|
86
|
+
"`#{Shellwords.join(Array(cmd))}' failed: #{status.inspect}"
|
86
87
|
end
|
87
88
|
end
|
data/lib/dtas/replaygain.rb
CHANGED
@@ -10,7 +10,11 @@
|
|
10
10
|
|
11
11
|
class DTAS::ReplayGain # :nodoc:
|
12
12
|
ATTRS = %w(reference_loudness track_gain album_gain track_peak album_peak)
|
13
|
-
|
13
|
+
ENV_ATTRS = {}
|
14
|
+
ATTRS.each do |a|
|
15
|
+
attr_reader a
|
16
|
+
ENV_ATTRS["REPLAYGAIN_#{a.upcase}"] = a
|
17
|
+
end
|
14
18
|
|
15
19
|
def check_gain(val)
|
16
20
|
/([+-]?\d+(?:\.\d+)?)/ =~ val ? $1 : nil
|
@@ -20,13 +24,25 @@ class DTAS::ReplayGain # :nodoc:
|
|
20
24
|
/(\d+(?:\.\d+)?)/ =~ val ? $1 : nil
|
21
25
|
end
|
22
26
|
|
27
|
+
# note: this strips the "dB" suffix, but that should be easier for apps
|
28
|
+
# to deal with anyways...
|
29
|
+
def to_env
|
30
|
+
rv = {}
|
31
|
+
# this will cause nil to be set if some envs are missing, this causes
|
32
|
+
# Process.spawn to unset the environment if it was previously set
|
33
|
+
# (leaked from some other process)
|
34
|
+
ENV_ATTRS.each do |env_name, attr_name|
|
35
|
+
rv[env_name] = __send__(attr_name)
|
36
|
+
end
|
37
|
+
rv
|
38
|
+
end
|
39
|
+
|
23
40
|
def initialize(comments)
|
24
41
|
comments or return
|
25
42
|
|
26
43
|
# the replaygain standard specifies 89.0 dB, but maybe some apps are
|
27
44
|
# different...
|
28
|
-
@reference_loudness =
|
29
|
-
check_gain(comments["REPLAYGAIN_REFERENCE_LOUDNESS"]) || "89.0"
|
45
|
+
@reference_loudness = check_gain(comments["REPLAYGAIN_REFERENCE_LOUDNESS"])
|
30
46
|
|
31
47
|
@track_gain = check_gain(comments["REPLAYGAIN_TRACK_GAIN"])
|
32
48
|
@album_gain = check_gain(comments["REPLAYGAIN_ALBUM_GAIN"])
|
data/lib/dtas/sink.rb
CHANGED
@@ -15,8 +15,8 @@ class DTAS::Sink # :nodoc:
|
|
15
15
|
attr_accessor :prio # any Integer
|
16
16
|
attr_accessor :active # boolean
|
17
17
|
attr_accessor :name
|
18
|
+
attr_accessor :pipe_size
|
18
19
|
attr_accessor :nonblock
|
19
|
-
attr_accessor :respawn
|
20
20
|
|
21
21
|
include DTAS::Command
|
22
22
|
include DTAS::Process
|
@@ -30,7 +30,6 @@ class DTAS::Sink # :nodoc:
|
|
30
30
|
"nonblock" => false,
|
31
31
|
"pipe_size" => nil,
|
32
32
|
"active" => false,
|
33
|
-
"respawn" => false,
|
34
33
|
})
|
35
34
|
|
36
35
|
DEVFD_RE = %r{/dev/fd/([a-zA-Z]\w*)\b}
|
data/lib/dtas/source.rb
CHANGED
@@ -2,146 +2,6 @@
|
|
2
2
|
# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
|
3
3
|
# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
|
4
4
|
require_relative '../dtas'
|
5
|
-
require_relative 'command'
|
6
|
-
require_relative 'format'
|
7
|
-
require_relative 'replaygain'
|
8
|
-
require_relative 'process'
|
9
|
-
require_relative 'serialize'
|
10
5
|
|
11
|
-
|
12
|
-
class DTAS::Source # :nodoc:
|
13
|
-
attr_reader :infile
|
14
|
-
attr_reader :offset
|
15
|
-
require_relative 'source/common'
|
16
|
-
require_relative 'source/mp3'
|
17
|
-
|
18
|
-
include DTAS::Command
|
19
|
-
include DTAS::Process
|
20
|
-
include DTAS::Source::Common
|
21
|
-
include DTAS::Source::Mp3
|
22
|
-
|
23
|
-
SOURCE_DEFAULTS = COMMAND_DEFAULTS.merge(
|
24
|
-
"command" => 'exec sox "$INFILE" $SOXFMT - $TRIMFX $RGFX',
|
25
|
-
"comments" => nil,
|
26
|
-
)
|
27
|
-
|
28
|
-
SIVS = %w(infile comments command env)
|
29
|
-
|
30
|
-
def initialize(infile, offset = nil)
|
31
|
-
command_init(SOURCE_DEFAULTS)
|
32
|
-
@format = nil
|
33
|
-
@infile = infile
|
34
|
-
@offset = offset
|
35
|
-
@comments = nil
|
36
|
-
@samples = nil
|
37
|
-
end
|
38
|
-
|
39
|
-
# this exists mainly to make the mpris interface easier, but it's not
|
40
|
-
# necessary, the mpris interface also knows the sample rate
|
41
|
-
def offset_us
|
42
|
-
(offset_samples / format.rate.to_f) * 1000000
|
43
|
-
end
|
44
|
-
|
45
|
-
# returns any offset in samples (relative to the original source file),
|
46
|
-
# likely zero unless seek was used
|
47
|
-
def offset_samples
|
48
|
-
return 0 unless @offset
|
49
|
-
case @offset
|
50
|
-
when /\A\d+s\z/
|
51
|
-
@offset.to_i
|
52
|
-
else
|
53
|
-
format.hhmmss_to_samples(@offset)
|
54
|
-
end
|
55
|
-
end
|
56
|
-
|
57
|
-
def precision
|
58
|
-
qx(%W(soxi -p #@infile), err: "/dev/null").to_i # sox.git f4562efd0aa3
|
59
|
-
rescue # fallback to parsing the whole output
|
60
|
-
s = qx(%W(soxi #@infile), err: "/dev/null")
|
61
|
-
s =~ /Precision\s+:\s*(\d+)-bit/
|
62
|
-
v = $1.to_i
|
63
|
-
return v if v > 0
|
64
|
-
raise TypeError, "could not determine precision for #@infile"
|
65
|
-
end
|
66
|
-
|
67
|
-
def format
|
68
|
-
@format ||= begin
|
69
|
-
fmt = DTAS::Format.new
|
70
|
-
fmt.from_file(@infile)
|
71
|
-
fmt.bits ||= precision
|
72
|
-
fmt
|
73
|
-
end
|
74
|
-
end
|
75
|
-
|
76
|
-
# A user may be downloading the file and start playing
|
77
|
-
# it before the download completes, this refreshes
|
78
|
-
def samples!
|
79
|
-
@samples = nil
|
80
|
-
samples
|
81
|
-
end
|
82
|
-
|
83
|
-
# This is the number of samples according to the samples in the source
|
84
|
-
# file itself, not the decoded output
|
85
|
-
def samples
|
86
|
-
@samples ||= qx(%W(soxi -s #@infile)).to_i
|
87
|
-
rescue => e
|
88
|
-
warn e.message
|
89
|
-
0
|
90
|
-
end
|
91
|
-
|
92
|
-
# just run soxi -a
|
93
|
-
def __load_comments
|
94
|
-
tmp = {}
|
95
|
-
case @infile
|
96
|
-
when String
|
97
|
-
err = ""
|
98
|
-
cmd = %W(soxi -a #@infile)
|
99
|
-
begin
|
100
|
-
qx(cmd, err: err).split(/\n/).each do |line|
|
101
|
-
key, value = line.split(/=/, 2)
|
102
|
-
key && value or next
|
103
|
-
# TODO: multi-line/multi-value/repeated tags
|
104
|
-
tmp[key.upcase] = value
|
105
|
-
end
|
106
|
-
rescue => e
|
107
|
-
if /FAIL formats: no handler for file extension/ =~ err
|
108
|
-
warn("#{xs(cmd)}: #{err}")
|
109
|
-
else
|
110
|
-
warn("#{e.message} (#{e.class})")
|
111
|
-
end
|
112
|
-
# TODO: fallbacks
|
113
|
-
end
|
114
|
-
end
|
115
|
-
tmp
|
116
|
-
end
|
117
|
-
|
118
|
-
def comments
|
119
|
-
@comments ||= __load_comments
|
120
|
-
end
|
121
|
-
|
122
|
-
def replaygain
|
123
|
-
DTAS::ReplayGain.new(comments) || DTAS::ReplayGain.new(mp3gain_comments)
|
124
|
-
end
|
125
|
-
|
126
|
-
def spawn(format, rg_state, opts)
|
127
|
-
raise "BUG: #{self.inspect}#spawn called twice" if @to_io
|
128
|
-
e = format.to_env
|
129
|
-
e["INFILE"] = @infile
|
130
|
-
|
131
|
-
# make sure these are visible to the "current" command...
|
132
|
-
@env["TRIMFX"] = @offset ? "trim #@offset" : nil
|
133
|
-
@env["RGFX"] = rg_state.effect(self) || nil
|
134
|
-
|
135
|
-
@pid = dtas_spawn(e.merge!(@env), command_string, opts)
|
136
|
-
end
|
137
|
-
|
138
|
-
def to_hsh
|
139
|
-
to_hash.delete_if { |k,v| v == SOURCE_DEFAULTS[k] }
|
140
|
-
end
|
141
|
-
|
142
|
-
def to_hash
|
143
|
-
rv = ivars_to_hash(SIVS)
|
144
|
-
rv["samples"] = samples
|
145
|
-
rv
|
146
|
-
end
|
6
|
+
module DTAS::Source # :nodoc:
|
147
7
|
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
|
3
|
+
# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
|
4
|
+
require_relative '../../dtas'
|
5
|
+
require_relative 'av_ff_common'
|
6
|
+
|
7
|
+
# this is usually one input file
|
8
|
+
class DTAS::Source::Av # :nodoc:
|
9
|
+
include DTAS::Source::AvFfCommon
|
10
|
+
|
11
|
+
AV_DEFAULTS = COMMAND_DEFAULTS.merge(
|
12
|
+
"command" =>
|
13
|
+
'avconv -v error $SSPOS -i "$INFILE" $AMAP -f sox - |' \
|
14
|
+
'sox -p $SOXFMT - $RGFX',
|
15
|
+
|
16
|
+
# this is above ffmpeg because this av is the Debian default and
|
17
|
+
# it's easier for me to test av than ff
|
18
|
+
"tryorder" => 1,
|
19
|
+
)
|
20
|
+
|
21
|
+
def initialize
|
22
|
+
command_init(AV_DEFAULTS)
|
23
|
+
@av_ff_probe = "avprobe"
|
24
|
+
end
|
25
|
+
|
26
|
+
def source_defaults
|
27
|
+
AV_DEFAULTS
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
|
3
|
+
# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
|
4
|
+
require_relative '../../dtas'
|
5
|
+
require_relative '../source'
|
6
|
+
require_relative '../replaygain'
|
7
|
+
require_relative 'file'
|
8
|
+
|
9
|
+
# Common code for libav (avconv/avprobe) and ffmpeg (and ffprobe)
|
10
|
+
# TODO: newer versions of both *probes support JSON, which will be easier to
|
11
|
+
# parse. However, the packaged libav version in Debian 7.0 does not
|
12
|
+
# support JSON, so we have an ugly parser...
|
13
|
+
module DTAS::Source::AvFfCommon # :nodoc:
|
14
|
+
include DTAS::Source::File
|
15
|
+
AStream = Struct.new(:duration, :channels, :rate)
|
16
|
+
AV_FF_TRYORDER = 1
|
17
|
+
|
18
|
+
attr_reader :precision # always 32
|
19
|
+
attr_reader :format
|
20
|
+
|
21
|
+
def try(infile, offset = nil)
|
22
|
+
rv = source_file_dup(infile, offset)
|
23
|
+
rv.av_ff_ok? or return
|
24
|
+
rv
|
25
|
+
end
|
26
|
+
|
27
|
+
def av_ff_ok?
|
28
|
+
@duration = nil
|
29
|
+
@format = DTAS::Format.new
|
30
|
+
@format.bits = 32 # always, since we still use the "sox" format
|
31
|
+
@comments = {}
|
32
|
+
@astreams = []
|
33
|
+
cmd = %W(#@av_ff_probe -show_streams -show_format #@infile)
|
34
|
+
err = ""
|
35
|
+
s = qx(@env, cmd, err_str: err, no_raise: true)
|
36
|
+
return false if Process::Status === s
|
37
|
+
return false if err =~ /Unable to find a suitable output format for/
|
38
|
+
s.scan(%r{^\[STREAM\]\n(.*?)\n\[/STREAM\]\n}m) do |_|
|
39
|
+
stream = $1
|
40
|
+
if stream =~ /^codec_type=audio$/
|
41
|
+
as = AStream.new
|
42
|
+
index = nil
|
43
|
+
stream =~ /^index=(\d+)\s*$/m and index = $1.to_i
|
44
|
+
stream =~ /^duration=([\d\.]+)\s*$/m and as.duration = $1.to_f
|
45
|
+
stream =~ /^channels=(\d)\s*$/m and as.channels = $1.to_i
|
46
|
+
stream =~ /^sample_rate=([\d\.]+)\s*$/m and as.rate = $1.to_i
|
47
|
+
index or raise "BUG: no audio index from #{Shellwords.join(cmd)}"
|
48
|
+
|
49
|
+
# some streams have zero channels
|
50
|
+
@astreams[index] = as if as.channels > 0 && as.rate > 0
|
51
|
+
end
|
52
|
+
end
|
53
|
+
s.scan(%r{^\[FORMAT\]\n(.*?)\n\[/FORMAT\]\n}m) do |_|
|
54
|
+
f = $1
|
55
|
+
f =~ /^duration=([\d\.]+)\s*$/m and @duration = $1.to_f
|
56
|
+
# TODO: multi-line/multi-value/repeated tags
|
57
|
+
f.gsub!(/^TAG:([^=]+)=(.*)$/i) { |_| @comments[$1.upcase] = $2 }
|
58
|
+
end
|
59
|
+
! @astreams.empty?
|
60
|
+
end
|
61
|
+
|
62
|
+
def sspos(offset)
|
63
|
+
offset =~ /\A(\d+)s\z/ or return "-ss #{offset}"
|
64
|
+
samples = $1.to_f
|
65
|
+
sprintf("-ss %0.9g", samples / @format.rate)
|
66
|
+
end
|
67
|
+
|
68
|
+
def select_astream(as)
|
69
|
+
@format.channels = as.channels
|
70
|
+
@format.rate = as.rate
|
71
|
+
|
72
|
+
# favor the duration of the stream we're playing instead of
|
73
|
+
# duration we got from [FORMAT]. However, some streams may not have
|
74
|
+
# a duration and only have it in [FORMAT]
|
75
|
+
@duration = as.duration if as.duration
|
76
|
+
end
|
77
|
+
|
78
|
+
def amap_fallback
|
79
|
+
@astreams.each_with_index do |as, index|
|
80
|
+
as or next
|
81
|
+
select_astream(as)
|
82
|
+
warn "no suitable audio stream in #@infile, trying stream=#{index}"
|
83
|
+
return "-map 0:#{i}"
|
84
|
+
end
|
85
|
+
raise "BUG: no audio stream in #@infile"
|
86
|
+
end
|
87
|
+
|
88
|
+
def spawn(player_format, rg_state, opts)
|
89
|
+
raise "BUG: #{self.inspect}#spawn called twice" if @to_io
|
90
|
+
amap = nil
|
91
|
+
|
92
|
+
# try to find an audio stream which matches our channel count
|
93
|
+
# we need to set @format for sspos() down below
|
94
|
+
@astreams.each_with_index do |as, i|
|
95
|
+
if as && as.channels == player_format.channels
|
96
|
+
select_astream(as)
|
97
|
+
amap = "-map 0:#{i}"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# fall back to the first audio stream
|
102
|
+
# we must call select_astream before sspos
|
103
|
+
amap ||= amap_fallback
|
104
|
+
|
105
|
+
e = @env.merge!(player_format.to_env)
|
106
|
+
|
107
|
+
# make sure these are visible to the source command...
|
108
|
+
e["INFILE"] = @infile
|
109
|
+
e["AMAP"] = amap
|
110
|
+
e["SSPOS"] = @offset ? sspos(@offset) : nil
|
111
|
+
e["RGFX"] = rg_state.effect(self) || nil
|
112
|
+
e.merge!(@rg.to_env) if @rg
|
113
|
+
|
114
|
+
@pid = dtas_spawn(e, command_string, opts)
|
115
|
+
end
|
116
|
+
|
117
|
+
# This is the number of samples according to the samples in the source
|
118
|
+
# file itself, not the decoded output
|
119
|
+
def samples
|
120
|
+
@samples ||= (@duration * @format.rate).round
|
121
|
+
end
|
122
|
+
|
123
|
+
def to_hsh
|
124
|
+
sd = source_defaults
|
125
|
+
to_hash.delete_if { |k,v| v == sd[k] }
|
126
|
+
end
|
127
|
+
end
|