dtas 0.3.0 → 0.4.0

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/Documentation/GNUmakefile +2 -0
  3. data/Documentation/dtas-player_protocol.txt +28 -1
  4. data/Documentation/dtas-splitfx.txt +167 -0
  5. data/Documentation/dtas-tl.txt +77 -0
  6. data/GIT-VERSION-GEN +2 -1
  7. data/GNUmakefile +1 -1
  8. data/README +2 -1
  9. data/Rakefile +7 -0
  10. data/bin/dtas-console +11 -1
  11. data/bin/dtas-splitfx +40 -0
  12. data/bin/dtas-tl +73 -0
  13. data/examples/README +3 -0
  14. data/examples/splitfx.sample.yml +19 -0
  15. data/lib/dtas/format.rb +11 -0
  16. data/lib/dtas/pipe.rb +0 -3
  17. data/lib/dtas/player.rb +38 -11
  18. data/lib/dtas/player/client_handler.rb +94 -7
  19. data/lib/dtas/process.rb +25 -3
  20. data/lib/dtas/sink.rb +0 -3
  21. data/lib/dtas/source/sox.rb +2 -1
  22. data/lib/dtas/splitfx.rb +342 -0
  23. data/lib/dtas/tracklist.rb +130 -0
  24. data/test/helper.rb +14 -1
  25. data/test/player_integration.rb +5 -3
  26. data/test/test_buffer.rb +4 -2
  27. data/test/test_env.rb +55 -0
  28. data/test/test_format.rb +1 -1
  29. data/test/test_format_change.rb +1 -1
  30. data/test/test_player.rb +1 -1
  31. data/test/test_player_client_handler.rb +1 -1
  32. data/test/test_player_integration.rb +3 -2
  33. data/test/test_process.rb +1 -1
  34. data/test/test_rg_integration.rb +4 -5
  35. data/test/test_rg_state.rb +1 -1
  36. data/test/test_sink.rb +1 -1
  37. data/test/test_sink_pipe_size.rb +1 -1
  38. data/test/test_sink_tee_integration.rb +1 -1
  39. data/test/test_source_av.rb +1 -1
  40. data/test/test_source_sox.rb +1 -1
  41. data/test/test_splitfx.rb +79 -0
  42. data/test/test_tracklist.rb +76 -0
  43. data/test/test_unixserver.rb +1 -1
  44. data/test/test_util.rb +1 -1
  45. metadata +23 -3
data/lib/dtas/sink.rb CHANGED
@@ -7,7 +7,6 @@ require_relative 'process'
7
7
  require_relative 'command'
8
8
  require_relative 'format'
9
9
  require_relative 'serialize'
10
- require_relative 'writable_iter'
11
10
 
12
11
  # this is a sink (endpoint, audio enters but never leaves)
13
12
  class DTAS::Sink # :nodoc:
@@ -20,7 +19,6 @@ class DTAS::Sink # :nodoc:
20
19
  include DTAS::Command
21
20
  include DTAS::Process
22
21
  include DTAS::Serialize
23
- include DTAS::WritableIter
24
22
 
25
23
  SINK_DEFAULTS = COMMAND_DEFAULTS.merge({
26
24
  "name" => nil, # order matters, this is first
@@ -39,7 +37,6 @@ class DTAS::Sink # :nodoc:
39
37
 
40
38
  def initialize
41
39
  command_init(SINK_DEFAULTS)
42
- writable_iter_init
43
40
  @sink = self
44
41
  end
45
42
 
@@ -11,6 +11,7 @@ class DTAS::Source::Sox # :nodoc:
11
11
 
12
12
  include DTAS::Source::File
13
13
  include DTAS::XS
14
+ extend DTAS::XS
14
15
 
15
16
  SOX_DEFAULTS = COMMAND_DEFAULTS.merge(
16
17
  "command" => 'exec sox "$INFILE" $SOXFMT - $TRIMFX $RGFX',
@@ -40,7 +41,7 @@ class DTAS::Source::Sox # :nodoc:
40
41
  def try(infile, offset = nil)
41
42
  err = ""
42
43
  cmd = %W(soxi -s #{infile})
43
- s = qx(@env, cmd, err_str: err, no_raise: true)
44
+ s = qx(@env.dup, cmd, err_str: err, no_raise: true)
44
45
  return if err =~ /soxi FAIL formats:/
45
46
  self.class.try_to_fail_harder(infile, s, cmd) or return
46
47
  source_file_dup(infile, offset)
@@ -0,0 +1,342 @@
1
+ # Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
2
+ # License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
3
+ # Unlike the stuff for dtas-player, dtas-splitfx is fairly tied to sox
4
+ # (but we may still pipe to ecasound or anything else)
5
+ require_relative '../dtas'
6
+ require_relative 'format'
7
+ require_relative 'process'
8
+ require_relative 'xs'
9
+ require 'tempfile'
10
+ class DTAS::SplitFX # :nodoc:
11
+ CMD = 'sox "$INFILE" $COMMENTS $OUTFMT "$TRACKNUMBER.$SUFFIX" '\
12
+ '$TRIMFX $RATEFX $DITHERFX'
13
+ include DTAS::Process
14
+ include DTAS::XS
15
+
16
+ class Skip < Struct.new(:tstart) # :nodoc:
17
+ def commit(_)
18
+ # noop
19
+ end
20
+ end
21
+
22
+ class T < Struct.new(:env, :comments, :tstart, :fade_in, :fade_out) # :nodoc:
23
+ def commit(advance_track_samples)
24
+ tlen = advance_track_samples - tstart
25
+ trimfx = "trim #{tstart}s #{tlen}s"
26
+ if fade_in
27
+ trimfx << " #{fade_in}"
28
+ end
29
+ if fade_out
30
+ tmp = fade_out.dup
31
+ fade_out_len = tmp.pop or
32
+ raise ArgumentError, "fade_out needs a time value"
33
+ fade_type = tmp.pop # may be nil
34
+ fade = " fade #{fade_type} 0 #{tlen}s #{fade_out_len}"
35
+ trimfx << fade
36
+ end
37
+ env["TRIMFX"] = trimfx
38
+ end
39
+ end
40
+
41
+ # vars:
42
+ # $CHANNELS (input)
43
+ # $BITS_PER_SAMPLE (input)
44
+ def initialize
45
+ @env = {}
46
+ @comments = {}
47
+ @track_start = 1
48
+ @track_zpad = true
49
+ @t2s = method(:t2s)
50
+ @infile = nil
51
+ @targets = {
52
+ "flac-cdda" => {
53
+ "command" => CMD,
54
+ "format" => {
55
+ "bits" => 16,
56
+ "rate" => 44100,
57
+ "type" => "flac",
58
+ "channels" => 2,
59
+ },
60
+ },
61
+ "opusenc" => {
62
+ "command" => 'sox "$INFILE" $COMMENTS $OUTFMT - ' \
63
+ '$TRIMFX $RATEFX $DITHERFX | opusenc --music ' \
64
+ '--raw-bits $BITS_PER_SAMPLE ' \
65
+ '$OPUSENC_BITRATE --raw-rate $RATE --raw-chan $CHANNELS ' \
66
+ '--raw-endianness $ENDIAN_OPUSENC ' \
67
+ '$OPUSENC_COMMENTS ' \
68
+ '- $TRACKNUMBER.opus',
69
+ "format" => {
70
+ "bits" => 16,
71
+ "rate" => 48000,
72
+ "type" => "s16",
73
+ "channels" => 2,
74
+ },
75
+ },
76
+ }
77
+ @tracks = []
78
+ @infmt = nil # wait until input is assigned
79
+ end
80
+
81
+ def _bool(hash, key)
82
+ val = hash[key]
83
+ case val
84
+ when false, true then yield val
85
+ when nil # ignore
86
+ else
87
+ raise TypeError, "'#{key}' must be boolean (true or false)"
88
+ end
89
+ end
90
+
91
+ def import(hash, overrides = {})
92
+ # merge overrides from the command-line
93
+ overrides.each do |k,v|
94
+ case v
95
+ when Hash then hash[k] = (hash[k] || {}).merge(v)
96
+ else
97
+ hash[k] = v
98
+ end
99
+ end
100
+
101
+ hash = hash.merge(overrides)
102
+ case v = hash["track_zpad"]
103
+ when Integer then @track_zpad = val
104
+ else
105
+ _bool(hash, "track_zpad") { |val| @track_zpad = val }
106
+ end
107
+
108
+ _bool(hash, "cdda_align") { |val| @t2s = method(val ? :t2s : :t2s_cdda) }
109
+
110
+ case v = hash["track_start"]
111
+ when Integer then @track_start = v
112
+ when nil
113
+ else
114
+ raise TypeError, "'track_start' must be an integer"
115
+ end
116
+
117
+ %w(comments env targets).each do |key|
118
+ case val = hash[key]
119
+ when Hash then instance_variable_get("@#{key}").merge!(val)
120
+ when nil
121
+ else
122
+ raise TypeError, "'#{key}' must be a hash"
123
+ end
124
+ end
125
+
126
+ @targets.each_value do |thsh|
127
+ case tfmt = thsh["format"]
128
+ when Hash
129
+ thsh["format"] = DTAS::Format.load(tfmt) unless tfmt.empty?
130
+ end
131
+ end
132
+
133
+ load_input!(hash)
134
+ load_tracks!(hash)
135
+ end
136
+
137
+ # FIXME: duplicate from dtas/source/sox
138
+ def precision
139
+ qx(@env, %W(soxi -p #@infile), err: "/dev/null").to_i # sox.git f4562efd0aa3
140
+ rescue # fallback to parsing the whole output
141
+ s = qx(@env, %W(soxi #@infile), err: "/dev/null")
142
+ s =~ /Precision\s+:\s*(\d+)-bit/n
143
+ v = $1.to_i
144
+ return v if v > 0
145
+ raise TypeError, "could not determine precision for #@infile"
146
+ end
147
+
148
+ def load_input!(hash)
149
+ @infile = hash["infile"] or raise ArgumentError, "'infile' not specified"
150
+ if infmt = hash["infmt"] # rarely needed
151
+ @infmt = DTAS::Format.load(infmt)
152
+ else # likely
153
+ @infmt = DTAS::Format.new
154
+ @infmt.channels = qx(@env, %W(soxi -c #@infile)).to_i
155
+ @infmt.rate = qx(@env, %W(soxi -r #@infile)).to_i
156
+ @infmt.bits ||= precision
157
+ # we don't care for type
158
+ end
159
+ end
160
+
161
+ def generic_target(target = "flac")
162
+ outfmt = @infmt.dup
163
+ outfmt.type = target
164
+ { "command" => CMD, "format" => outfmt }
165
+ end
166
+
167
+ def spawn(target, t, opts)
168
+ target = @targets[target] || generic_target(target)
169
+ outfmt = target["format"]
170
+ env = outfmt.to_env
171
+
172
+ # set very high quality resampling if using 24-bit or higher output
173
+ if outfmt.rate != @infmt.rate
174
+ if outfmt.bits
175
+ # set very-high resampling quality for 24-bit outputs
176
+ quality = "-v" if outfmt.bits >= 24
177
+ else
178
+ # assume output bits matches input bits
179
+ quality = "-v" if @infmt.bits >= 24
180
+ end
181
+ env["RATEFX"] = "rate #{quality} #{outfmt.rate}"
182
+ end
183
+
184
+ # add noise-shaped dither for 16-bit (sox manual seems to recommend this)
185
+ outfmt.bits && outfmt.bits <= 16 and env["DITHERFX"] = "dither -s"
186
+ comments = Tempfile.new(%W(dtas-splitfx-#{t.comments["TRACKNUMBER"]} .txt))
187
+ comments.sync = true
188
+ t.comments.each do |k,v|
189
+ env[k] = v.to_s
190
+ comments.puts("#{k}=#{v}")
191
+ end
192
+ env["COMMENTS"] = "--comment-file=#{comments.path}"
193
+ env["INFILE"] = @infile
194
+ env["OUTFMT"] = xs(outfmt.to_sox_arg)
195
+ env["SUFFIX"] = outfmt.type
196
+ env.merge!(t.env)
197
+
198
+ command = target["command"]
199
+ tmp = Shellwords.split(command).map do |arg|
200
+ qx(env, "printf %s \"#{arg}\"")
201
+ end
202
+ echo = "echo #{xs(tmp)}"
203
+ if opts[:dryrun]
204
+ command = echo
205
+ else
206
+ system(echo) unless opts[:silent]
207
+ end
208
+
209
+ # pgroup: false so Ctrl-C on command-line will immediately stop everything
210
+ [ dtas_spawn(env, command, pgroup: false), comments ]
211
+ end
212
+
213
+ def load_tracks!(hash)
214
+ tracks = hash["tracks"] or raise ArgumentError, "'tracks' not specified"
215
+ tracks.each { |line| parse_track(Shellwords.split(line)) }
216
+
217
+ fmt = "%d"
218
+ case @track_zpad
219
+ when true
220
+ max = @track_start - 1 + @tracks.size
221
+ fmt = "%0#{max.to_s.size}d"
222
+ when Integer
223
+ fmt = "%0#{@track_zpad}d"
224
+ else
225
+ fmt = "%d"
226
+ end
227
+ nr = @track_start
228
+ @tracks.delete_if do |t|
229
+ case t
230
+ when Skip
231
+ true
232
+ else
233
+ t.comments["TRACKNUMBER"] = sprintf(fmt, nr)
234
+ nr += 1
235
+ false
236
+ end
237
+ end
238
+ end
239
+
240
+ # argv:
241
+ # [ 't', '0:05', 'track one', 'fade_in=t 4', '.comment=blah' ]
242
+ # [ 'stop', '1:00' ]
243
+ def parse_track(argv)
244
+ case cmd = argv.shift
245
+ when "t"
246
+ start_time = argv.shift
247
+ title = argv.shift
248
+ t = T.new
249
+ t.tstart = @t2s.call(start_time)
250
+ t.comments = @comments.dup
251
+ t.comments["TITLE"] = title
252
+ t.env = @env.dup
253
+
254
+ argv.each do |arg|
255
+ case arg
256
+ when %r{\Afade_in=(.+)\z}
257
+ # generate fade-in effect
258
+ # $1 = "t 4" => "fade t 4 0 0"
259
+ t.fade_in = "fade #$1 0 0"
260
+ when %r{\Afade_out=(.+)\z} # $1 = "t 4" or just "4"
261
+ t.fade_out = $1.split(/\s+/)
262
+ when %r{\A\.(\w+)=(.+)\z} then t.comments[$1] = $2
263
+ else
264
+ raise ArgumentError, "unrecognized arg(s): #{xs(argv)}"
265
+ end
266
+ end
267
+
268
+ prev = @tracks.last and prev.commit(t.tstart)
269
+ @tracks << t
270
+ when "skip"
271
+ stop_time = argv.shift
272
+ argv.empty? or raise ArgumentError, "skip does not take extra args"
273
+ s = Skip.new
274
+ s.tstart = @t2s.call(stop_time)
275
+ # s.comments = {}
276
+ # s.env = {}
277
+ prev = @tracks.last or raise ArgumentError, "no tracks to skip"
278
+ prev.commit(s.tstart)
279
+ @tracks << s
280
+ when "stop"
281
+ stop_time = argv.shift
282
+ argv.empty? or raise ArgumentError, "stop does not take extra args"
283
+ samples = @t2s.call(stop_time)
284
+ prev = @tracks.last and prev.commit(samples)
285
+ else
286
+ raise ArgumentError, "unknown command: #{xs(Array(cmd))}"
287
+ end
288
+ end
289
+
290
+ # like t2s, but align to CDDA sectors (75 frames per second)
291
+ def t2s_cdda(time)
292
+ time = time.dup
293
+ frac = 0
294
+
295
+ # fractions of a second, convert to samples based on sample rate
296
+ # taking into account CDDA alignment
297
+ if time.sub!(/\.(\d+)\z/, "")
298
+ s = "0.#$1".to_f * @infmt.rate / 75
299
+ frac = s.round * 75
300
+ end
301
+
302
+ # feed the rest to the normal function
303
+ t2s(time) + frac
304
+ end
305
+
306
+ def t2s(time)
307
+ @infmt.hhmmss_to_samples(time)
308
+ end
309
+
310
+ def run(target, opts = {})
311
+ fails = []
312
+ tracks = @tracks.dup
313
+ pids = {}
314
+ jobs = opts[:jobs] || tracks.size # jobs == nil => everything at once
315
+ jobs.times.each do
316
+ t = tracks.shift or break
317
+ pid, tmp = spawn(target, t, opts)
318
+ pids[pid] = [ t, tmp ]
319
+ end
320
+
321
+ while pids.size > 0
322
+ pid, status = Process.waitpid2(-1)
323
+ done = pids.delete(pid)
324
+ if status.success?
325
+ if t = tracks.shift
326
+ pid, tmp = spawn(target, t, opts)
327
+ pids[pid] = [ t, tmp ]
328
+ end
329
+ puts "DONE #{done[0].inspect}" if $DEBUG
330
+ done[1].close!
331
+ else
332
+ fails << [ t, status ]
333
+ end
334
+ end
335
+
336
+ return true if fails.empty? && tracks.empty?
337
+ fails.each do |(_t,s)|
338
+ warn "FAIL #{s.inspect} #{_t.inspect}"
339
+ end
340
+ false
341
+ end
342
+ end
@@ -0,0 +1,130 @@
1
+ # Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
2
+ # License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
3
+ require_relative '../dtas'
4
+ require_relative 'serialize'
5
+
6
+ # this is inspired by the MPRIS 2.0 TrackList spec
7
+ class DTAS::Tracklist # :nodoc:
8
+ include DTAS::Serialize
9
+ attr_accessor :repeat # true, false, 1
10
+
11
+ SIVS = %w(list pos repeat)
12
+ TL_DEFAULTS = {
13
+ "list" => [],
14
+ "pos" => -1,
15
+ "repeat" => false,
16
+ }
17
+
18
+ def self.load(hash)
19
+ obj = new
20
+ obj.instance_eval do
21
+ list = hash["list"] and @list.replace(list)
22
+ @pos = hash["pos"] || -1
23
+ @repeat = hash["repeat"] || false
24
+ end
25
+ obj
26
+ end
27
+
28
+ def to_hsh
29
+ ivars_to_hash(SIVS).delete_if { |k,v| TL_DEFAULTS[k] == v }
30
+ end
31
+
32
+ def initialize
33
+ TL_DEFAULTS.each { |k,v| instance_variable_set("@#{k}", v) }
34
+ @list = []
35
+ @goto_off = @goto_pos = nil
36
+ end
37
+
38
+ def reset
39
+ @goto_off = @goto_pos = nil
40
+ @pos = TL_DEFAULTS["pos"]
41
+ end
42
+
43
+ def size
44
+ @list.size
45
+ end
46
+
47
+ # caching this probably isn't worth it. a tracklist is usually
48
+ # a few tens of tracks, maybe a hundred at most.
49
+ def _track_id_map
50
+ by_track_id = {}
51
+ @list.each_with_index { |t,i| by_track_id[t.object_id] = i }
52
+ by_track_id
53
+ end
54
+
55
+ def get_tracks(track_ids)
56
+ by_track_id = _track_id_map
57
+ track_ids.map do |track_id|
58
+ idx = by_track_id[track_id]
59
+ # dtas-mpris fills in the metadata, we just return a path
60
+ [ track_id, idx ? @list[idx] : nil ]
61
+ end
62
+ end
63
+
64
+ def tracks
65
+ @list.map { |t| t.object_id }
66
+ end
67
+
68
+ def advance_track(repeat_ok = true)
69
+ return if @list.empty?
70
+ # @repeat == 1 for single track repeat
71
+ next_pos = @goto_pos || @pos + (@repeat == 1 ? 0 : 1)
72
+ next_off = @goto_off # nil by default
73
+ @goto_pos = @goto_off = nil
74
+ if @list[next_pos]
75
+ @pos = next_pos
76
+ elsif @repeat && repeat_ok
77
+ next_pos = @pos = 0
78
+ else
79
+ return
80
+ end
81
+ [ @list[next_pos], next_off ]
82
+ end
83
+
84
+ def cur_track
85
+ @pos >= 0 ? @list[@pos] : nil
86
+ end
87
+
88
+ def add_track(track, after_track_id = nil, set_as_current = false)
89
+ if after_track_id
90
+ by_track_id = _track_id_map
91
+ idx = by_track_id[after_track_id] or
92
+ raise ArgumentError, "after_track_id invalid"
93
+ @list[idx, 1] = [ @list[idx], track ]
94
+ @pos = idx + 1 if set_as_current
95
+ else # nil = first_track
96
+ @list.unshift(track)
97
+ @pos = 0 if set_as_current
98
+ end
99
+ track.object_id
100
+ end
101
+
102
+ def remove_track(track_id)
103
+ by_track_id = _track_id_map
104
+ if idx = by_track_id.delete(track_id)
105
+ @list[idx] = nil
106
+ @list.compact!
107
+ # TODO: what do we do with @pos (and the currently-playing track)
108
+ end
109
+ end
110
+
111
+ def go_to(track_id, offset_hhmmss = nil)
112
+ by_track_id = _track_id_map
113
+ if idx = by_track_id[track_id]
114
+ @goto_off = offset_hhmmss
115
+ return @list[@goto_pos = idx]
116
+ end
117
+ @goto_pos = nil
118
+ # noop if track_id is invalid
119
+ end
120
+
121
+ def previous!
122
+ return if @list.empty?
123
+ prev_idx = @pos - 1
124
+ if prev_idx < 0
125
+ # stop playback if nothing to go back to.
126
+ prev_idx = @repeat ? @list.size - 1 : @list.size
127
+ end
128
+ @goto_pos = prev_idx
129
+ end
130
+ end