wavesync 1.0.0.alpha2 → 1.0.0.alpha3
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 +46 -60
- data/config/devices.yml +3 -0
- data/lib/wavesync/acid_chunk.rb +9 -4
- data/lib/wavesync/analyzer.rb +9 -4
- data/lib/wavesync/audio.rb +43 -4
- data/lib/wavesync/audio_format.rb +1 -0
- data/lib/wavesync/bpm_detector.rb +14 -7
- data/lib/wavesync/cli.rb +2 -0
- data/lib/wavesync/commands/analyze.rb +2 -0
- data/lib/wavesync/commands/command.rb +2 -1
- data/lib/wavesync/commands/help.rb +6 -0
- data/lib/wavesync/commands/set.rb +3 -0
- data/lib/wavesync/commands/sync.rb +6 -2
- data/lib/wavesync/commands.rb +9 -11
- data/lib/wavesync/config.rb +7 -1
- data/lib/wavesync/cue_chunk.rb +179 -0
- data/lib/wavesync/device.rb +19 -3
- data/lib/wavesync/essentia_bpm_detector.rb +36 -0
- data/lib/wavesync/file_converter.rb +2 -0
- data/lib/wavesync/path_resolver.rb +14 -5
- data/lib/wavesync/percival_bpm_detector.rb +29 -0
- data/lib/wavesync/python_venv.rb +25 -0
- data/lib/wavesync/scanner.rb +58 -9
- data/lib/wavesync/set.rb +17 -1
- data/lib/wavesync/set_editor.rb +333 -31
- data/lib/wavesync/track_padding.rb +7 -4
- data/lib/wavesync/ui.rb +28 -4
- data/lib/wavesync/version.rb +2 -1
- data/lib/wavesync.rb +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 460ae33ce4f3fb42e31cb8505f3ce403f558080d4f1a41f9ca84466c41d3cf27
|
|
4
|
+
data.tar.gz: '084dfb18be270a6d702295326eb70538bd38106ba6ddd853af834d46acf404e5'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e20ff7f979e58e2602fe5b02452e2b6aa1a619cfbb27b303665fa986377942154f5f976f20da97ce23d7e9b3982d5b01eb08fbdcc84532704d2a287b6ed8de05
|
|
7
|
+
data.tar.gz: eb710a3fcc8b9e729bbd86f3e2f506fdfadcad64ce0c112f4c6480677e09bee9f61c1de69bf54f942429cb9c6708c5652b9b083893ab9b05e6795c304996aad9
|
data/README.md
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
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
|
|
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.
|
|
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
|
|
|
7
|
-
|
|
8
|
-
- Elektron Octatrack MKII
|
|
7
|
+

|
|
9
8
|
|
|
10
9
|
## Supported file types
|
|
11
10
|
|
|
@@ -20,21 +19,32 @@ Unsupported file types will be ignored when syncing.
|
|
|
20
19
|
|
|
21
20
|
## Installation
|
|
22
21
|
|
|
23
|
-
1. Install
|
|
22
|
+
1. Install Ruby
|
|
23
|
+
|
|
24
|
+
https://www.ruby-lang.org/en/documentation/installation/
|
|
25
|
+
|
|
26
|
+
2. Install dependencies
|
|
24
27
|
|
|
25
28
|
```bash
|
|
26
|
-
brew install ffmpeg
|
|
27
|
-
brew install taglib
|
|
28
|
-
brew install bpm-tools # required for analyze command
|
|
29
|
+
brew install ffmpeg
|
|
30
|
+
brew install taglib
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
3. Install Wavesync
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
36
|
gem install wavesync --pre
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
4. Set up the Python environment for BPM analysis
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
brew install python@3.11
|
|
43
|
+
python3.11 -m venv ~/.wavesync-venv
|
|
44
|
+
~/.wavesync-venv/bin/pip install essentia
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
5. Install field kit (only required for syncing to TP-7)
|
|
38
48
|
|
|
39
49
|
https://teenage.engineering/guides/fieldkit
|
|
40
50
|
|
|
@@ -61,91 +71,67 @@ devices:
|
|
|
61
71
|
- `model`: device model (`TP-7` or `Octatrack`)
|
|
62
72
|
- `path`: path to the device's library directory
|
|
63
73
|
|
|
64
|
-
|
|
74
|
+
## Usage
|
|
65
75
|
|
|
66
|
-
|
|
76
|
+
### Help
|
|
67
77
|
|
|
68
78
|
```bash
|
|
79
|
+
# List all available commands
|
|
69
80
|
wavesync help
|
|
70
81
|
```
|
|
71
82
|
|
|
72
|
-
|
|
83
|
+
### Analyze
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Analyze library files for BPM and write results to file metadata
|
|
87
|
+
# Files that already have BPM set are skipped
|
|
88
|
+
wavesync analyze
|
|
89
|
+
|
|
90
|
+
# Overwrite existing BPM values
|
|
91
|
+
wavesync analyze -f
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Sync
|
|
73
95
|
|
|
74
96
|
```bash
|
|
75
|
-
# Sync library
|
|
97
|
+
# Sync library
|
|
76
98
|
# If multiple devices are configured, you will be prompted to select one
|
|
77
99
|
wavesync sync
|
|
78
100
|
|
|
79
|
-
#
|
|
80
|
-
wavesync sync -c /path/to/wavesync.yml
|
|
81
|
-
|
|
82
|
-
# Sync to a specific device only (by name as defined in config)
|
|
101
|
+
# Sync to a specific device (by name as defined in config)
|
|
83
102
|
wavesync sync -d Octatrack
|
|
84
103
|
|
|
85
104
|
# Pad each track with silence so its total length aligns to a multiple of 64 bars
|
|
86
105
|
# (Octatrack only — requires BPM metadata on each track)
|
|
87
106
|
wavesync sync -p
|
|
88
|
-
|
|
89
|
-
# Analyze library files for BPM and write results to file metadata
|
|
90
|
-
# Files that already have BPM set are skipped
|
|
91
|
-
wavesync analyze
|
|
92
|
-
|
|
93
|
-
# Overwrite existing BPM values
|
|
94
|
-
wavesync analyze --force
|
|
95
|
-
|
|
96
|
-
# Analyze with a specific config path
|
|
97
|
-
wavesync analyze -c /path/to/wavesync.yml
|
|
98
107
|
```
|
|
99
108
|
|
|
100
|
-
|
|
109
|
+
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.
|
|
101
110
|
|
|
102
|
-
|
|
111
|
+
### Sets (experimental)
|
|
112
|
+
|
|
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.
|
|
103
114
|
|
|
104
115
|
```bash
|
|
105
116
|
# Create a new set and open the interactive editor
|
|
106
|
-
wavesync set create
|
|
117
|
+
wavesync set create NAME
|
|
107
118
|
|
|
108
119
|
# Edit an existing set
|
|
109
|
-
wavesync set edit
|
|
120
|
+
wavesync set edit NAME
|
|
110
121
|
|
|
111
122
|
# List all sets
|
|
112
123
|
wavesync set list
|
|
113
124
|
```
|
|
114
125
|
|
|
115
|
-
The set editor is a keyboard-driven interactive UI:
|
|
116
|
-
|
|
117
|
-
| Key | Action |
|
|
118
|
-
|-----|--------|
|
|
119
|
-
| `↑` / `↓` | Navigate tracks |
|
|
120
|
-
| `space` | Play / pause selected track |
|
|
121
|
-
| `a` | Add track after selection |
|
|
122
|
-
| `u` | Move selected track up |
|
|
123
|
-
| `d` | Move selected track down |
|
|
124
|
-
| `r` | Remove selected track |
|
|
125
|
-
| `s` | Save and exit |
|
|
126
|
-
| `c` | Cancel without saving |
|
|
127
|
-
|
|
128
|
-
The playing track is indicated by `▶` (playing) or `⏸` (paused) before the track number. When a track finishes, the next track plays automatically. Changes are only written to disk when you press `s`.
|
|
129
|
-
|
|
130
|
-
## Sample Rate Selection
|
|
131
|
-
|
|
132
|
-
When a source file's sample rate isn't supported by the target device, Wavesync selects the closest supported rate. For files with equal distance to two rates, it chooses the higher rate to minimize quality loss.
|
|
133
|
-
|
|
134
|
-
Example: If a 96kHz file is synced to an Octatrack (which only supports 44.1kHz), it will be downsampled to 44.1kHz.
|
|
135
|
-
|
|
136
126
|
## Development
|
|
137
127
|
|
|
138
|
-
### Running Tests
|
|
128
|
+
### Running Tests, RuboCop, and Type Checks
|
|
139
129
|
|
|
140
130
|
```bash
|
|
141
|
-
rake
|
|
131
|
+
rake
|
|
142
132
|
```
|
|
143
133
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
```bash
|
|
147
|
-
bundle exec rubocop
|
|
148
|
-
```
|
|
134
|
+
This runs RuboCop (with auto-fix), Steep type checks, and the test suite in sequence.
|
|
149
135
|
|
|
150
136
|
### Releasing
|
|
151
137
|
|
data/config/devices.yml
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
devices:
|
|
2
2
|
- name: TP-7
|
|
3
3
|
sample_rates:
|
|
4
|
+
- 22050
|
|
4
5
|
- 44100
|
|
5
6
|
- 48000
|
|
6
7
|
- 88200
|
|
7
8
|
- 96000
|
|
8
9
|
bit_depths:
|
|
10
|
+
- 8
|
|
9
11
|
- 16
|
|
10
12
|
- 24
|
|
11
13
|
file_types:
|
|
@@ -17,6 +19,7 @@ devices:
|
|
|
17
19
|
sample_rates:
|
|
18
20
|
- 44100
|
|
19
21
|
bit_depths:
|
|
22
|
+
- 8
|
|
20
23
|
- 16
|
|
21
24
|
- 24
|
|
22
25
|
file_types:
|
data/lib/wavesync/acid_chunk.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
module Wavesync
|
|
4
5
|
class AcidChunk
|
|
@@ -27,6 +28,7 @@ module Wavesync
|
|
|
27
28
|
UINT32_LITTLE_ENDIAN = 'V'
|
|
28
29
|
FLOAT32_LITTLE_ENDIAN = 'e'
|
|
29
30
|
|
|
31
|
+
#: (String filepath) -> Float?
|
|
30
32
|
def self.read_bpm(filepath)
|
|
31
33
|
File.open(filepath, 'rb') do |file|
|
|
32
34
|
file.seek(RIFF_HEADER_SIZE)
|
|
@@ -34,10 +36,10 @@ module Wavesync
|
|
|
34
36
|
chunk_id = file.read(CHUNK_ID_SIZE)
|
|
35
37
|
break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
|
|
36
38
|
|
|
37
|
-
chunk_size = file.read(CHUNK_SIZE_FIELD_SIZE).unpack1(UINT32_LITTLE_ENDIAN)
|
|
39
|
+
chunk_size = ((file.read(CHUNK_SIZE_FIELD_SIZE) || '').unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
|
|
38
40
|
if chunk_id == ACID_CHUNK_ID
|
|
39
41
|
file.seek(ACID_TEMPO_OFFSET, IO::SEEK_CUR)
|
|
40
|
-
return file.read(ACID_TEMPO_SIZE).unpack1(FLOAT32_LITTLE_ENDIAN)
|
|
42
|
+
return ((file.read(ACID_TEMPO_SIZE) || '').unpack1(FLOAT32_LITTLE_ENDIAN) || 0).to_f
|
|
41
43
|
else
|
|
42
44
|
padding = chunk_size.odd? ? 1 : 0
|
|
43
45
|
file.seek(chunk_size + padding, IO::SEEK_CUR)
|
|
@@ -47,6 +49,7 @@ module Wavesync
|
|
|
47
49
|
nil
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
#: (String source_filepath, String output_filepath, Integer | Float | String new_bpm) -> void
|
|
50
53
|
def self.write_bpm(source_filepath, output_filepath, new_bpm)
|
|
51
54
|
bpm_bytes = [new_bpm.to_f].pack(FLOAT32_LITTLE_ENDIAN)
|
|
52
55
|
acid_chunk_found = false
|
|
@@ -60,8 +63,8 @@ module Wavesync
|
|
|
60
63
|
chunk_id = input.read(CHUNK_ID_SIZE)
|
|
61
64
|
break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
|
|
62
65
|
|
|
63
|
-
chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE)
|
|
64
|
-
chunk_size = chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN)
|
|
66
|
+
chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE) || ''
|
|
67
|
+
chunk_size = (chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
|
|
65
68
|
|
|
66
69
|
output.write(chunk_id)
|
|
67
70
|
output.write(chunk_size_bytes)
|
|
@@ -94,6 +97,7 @@ module Wavesync
|
|
|
94
97
|
update_riff_size(output_filepath)
|
|
95
98
|
end
|
|
96
99
|
|
|
100
|
+
#: (untyped output, Float bpm) -> void
|
|
97
101
|
def self.create_acid_chunk(output, bpm)
|
|
98
102
|
# Write ACID chunk ID
|
|
99
103
|
output.write(ACID_CHUNK_ID)
|
|
@@ -125,6 +129,7 @@ module Wavesync
|
|
|
125
129
|
output.write([bpm].pack(FLOAT32_LITTLE_ENDIAN))
|
|
126
130
|
end
|
|
127
131
|
|
|
132
|
+
#: (String filepath) -> void
|
|
128
133
|
def self.update_riff_size(filepath)
|
|
129
134
|
File.open(filepath, 'r+b') do |file|
|
|
130
135
|
file.seek(0, IO::SEEK_END)
|
data/lib/wavesync/analyzer.rb
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
module Wavesync
|
|
4
5
|
class Analyzer
|
|
5
6
|
CONFIRM_MESSAGE = 'wavesync analyze will add bpm meta data to files in library. Continue? [y/N] '
|
|
7
|
+
SETUP_INSTRUCTIONS = 'brew install python@3.11 && python3.11 -m venv ~/.wavesync-venv && ~/.wavesync-venv/bin/pip install essentia'
|
|
6
8
|
|
|
9
|
+
#: (String library_path) -> void
|
|
7
10
|
def initialize(library_path)
|
|
8
|
-
@library_path = File.expand_path(library_path)
|
|
9
|
-
@audio_files = find_audio_files
|
|
10
|
-
@ui = UI.new
|
|
11
|
+
@library_path = File.expand_path(library_path) #: String
|
|
12
|
+
@audio_files = find_audio_files #: Array[String]
|
|
13
|
+
@ui = UI.new #: UI
|
|
11
14
|
end
|
|
12
15
|
|
|
16
|
+
#: (?overwrite: bool) -> void
|
|
13
17
|
def analyze(overwrite: false)
|
|
14
18
|
unless BpmDetector.available?
|
|
15
|
-
puts
|
|
19
|
+
puts "Error: essentia is not installed. Set up the Python venv with:\n #{SETUP_INSTRUCTIONS}"
|
|
16
20
|
exit 1
|
|
17
21
|
end
|
|
18
22
|
|
|
@@ -36,6 +40,7 @@ module Wavesync
|
|
|
36
40
|
|
|
37
41
|
private
|
|
38
42
|
|
|
43
|
+
#: () -> Array[String]
|
|
39
44
|
def find_audio_files
|
|
40
45
|
Audio.find_all(@library_path).sort
|
|
41
46
|
end
|
data/lib/wavesync/audio.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require 'streamio-ffmpeg'
|
|
4
5
|
require 'securerandom'
|
|
@@ -10,35 +11,42 @@ module Wavesync
|
|
|
10
11
|
class Audio
|
|
11
12
|
SUPPORTED_FORMATS = %w[.m4a .mp3 .wav .aif .aiff].freeze
|
|
12
13
|
|
|
14
|
+
#: (String library_path) -> Array[String]
|
|
13
15
|
def self.find_all(library_path)
|
|
14
16
|
Dir.glob(File.join(library_path, '**', '*'))
|
|
15
17
|
.select { |f| SUPPORTED_FORMATS.include?(File.extname(f).downcase) }
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
#: (String file_path) -> void
|
|
18
21
|
def initialize(file_path)
|
|
19
|
-
@file_path = file_path
|
|
20
|
-
@file_ext = File.extname(@file_path).downcase
|
|
21
|
-
@audio = FFMPEG::Movie.new(file_path)
|
|
22
|
+
@file_path = file_path #: String
|
|
23
|
+
@file_ext = File.extname(@file_path).downcase #: String
|
|
24
|
+
@audio = FFMPEG::Movie.new(file_path) #: untyped
|
|
22
25
|
end
|
|
23
26
|
|
|
27
|
+
#: () -> Float
|
|
24
28
|
def duration
|
|
25
29
|
@audio.duration
|
|
26
30
|
end
|
|
27
31
|
|
|
32
|
+
#: () -> Integer?
|
|
28
33
|
def sample_rate
|
|
29
34
|
@sample_rate ||= @audio.audio_sample_rate
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
#: () -> Integer?
|
|
32
38
|
def bit_depth
|
|
33
39
|
@bit_depth ||= calculate_bit_depth
|
|
34
40
|
end
|
|
35
41
|
|
|
42
|
+
#: () -> (String | Integer)?
|
|
36
43
|
def bpm
|
|
37
44
|
return @bpm if defined?(@bpm)
|
|
38
45
|
|
|
39
46
|
@bpm = bpm_from_file
|
|
40
47
|
end
|
|
41
48
|
|
|
49
|
+
#: () -> AudioFormat
|
|
42
50
|
def format
|
|
43
51
|
AudioFormat.new(
|
|
44
52
|
file_type: @file_ext.delete_prefix('.'),
|
|
@@ -47,6 +55,21 @@ module Wavesync
|
|
|
47
55
|
)
|
|
48
56
|
end
|
|
49
57
|
|
|
58
|
+
#: () -> Array[{identifier: Integer, sample_offset: Integer, label: String?}]
|
|
59
|
+
def cue_points
|
|
60
|
+
return [] unless @file_ext == '.wav'
|
|
61
|
+
|
|
62
|
+
CueChunk.read(@file_path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
|
|
66
|
+
def write_cue_points(cue_points)
|
|
67
|
+
temp_path = "#{@file_path}.tmp"
|
|
68
|
+
CueChunk.write(@file_path, temp_path, cue_points)
|
|
69
|
+
FileUtils.mv(temp_path, @file_path)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#: (String | Integer | Float bpm) -> void
|
|
50
73
|
def write_bpm(bpm)
|
|
51
74
|
case @file_ext
|
|
52
75
|
when '.m4a'
|
|
@@ -61,6 +84,7 @@ module Wavesync
|
|
|
61
84
|
@bpm = bpm
|
|
62
85
|
end
|
|
63
86
|
|
|
87
|
+
#: (String target_path, ?target_sample_rate: Integer?, ?target_file_type: String?, ?target_bit_depth: Integer?, ?padding_seconds: Numeric?) -> bool
|
|
64
88
|
def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil)
|
|
65
89
|
options = build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds)
|
|
66
90
|
ext = target_file_type || @file_ext.delete_prefix('.')
|
|
@@ -83,6 +107,7 @@ module Wavesync
|
|
|
83
107
|
|
|
84
108
|
private
|
|
85
109
|
|
|
110
|
+
#: () -> Integer?
|
|
86
111
|
def calculate_bit_depth
|
|
87
112
|
data = @audio.metadata
|
|
88
113
|
return nil unless data && data[:streams]
|
|
@@ -96,8 +121,9 @@ module Wavesync
|
|
|
96
121
|
nil
|
|
97
122
|
end
|
|
98
123
|
|
|
124
|
+
#: (Integer? target_sample_rate, Integer? target_bit_depth, ?Numeric? padding_seconds) -> Hash[Symbol, untyped]
|
|
99
125
|
def build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds = nil)
|
|
100
|
-
options = { custom: %w[-loglevel warning -nostats -hide_banner] }
|
|
126
|
+
options = { custom: %w[-loglevel warning -nostats -hide_banner] } #: Hash[Symbol, untyped]
|
|
101
127
|
|
|
102
128
|
if target_bit_depth == 24
|
|
103
129
|
options[:audio_codec] = 'pcm_s24le'
|
|
@@ -116,6 +142,7 @@ module Wavesync
|
|
|
116
142
|
options
|
|
117
143
|
end
|
|
118
144
|
|
|
145
|
+
#: () -> (String | Integer)?
|
|
119
146
|
def bpm_from_file
|
|
120
147
|
case @file_ext
|
|
121
148
|
when '.m4a'
|
|
@@ -129,6 +156,7 @@ module Wavesync
|
|
|
129
156
|
end
|
|
130
157
|
end
|
|
131
158
|
|
|
159
|
+
#: () -> Integer?
|
|
132
160
|
def bpm_from_m4a
|
|
133
161
|
TagLib::MP4::File.open(@file_path) do |file|
|
|
134
162
|
tag = file.tag
|
|
@@ -136,6 +164,7 @@ module Wavesync
|
|
|
136
164
|
end
|
|
137
165
|
end
|
|
138
166
|
|
|
167
|
+
#: () -> String?
|
|
139
168
|
def bpm_from_mp3
|
|
140
169
|
TagLib::MPEG::File.open(@file_path) do |file|
|
|
141
170
|
tag = file.id3v2_tag
|
|
@@ -143,6 +172,7 @@ module Wavesync
|
|
|
143
172
|
end
|
|
144
173
|
end
|
|
145
174
|
|
|
175
|
+
#: () -> (String | Integer)?
|
|
146
176
|
def bpm_from_wav
|
|
147
177
|
TagLib::RIFF::WAV::File.open(@file_path) do |file|
|
|
148
178
|
tag = file.id3v2_tag
|
|
@@ -153,6 +183,7 @@ module Wavesync
|
|
|
153
183
|
bpm_from_acid_chunk
|
|
154
184
|
end
|
|
155
185
|
|
|
186
|
+
#: () -> String?
|
|
156
187
|
def bpm_from_aiff
|
|
157
188
|
TagLib::RIFF::AIFF::File.open(@file_path) do |file|
|
|
158
189
|
tag = file.tag
|
|
@@ -160,26 +191,31 @@ module Wavesync
|
|
|
160
191
|
end
|
|
161
192
|
end
|
|
162
193
|
|
|
194
|
+
#: (untyped tag) -> Integer?
|
|
163
195
|
def bpm_from_item_map(tag)
|
|
164
196
|
tmpo = tag.item_map['tmpo']&.to_int
|
|
165
197
|
tmpo&.zero? ? nil : tmpo
|
|
166
198
|
end
|
|
167
199
|
|
|
200
|
+
#: (untyped tag) -> String?
|
|
168
201
|
def bpm_from_frame_list(tag)
|
|
169
202
|
tag.frame_list('TBPM').first&.to_s
|
|
170
203
|
end
|
|
171
204
|
|
|
205
|
+
#: () -> Integer?
|
|
172
206
|
def bpm_from_acid_chunk
|
|
173
207
|
tmpo = Wavesync::AcidChunk.read_bpm(@file_path).to_i
|
|
174
208
|
tmpo&.zero? ? nil : tmpo
|
|
175
209
|
end
|
|
176
210
|
|
|
211
|
+
#: (String | Integer | Float bpm) -> void
|
|
177
212
|
def write_bpm_to_wav(bpm)
|
|
178
213
|
temp_path = "#{@file_path}.tmp"
|
|
179
214
|
AcidChunk.write_bpm(@file_path, temp_path, bpm)
|
|
180
215
|
FileUtils.mv(temp_path, @file_path)
|
|
181
216
|
end
|
|
182
217
|
|
|
218
|
+
#: (String | Integer | Float bpm) -> void
|
|
183
219
|
def write_bpm_to_mp3(bpm)
|
|
184
220
|
TagLib::MPEG::File.open(@file_path) do |file|
|
|
185
221
|
write_id3v2_bpm(file.id3v2_tag(true), bpm)
|
|
@@ -187,6 +223,7 @@ module Wavesync
|
|
|
187
223
|
end
|
|
188
224
|
end
|
|
189
225
|
|
|
226
|
+
#: (String | Integer | Float bpm) -> void
|
|
190
227
|
def write_bpm_to_m4a(bpm)
|
|
191
228
|
TagLib::MP4::File.open(@file_path) do |file|
|
|
192
229
|
tag = file.tag
|
|
@@ -195,6 +232,7 @@ module Wavesync
|
|
|
195
232
|
end
|
|
196
233
|
end
|
|
197
234
|
|
|
235
|
+
#: (String | Integer | Float bpm) -> void
|
|
198
236
|
def write_bpm_to_aiff(bpm)
|
|
199
237
|
TagLib::RIFF::AIFF::File.open(@file_path) do |file|
|
|
200
238
|
write_id3v2_bpm(file.tag, bpm)
|
|
@@ -202,6 +240,7 @@ module Wavesync
|
|
|
202
240
|
end
|
|
203
241
|
end
|
|
204
242
|
|
|
243
|
+
#: (untyped tag, String | Integer | Float bpm) -> void
|
|
205
244
|
def write_id3v2_bpm(tag, bpm)
|
|
206
245
|
tag.remove_frames('TBPM')
|
|
207
246
|
frame = TagLib::ID3v2::TextIdentificationFrame.new('TBPM', TagLib::String::UTF8)
|
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
require_relative 'essentia_bpm_detector'
|
|
5
|
+
require_relative 'percival_bpm_detector'
|
|
4
6
|
|
|
5
7
|
module Wavesync
|
|
6
8
|
class BpmDetector
|
|
9
|
+
CONFIDENCE_THRESHOLD = 2.0
|
|
10
|
+
|
|
11
|
+
#: () -> bool?
|
|
7
12
|
def self.available?
|
|
8
|
-
|
|
13
|
+
EssentiaBpmDetector.available? || PercivalBpmDetector.available?
|
|
9
14
|
end
|
|
10
15
|
|
|
16
|
+
#: (String file_path) -> Integer?
|
|
11
17
|
def self.detect(file_path)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
if EssentiaBpmDetector.available?
|
|
19
|
+
essentia_result = EssentiaBpmDetector.detect(file_path)
|
|
20
|
+
return essentia_result[:bpm] if essentia_result && essentia_result[:confidence] > CONFIDENCE_THRESHOLD
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
PercivalBpmDetector.detect(file_path) if PercivalBpmDetector.available?
|
|
17
24
|
end
|
|
18
25
|
end
|
|
19
26
|
end
|
data/lib/wavesync/cli.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require_relative 'commands'
|
|
4
5
|
|
|
5
6
|
module Wavesync
|
|
6
7
|
class CLI
|
|
8
|
+
#: () -> void
|
|
7
9
|
def self.start
|
|
8
10
|
command_name = ARGV.first && !ARGV.first.start_with?('-') ? ARGV.shift : 'sync'
|
|
9
11
|
command_class = Commands::ALL.find { |cmd| command_name == cmd.name }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -11,6 +12,7 @@ module Wavesync
|
|
|
11
12
|
self.description = 'Detect and write BPM metadata to library tracks'
|
|
12
13
|
self.options = [FORCE_OPTION].freeze
|
|
13
14
|
|
|
15
|
+
#: () -> void
|
|
14
16
|
def run
|
|
15
17
|
options, config = parse_options(banner: 'Usage: wavesync analyze [options]') do |opts, opts_hash|
|
|
16
18
|
opts.on(*FORCE_OPTION.to_a) { opts_hash[:overwrite] = true }
|
|
@@ -13,8 +13,9 @@ module Wavesync
|
|
|
13
13
|
def subcommands = @subcommands || []
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
#: (banner: String) ?{ (OptionParser, Hash[Symbol, untyped]) -> void } -> [Hash[Symbol, untyped], Config]
|
|
16
17
|
def parse_options(banner:)
|
|
17
|
-
options = {}
|
|
18
|
+
options = {} #: Hash[Symbol, untyped]
|
|
18
19
|
OptionParser.new do |opts|
|
|
19
20
|
opts.banner = banner
|
|
20
21
|
opts.on(*CONFIG_OPTION.to_a) { |value| options[:config] = value }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
require 'rainbow'
|
|
@@ -11,6 +12,7 @@ module Wavesync
|
|
|
11
12
|
|
|
12
13
|
DESCRIPTION_COLUMN = 23
|
|
13
14
|
|
|
15
|
+
#: () -> void
|
|
14
16
|
def run
|
|
15
17
|
subcommand_name = ARGV.shift
|
|
16
18
|
|
|
@@ -30,6 +32,7 @@ module Wavesync
|
|
|
30
32
|
|
|
31
33
|
private
|
|
32
34
|
|
|
35
|
+
#: () -> void
|
|
33
36
|
def show_general_help
|
|
34
37
|
puts 'Usage: wavesync [command] [options]'
|
|
35
38
|
puts ''
|
|
@@ -47,6 +50,7 @@ module Wavesync
|
|
|
47
50
|
GLOBAL_OPTIONS.each { |option| puts format_option_line(option, indent: 2) }
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
#: (untyped command) -> void
|
|
50
54
|
def show_command_help(command)
|
|
51
55
|
if command.subcommands.any?
|
|
52
56
|
puts "Usage: wavesync #{command.name} <subcommand> [options]"
|
|
@@ -68,10 +72,12 @@ module Wavesync
|
|
|
68
72
|
end
|
|
69
73
|
end
|
|
70
74
|
|
|
75
|
+
#: (String name, String description) -> String
|
|
71
76
|
def format_command_line(name, description)
|
|
72
77
|
" #{name.ljust(DESCRIPTION_COLUMN - 2)}#{description}"
|
|
73
78
|
end
|
|
74
79
|
|
|
80
|
+
#: (untyped option, indent: Integer) -> String
|
|
75
81
|
def format_option_line(option, indent:)
|
|
76
82
|
key = "#{option.short}, #{option.long}"
|
|
77
83
|
Rainbow("#{' ' * indent}#{key.ljust(DESCRIPTION_COLUMN - indent)}#{option.description}").darkgray
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -12,6 +13,7 @@ module Wavesync
|
|
|
12
13
|
Subcommand.new(usage: 'set list', description: 'List all track sets')
|
|
13
14
|
].freeze
|
|
14
15
|
|
|
16
|
+
#: () -> void
|
|
15
17
|
def run
|
|
16
18
|
subcommand = ARGV.shift
|
|
17
19
|
|
|
@@ -50,6 +52,7 @@ module Wavesync
|
|
|
50
52
|
|
|
51
53
|
private
|
|
52
54
|
|
|
55
|
+
#: (String subcommand) -> String
|
|
53
56
|
def require_name(subcommand)
|
|
54
57
|
name = ARGV.shift
|
|
55
58
|
unless name
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -12,6 +13,7 @@ module Wavesync
|
|
|
12
13
|
self.description = 'Sync music library to a device'
|
|
13
14
|
self.options = [DEVICE_OPTION, PAD_OPTION].freeze
|
|
14
15
|
|
|
16
|
+
#: () -> void
|
|
15
17
|
def run
|
|
16
18
|
options, config = parse_options(banner: 'Usage: wavesync sync [options]') do |opts, opts_hash|
|
|
17
19
|
opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
|
|
@@ -40,11 +42,13 @@ module Wavesync
|
|
|
40
42
|
exit 1
|
|
41
43
|
end
|
|
42
44
|
[device_config, device]
|
|
43
|
-
end
|
|
45
|
+
end #: Array[untyped]
|
|
44
46
|
|
|
45
47
|
scanner = Wavesync::Scanner.new(config.library)
|
|
46
48
|
|
|
47
|
-
device_pairs.each do |
|
|
49
|
+
device_pairs.each do |pair|
|
|
50
|
+
device_config = pair[0] #: { name: String, model: String, path: String }
|
|
51
|
+
device = pair[1] #: Wavesync::Device
|
|
48
52
|
scanner.sync(device_config[:path], device, pad: options[:pad] || false)
|
|
49
53
|
end
|
|
50
54
|
end
|