alacit 0.1.1 → 1.0.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.
@@ -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