alacit 0.1.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,33 @@
1
+ # AlacIt Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ * Cue-Sheet Splitting (Extraction) - If a matching-named `.cue` sheet is found in the same directory as the audio file, then multiple M4A's are generated based on the Cue Sheet data.
6
+
7
+ ## 0.1.1
8
+
9
+ * Fix RubyGems summary and description.
10
+
11
+ ## 0.1.0
12
+
13
+ * APE support
14
+
15
+ ## 0.0.3
16
+
17
+ * Accepts any assortment of files and directories, and it will process them all in batch.
18
+ * Non-destructive by default. Added `--force` option.
19
+ * Alacit is now a Gem.
20
+ * Suppress FFmpeg's loud output.
21
+ * More UNIX-standard exit codes.
22
+ * Added Unit tests.
23
+ * Code is more Object-oriented.
24
+ * Remove unnecessary install instructions now that it's a Gem.
25
+ * Fix error in help text.
26
+
27
+ ## 0.0.2
28
+
29
+ * WAV support
30
+
31
+ ## 0.0.1
32
+
33
+ * Convert FLAC files to ALAC M4A's.
@@ -6,6 +6,7 @@ Apple Lossless conversion utility. Converts APE, FLAC, and WAV audio files to A
6
6
  * No quality loss
7
7
  * Basic metadata survives: Song, Artist, etc.
8
8
  * Converts entire directories, single files, or any combination thereof.
9
+ * Cue-Sheet splitting / extraction
9
10
  * Puts converted files in same dir as source.
10
11
 
11
12
  ### Install
@@ -51,6 +52,10 @@ AlacIt won't overwrite existing files by default. If you need to, just force ove
51
52
  alacit --force song.flac
52
53
  alacit -f song.flac
53
54
 
55
+ #### Cue-Sheet Splitting
56
+
57
+ Have you ever downloaded an album and it's a single, large audio file along with a `.cue` file? AlacIt will split that into individual files for you. If a matching-named `.cue` sheet is found in the same directory as the audio file, then multiple M4A's are generated based on the Cue Sheet data.
58
+
54
59
  ### Dependencies
55
60
 
56
61
  * **Ruby 1.9.2+**
@@ -9,8 +9,8 @@ spec = Gem::Specification.new do |s|
9
9
  s.email = 'me@russbrooks.com'
10
10
  s.homepage = 'http://russbrooks.com'
11
11
  s.platform = Gem::Platform::RUBY
12
- s.summary = 'APE, FLAC, and WAV to Apple Lossless (ALAC) batch conversion utility.'
13
- s.description = 'Quickly convert entire directories of APE, FLAC, and WAV files to Apple Lossless (ALAC) for importation into iTunes, iPhones, iPads, and iPods.'
12
+ s.summary = 'APE, FLAC, and WAV to Apple Lossless (ALAC) batch conversion utility and cue-sheet splitter.'
13
+ s.description = 'Quickly convert entire directories of APE, FLAC, and WAV files to Apple Lossless (ALAC) for importation into iTunes, iPhones, iPads, and iPods. It does Cue-Sheet splitting too.'
14
14
  s.files = `git ls-files`.split("\n")
15
15
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
16
16
  s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
@@ -12,9 +12,11 @@
12
12
  # On Linux: `sudo apt-get install flac ffmpeg`
13
13
  # Windows : [untested]
14
14
 
15
+ require 'application'
16
+ require 'cuesheet'
17
+ require 'index'
15
18
  require 'open3'
16
19
  require 'version'
17
- require 'application'
18
20
 
19
21
  module AlacIt
20
22
  class Converter < Application
@@ -44,22 +46,28 @@ module AlacIt
44
46
 
45
47
  unless Dir.glob(source_glob).empty?
46
48
  Dir.glob(source_glob) do |file|
47
- m4a_file = file.chomp(File.extname(file)) + '.m4a'
49
+ cue_file = file.chomp(File.extname(file)) + '.cue'
50
+
51
+ if File.exists? cue_file
52
+ extract_songs file, cue_file
53
+ else
54
+ m4a_file = file.chomp(File.extname(file)) + '.m4a'
48
55
 
49
- if !File.exists?(m4a_file) || @options[:force]
50
- command = 'ffmpeg -y -i "' + file + '" -c:a alac "' + m4a_file + '"'
51
- stdout_str, stderr_str, status = Open3.capture3(command)
56
+ if !File.exists?(m4a_file) || @options[:force]
57
+ command = 'ffmpeg -y -i "' + file + '" -c:a alac "' + m4a_file + '"'
58
+ stdout_str, stderr_str, status = Open3.capture3(command)
52
59
 
53
- if status.success?
54
- puts "#{file} converted."
60
+ if status.success?
61
+ puts "#{file} converted."
62
+ else
63
+ $stderr.puts "Error: #{file}: File could not be converted."
64
+ $stderr.puts stderr_str.split("\n").last
65
+ next
66
+ end
55
67
  else
56
- $stderr.puts "Error: #{file}: File could not be converted."
57
- $stderr.puts stderr_str.split("\n").last
68
+ $stderr.puts "Error: \"#{m4a_file}\" exists. Use --force option to overwrite."
58
69
  next
59
70
  end
60
- else
61
- $stderr.puts "Error: #{m4a_file} exists. Use --force option to overwrite."
62
- next
63
71
  end
64
72
  end
65
73
  else
@@ -71,22 +79,30 @@ module AlacIt
71
79
  def convert_file(file)
72
80
  if File.extname(file) =~ /(\.ape|\.flac|\.wav)/i
73
81
  if File.exists? file
74
- m4a_file = file.chomp(File.extname(file)) + '.m4a'
82
+ cue_file = file.chomp(File.extname(file)) + '.cue'
75
83
 
76
- if !File.exists?(m4a_file) || @options[:force]
77
- command = 'ffmpeg -y -i "' + file + '" -acodec alac "' + m4a_file + '"'
78
- stdout_str, stderr_str, status = Open3.capture3(command)
84
+ if File.exists? cue_file
85
+ extract_songs file, cue_file
86
+ else
87
+ # File has no Cuesheet. Convert the entire file.
88
+ m4a_file = file.chomp(File.extname(file)) + '.m4a'
89
+
90
+ if !File.exists?(m4a_file) || @options[:force]
91
+ command = 'ffmpeg -y -i "' + file + '" -c:a alac "' + m4a_file + '"'
92
+
93
+ stdout_str, stderr_str, status = Open3.capture3(command)
79
94
 
80
- if status.success?
81
- puts "#{file} converted."
95
+ if status.success?
96
+ puts "#{file} converted."
97
+ else
98
+ $stderr.puts "Error: #{file}: File could not be converted."
99
+ $stderr.puts stderr_str.split("\n").last
100
+ return
101
+ end
82
102
  else
83
- $stderr.puts "Error: #{file}: File could not be converted."
84
- $stderr.puts stderr_str.split("\n").last
103
+ $stderr.puts "Error: \"#{m4a_file}\" exists. Use --force option to overwrite."
85
104
  return
86
105
  end
87
- else
88
- $stderr.puts "Error: #{m4a_file} exists."
89
- return
90
106
  end
91
107
  else
92
108
  $stderr.puts "Error: #{file}: No such file."
@@ -97,6 +113,37 @@ module AlacIt
97
113
  return
98
114
  end
99
115
  end
116
+
117
+ def extract_songs(file, cue_file)
118
+ cuesheet = AlacIt::Cuesheet.new(File.read(cue_file))
119
+ cuesheet.parse!
120
+
121
+ cuesheet.songs.each do |song|
122
+ m4a_filename = song[:track].to_s.rjust(2, '0') + ' - ' + song[:title] + '.m4a'
123
+ m4a_file = File.join(File.dirname(file), m4a_filename)
124
+
125
+ if !File.exists?(m4a_file) || @options[:force]
126
+ command = 'ffmpeg -y'
127
+ command << ' -i "' + file + '" -c:a alac'
128
+ command << ' -ss ' + song[:index].to_human_ms
129
+ command << (song[:duration].nil? ? '' : ' -t ' + song[:duration].to_human_ms)
130
+ command << ' "' + m4a_file + '"'
131
+
132
+ stdout_str, stderr_str, status = Open3.capture3(command)
133
+
134
+ if status.success?
135
+ puts "\"#{m4a_filename}\" extracted based on cue sheet."
136
+ else
137
+ $stderr.puts "Error: \"#{m4a_filename}\": File could not be extracted."
138
+ $stderr.puts stderr_str.split("\n").last
139
+ next
140
+ end
141
+ else
142
+ $stderr.puts "Error: \"#{m4a_filename}\" exists. Use --force option to overwrite."
143
+ next
144
+ end
145
+ end
146
+ end
100
147
  end
101
148
  end
102
149
 
@@ -0,0 +1,92 @@
1
+ module AlacIt
2
+ class Cuesheet
3
+ attr_reader :cuesheet, :songs, :track_duration
4
+
5
+ def initialize(cuesheet, track_duration = nil)
6
+ @cuesheet = cuesheet
7
+ @reg = {
8
+ :track => %r(TRACK (\d{1,3}) AUDIO),
9
+ :performer => %r(PERFORMER "(.*)"),
10
+ :title => %r(TITLE "(.*)"),
11
+ :index => %r(INDEX \d{1,3} (\d{1,3}):(\d{1,2}):(\d{1,2}))
12
+ }
13
+ @track_duration = AlacIt::Index.new(track_duration) if track_duration
14
+ end
15
+
16
+ def parse!
17
+ @songs = parse_titles.map{ |title| {:title => title} }
18
+ @songs.each_with_index do |song, i|
19
+ song[:performer] = parse_performers[i]
20
+ song[:track] = parse_tracks[i]
21
+ song[:index] = parse_indices[i]
22
+ end
23
+ raise AlacIt::InvalidCuesheet.new('Cuesheet is malformed!') unless valid?
24
+ calculate_song_durations!
25
+ true
26
+ end
27
+
28
+ def position(value)
29
+ index = Index.new(value)
30
+ return @songs.first if index < @songs.first[:index]
31
+ @songs.each_with_index do |song, i|
32
+ return song if song == @songs.last
33
+ return song if between(song[:index], @songs[i + 1][:index], index)
34
+ end
35
+ end
36
+
37
+ def valid?
38
+ @songs.all? do |song|
39
+ [:performer, :track, :index, :title].all? do |key|
40
+ song[key] != nil
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def calculate_song_durations!
48
+ @songs.each_with_index do |song, i|
49
+ if song == @songs.last
50
+ song[:duration] = (@track_duration - song[:index]) if @track_duration
51
+ return
52
+ end
53
+ song[:duration] = @songs[i + 1][:index] - song[:index]
54
+ end
55
+ end
56
+
57
+ def between(a, b, position_index)
58
+ (position_index > a) && (position_index < b)
59
+ end
60
+
61
+ def parse_titles
62
+ unless @titles
63
+ @titles = cuesheet_scan(:title).map{ |title| title.first }
64
+ @titles.delete_at(0)
65
+ end
66
+ @titles
67
+ end
68
+
69
+ def parse_performers
70
+ unless @performers
71
+ @performers = cuesheet_scan(:performer).map{ |performer| performer.first }
72
+ @performers.delete_at(0)
73
+ end
74
+ @performers
75
+ end
76
+
77
+ def parse_tracks
78
+ @tracks ||= cuesheet_scan(:track).map{ |track| track.first.to_i }
79
+ end
80
+
81
+ def parse_indices
82
+ @indices ||= cuesheet_scan(:index).map{ |index| AlacIt::Index.new([index[0].to_i, index[1].to_i, index[2].to_i]) }
83
+ end
84
+
85
+ def cuesheet_scan(field)
86
+ scan = @cuesheet.scan(@reg[field])
87
+ raise InvalidCuesheet.new("No fields were found for #{field.to_s}") if scan.empty?
88
+ scan
89
+ end
90
+
91
+ end
92
+ end
@@ -0,0 +1,112 @@
1
+ module AlacIt
2
+ class Index
3
+ SECONDS_PER_MINUTE = 60
4
+ FRAMES_PER_SECOND = 75
5
+ FRAMES_PER_MINUTE = FRAMES_PER_SECOND * 60
6
+
7
+ attr_reader :minutes, :seconds, :frames
8
+
9
+ def initialize(value=nil)
10
+ case value
11
+ when Array
12
+ set_from_array!(value)
13
+ when Integer
14
+ set_from_integer!(value)
15
+ end
16
+ end
17
+
18
+ def to_f
19
+ ((@minutes * SECONDS_PER_MINUTE) + (@seconds) + (@frames.to_f / FRAMES_PER_SECOND)).to_f
20
+ end
21
+
22
+ def to_i
23
+ to_f.floor
24
+ end
25
+
26
+ def to_a
27
+ [@minutes, @seconds, @frames]
28
+ end
29
+
30
+ def to_s
31
+ "#{'%02d' % @minutes}:#{'%02d' % @seconds}:#{'%02d' % @frames}"
32
+ end
33
+
34
+ def to_human_ms
35
+ "00:#{'%02d' % @minutes}:#{'%02d' % @seconds}" + (@frames.to_f / 75).round(3).to_s[1..-1]
36
+ end
37
+
38
+ def +(other)
39
+ self.class.new(carrying_addition(other))
40
+ end
41
+
42
+ def -(other)
43
+ self.class.new(carrying_subtraction(other))
44
+ end
45
+
46
+ def >(other)
47
+ self.to_f > other.to_f
48
+ end
49
+
50
+ def <(other)
51
+ self.to_f < other.to_f
52
+ end
53
+
54
+ def ==(other)
55
+ self.to_a == other.to_a
56
+ end
57
+
58
+ def each
59
+ to_a.each { |value| yield value }
60
+ end
61
+
62
+ private
63
+
64
+ def carrying_addition(other)
65
+ minutes, seconds, frames = *[@minutes + other.minutes,
66
+ @seconds + other.seconds, @frames + other.frames]
67
+
68
+ seconds, frames = *convert_with_rate(frames, seconds, FRAMES_PER_SECOND)
69
+ minutes, seconds = *convert_with_rate(seconds, minutes, SECONDS_PER_MINUTE)
70
+ [minutes, seconds, frames]
71
+ end
72
+
73
+ def carrying_subtraction(other)
74
+ seconds = minutes = 0
75
+
76
+ my_frames = @frames + (@seconds * FRAMES_PER_SECOND) + (@minutes * FRAMES_PER_MINUTE)
77
+ other_frames = other.frames + (other.seconds * FRAMES_PER_SECOND) + (other.minutes * FRAMES_PER_MINUTE)
78
+ frames = my_frames - other_frames
79
+
80
+ seconds, frames = *convert_with_rate(frames, seconds, FRAMES_PER_SECOND)
81
+ minutes, seconds = *convert_with_rate(seconds, minutes, SECONDS_PER_MINUTE)
82
+ [minutes, seconds, frames]
83
+ end
84
+
85
+ def convert_with_rate(from, to, rate, step=1)
86
+ while from >= rate
87
+ to += step
88
+ from -= rate
89
+ end
90
+ [to, from]
91
+ end
92
+
93
+ def set_from_array!(array)
94
+ if array.size != 3 || array.any?{|element| !element.is_a?(Integer)}
95
+ raise ArgumentError.new("Must be initialized with an array in the format of [minutes, seconds,frames], all integers")
96
+ end
97
+ @minutes, @seconds, @frames = *array
98
+ end
99
+
100
+ def set_from_integer!(seconds)
101
+ @minutes = 0
102
+ @frames = 0
103
+ @seconds = seconds
104
+
105
+ while @seconds >= SECONDS_PER_MINUTE
106
+ @minutes += 1
107
+ @seconds -= SECONDS_PER_MINUTE
108
+ end
109
+ end
110
+
111
+ end
112
+ end
@@ -1,3 +1,3 @@
1
1
  module AlacIt
2
- VERSION = '0.1.1'
2
+ VERSION = '1.0.0'
3
3
  end
Binary file
Binary file
@@ -36,12 +36,12 @@ class AlacItTest < MiniTest::Unit::TestCase
36
36
  end
37
37
 
38
38
  def test_single_ape
39
- FileUtils.cp 'test/fixtures/test.ape', @temp_dir
39
+ FileUtils.cp 'test/fixtures/test3.ape', @temp_dir
40
40
  ARGV.clear
41
- ARGV << File.join(@temp_dir, 'test.ape')
41
+ ARGV << File.join(@temp_dir, 'test3.ape')
42
42
  out, = capture_io { @app.convert }
43
- assert_match /test\.ape converted/, out
44
- assert_equal File.exists?(File.join(@temp_dir, 'test.m4a')), true
43
+ assert_match /test3\.ape converted/, out
44
+ assert_equal File.exists?(File.join(@temp_dir, 'test3.m4a')), true
45
45
  end
46
46
 
47
47
  def test_single_flac
@@ -62,6 +62,22 @@ class AlacItTest < MiniTest::Unit::TestCase
62
62
  assert_equal File.exists?(File.join(@temp_dir, 'test2.m4a')), true
63
63
  end
64
64
 
65
+ def test_single_ape_with_cue_file
66
+ FileUtils.cp 'test/fixtures/test3.ape', @temp_dir
67
+ FileUtils.cp 'test/fixtures/test3.cue', @temp_dir
68
+ ARGV.clear
69
+ ARGV << File.join(@temp_dir, 'test3.ape')
70
+ out, = capture_io { @app.convert }
71
+ refute_match /test3\.ape converted/, out
72
+ assert_match /01 - Track1.m4a\" extracted based on cue sheet/, out
73
+ assert_match /02 - Track2.m4a\" extracted based on cue sheet/, out
74
+ assert_match /03 - Track3.m4a\" extracted based on cue sheet/, out
75
+ assert_equal File.exists?(File.join(@temp_dir, 'test3.m4a')), false
76
+ assert_equal File.exists?(File.join(@temp_dir, '01 - Track1.m4a')), true
77
+ assert_equal File.exists?(File.join(@temp_dir, '02 - Track2.m4a')), true
78
+ assert_equal File.exists?(File.join(@temp_dir, '03 - Track3.m4a')), true
79
+ end
80
+
65
81
  def test_mixed_directory
66
82
  FileUtils.cp 'test/fixtures/test.flac', @temp_dir
67
83
  FileUtils.cp 'test/fixtures/test2.wav', @temp_dir
@@ -80,7 +96,7 @@ class AlacItTest < MiniTest::Unit::TestCase
80
96
  ARGV.clear
81
97
  ARGV << File.join(@temp_dir, 'test.flac')
82
98
  out, err = capture_io { @app.convert }
83
- assert_match /test\.m4a exists/, err
99
+ assert_match /test\.m4a\" exists/, err
84
100
  end
85
101
 
86
102
  def test_single_flac_file_exists_force_overwrite
@@ -100,7 +116,7 @@ class AlacItTest < MiniTest::Unit::TestCase
100
116
  ARGV.clear
101
117
  ARGV << File.join(@temp_dir)
102
118
  out, err = capture_io { @app.convert }
103
- assert_match /test\.m4a exists/, err
119
+ assert_match /test\.m4a\" exists/, err
104
120
  assert_match /test2\.wav converted/, out
105
121
  assert_equal File.exists?(File.join(@temp_dir, 'test2.m4a')), true
106
122
  end
File without changes
@@ -0,0 +1,19 @@
1
+ REM GENRE "Techno"
2
+ REM DATE 2012
3
+ REM DISCID CF0A720E
4
+ REM COMMENT "ExactAudioCopy v0.99pb5"
5
+ PERFORMER "Vader"
6
+ TITLE "Test Title"
7
+ FILE "test3.ape" WAVE
8
+ TRACK 01 AUDIO
9
+ TITLE "Track1"
10
+ PERFORMER "Faithless"
11
+ INDEX 01 00:00:00
12
+ TRACK 02 AUDIO
13
+ TITLE "Track2"
14
+ PERFORMER "Faithless"
15
+ INDEX 01 00:30:74
16
+ TRACK 03 AUDIO
17
+ TITLE "Track3"
18
+ PERFORMER "Faithless"
19
+ INDEX 01 00:60:39
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: alacit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 1.0.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-04-28 00:00:00.000000000 Z
12
+ date: 2012-04-29 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rake
@@ -28,32 +28,38 @@ dependencies:
28
28
  - !ruby/object:Gem::Version
29
29
  version: '0'
30
30
  description: Quickly convert entire directories of APE, FLAC, and WAV files to Apple
31
- Lossless (ALAC) for importation into iTunes, iPhones, iPads, and iPods.
31
+ Lossless (ALAC) for importation into iTunes, iPhones, iPads, and iPods. It does
32
+ Cue-Sheet splitting too.
32
33
  email: me@russbrooks.com
33
34
  executables:
34
35
  - alacit
35
36
  extensions: []
36
37
  extra_rdoc_files: []
37
38
  files:
39
+ - CHANGELOG.md
38
40
  - Gemfile
39
41
  - Gemfile.lock
40
42
  - LICENSE.txt
41
- - README.markdown
43
+ - README.md
42
44
  - Rakefile
43
45
  - alacit.gemspec
44
46
  - bin/alacit
45
47
  - lib/alacit.rb
46
48
  - lib/application.rb
49
+ - lib/cuesheet.rb
50
+ - lib/index.rb
47
51
  - lib/version.rb
48
52
  - pkg/alacit-0.0.2.gem
49
53
  - pkg/alacit-0.0.3.gem
50
54
  - pkg/alacit-0.1.0.gem
51
55
  - pkg/alacit-0.1.1.gem
56
+ - pkg/alacit-1.0.0.gem
52
57
  - test/converter_test.rb
53
- - test/fixtures/test.ape
54
58
  - test/fixtures/test.flac
55
59
  - test/fixtures/test.m4a
56
60
  - test/fixtures/test2.wav
61
+ - test/fixtures/test3.ape
62
+ - test/fixtures/test3.cue
57
63
  homepage: http://russbrooks.com
58
64
  licenses: []
59
65
  post_install_message:
@@ -77,10 +83,12 @@ rubyforge_project:
77
83
  rubygems_version: 1.8.21
78
84
  signing_key:
79
85
  specification_version: 3
80
- summary: APE, FLAC, and WAV to Apple Lossless (ALAC) batch conversion utility.
86
+ summary: APE, FLAC, and WAV to Apple Lossless (ALAC) batch conversion utility and
87
+ cue-sheet splitter.
81
88
  test_files:
82
89
  - test/converter_test.rb
83
- - test/fixtures/test.ape
84
90
  - test/fixtures/test.flac
85
91
  - test/fixtures/test.m4a
86
92
  - test/fixtures/test2.wav
93
+ - test/fixtures/test3.ape
94
+ - test/fixtures/test3.cue