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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Documentation/dtas-console.txt +15 -0
  4. data/Documentation/dtas-player_protocol.txt +5 -3
  5. data/Documentation/dtas-sourceedit.txt +14 -7
  6. data/GIT-VERSION-GEN +3 -3
  7. data/INSTALL +3 -3
  8. data/README +6 -5
  9. data/Rakefile +16 -7
  10. data/bin/dtas-console +48 -3
  11. data/bin/dtas-cueedit +1 -1
  12. data/bin/dtas-sinkedit +12 -28
  13. data/bin/dtas-sourceedit +15 -30
  14. data/lib/dtas.rb +1 -1
  15. data/lib/dtas/command.rb +0 -5
  16. data/lib/dtas/compat_onenine.rb +2 -2
  17. data/lib/dtas/disclaimer.rb +4 -3
  18. data/lib/dtas/edit_client.rb +48 -0
  19. data/lib/dtas/format.rb +2 -9
  20. data/lib/dtas/player.rb +64 -28
  21. data/lib/dtas/player/client_handler.rb +39 -20
  22. data/lib/dtas/process.rb +16 -15
  23. data/lib/dtas/replaygain.rb +19 -3
  24. data/lib/dtas/sink.rb +1 -2
  25. data/lib/dtas/source.rb +1 -141
  26. data/lib/dtas/source/av.rb +29 -0
  27. data/lib/dtas/source/av_ff_common.rb +127 -0
  28. data/lib/dtas/source/{command.rb → cmd.rb} +1 -1
  29. data/lib/dtas/source/ff.rb +30 -0
  30. data/lib/dtas/source/file.rb +94 -0
  31. data/lib/dtas/source/{mp3.rb → mp3gain.rb} +1 -1
  32. data/lib/dtas/source/sox.rb +114 -0
  33. data/lib/dtas/unix_client.rb +1 -9
  34. data/test/player_integration.rb +5 -17
  35. data/test/test_format.rb +10 -14
  36. data/test/test_format_change.rb +4 -8
  37. data/test/test_player_integration.rb +50 -62
  38. data/test/test_process.rb +33 -0
  39. data/test/test_rg_integration.rb +45 -35
  40. data/test/test_sink_pipe_size.rb +20 -0
  41. data/test/test_sink_tee_integration.rb +2 -4
  42. data/test/{test_source.rb → test_source_av.rb} +16 -16
  43. data/test/test_source_sox.rb +115 -0
  44. metadata +23 -12
  45. data/.rsync_doc +0 -3
@@ -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
- if err = opts[:err]
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 err
61
+ if err_str
62
62
  we.close
63
63
  res = ""
64
- want = { r => res, re => err }
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(bytes > 0 ? bytes : 11)
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
- raise RuntimeError, "`#{xs(cmd)}' failed: #{status.inspect}"
84
+ return status if no_raise
85
+ raise RuntimeError,
86
+ "`#{Shellwords.join(Array(cmd))}' failed: #{status.inspect}"
86
87
  end
87
88
  end
@@ -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
- ATTRS.each { |a| attr_reader a }
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"])
@@ -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}
@@ -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
- # this is usually one input file
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
@@ -6,7 +6,7 @@ require_relative '../source'
6
6
  require_relative '../command'
7
7
  require_relative '../serialize'
8
8
 
9
- class DTAS::Source::Command # :nodoc:
9
+ class DTAS::Source::Cmd # :nodoc:
10
10
  require_relative '../source/common'
11
11
 
12
12
  include DTAS::Command