wavesync 1.0.0.alpha1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/bin/wavesync +8 -0
- data/config/devices.yml +27 -0
- data/lib/wavesync/acid_chunk.rb +140 -0
- data/lib/wavesync/analyzer.rb +39 -0
- data/lib/wavesync/audio.rb +212 -0
- data/lib/wavesync/audio_format.rb +13 -0
- data/lib/wavesync/bpm_detector.rb +19 -0
- data/lib/wavesync/cli.rb +159 -0
- data/lib/wavesync/config.rb +70 -0
- data/lib/wavesync/device.rb +70 -0
- data/lib/wavesync/file_converter.rb +40 -0
- data/lib/wavesync/path_resolver.rb +55 -0
- data/lib/wavesync/scanner.rb +97 -0
- data/lib/wavesync/set.rb +82 -0
- data/lib/wavesync/set_editor.rb +245 -0
- data/lib/wavesync/track_padding.rb +27 -0
- data/lib/wavesync/ui.rb +139 -0
- data/lib/wavesync/version.rb +5 -0
- data/lib/wavesync.rb +21 -0
- metadata +144 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5ba5ed4b274c7287903407029c902eeee5baeb3138f7f4fe19127a2ceca5329f
|
|
4
|
+
data.tar.gz: c2653fe2efc113dafd67814a7c653bc2f7a9c068369800775028397ecc963793
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 846c29ec104723435dbb14bb4eb7902e17ed3276c322de9d82664ede303eb3878394bff44ee0769b934e3d39066ddc9d437656565f71c7ccf4ab8a956b42b2d0
|
|
7
|
+
data.tar.gz: a3b5135a634bf8d088cc8886e4244d62ddfddd7428f974781d47037432ca80476e92bb736d5fa0f0467e5d64c9d07b8fd73c331c78b031d395c25ce432095483
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andreas Zecher
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Wavesync
|
|
2
|
+
|
|
3
|
+
Wavesync is a Ruby-based CLI tool that scans your music library and automatically converts audio files to match the specifications of specific hardware music devices like the teenage engineering TP-7 and Elektron Octatrack, 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. It also reads BPM information from the original file and converts it so that the target device can read it.
|
|
4
|
+
|
|
5
|
+
## Supported devices
|
|
6
|
+
|
|
7
|
+
- teenage engineering TP-7
|
|
8
|
+
- Elektron Octatrack MKII
|
|
9
|
+
|
|
10
|
+
## Supported file types
|
|
11
|
+
|
|
12
|
+
Wavesync supports the following file types in your source library:
|
|
13
|
+
|
|
14
|
+
- M4A
|
|
15
|
+
- MP3
|
|
16
|
+
- WAV
|
|
17
|
+
- AIF
|
|
18
|
+
|
|
19
|
+
Unsupported file types will be ignored when syncing.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
1. Install dependencies
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
brew install ffmpeg # required for all commands
|
|
27
|
+
brew install taglib # required for all commands
|
|
28
|
+
brew install bpm-tools # required for analyze command
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
2. Install Wavesync
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
gem install wavesync --pre
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
3. Install field kit (only required for syncing to TP-7)
|
|
38
|
+
|
|
39
|
+
https://teenage.engineering/guides/fieldkit
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Wavesync is configured via a YAML file. By default it looks for `~/wavesync.yml`. You can also pass a path explicitly with the `-c` flag.
|
|
44
|
+
|
|
45
|
+
### wavesync.yml format
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
library: ~/Music/Library
|
|
49
|
+
devices:
|
|
50
|
+
- name: TP-7
|
|
51
|
+
model: TP-7
|
|
52
|
+
path: ~/Library/Containers/engineering.teenage.fieldkit/Data/Documents/TP-7 MTP Device-F1ELN21A/library
|
|
53
|
+
- name: Octatrack
|
|
54
|
+
model: Octatrack
|
|
55
|
+
path: /Volumes/OCTATRACK/LIBRARY/AUDIO
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `library`: path to your source music library
|
|
59
|
+
- `devices`: list of devices to sync to, each with:
|
|
60
|
+
- `name`: a label for this device, used with the `-d` command-line option
|
|
61
|
+
- `model`: device model (`TP-7` or `Octatrack`)
|
|
62
|
+
- `path`: path to the device's library directory
|
|
63
|
+
|
|
64
|
+
Wavesync will exit with an error if a device model in the config is not supported.
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Sync library to all devices (uses default config at ~/wavesync.yml)
|
|
70
|
+
wavesync sync
|
|
71
|
+
|
|
72
|
+
# Use a config at a specific path
|
|
73
|
+
wavesync sync -c /path/to/wavesync.yml
|
|
74
|
+
|
|
75
|
+
# Sync to a specific device only (by name as defined in config)
|
|
76
|
+
wavesync sync -d Octatrack
|
|
77
|
+
|
|
78
|
+
# Pad each track with silence so its total length aligns to a multiple of 64 bars
|
|
79
|
+
# (Octatrack only — requires BPM metadata on each track)
|
|
80
|
+
wavesync sync -p
|
|
81
|
+
|
|
82
|
+
# Analyze library files for BPM and write results to file metadata
|
|
83
|
+
# Files that already have BPM set are skipped
|
|
84
|
+
wavesync analyze
|
|
85
|
+
|
|
86
|
+
# Overwrite existing BPM values
|
|
87
|
+
wavesync analyze --force
|
|
88
|
+
|
|
89
|
+
# Analyze with a specific config path
|
|
90
|
+
wavesync analyze -c /path/to/wavesync.yml
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Sets
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Create a new set and open the interactive editor
|
|
99
|
+
wavesync set create <name>
|
|
100
|
+
|
|
101
|
+
# Edit an existing set
|
|
102
|
+
wavesync set edit <name>
|
|
103
|
+
|
|
104
|
+
# List all sets
|
|
105
|
+
wavesync set list
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The set editor is a keyboard-driven interactive UI:
|
|
109
|
+
|
|
110
|
+
| Key | Action |
|
|
111
|
+
|-----|--------|
|
|
112
|
+
| `↑` / `↓` | Navigate tracks |
|
|
113
|
+
| `space` | Play / pause selected track |
|
|
114
|
+
| `a` | Add track after selection |
|
|
115
|
+
| `u` | Move selected track up |
|
|
116
|
+
| `d` | Move selected track down |
|
|
117
|
+
| `r` | Remove selected track |
|
|
118
|
+
| `s` | Save and exit |
|
|
119
|
+
| `c` | Cancel without saving |
|
|
120
|
+
|
|
121
|
+
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`.
|
|
122
|
+
|
|
123
|
+
## Sample Rate Selection
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
Example: If a 96kHz file is synced to an Octatrack (which only supports 44.1kHz), it will be downsampled to 44.1kHz.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
### Running Tests
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
rake test
|
|
135
|
+
```
|
data/bin/wavesync
ADDED
data/config/devices.yml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
devices:
|
|
2
|
+
- name: TP-7
|
|
3
|
+
sample_rates:
|
|
4
|
+
- 44100
|
|
5
|
+
- 48000
|
|
6
|
+
- 88200
|
|
7
|
+
- 96000
|
|
8
|
+
bit_depths:
|
|
9
|
+
- 16
|
|
10
|
+
- 24
|
|
11
|
+
file_types:
|
|
12
|
+
- wav
|
|
13
|
+
- mp3
|
|
14
|
+
bpm_source: :acid_chunk
|
|
15
|
+
|
|
16
|
+
- name: Octatrack
|
|
17
|
+
sample_rates:
|
|
18
|
+
- 44100
|
|
19
|
+
bit_depths:
|
|
20
|
+
- 16
|
|
21
|
+
- 24
|
|
22
|
+
file_types:
|
|
23
|
+
- wav
|
|
24
|
+
- aiff
|
|
25
|
+
- aif
|
|
26
|
+
bpm_source: :filename
|
|
27
|
+
bar_multiple: 64
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavesync
|
|
4
|
+
class AcidChunk
|
|
5
|
+
RIFF_HEADER_SIZE = 12 # 'RIFF' (4) + size (4) + 'WAVE' (4)
|
|
6
|
+
CHUNK_ID_SIZE = 4
|
|
7
|
+
CHUNK_SIZE_FIELD_SIZE = 4
|
|
8
|
+
ACID_CHUNK_ID = 'acid'
|
|
9
|
+
ACID_CHUNK_DATA_SIZE = 24
|
|
10
|
+
ACID_TYPE_FLAGS_SIZE = 4
|
|
11
|
+
ACID_ROOT_NOTE_SIZE = 2
|
|
12
|
+
ACID_UNKNOWN1_SIZE = 2
|
|
13
|
+
ACID_UNKNOWN2_SIZE = 4
|
|
14
|
+
ACID_NUM_BEATS_SIZE = 4
|
|
15
|
+
ACID_METER_DENOM_SIZE = 2
|
|
16
|
+
ACID_METER_NUMER_SIZE = 2
|
|
17
|
+
ACID_TEMPO_SIZE = 4
|
|
18
|
+
|
|
19
|
+
ACID_TEMPO_OFFSET = ACID_TYPE_FLAGS_SIZE +
|
|
20
|
+
ACID_ROOT_NOTE_SIZE +
|
|
21
|
+
ACID_UNKNOWN1_SIZE +
|
|
22
|
+
ACID_UNKNOWN2_SIZE +
|
|
23
|
+
ACID_NUM_BEATS_SIZE +
|
|
24
|
+
ACID_METER_DENOM_SIZE +
|
|
25
|
+
ACID_METER_NUMER_SIZE
|
|
26
|
+
|
|
27
|
+
UINT32_LITTLE_ENDIAN = 'V'
|
|
28
|
+
FLOAT32_LITTLE_ENDIAN = 'e'
|
|
29
|
+
|
|
30
|
+
def self.read_bpm(filepath)
|
|
31
|
+
File.open(filepath, 'rb') do |file|
|
|
32
|
+
file.seek(RIFF_HEADER_SIZE)
|
|
33
|
+
until file.eof?
|
|
34
|
+
chunk_id = file.read(CHUNK_ID_SIZE)
|
|
35
|
+
break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
|
|
36
|
+
|
|
37
|
+
chunk_size = file.read(CHUNK_SIZE_FIELD_SIZE).unpack1(UINT32_LITTLE_ENDIAN)
|
|
38
|
+
if chunk_id == ACID_CHUNK_ID
|
|
39
|
+
file.seek(ACID_TEMPO_OFFSET, IO::SEEK_CUR)
|
|
40
|
+
return file.read(ACID_TEMPO_SIZE).unpack1(FLOAT32_LITTLE_ENDIAN)
|
|
41
|
+
else
|
|
42
|
+
padding = chunk_size.odd? ? 1 : 0
|
|
43
|
+
file.seek(chunk_size + padding, IO::SEEK_CUR)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.write_bpm(source_filepath, output_filepath, new_bpm)
|
|
51
|
+
bpm_bytes = [new_bpm.to_f].pack(FLOAT32_LITTLE_ENDIAN)
|
|
52
|
+
acid_chunk_found = false
|
|
53
|
+
|
|
54
|
+
File.open(source_filepath, 'rb') do |input|
|
|
55
|
+
File.open(output_filepath, 'wb') do |output|
|
|
56
|
+
riff_header = input.read(RIFF_HEADER_SIZE)
|
|
57
|
+
output.write(riff_header)
|
|
58
|
+
|
|
59
|
+
until input.eof?
|
|
60
|
+
chunk_id = input.read(CHUNK_ID_SIZE)
|
|
61
|
+
break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
|
|
62
|
+
|
|
63
|
+
chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE)
|
|
64
|
+
chunk_size = chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN)
|
|
65
|
+
|
|
66
|
+
output.write(chunk_id)
|
|
67
|
+
output.write(chunk_size_bytes)
|
|
68
|
+
|
|
69
|
+
if chunk_id == ACID_CHUNK_ID
|
|
70
|
+
acid_chunk_found = true
|
|
71
|
+
|
|
72
|
+
output.write(input.read(ACID_TEMPO_OFFSET))
|
|
73
|
+
|
|
74
|
+
input.read(ACID_TEMPO_SIZE)
|
|
75
|
+
|
|
76
|
+
output.write(bpm_bytes)
|
|
77
|
+
|
|
78
|
+
remaining = chunk_size - ACID_TEMPO_OFFSET - ACID_TEMPO_SIZE
|
|
79
|
+
output.write(input.read(remaining)) if remaining.positive?
|
|
80
|
+
|
|
81
|
+
output.write(input.read(1)) if chunk_size.odd?
|
|
82
|
+
else
|
|
83
|
+
padding = chunk_size.odd? ? 1 : 0
|
|
84
|
+
output.write(input.read(chunk_size + padding))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
create_acid_chunk(output, new_bpm.to_f) unless acid_chunk_found
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
return if acid_chunk_found
|
|
93
|
+
|
|
94
|
+
update_riff_size(output_filepath)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.create_acid_chunk(output, bpm)
|
|
98
|
+
# Write ACID chunk ID
|
|
99
|
+
output.write(ACID_CHUNK_ID)
|
|
100
|
+
|
|
101
|
+
# Write chunk size (24 bytes)
|
|
102
|
+
output.write([ACID_CHUNK_DATA_SIZE].pack(UINT32_LITTLE_ENDIAN))
|
|
103
|
+
|
|
104
|
+
# Type flags (4 bytes) - 0x01 for one-shot
|
|
105
|
+
output.write([0x01].pack(UINT32_LITTLE_ENDIAN))
|
|
106
|
+
|
|
107
|
+
# Root note (2 bytes) - 0x003C (middle C / 60)
|
|
108
|
+
output.write([0x003C].pack('v'))
|
|
109
|
+
|
|
110
|
+
# Unknown1 (2 bytes)
|
|
111
|
+
output.write([0x0000].pack('v'))
|
|
112
|
+
|
|
113
|
+
# Unknown2 (4 bytes)
|
|
114
|
+
output.write([0x00000000].pack(UINT32_LITTLE_ENDIAN))
|
|
115
|
+
|
|
116
|
+
# Number of beats (4 bytes) - 0 for non-loop
|
|
117
|
+
output.write([0x00000000].pack(UINT32_LITTLE_ENDIAN))
|
|
118
|
+
|
|
119
|
+
# Meter denominator (2 bytes) - 4 for 4/4 time
|
|
120
|
+
output.write([0x0004].pack('v'))
|
|
121
|
+
|
|
122
|
+
# Meter numerator (2 bytes) - 4 for 4/4 time
|
|
123
|
+
output.write([0x0004].pack('v'))
|
|
124
|
+
|
|
125
|
+
output.write([bpm].pack(FLOAT32_LITTLE_ENDIAN))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.update_riff_size(filepath)
|
|
129
|
+
File.open(filepath, 'r+b') do |file|
|
|
130
|
+
file.seek(0, IO::SEEK_END)
|
|
131
|
+
file_size = file.tell
|
|
132
|
+
|
|
133
|
+
riff_size = file_size - 8
|
|
134
|
+
|
|
135
|
+
file.seek(4)
|
|
136
|
+
file.write([riff_size].pack(UINT32_LITTLE_ENDIAN))
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavesync
|
|
4
|
+
class Analyzer
|
|
5
|
+
def initialize(library_path)
|
|
6
|
+
@library_path = File.expand_path(library_path)
|
|
7
|
+
@audio_files = find_audio_files
|
|
8
|
+
@ui = UI.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def analyze(overwrite: false)
|
|
12
|
+
unless BpmDetector.available?
|
|
13
|
+
puts 'Error: bpm-tools or ffmpeg is not installed. Install with: brew install bpm-tools ffmpeg'
|
|
14
|
+
exit 1
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@audio_files.each_with_index do |file, index|
|
|
18
|
+
audio = Audio.new(file)
|
|
19
|
+
|
|
20
|
+
if audio.bpm && !overwrite
|
|
21
|
+
@ui.analyze_progress(index, @audio_files.size)
|
|
22
|
+
@ui.analyze_skip(file, audio.bpm)
|
|
23
|
+
next
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
bpm = BpmDetector.detect(file)
|
|
27
|
+
audio.write_bpm(bpm) if bpm
|
|
28
|
+
@ui.analyze_progress(index, @audio_files.size)
|
|
29
|
+
@ui.analyze_result(file, bpm)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def find_audio_files
|
|
36
|
+
Audio.find_all(@library_path).sort
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'streamio-ffmpeg'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'taglib'
|
|
8
|
+
|
|
9
|
+
module Wavesync
|
|
10
|
+
class Audio
|
|
11
|
+
SUPPORTED_FORMATS = %w[.m4a .mp3 .wav .aif .aiff].freeze
|
|
12
|
+
|
|
13
|
+
def self.find_all(library_path)
|
|
14
|
+
Dir.glob(File.join(library_path, '**', '*'))
|
|
15
|
+
.select { |f| SUPPORTED_FORMATS.include?(File.extname(f).downcase) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
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
|
+
end
|
|
23
|
+
|
|
24
|
+
def duration
|
|
25
|
+
@audio.duration
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sample_rate
|
|
29
|
+
@sample_rate ||= @audio.audio_sample_rate
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def bit_depth
|
|
33
|
+
@bit_depth ||= calculate_bit_depth
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def bpm
|
|
37
|
+
return @bpm if defined?(@bpm)
|
|
38
|
+
|
|
39
|
+
@bpm = bpm_from_file
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def format
|
|
43
|
+
AudioFormat.new(
|
|
44
|
+
file_type: @file_ext.delete_prefix('.'),
|
|
45
|
+
sample_rate: sample_rate,
|
|
46
|
+
bit_depth: bit_depth
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_bpm(bpm)
|
|
51
|
+
case @file_ext
|
|
52
|
+
when '.m4a'
|
|
53
|
+
write_bpm_to_m4a(bpm)
|
|
54
|
+
when '.mp3'
|
|
55
|
+
write_bpm_to_mp3(bpm)
|
|
56
|
+
when '.wav'
|
|
57
|
+
write_bpm_to_wav(bpm)
|
|
58
|
+
when '.aif', '.aiff'
|
|
59
|
+
write_bpm_to_aiff(bpm)
|
|
60
|
+
end
|
|
61
|
+
@bpm = bpm
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil)
|
|
65
|
+
options = build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds)
|
|
66
|
+
ext = target_file_type || @file_ext.delete_prefix('.')
|
|
67
|
+
temp_path = File.join(
|
|
68
|
+
Dir.tmpdir,
|
|
69
|
+
"wavesync_transcode_#{SecureRandom.hex}.#{ext}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
@audio.transcode(temp_path, options)
|
|
74
|
+
FileUtils.install(temp_path, target_path)
|
|
75
|
+
true
|
|
76
|
+
rescue Errno::ENOENT
|
|
77
|
+
puts 'Errno::ENOENT'
|
|
78
|
+
false
|
|
79
|
+
ensure
|
|
80
|
+
FileUtils.rm_f(temp_path)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def calculate_bit_depth
|
|
87
|
+
data = @audio.metadata
|
|
88
|
+
return nil unless data && data[:streams]
|
|
89
|
+
|
|
90
|
+
audio_stream = data[:streams].find { |s| s[:codec_type] == 'audio' }
|
|
91
|
+
return nil unless audio_stream
|
|
92
|
+
|
|
93
|
+
bits_per_sample = audio_stream[:bits_per_sample]
|
|
94
|
+
return bits_per_sample if bits_per_sample&.positive?
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds = nil)
|
|
100
|
+
options = { custom: %w[-loglevel warning -nostats -hide_banner] }
|
|
101
|
+
|
|
102
|
+
if target_bit_depth == 24
|
|
103
|
+
options[:audio_codec] = 'pcm_s24le'
|
|
104
|
+
elsif target_bit_depth == 16
|
|
105
|
+
options[:audio_codec] = 'pcm_s16le'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
options[:audio_codec] = 'pcm_s24le'
|
|
109
|
+
options[:audio_sample_rate] = target_sample_rate if target_sample_rate
|
|
110
|
+
|
|
111
|
+
if padding_seconds&.positive?
|
|
112
|
+
total_duration = @audio.duration + padding_seconds
|
|
113
|
+
options[:custom] += ['-af', "apad=whole_dur=#{total_duration.round(6)}"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
options
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def bpm_from_file
|
|
120
|
+
case @file_ext
|
|
121
|
+
when '.m4a'
|
|
122
|
+
bpm_from_m4a
|
|
123
|
+
when '.mp3'
|
|
124
|
+
bpm_from_mp3
|
|
125
|
+
when '.wav'
|
|
126
|
+
bpm_from_wav
|
|
127
|
+
when '.aif', '.aiff'
|
|
128
|
+
bpm_from_aiff
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def bpm_from_m4a
|
|
133
|
+
TagLib::MP4::File.open(@file_path) do |file|
|
|
134
|
+
tag = file.tag
|
|
135
|
+
return bpm_from_item_map(tag) if tag
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def bpm_from_mp3
|
|
140
|
+
TagLib::MPEG::File.open(@file_path) do |file|
|
|
141
|
+
tag = file.id3v2_tag
|
|
142
|
+
return bpm_from_frame_list(tag) if tag
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def bpm_from_wav
|
|
147
|
+
TagLib::RIFF::WAV::File.open(@file_path) do |file|
|
|
148
|
+
tag = file.id3v2_tag
|
|
149
|
+
bpm_from_frame_list = bpm_from_frame_list(tag) if tag
|
|
150
|
+
return bpm_from_frame_list if bpm_from_frame_list
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
bpm_from_acid_chunk
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def bpm_from_aiff
|
|
157
|
+
TagLib::RIFF::AIFF::File.open(@file_path) do |file|
|
|
158
|
+
tag = file.tag
|
|
159
|
+
return bpm_from_frame_list(tag) if tag
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def bpm_from_item_map(tag)
|
|
164
|
+
tmpo = tag.item_map['tmpo']&.to_int
|
|
165
|
+
tmpo&.zero? ? nil : tmpo
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def bpm_from_frame_list(tag)
|
|
169
|
+
tag.frame_list('TBPM').first&.to_s
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def bpm_from_acid_chunk
|
|
173
|
+
tmpo = Wavesync::AcidChunk.read_bpm(@file_path).to_i
|
|
174
|
+
tmpo&.zero? ? nil : tmpo
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def write_bpm_to_wav(bpm)
|
|
178
|
+
temp_path = "#{@file_path}.tmp"
|
|
179
|
+
AcidChunk.write_bpm(@file_path, temp_path, bpm)
|
|
180
|
+
FileUtils.mv(temp_path, @file_path)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def write_bpm_to_mp3(bpm)
|
|
184
|
+
TagLib::MPEG::File.open(@file_path) do |file|
|
|
185
|
+
write_id3v2_bpm(file.id3v2_tag(true), bpm)
|
|
186
|
+
file.save
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def write_bpm_to_m4a(bpm)
|
|
191
|
+
TagLib::MP4::File.open(@file_path) do |file|
|
|
192
|
+
tag = file.tag
|
|
193
|
+
tag.item_map.insert('tmpo', TagLib::MP4::Item.from_int(bpm.to_i))
|
|
194
|
+
file.save
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def write_bpm_to_aiff(bpm)
|
|
199
|
+
TagLib::RIFF::AIFF::File.open(@file_path) do |file|
|
|
200
|
+
write_id3v2_bpm(file.tag, bpm)
|
|
201
|
+
file.save
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def write_id3v2_bpm(tag, bpm)
|
|
206
|
+
tag.remove_frames('TBPM')
|
|
207
|
+
frame = TagLib::ID3v2::TextIdentificationFrame.new('TBPM', TagLib::String::UTF8)
|
|
208
|
+
frame.text = bpm.to_s
|
|
209
|
+
tag.add_frame(frame)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavesync
|
|
4
|
+
AudioFormat = Data.define(:file_type, :sample_rate, :bit_depth) do
|
|
5
|
+
def merge(other)
|
|
6
|
+
with(
|
|
7
|
+
file_type: other.file_type || file_type,
|
|
8
|
+
sample_rate: other.sample_rate || sample_rate,
|
|
9
|
+
bit_depth: other.bit_depth || bit_depth
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Wavesync
|
|
6
|
+
class BpmDetector
|
|
7
|
+
def self.available?
|
|
8
|
+
system('which bpm > /dev/null 2>&1') && system('which ffmpeg > /dev/null 2>&1')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.detect(file_path)
|
|
12
|
+
output = `ffmpeg -i #{Shellwords.escape(file_path)} -ac 1 -ar 44100 -f f32le - 2>/dev/null | bpm`
|
|
13
|
+
bpm = output.strip.to_f
|
|
14
|
+
bpm.positive? ? bpm.round : nil
|
|
15
|
+
rescue StandardError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|