Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ wavesync sync -c /path/to/wavesync.yml
# Sync to a specific device only (by name as defined in config)
wavesync sync -d Octatrack

# Pad each track with silence so its total length aligns to a multiple of 64 bars
# (Octatrack only — requires BPM metadata on each track)
wavesync sync -p

# Analyze library files for BPM and write results to file metadata
# Files that already have BPM set are skipped
wavesync analyze
Expand Down
1 change: 1 addition & 0 deletions config/devices.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ devices:
- aiff
- aif
bpm_source: :filename
bar_multiple: 64
1 change: 1 addition & 0 deletions lib/wavesync.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module Wavesync
require 'wavesync/acid_chunk'
require 'wavesync/audio_format'
require 'wavesync/audio'
require 'wavesync/track_padding'
require 'wavesync/config'
require 'wavesync/device'
require 'wavesync/ui'
Expand Down
23 changes: 18 additions & 5 deletions lib/wavesync/audio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ def initialize(file_path)
@file_path = file_path
@file_ext = File.extname(@file_path).downcase
@audio = FFMPEG::Movie.new(file_path)
@bpm = bpm_from_file
end

def duration
@audio.duration
end

def sample_rate
Expand All @@ -30,7 +33,11 @@ def bit_depth
@bit_depth ||= calculate_bit_depth
end

attr_reader :bpm
def bpm
return @bpm if defined?(@bpm)

@bpm = bpm_from_file
end

def format
AudioFormat.new(
Expand All @@ -54,8 +61,8 @@ def write_bpm(bpm)
@bpm = bpm
end

def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil)
options = build_transcode_options(target_sample_rate, target_bit_depth)
def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil)
options = build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds)
ext = target_file_type || @file_ext.delete_prefix('.')
temp_path = File.join(
Dir.tmpdir,
Expand Down Expand Up @@ -89,7 +96,7 @@ def calculate_bit_depth
nil
end

def build_transcode_options(target_sample_rate, target_bit_depth)
def build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds = nil)
options = { custom: %w[-loglevel warning -nostats -hide_banner] }

if target_bit_depth == 24
Expand All @@ -100,6 +107,12 @@ def build_transcode_options(target_sample_rate, target_bit_depth)

options[:audio_codec] = 'pcm_s24le'
options[:audio_sample_rate] = target_sample_rate if target_sample_rate

if padding_seconds&.positive?
total_duration = @audio.duration + padding_seconds
options[:custom] += ['-af', "apad=whole_dur=#{total_duration.round(6)}"]
end

options
end

Expand Down
7 changes: 6 additions & 1 deletion lib/wavesync/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def self.start_sync
opts.on('-c', '--config PATH', 'Path to wavesync config YAML file') do |value|
options[:config] = value
end

opts.on('-p', '--pad', 'Pad tracks with silence so total length is a multiple of 64 bars (Octatrack only)') do
options[:pad] = true
end
end

parser.parse!
Expand Down Expand Up @@ -61,7 +65,8 @@ def self.start_sync
scanner = Wavesync::Scanner.new(config.library)

device_configs.each do |device_config|
scanner.sync(device_config[:path], Wavesync::Device.find_by(name: device_config[:model]))
scanner.sync(device_config[:path], Wavesync::Device.find_by(name: device_config[:model]),
pad: options[:pad] || false)
end
end

Expand Down
8 changes: 5 additions & 3 deletions lib/wavesync/device.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
require 'yaml'
module Wavesync
class Device
attr_reader :name, :sample_rates, :bit_depths, :file_types, :bpm_source
attr_reader :name, :sample_rates, :bit_depths, :file_types, :bpm_source, :bar_multiple

def initialize(name:, sample_rates:, bit_depths:, file_types:, bpm_source: nil)
def initialize(name:, sample_rates:, bit_depths:, file_types:, bpm_source: nil, bar_multiple: nil)
@name = name
@sample_rates = sample_rates
@bit_depths = bit_depths
@file_types = file_types
@bpm_source = bpm_source
@bar_multiple = bar_multiple
end

def self.config_path
Expand All @@ -33,7 +34,8 @@ def self.load_from_yaml
sample_rates: attrs['sample_rates'],
bit_depths: attrs['bit_depths'],
file_types: attrs['file_types'],
bpm_source: attrs['bpm_source']&.to_sym
bpm_source: attrs['bpm_source']&.to_sym,
bar_multiple: attrs['bar_multiple']
)
end
end
Expand Down
18 changes: 14 additions & 4 deletions lib/wavesync/file_converter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

module Wavesync
class FileConverter
def convert(audio, source_file_path, path_resolver, source_format, target_format, &before_transcode)
return false unless target_format.file_type || target_format.sample_rate || target_format.bit_depth
DURATION_TOLERANCE_SECONDS = 0.5

def convert(audio, source_file_path, path_resolver, source_format, target_format, padding_seconds: nil, &before_transcode)
needs_format_conversion = target_format.file_type || target_format.sample_rate || target_format.bit_depth
return false unless needs_format_conversion || padding_seconds&.positive?

target_path = path_resolver.resolve(source_file_path, audio, target_file_type: target_format.file_type)

Expand All @@ -15,14 +18,21 @@ def convert(audio, source_file_path, path_resolver, source_format, target_format
return false if source_converted_path.exist?
end

return false if target_path.exist?
if target_path.exist?
existing_duration = Audio.new(target_path.to_s).duration
expected_duration = audio.duration + (padding_seconds || 0)
return false if (existing_duration - expected_duration).abs < DURATION_TOLERANCE_SECONDS

target_path.delete
end

target_path.dirname.mkpath
before_transcode&.call

audio.transcode(target_path.to_s, target_sample_rate: target_format.sample_rate,
target_file_type: target_format.file_type,
target_bit_depth: target_format.bit_depth || source_format.bit_depth)
target_bit_depth: target_format.bit_depth || source_format.bit_depth,
padding_seconds: padding_seconds)

true
end
Expand Down
18 changes: 14 additions & 4 deletions lib/wavesync/scanner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,33 @@ def initialize(source_library_path)
FFMPEG.logger = Logger.new(File::NULL)
end

def sync(target_library_path, device)
def sync(target_library_path, device, pad: false)
path_resolver = PathResolver.new(@source_library_path, target_library_path, device)
skipped_count = 0
conversion_count = 0
@ui.sync_progress(0, @audio_files.size, device)

@audio_files.each_with_index do |file, index|
audio = Audio.new(file)
@ui.bpm(audio.bpm)

source_format = audio.format
target_format = device.target_format(source_format, file)

padding_seconds = nil
original_bars = nil
target_bars = nil
if pad && device.bar_multiple
padding_seconds = TrackPadding.compute(audio.duration, audio.bpm, device.bar_multiple)
original_bars, target_bars = TrackPadding.bar_counts(audio.duration, audio.bpm, device.bar_multiple) unless padding_seconds.zero?
padding_seconds = nil if padding_seconds.zero?
end

@ui.bpm(audio.bpm, original_bars: original_bars, target_bars: target_bars)
@ui.file_progress(file)

if target_format.file_type || target_format.sample_rate || target_format.bit_depth
converted = @converter.convert(audio, file, path_resolver, source_format, target_format) do
if target_format.file_type || target_format.sample_rate || target_format.bit_depth || padding_seconds
converted = @converter.convert(audio, file, path_resolver, source_format, target_format,
padding_seconds: padding_seconds) do
@ui.conversion_progress(source_format, target_format)
end
target_path = path_resolver.resolve(file, audio, target_file_type: target_format.file_type)
Expand Down
27 changes: 27 additions & 0 deletions lib/wavesync/track_padding.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Wavesync
class TrackPadding
BEATS_PER_BAR = 4

def self.compute(duration_seconds, bpm, bar_multiple)
return 0 if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0

seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
track_bars = (duration_seconds / seconds_per_bar).round(6)
target_bars = (track_bars / bar_multiple.to_f).ceil * bar_multiple

padding = (target_bars * seconds_per_bar) - duration_seconds
padding < 0.001 ? 0 : padding
end

def self.bar_counts(duration_seconds, bpm, bar_multiple)
return [nil, nil] if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0

seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
original_bars = (duration_seconds / seconds_per_bar).round
target_bars = ((original_bars.to_f / bar_multiple).ceil * bar_multiple).to_i
[original_bars, target_bars]
end
end
end
7 changes: 5 additions & 2 deletions lib/wavesync/ui.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,14 @@ def skip
sticky(in_color('↷ Skipping, already synced', :highlight), 3)
end

def bpm(tbpm)
def bpm(tbpm, original_bars: nil, target_bars: nil)
if tbpm.nil?
sticky('', 4)
elsif original_bars && target_bars
bar_info = original_bars == target_bars ? "#{original_bars} bars" : "#{original_bars} → #{target_bars} bars"
sticky("#{tbpm} bpm · #{bar_info}", 4)
else
sticky("#{tbpm}bpm", 4)
sticky("#{tbpm} bpm", 4)
end
end

Expand Down
65 changes: 65 additions & 0 deletions test/wavesync/track_padding_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

require_relative 'test_case'
require_relative '../../lib/wavesync/track_padding'

module Wavesync
class TrackPaddingTest < Wavesync::TestCase
# At 120 bpm, 1 bar = 4 * 60/120 = 2 seconds.
# 64 bars = 128 seconds. 128 bars = 256 seconds.

test 'returns 0 when bpm is nil' do
assert_equal 0, TrackPadding.compute(60, nil, 64)
end

test 'returns 0 when bpm is zero' do
assert_equal 0, TrackPadding.compute(60, 0, 64)
end

test 'returns 0 when duration is nil' do
assert_equal 0, TrackPadding.compute(nil, 120, 64)
end

test 'returns 0 when duration is zero' do
assert_equal 0, TrackPadding.compute(0, 120, 64)
end

test 'returns 0 when track already aligns to bar_multiple bars' do
# 120 bpm: 1 bar = 2s, 64 bars = 128s — no padding needed
assert_equal 0, TrackPadding.compute(128, 120, 64)
end

test 'pads a short track up to 64 bars' do
# 120 bpm: 1 bar = 2s, 64 bars = 128s
# 10s track → target 128s → padding = 118s
assert_in_delta 118.0, TrackPadding.compute(10, 120, 64), 0.001
end

test 'pads a track that exceeds 64 bars up to 128 bars' do
# 120 bpm: 1 bar = 2s, 64 bars = 128s, 128 bars = 256s
# 130s track → next multiple of 64 bars is 128 bars = 256s → padding = 126s
assert_in_delta 126.0, TrackPadding.compute(130, 120, 64), 0.001
end

test 'returns 0 when track aligns to 128 bars exactly' do
# 120 bpm: 128 bars = 256s
assert_equal 0, TrackPadding.compute(256, 120, 64)
end

test 'works with non-round bpm' do
# 140 bpm: 1 bar = 4 * 60/140 = 12/7 s ≈ 1.714286s, 64 bars ≈ 109.714s
bpm = 140
seconds_per_bar = 4 * 60.0 / bpm
target_bars = 64
target_duration = target_bars * seconds_per_bar
duration = 10.0
expected_padding = target_duration - duration
assert_in_delta expected_padding, TrackPadding.compute(duration, bpm, 64), 0.001
end

test 'handles string bpm' do
# Same as numeric bpm test
assert_in_delta 118.0, TrackPadding.compute(10, '120', 64), 0.001
end
end
end