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 +4 -4
- data/README.md +64 -14
- data/config/devices.yml +17 -0
- data/lib/wavesync/acid_chunk.rb +36 -0
- data/lib/wavesync/analyzer.rb +8 -0
- data/lib/wavesync/audio.rb +118 -94
- data/lib/wavesync/audio_format.rb +8 -2
- data/lib/wavesync/cli.rb +1 -0
- data/lib/wavesync/commands/clear_cache.rb +75 -0
- data/lib/wavesync/commands/command.rb +2 -0
- data/lib/wavesync/commands/pull.rb +43 -0
- data/lib/wavesync/commands/setlist.rb +66 -0
- data/lib/wavesync/commands/sync.rb +15 -26
- data/lib/wavesync/commands.rb +44 -2
- data/lib/wavesync/config.rb +37 -3
- data/lib/wavesync/cue_chunk.rb +24 -0
- data/lib/wavesync/device.rb +14 -5
- data/lib/wavesync/essentia_bpm_detector.rb +3 -1
- data/lib/wavesync/ffmpeg/probe.rb +74 -0
- data/lib/wavesync/ffmpeg.rb +144 -0
- data/lib/wavesync/file_converter.rb +6 -3
- data/lib/wavesync/libmtp.rb +333 -0
- data/lib/wavesync/logger.rb +84 -0
- data/lib/wavesync/path_resolver.rb +19 -2
- data/lib/wavesync/percival_bpm_detector.rb +3 -1
- data/lib/wavesync/scanner.rb +117 -50
- data/lib/wavesync/{set.rb → setlist.rb} +13 -13
- data/lib/wavesync/{set_editor.rb → setlist_editor.rb} +52 -43
- data/lib/wavesync/track_padding.rb +10 -2
- data/lib/wavesync/transport/filesystem.rb +36 -0
- data/lib/wavesync/transport/mtp.rb +285 -0
- data/lib/wavesync/transport.rb +21 -0
- data/lib/wavesync/ui.rb +43 -12
- data/lib/wavesync/version.rb +1 -1
- data/lib/wavesync.rb +6 -2
- metadata +13 -32
- data/lib/wavesync/commands/set.rb +0 -66
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23b8fdc3c68bfda6239ffd78ad4a39ce2af81ff9c9925b197a5fdb8fe93b2cea
|
|
4
|
+
data.tar.gz: 345fcdc5625e384dc29a97060c6fe53d489cef2408e83ce983f240f6883320ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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
|
|
47
|
+
5. Install libmtp (only required for syncing to TP-7)
|
|
48
48
|
|
|
49
|
-
|
|
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
|
-
|
|
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 `
|
|
72
|
-
- `path`:
|
|
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
|
-
###
|
|
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
|
|
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
|
|
117
|
-
wavesync
|
|
150
|
+
# Create a new setlist and open the interactive editor
|
|
151
|
+
wavesync setlist create NAME
|
|
118
152
|
|
|
119
|
-
# Edit an existing
|
|
120
|
-
wavesync
|
|
153
|
+
# Edit an existing setlist
|
|
154
|
+
wavesync setlist edit NAME
|
|
121
155
|
|
|
122
|
-
# List all
|
|
123
|
-
wavesync
|
|
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
|
data/lib/wavesync/acid_chunk.rb
CHANGED
|
@@ -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)
|
data/lib/wavesync/analyzer.rb
CHANGED
|
@@ -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
|
data/lib/wavesync/audio.rb
CHANGED
|
@@ -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
|
-
|
|
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 { |
|
|
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::
|
|
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.
|
|
34
|
+
@sample_rate ||= @audio.sample_rate
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
#: () -> Integer?
|
|
38
38
|
def bit_depth
|
|
39
|
-
@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
|
-
@
|
|
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
|
-
|
|
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
|
-
#: () ->
|
|
111
|
-
def
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
#: (
|
|
195
|
-
def
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
#: (
|
|
244
|
-
def
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
@@ -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
|