wavesync 1.0.0.alpha3 → 1.0.0.beta1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 460ae33ce4f3fb42e31cb8505f3ce403f558080d4f1a41f9ca84466c41d3cf27
4
- data.tar.gz: '084dfb18be270a6d702295326eb70538bd38106ba6ddd853af834d46acf404e5'
3
+ metadata.gz: 23b8fdc3c68bfda6239ffd78ad4a39ce2af81ff9c9925b197a5fdb8fe93b2cea
4
+ data.tar.gz: 345fcdc5625e384dc29a97060c6fe53d489cef2408e83ce983f240f6883320ed
5
5
  SHA512:
6
- metadata.gz: e20ff7f979e58e2602fe5b02452e2b6aa1a619cfbb27b303665fa986377942154f5f976f20da97ce23d7e9b3982d5b01eb08fbdcc84532704d2a287b6ed8de05
7
- data.tar.gz: eb710a3fcc8b9e729bbd86f3e2f506fdfadcad64ce0c112f4c6480677e09bee9f61c1de69bf54f942429cb9c6708c5652b9b083893ab9b05e6795c304996aad9
6
+ metadata.gz: 356a7899dde02d7024b2079ff0d9d4df03dd2f566d58e7a72f34ed059c335c2a9d5e6fd277ef5496a61a38a5bf53adfbc938b1f9b5a33f7a5936e246374b6a2d
7
+ data.tar.gz: 8c5574ff833511fa4f0b8e2abdde9cd43e103fae6e55755612ea3232f26a2b3726993c3de5beb8fa027f535dec74ef1ce64d8b2529a04984b502173b7dab5d27
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Wavesync
2
2
 
3
- Wavesync is a Ruby-based CLI tool that scans your music library and automatically converts audio files to match the specifications of the [teenage engineering TP-7](https://teenage.engineering/products/tp-7) and the [Elektron Octatrack MKII](https://www.elektron.se/explore/octatrack-mkii), adjusting sample rate, bit depth and file format as needed while preserving your original library structure and only converting files that don't already meet the device requirements.
3
+ Wavesync is a Ruby-based CLI tool that scans your music library and automatically converts audio files to match the specifications of the [teenage engineering TP-7](https://teenage.engineering/products/tp-7), the [Elektron Octatrack MKII](https://www.elektron.se/explore/octatrack-mkii), and the [Playdate](https://kicooya.com/), adjusting sample rate, bit depth and file format as needed while preserving your original library structure and only converting files that don't already meet the device requirements.
4
4
 
5
5
  It can also analyse the BPM of the tracks in your library and store the information as metadata in your files, as well as convert the metadata so that the target device can read the BPM. When syncing to the Octatrack, you can choose to add padding to each track, so that it's dead-simple to auto-slice your tracks to a multiple of full bars which allows precise looping, and seamlessly jumping to different parts of a track with Octatrack's sequencer.
6
6
 
@@ -44,9 +44,18 @@ python3.11 -m venv ~/.wavesync-venv
44
44
  ~/.wavesync-venv/bin/pip install essentia
45
45
  ```
46
46
 
47
- 5. Install field kit (only required for syncing to TP-7)
47
+ 5. Install libmtp (only required for syncing to TP-7)
48
48
 
49
- https://teenage.engineering/guides/fieldkit
49
+ ```bash
50
+ brew install libmtp
51
+ ```
52
+
53
+ Recent versions of field kit no longer expose the TP-7 as a filesystem path, so wavesync talks to the device over MTP directly.
54
+
55
+ Before syncing the TP-7:
56
+
57
+ - Quit field kit and any other app that may claim the TP-7. They cannot run at the same time as wavesync — only one process can hold the MTP session.
58
+ - Put the TP-7 into MTP mode: hold down the Stop button while turning the TP-7 on, and keep holding until MTP mode is engaged.
50
59
 
51
60
  ## Configuration
52
61
 
@@ -59,17 +68,27 @@ library: ~/Music/Library
59
68
  devices:
60
69
  - name: TP-7
61
70
  model: TP-7
62
- path: ~/Library/Containers/engineering.teenage.fieldkit/Data/Documents/TP-7 MTP Device-F1ELN21A/library
71
+ transport: mtp
72
+ path: library
63
73
  - name: Octatrack
64
74
  model: Octatrack
65
75
  path: /Volumes/OCTATRACK/LIBRARY/AUDIO
76
+ - name: Playdate
77
+ model: Playdate
78
+ path: /Volumes/PLAYDATE/Data/com.abenokobo.kicooya/media/music
66
79
  ```
67
80
 
68
81
  - `library`: path to your source music library
69
82
  - `devices`: list of devices to sync to, each with:
70
83
  - `name`: a label for this device, used with the `-d` command-line option
71
- - `model`: device model (`TP-7` or `Octatrack`)
72
- - `path`: path to the device's library directory
84
+ - `model`: device model (`TP-7`, `Octatrack`, or `Playdate`)
85
+ - `path`: where to write files on the device
86
+ - For `transport: filesystem` (default): a path on your local filesystem (e.g. a mounted USB volume).
87
+ - For `transport: mtp`: a folder path inside the device (e.g. `library` for the TP-7).
88
+ - `transport` (optional): `filesystem` (default) or `mtp`. Use `mtp` for the TP-7.
89
+ - `mp3_bitrate` (optional): bitrate in kbps to use when encoding MP3 files for this device. Accepted values: `96`, `128`, `160`, `192`, `256`, `320`. Defaults to `192`. Source files that are already MP3 are copied as-is regardless of this setting.
90
+
91
+ When syncing over MTP, wavesync caches converted files in `~/.cache/wavesync/<device-name>/` so subsequent syncs only push files that aren't already on the device.
73
92
 
74
93
  ## Usage
75
94
 
@@ -108,19 +127,34 @@ wavesync sync -p
108
127
 
109
128
  When a source file's sample rate isn't supported by the target device, Wavesync selects the closest supported rate. Example: If a 96kHz file is synced to an Octatrack (which only supports 44.1kHz), it will be downsampled to 44.1kHz.
110
129
 
111
- ### Sets (experimental)
130
+ ### Pull
131
+
132
+ ```bash
133
+ # Read cue points from the device's WAV files and write them back into the
134
+ # matching source files in your library. Useful when you've added or edited
135
+ # cue points on the device and want them reflected in the source library.
136
+ # If multiple devices are configured, you will be prompted to select one.
137
+ wavesync pull
138
+
139
+ # Pull from a specific device (by name as defined in config)
140
+ wavesync pull -d TP-7
141
+ ```
142
+
143
+ Only WAV source files with matching WAV target files on the device are updated. If the source already has the same cue points as the device, it is left untouched.
144
+
145
+ ### Setlists (experimental)
112
146
 
113
- A set is a named, ordered selection of tracks from your library. Sets are stored as YAML files inside a `.sets` folder within the library directory. Syncing sets to devices is not yet implemented.
147
+ A setlist is a named, ordered selection of tracks from your library. Setlists are stored as YAML files inside a `.setlists` folder within the library directory. Syncing setlists to devices is not yet implemented.
114
148
 
115
149
  ```bash
116
- # Create a new set and open the interactive editor
117
- wavesync set create NAME
150
+ # Create a new setlist and open the interactive editor
151
+ wavesync setlist create NAME
118
152
 
119
- # Edit an existing set
120
- wavesync set edit NAME
153
+ # Edit an existing setlist
154
+ wavesync setlist edit NAME
121
155
 
122
- # List all sets
123
- wavesync set list
156
+ # List all setlists
157
+ wavesync setlist list
124
158
  ```
125
159
 
126
160
  ## Development
@@ -133,6 +167,22 @@ rake
133
167
 
134
168
  This runs RuboCop (with auto-fix), Steep type checks, and the test suite in sequence.
135
169
 
170
+ ### Running Integration Tests
171
+
172
+ Integration tests sync against real connected devices and are not run as part of the default `rake` task.
173
+
174
+ ```bash
175
+ rake test:integration
176
+ ```
177
+
178
+ ### Regenerating Test Fixtures
179
+
180
+ Test fixtures are pre-generated audio files committed to the repository. To regenerate them:
181
+
182
+ ```bash
183
+ rake fixtures:generate
184
+ ```
185
+
136
186
  ### Releasing
137
187
 
138
188
  ```bash
data/config/devices.yml CHANGED
@@ -14,6 +14,10 @@ devices:
14
14
  - wav
15
15
  - mp3
16
16
  bpm_source: :acid_chunk
17
+ unsupported_characters:
18
+ - "™"
19
+ - '"'
20
+ uppercase_paths: true
17
21
 
18
22
  - name: Octatrack
19
23
  sample_rates:
@@ -28,3 +32,16 @@ devices:
28
32
  - aif
29
33
  bpm_source: :filename
30
34
  bar_multiple: 64
35
+ unsupported_characters:
36
+ - "™"
37
+ - '"'
38
+ - "’"
39
+ - ":"
40
+ - "?"
41
+
42
+ - name: Playdate
43
+ sample_rates:
44
+ - 44100
45
+ file_types:
46
+ - mp3
47
+ transliterate_metadata: true
@@ -49,6 +49,42 @@ module Wavesync
49
49
  nil
50
50
  end
51
51
 
52
+ #: (String filepath, Integer | Float | String new_bpm) -> void
53
+ def self.write_bpm_in_place(filepath, new_bpm)
54
+ bpm_bytes = [new_bpm.to_f].pack(FLOAT32_LITTLE_ENDIAN)
55
+
56
+ File.open(filepath, 'r+b') do |file|
57
+ file.seek(RIFF_HEADER_SIZE)
58
+
59
+ acid_chunk_found = false
60
+
61
+ until file.eof?
62
+ chunk_id = file.read(CHUNK_ID_SIZE)
63
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
64
+
65
+ chunk_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE) || ''
66
+ chunk_size = (chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
67
+
68
+ if chunk_id == ACID_CHUNK_ID
69
+ file.seek(ACID_TEMPO_OFFSET, IO::SEEK_CUR)
70
+ file.write(bpm_bytes)
71
+ acid_chunk_found = true
72
+ break
73
+ else
74
+ padding = chunk_size.odd? ? 1 : 0
75
+ file.seek(chunk_size + padding, IO::SEEK_CUR)
76
+ end
77
+ end
78
+
79
+ unless acid_chunk_found
80
+ file.seek(0, IO::SEEK_END)
81
+ create_acid_chunk(file, new_bpm.to_f)
82
+ end
83
+ end
84
+
85
+ update_riff_size(filepath)
86
+ end
87
+
52
88
  #: (String source_filepath, String output_filepath, Integer | Float | String new_bpm) -> void
53
89
  def self.write_bpm(source_filepath, output_filepath, new_bpm)
54
90
  bpm_bytes = [new_bpm.to_f].pack(FLOAT32_LITTLE_ENDIAN)
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
+ require_relative 'logger'
5
+
4
6
  module Wavesync
5
7
  class Analyzer
6
8
  CONFIRM_MESSAGE = 'wavesync analyze will add bpm meta data to files in library. Continue? [y/N] '
@@ -9,6 +11,7 @@ module Wavesync
9
11
  #: (String library_path) -> void
10
12
  def initialize(library_path)
11
13
  @library_path = File.expand_path(library_path) #: String
14
+ Logger.configure(@library_path)
12
15
  @audio_files = find_audio_files #: Array[String]
13
16
  @ui = UI.new #: UI
14
17
  end
@@ -22,6 +25,8 @@ module Wavesync
22
25
 
23
26
  return unless @ui.confirm(CONFIRM_MESSAGE)
24
27
 
28
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+
25
30
  @audio_files.each_with_index do |file, index|
26
31
  audio = Audio.new(file)
27
32
 
@@ -36,6 +41,9 @@ module Wavesync
36
41
  @ui.analyze_progress(index, @audio_files.size)
37
42
  @ui.analyze_result(file, bpm)
38
43
  end
44
+
45
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
46
+ Logger.log_run_time(elapsed)
39
47
  end
40
48
 
41
49
  private
@@ -1,11 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- require 'streamio-ffmpeg'
5
4
  require 'securerandom'
6
5
  require 'tmpdir'
7
6
  require 'fileutils'
8
- require 'taglib'
7
+ require_relative 'logger'
9
8
 
10
9
  module Wavesync
11
10
  class Audio
@@ -14,14 +13,15 @@ module Wavesync
14
13
  #: (String library_path) -> Array[String]
15
14
  def self.find_all(library_path)
16
15
  Dir.glob(File.join(library_path, '**', '*'))
17
- .select { |f| SUPPORTED_FORMATS.include?(File.extname(f).downcase) }
16
+ .select { |file| SUPPORTED_FORMATS.include?(File.extname(file).downcase) }
17
+ .sort_by(&:downcase)
18
18
  end
19
19
 
20
20
  #: (String file_path) -> void
21
21
  def initialize(file_path)
22
22
  @file_path = file_path #: String
23
23
  @file_ext = File.extname(@file_path).downcase #: String
24
- @audio = FFMPEG::Movie.new(file_path) #: untyped
24
+ @audio = Wavesync::FFMPEG::Probe.new(file_path) #: Wavesync::FFMPEG::Probe
25
25
  end
26
26
 
27
27
  #: () -> Float
@@ -31,12 +31,17 @@ module Wavesync
31
31
 
32
32
  #: () -> Integer?
33
33
  def sample_rate
34
- @sample_rate ||= @audio.audio_sample_rate
34
+ @sample_rate ||= @audio.sample_rate
35
35
  end
36
36
 
37
37
  #: () -> Integer?
38
38
  def bit_depth
39
- @bit_depth ||= calculate_bit_depth
39
+ @bit_depth ||= @audio.bit_depth
40
+ end
41
+
42
+ #: () -> Integer?
43
+ def bitrate
44
+ @bitrate ||= @audio.bitrate
40
45
  end
41
46
 
42
47
  #: () -> (String | Integer)?
@@ -51,7 +56,8 @@ module Wavesync
51
56
  AudioFormat.new(
52
57
  file_type: @file_ext.delete_prefix('.'),
53
58
  sample_rate: sample_rate,
54
- bit_depth: bit_depth
59
+ bit_depth: bit_depth,
60
+ bitrate: bitrate
55
61
  )
56
62
  end
57
63
 
@@ -69,6 +75,56 @@ module Wavesync
69
75
  FileUtils.mv(temp_path, @file_path)
70
76
  end
71
77
 
78
+ ID3V2_FRAME_TITLE = 'TIT2'
79
+ ID3V2_FRAME_ARTIST = 'TPE1'
80
+ ID3V2_FRAME_ALBUM = 'TALB'
81
+ ID3V2_FRAME_ALBUM_ARTIST = 'TPE2'
82
+ ID3V2_FRAME_GENRE = 'TCON'
83
+ ID3V2_FRAME_COMPOSER = 'TCOM'
84
+ ID3V2_FRAME_ENCODED_BY = 'TENC'
85
+ ID3V2_FRAME_COMPILATION = 'TCMP'
86
+
87
+ FRAME_ID_TO_FFMPEG_KEY = {
88
+ ID3V2_FRAME_TITLE => 'title',
89
+ ID3V2_FRAME_ARTIST => 'artist',
90
+ ID3V2_FRAME_ALBUM => 'album',
91
+ ID3V2_FRAME_ALBUM_ARTIST => 'album_artist',
92
+ ID3V2_FRAME_GENRE => 'genre',
93
+ ID3V2_FRAME_COMPOSER => 'composer',
94
+ ID3V2_FRAME_ENCODED_BY => 'encoded_by',
95
+ ID3V2_FRAME_COMPILATION => 'compilation'
96
+ }.freeze
97
+
98
+ FFMPEG_KEY_TO_FRAME_ID = FRAME_ID_TO_FFMPEG_KEY.invert.freeze
99
+
100
+ COMBINING_MARKS = /\p{Mn}/
101
+
102
+ #: () -> Hash[String, String]
103
+ def transliterated_tag_changes
104
+ current_tags = @audio.tags
105
+ changes = {} #: Hash[String, String]
106
+
107
+ FRAME_ID_TO_FFMPEG_KEY.each_value do |ffmpeg_key|
108
+ current_value = find_in_tags(current_tags, ffmpeg_key)
109
+ next if current_value.nil?
110
+
111
+ transliterated = transliterate(current_value)
112
+ changes[ffmpeg_key] = transliterated if transliterated != current_value
113
+ end
114
+
115
+ changes
116
+ end
117
+
118
+ #: () -> void
119
+ def transliterate_tags
120
+ return unless @file_ext == '.mp3'
121
+
122
+ changes = transliterated_tag_changes
123
+ return if changes.empty?
124
+
125
+ write_file_metadata(changes)
126
+ end
127
+
72
128
  #: (String | Integer | Float bpm) -> void
73
129
  def write_bpm(bpm)
74
130
  case @file_ext
@@ -84,21 +140,26 @@ module Wavesync
84
140
  @bpm = bpm
85
141
  end
86
142
 
87
- #: (String target_path, ?target_sample_rate: Integer?, ?target_file_type: String?, ?target_bit_depth: Integer?, ?padding_seconds: Numeric?) -> bool
88
- def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil)
89
- options = build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds)
143
+ #: (String target_path, ?target_sample_rate: Integer?, ?target_file_type: String?, ?target_bit_depth: Integer?, ?padding_seconds: Numeric?, ?metadata: Hash[String, String], ?target_bitrate: Integer) ?{ (String) -> void } -> bool
144
+ def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil, metadata: {}, target_bitrate: 192)
90
145
  ext = target_file_type || @file_ext.delete_prefix('.')
91
- temp_path = File.join(
92
- Dir.tmpdir,
93
- "wavesync_transcode_#{SecureRandom.hex}.#{ext}"
94
- )
146
+ temp_path = File.join(Dir.tmpdir, "wavesync_transcode_#{SecureRandom.hex}.#{ext}")
95
147
 
96
148
  begin
97
- @audio.transcode(temp_path, options)
149
+ command = Wavesync::FFMPEG.new.input(@file_path).audio_codec(transcode_codec(ext, target_bit_depth))
150
+ command.audio_bitrate("#{target_bitrate}k") if ext == 'mp3'
151
+ command.sample_rate(target_sample_rate) if target_sample_rate
152
+ if padding_seconds&.positive?
153
+ total_duration = @audio.duration + padding_seconds
154
+ command.audio_filter("apad=whole_dur=#{total_duration.round(6)}")
155
+ end
156
+ metadata.each { |key, value| command.metadata(key, value) }
157
+ command.run(temp_path)
158
+ yield temp_path if block_given?
98
159
  FileUtils.install(temp_path, target_path)
99
160
  true
100
- rescue Errno::ENOENT
101
- puts 'Errno::ENOENT'
161
+ rescue Errno::ENOENT => e
162
+ Logger.log_error(e, call_site: 'Audio#transcode', arguments: { target_path:, target_sample_rate:, target_file_type:, target_bit_depth:, padding_seconds:, target_bitrate: })
102
163
  false
103
164
  ensure
104
165
  FileUtils.rm_f(temp_path)
@@ -107,39 +168,11 @@ module Wavesync
107
168
 
108
169
  private
109
170
 
110
- #: () -> Integer?
111
- def calculate_bit_depth
112
- data = @audio.metadata
113
- return nil unless data && data[:streams]
114
-
115
- audio_stream = data[:streams].find { |s| s[:codec_type] == 'audio' }
116
- return nil unless audio_stream
117
-
118
- bits_per_sample = audio_stream[:bits_per_sample]
119
- return bits_per_sample if bits_per_sample&.positive?
171
+ #: (String target_file_type, Integer? target_bit_depth) -> String
172
+ def transcode_codec(target_file_type, target_bit_depth)
173
+ return 'libmp3lame' if target_file_type == 'mp3'
120
174
 
121
- nil
122
- end
123
-
124
- #: (Integer? target_sample_rate, Integer? target_bit_depth, ?Numeric? padding_seconds) -> Hash[Symbol, untyped]
125
- def build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds = nil)
126
- options = { custom: %w[-loglevel warning -nostats -hide_banner] } #: Hash[Symbol, untyped]
127
-
128
- if target_bit_depth == 24
129
- options[:audio_codec] = 'pcm_s24le'
130
- elsif target_bit_depth == 16
131
- options[:audio_codec] = 'pcm_s16le'
132
- end
133
-
134
- options[:audio_codec] = 'pcm_s24le'
135
- options[:audio_sample_rate] = target_sample_rate if target_sample_rate
136
-
137
- if padding_seconds&.positive?
138
- total_duration = @audio.duration + padding_seconds
139
- options[:custom] += ['-af', "apad=whole_dur=#{total_duration.round(6)}"]
140
- end
141
-
142
- options
175
+ target_bit_depth == 16 ? 'pcm_s16le' : 'pcm_s24le'
143
176
  end
144
177
 
145
178
  #: () -> (String | Integer)?
@@ -158,48 +191,36 @@ module Wavesync
158
191
 
159
192
  #: () -> Integer?
160
193
  def bpm_from_m4a
161
- TagLib::MP4::File.open(@file_path) do |file|
162
- tag = file.tag
163
- return bpm_from_item_map(tag) if tag
164
- end
194
+ value = find_in_tags(@audio.tags, 'BPM')
195
+ return nil if value.nil?
196
+
197
+ int_value = value.to_i
198
+ int_value.zero? ? nil : int_value
165
199
  end
166
200
 
167
201
  #: () -> String?
168
202
  def bpm_from_mp3
169
- TagLib::MPEG::File.open(@file_path) do |file|
170
- tag = file.id3v2_tag
171
- return bpm_from_frame_list(tag) if tag
172
- end
203
+ value = find_in_tags(@audio.tags, 'TBPM')
204
+ value&.then { |v| v.empty? ? nil : v }
173
205
  end
174
206
 
175
207
  #: () -> (String | Integer)?
176
208
  def bpm_from_wav
177
- TagLib::RIFF::WAV::File.open(@file_path) do |file|
178
- tag = file.id3v2_tag
179
- bpm_from_frame_list = bpm_from_frame_list(tag) if tag
180
- return bpm_from_frame_list if bpm_from_frame_list
181
- end
209
+ value = find_in_tags(@audio.tags, 'TBPM')
210
+ return value if value && !value.empty?
182
211
 
183
212
  bpm_from_acid_chunk
184
213
  end
185
214
 
186
215
  #: () -> String?
187
216
  def bpm_from_aiff
188
- TagLib::RIFF::AIFF::File.open(@file_path) do |file|
189
- tag = file.tag
190
- return bpm_from_frame_list(tag) if tag
191
- end
217
+ value = find_in_tags(@audio.tags, 'TBPM')
218
+ value&.then { |v| v.empty? ? nil : v }
192
219
  end
193
220
 
194
- #: (untyped tag) -> Integer?
195
- def bpm_from_item_map(tag)
196
- tmpo = tag.item_map['tmpo']&.to_int
197
- tmpo&.zero? ? nil : tmpo
198
- end
199
-
200
- #: (untyped tag) -> String?
201
- def bpm_from_frame_list(tag)
202
- tag.frame_list('TBPM').first&.to_s
221
+ #: (Hash[String, String] tags, String key) -> String?
222
+ def find_in_tags(tags, key)
223
+ tags.find { |k, _| k.casecmp(key).zero? }&.last
203
224
  end
204
225
 
205
226
  #: () -> Integer?
@@ -217,35 +238,38 @@ module Wavesync
217
238
 
218
239
  #: (String | Integer | Float bpm) -> void
219
240
  def write_bpm_to_mp3(bpm)
220
- TagLib::MPEG::File.open(@file_path) do |file|
221
- write_id3v2_bpm(file.id3v2_tag(true), bpm)
222
- file.save
223
- end
241
+ write_file_metadata('TBPM' => bpm.to_s)
224
242
  end
225
243
 
226
244
  #: (String | Integer | Float bpm) -> void
227
245
  def write_bpm_to_m4a(bpm)
228
- TagLib::MP4::File.open(@file_path) do |file|
229
- tag = file.tag
230
- tag.item_map.insert('tmpo', TagLib::MP4::Item.from_int(bpm.to_i))
231
- file.save
232
- end
246
+ write_file_metadata('BPM' => bpm.to_i.to_s)
233
247
  end
234
248
 
235
249
  #: (String | Integer | Float bpm) -> void
236
250
  def write_bpm_to_aiff(bpm)
237
- TagLib::RIFF::AIFF::File.open(@file_path) do |file|
238
- write_id3v2_bpm(file.tag, bpm)
239
- file.save
240
- end
251
+ write_file_metadata('TBPM' => bpm.to_s)
252
+ end
253
+
254
+ #: (Hash[String, String] metadata_hash) -> void
255
+ def write_file_metadata(metadata_hash)
256
+ ext = File.extname(@file_path)
257
+ temp_path = File.join(Dir.tmpdir, "wavesync_meta_#{SecureRandom.hex}#{ext}")
258
+ command = FFMPEG.new.input(@file_path).copy_streams.map_metadata(0)
259
+ command.movflags('+use_metadata_tags') if ext == '.m4a'
260
+ command.write_id3v2(1) if %w[.aif .aiff].include?(ext)
261
+ metadata_hash.each { |key, value| command.metadata(key, value) }
262
+ command.run(temp_path)
263
+ FileUtils.mv(temp_path, @file_path)
264
+ ensure
265
+ FileUtils.rm_f(temp_path)
241
266
  end
242
267
 
243
- #: (untyped tag, String | Integer | Float bpm) -> void
244
- def write_id3v2_bpm(tag, bpm)
245
- tag.remove_frames('TBPM')
246
- frame = TagLib::ID3v2::TextIdentificationFrame.new('TBPM', TagLib::String::UTF8)
247
- frame.text = bpm.to_s
248
- tag.add_frame(frame)
268
+ #: (String string) -> String
269
+ def transliterate(string)
270
+ string
271
+ .unicode_normalize(:nfd)
272
+ .gsub(COMBINING_MARKS, '')
249
273
  end
250
274
  end
251
275
  end
@@ -1,13 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wavesync
4
- AudioFormat = Data.define(:file_type, :sample_rate, :bit_depth) do
4
+ AudioFormat = Data.define(:file_type, :sample_rate, :bit_depth, :bitrate) do
5
+ #: (file_type: String?, sample_rate: Integer?, bit_depth: Integer?, ?bitrate: Integer?) -> void
6
+ def initialize(file_type:, sample_rate:, bit_depth:, bitrate: nil)
7
+ super
8
+ end
9
+
5
10
  #: (AudioFormat other) -> AudioFormat
6
11
  def merge(other)
7
12
  with(
8
13
  file_type: other.file_type || file_type,
9
14
  sample_rate: other.sample_rate || sample_rate,
10
- bit_depth: other.bit_depth || bit_depth
15
+ bit_depth: other.bit_depth || bit_depth,
16
+ bitrate: other.bitrate || bitrate
11
17
  )
12
18
  end
13
19
  end
data/lib/wavesync/cli.rb CHANGED
@@ -7,6 +7,7 @@ module Wavesync
7
7
  class CLI
8
8
  #: () -> void
9
9
  def self.start
10
+ Logger.capture_invocation(ARGV.dup)
10
11
  command_name = ARGV.first && !ARGV.first.start_with?('-') ? ARGV.shift : 'sync'
11
12
  command_class = Commands::ALL.find { |cmd| command_name == cmd.name }
12
13
 
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'fileutils'
5
+ require 'optparse'
6
+ require_relative '../transport'
7
+
8
+ module Wavesync
9
+ module Commands
10
+ class ClearCache < Command
11
+ DEVICE_OPTION = Option.new(short: '-d', long: '--device NAME', description: 'Clear cache for a specific device only')
12
+
13
+ self.name = 'clear-cache'
14
+ self.description = 'Delete the on-disk staging cache used for MTP devices'
15
+ self.options = [DEVICE_OPTION].freeze
16
+
17
+ #: () -> void
18
+ def run
19
+ options, config = parse_options(banner: 'Usage: wavesync clear-cache [options]') do |opts, opts_hash|
20
+ opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
21
+ end
22
+
23
+ mtp_devices = config.device_configs.select { |device_config| device_config[:transport] == 'mtp' }
24
+ if options[:device]
25
+ mtp_devices = mtp_devices.select { |device_config| device_config[:name] == options[:device] }
26
+ if mtp_devices.empty?
27
+ puts "No MTP device named \"#{options[:device]}\" found in config."
28
+ exit 1
29
+ end
30
+ end
31
+
32
+ if mtp_devices.empty?
33
+ puts 'No MTP devices configured.'
34
+ return
35
+ end
36
+
37
+ mtp_devices.each { |device_config| clear_one(device_config[:name]) }
38
+ end
39
+
40
+ private
41
+
42
+ #: (String device_name) -> void
43
+ def clear_one(device_name)
44
+ path = Wavesync::Transport::Mtp.cache_path(device_name)
45
+ unless File.directory?(path)
46
+ puts "No cache for #{device_name} at #{path}"
47
+ return
48
+ end
49
+
50
+ size_bytes = directory_size(path)
51
+ FileUtils.rm_rf(path)
52
+ puts "Cleared #{path} (#{format_bytes(size_bytes)})"
53
+ end
54
+
55
+ #: (String path) -> Integer
56
+ def directory_size(path)
57
+ Dir.glob(File.join(path, '**', '*'))
58
+ .reject { |entry| File.directory?(entry) }
59
+ .sum { |entry| File.size(entry) }
60
+ end
61
+
62
+ #: (Integer bytes) -> String
63
+ def format_bytes(bytes)
64
+ units = %w[B KB MB GB TB]
65
+ size = bytes.to_f
66
+ unit_index = 0
67
+ while size >= 1024 && unit_index < units.length - 1
68
+ size /= 1024
69
+ unit_index += 1
70
+ end
71
+ unit_index.zero? ? "#{bytes} #{units[unit_index]}" : format('%<size>.1f %<unit>s', size: size, unit: units[unit_index])
72
+ end
73
+ end
74
+ end
75
+ end
@@ -23,6 +23,8 @@ module Wavesync
23
23
  end.parse!
24
24
  config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
25
25
  config = Commands.load_config(config_path)
26
+ Logger.configure(config.library)
27
+ Logger.log_invocation
26
28
  [options, config]
27
29
  end
28
30
  end