From 571d07e99bc0051ad51e1f5947a0ad28eefb557f Mon Sep 17 00:00:00 2001 From: Eric Wong Date: Mon, 30 Sep 2013 03:26:00 +0000 Subject: player: support seeking based on embedded cuesheet (FLAC) This adds the ability to seek internally within FLAC file based on the internal CUE sheet. Other formats may be supported in the future, but FLAC is the only one I know of which supports embedded cue sheets. Note: flac 1.3.0 is recommended for users of non-CDDA-compatible formats. See updates to dtas-player_protocol(7) for details. --- Documentation/dtas-player_protocol.txt | 17 ++++++++ lib/dtas/cue_index.rb | 39 +++++++++++++++++ lib/dtas/player.rb | 2 + lib/dtas/player/client_handler.rb | 79 +++++++++++++++++++++++++++++++--- lib/dtas/source/file.rb | 29 +++++++++++++ test/test_source_sox.rb | 43 ++++++++++++++++++ 6 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 lib/dtas/cue_index.rb diff --git a/Documentation/dtas-player_protocol.txt b/Documentation/dtas-player_protocol.txt index be1063a..daafa3e 100644 --- a/Documentation/dtas-player_protocol.txt +++ b/Documentation/dtas-player_protocol.txt @@ -80,6 +80,23 @@ Commands here should be alphabetized according to `LC_ALL=C sort' * clear - clear current queue (current track/command continues running) PENDING: this may be renamed to "queue clear" or "queue-clear" +* cue - display the index/offsets of the file based on the embedded + cue sheet, if any + +* cue next - skip to the next section of the track based on the + embedded cue sheet. This may skip to the next track if there is + no embedded cue sheet or if playing the last (embedded) track + +* cue prev - skip to the previous section of the track based on + the embedded cue sheet. This may just seek to the beginning + if there is no embedded cue sheet or if we are playing the first + (embedded) track. + +* cue goto INTEGER - go to the embedded track with cue index denoted + by INTEGER (0 is first track as returned by "cue"). Negative + values of INTEGER allows selecting track relative to the last + track (-1 is the last track, -2 is the penultimate, and so on). + * current - output information about the currently-playing track/command in YAML. The structure of this is unstable and subject to change. diff --git a/lib/dtas/cue_index.rb b/lib/dtas/cue_index.rb new file mode 100644 index 0000000..09c3709 --- /dev/null +++ b/lib/dtas/cue_index.rb @@ -0,0 +1,39 @@ +# Copyright (C) 2013, Eric Wong and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative '../dtas' +class DTAS::CueIndex + attr_reader :offset + attr_reader :index + + def initialize(index, offset) + @index = index.to_i + + # must be compatible with the sox "trim" effect + @offset = offset # "#{INTEGER}s" (samples) or HH:MM:SS:FRAC + end + + def to_hash + { "index" => @index, "offset" => @offset } + end + + def offset_samples(format) + case @offset + when /\A(\d+)s\z/ + $1.to_i + else + format.hhmmss_to_samples(@offset) + end + end + + def pregap? + @index == 0 + end + + def track? + @index == 1 + end + + def subindex? + @index > 1 + end +end diff --git a/lib/dtas/player.rb b/lib/dtas/player.rb index 7567dfc..efefcbf 100644 --- a/lib/dtas/player.rb +++ b/lib/dtas/player.rb @@ -206,6 +206,8 @@ class DTAS::Player # :nodoc: io.emit("OK") when "rg" rg_handler(io, msg) + when "cue" + cue_handler(io, msg) when "skip" skip_handler(io, msg) when "sink" diff --git a/lib/dtas/player/client_handler.rb b/lib/dtas/player/client_handler.rb index 91dfada..2f356ab 100644 --- a/lib/dtas/player/client_handler.rb +++ b/lib/dtas/player/client_handler.rb @@ -351,6 +351,16 @@ module DTAS::Player::ClientHandler # :nodoc: @paused ? do_play : do_pause end + def seek_internal(cur, offset) + if cur.requeued + @queue[0][1] = offset + else + @queue.unshift([ cur.infile, offset ]) + cur.requeued = true + __buf_reset(cur.dst) # trigger EPIPE + end + end + def do_seek(io, offset) if @current if @current.respond_to?(:infile) @@ -364,13 +374,7 @@ module DTAS::Player::ClientHandler # :nodoc: rescue ArgumentError return io.emit("ERR bad time format") end - if @current.requeued - @queue[0][1] = offset - else - @queue.unshift([ @current.infile, offset ]) - @current.requeued = true - __buf_reset(@current.dst) # trigger EPIPE - end + seek_internal(@current, offset) else return io.emit("ERR unseekable") end @@ -607,5 +611,66 @@ module DTAS::Player::ClientHandler # :nodoc: io.emit("OK") end end + + def __bp_prev_next(io, msg, cur, bp) + case type = msg[1] + when nil, "track" + bp.keep_if { |ci| ci.track? } + when "pregap" + bp.keep_if { |ci| ci.pregap? } + when "subindex" # any subindex + bp.keep_if { |ci| ci.subindex? } + when /\A\d+\z/ # exact subindex match + si = type.to_i + bp.keep_if { |ci| ci.index == si } + when "any" # anything goes + else + return io.emit("INVALID TYPE") + end + fmt = cur.format + case msg[0] + when "next" + ds = __current_decoded_samples + bp.each do |ci| + next if ci.offset_samples(fmt) < ds + seek_internal(cur, ci.offset) + return io.emit("OK") + end + # go to the next (real) track if not found + __current_drop + when "prev" + os = cur.offset_samples # where we currently started + bp.reverse_each do |ci| + next if ci.offset_samples(fmt) >= os + seek_internal(cur, ci.offset) + return io.emit("OK") + end + # offset may be nil/zero if we couldn't find a previous breakpoint + seek_internal(cur, '0') + end + io.emit("OK") + end + + def cue_handler(io, msg) + cur = @current + if cur.respond_to?(:cuebreakpoints) + offset = nil + bp = cur.cuebreakpoints + case cmd = msg[0] + when nil + tmp = { "infile" => cur.infile, "cue" => bp.map { |ci| ci.to_hash } } + io.emit(tmp.to_yaml) + when "next", "prev" + return __bp_prev_next(io, msg, cur, bp) + when "goto" + index = msg[1] or return io.emit("NOINDEX") + ci = bp[index.to_i] or return io.emit("BADINDEX") + seek_internal(cur, ci.offset) + return io.emit("OK") + end + else + io.emit("NOCUE") + end + end end # :startdoc: diff --git a/lib/dtas/source/file.rb b/lib/dtas/source/file.rb index 3dadc67..8657b0c 100644 --- a/lib/dtas/source/file.rb +++ b/lib/dtas/source/file.rb @@ -5,6 +5,7 @@ require_relative '../source' require_relative '../command' require_relative '../format' require_relative '../process' +require_relative '../cue_index' module DTAS::Source::File # :nodoc: attr_reader :infile @@ -33,6 +34,7 @@ module DTAS::Source::File # :nodoc: @offset = offset @comments = nil @samples = nil + @cuebp = nil @rg = nil end @@ -90,4 +92,31 @@ module DTAS::Source::File # :nodoc: defaults = source_defaults # see dtas/source/{av,sox}.rb to_source_cat.delete_if { |k,v| v == defaults[k] } end + + def cuebreakpoints + rv = @cuebp and return rv + rv = [] + begin + str = qx(@env, %W(metaflac --export-cuesheet-to=- #@infile)) + rescue + return rv + end + str.scan(/^ INDEX (\d+) (\S+)/) do |m| + index = m[0] + time = m[1].dup + case time + when /\A\d+\z/ + time << "s" # sample count (flac 1.3.0) + else # HH:MM:SS:FF + # FF/75 CDDA frames per second, convert to fractional seconds + time.sub!(/:(\d+)\z/, "") + frames = $1.to_f + if frames > 0 + time = sprintf("#{time}.%0.6g", frames / 75.0) + end + end + rv << DTAS::CueIndex.new(index, time) + end + @cuebp = rv + end end diff --git a/test/test_source_sox.rb b/test/test_source_sox.rb index 367f4df..36605a5 100644 --- a/test/test_source_sox.rb +++ b/test/test_source_sox.rb @@ -111,4 +111,47 @@ class TestSource < Testcase tmp.unlink end end + + def test_flac_cuesheet_cdda + return if `which metaflac`.strip.size == 0 + tmp = Tempfile.new(%W(tmp .flac)) + x(%W(sox -n -r44100 -b16 -c2 #{tmp.path} synth 5 pluck vol -1dB)) + cue = Tempfile.new(%W(tmp .cue)) + cue.puts %Q(FILE "ignored.flac" FLAC) + cue.puts " TRACK 01 AUDIO" + cue.puts " INDEX 01 00:00:00" + cue.puts " TRACK 02 AUDIO" + cue.puts " INDEX 01 00:01:40" + cue.puts " TRACK 03 AUDIO" + cue.puts " INDEX 01 00:03:00" + cue.flush + x(%W(metaflac --import-cuesheet-from=#{cue.path} #{tmp.path})) + source = DTAS::Source::Sox.new.try(tmp.path) + offsets = source.cuebreakpoints.map(&:offset) + assert_equal %w(00:00 00:01.0.533333 00:03), offsets + source.cuebreakpoints.all?(&:track?) + end + + def test_flac_cuesheet_48 + return if `which metaflac`.strip.size == 0 + ver = `flac --version`.split(/ /)[1].strip + ver.to_f >= 1.3 or return # flac 1.3.0 fixed non-44.1k rate support + + tmp = Tempfile.new(%W(tmp .flac)) + x(%W(sox -n -r48000 -c2 -b24 #{tmp.path} synth 5 pluck vol -1dB)) + cue = Tempfile.new(%W(tmp .cue)) + cue.puts %Q(FILE "ignored.flac" FLAC) + cue.puts " TRACK 01 AUDIO" + cue.puts " INDEX 01 00:00:00" + cue.puts " TRACK 02 AUDIO" + cue.puts " INDEX 01 00:01:00" + cue.puts " TRACK 03 AUDIO" + cue.puts " INDEX 01 00:03:00" + cue.flush + x(%W(metaflac --import-cuesheet-from=#{cue.path} #{tmp.path})) + source = DTAS::Source::Sox.new.try(tmp.path) + offsets = source.cuebreakpoints.map(&:offset) + assert_equal %w(0s 48000s 144000s), offsets + source.cuebreakpoints.all?(&:track?) + end end -- cgit v1.2.3-24-ge0c7