dtas 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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