From 9151c5abfa4eb727101e36274c3ab93e8184314c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 05:18:27 +0000 Subject: [PATCH] Add bar-aligned silence padding to Octatrack tracks on sync Adds a -p / --pad flag to `wavesync sync` that pads each track with silence so its total length aligns to a multiple of 64 bars (Octatrack only, requires BPM metadata). Re-converts existing files when the expected duration changes. Lazy-evaluates BPM in Audio so TagLib is opened at most once per instance. https://claude.ai/code/session_01Qwfs9ncsZr2xiKxthYeAQU --- README.md | 4 ++ config/devices.yml | 1 + lib/wavesync.rb | 1 + lib/wavesync/audio.rb | 23 +++++++--- lib/wavesync/cli.rb | 7 +++- lib/wavesync/device.rb | 8 ++-- lib/wavesync/file_converter.rb | 18 ++++++-- lib/wavesync/scanner.rb | 18 ++++++-- lib/wavesync/track_padding.rb | 27 ++++++++++++ lib/wavesync/ui.rb | 7 +++- test/wavesync/track_padding_test.rb | 65 +++++++++++++++++++++++++++++ 11 files changed, 160 insertions(+), 19 deletions(-) create mode 100644 lib/wavesync/track_padding.rb create mode 100644 test/wavesync/track_padding_test.rb diff --git a/README.md b/README.md index 28533d0..a325517 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/devices.yml b/config/devices.yml index e32f7ec..f451439 100644 --- a/config/devices.yml +++ b/config/devices.yml @@ -24,3 +24,4 @@ devices: - aiff - aif bpm_source: :filename + bar_multiple: 64 diff --git a/lib/wavesync.rb b/lib/wavesync.rb index 92b046b..5123c6d 100644 --- a/lib/wavesync.rb +++ b/lib/wavesync.rb @@ -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' diff --git a/lib/wavesync/audio.rb b/lib/wavesync/audio.rb index 4ab3098..1a5a3a3 100644 --- a/lib/wavesync/audio.rb +++ b/lib/wavesync/audio.rb @@ -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 @@ -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( @@ -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, @@ -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 @@ -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 diff --git a/lib/wavesync/cli.rb b/lib/wavesync/cli.rb index 1774ba3..58ea3b2 100644 --- a/lib/wavesync/cli.rb +++ b/lib/wavesync/cli.rb @@ -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! @@ -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 diff --git a/lib/wavesync/device.rb b/lib/wavesync/device.rb index 1f38c8a..305d597 100644 --- a/lib/wavesync/device.rb +++ b/lib/wavesync/device.rb @@ -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 @@ -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 diff --git a/lib/wavesync/file_converter.rb b/lib/wavesync/file_converter.rb index 4a7b32d..52460a5 100644 --- a/lib/wavesync/file_converter.rb +++ b/lib/wavesync/file_converter.rb @@ -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) @@ -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 diff --git a/lib/wavesync/scanner.rb b/lib/wavesync/scanner.rb index 27b0c64..7b33e90 100644 --- a/lib/wavesync/scanner.rb +++ b/lib/wavesync/scanner.rb @@ -14,7 +14,7 @@ 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 @@ -22,15 +22,25 @@ def sync(target_library_path, 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) diff --git a/lib/wavesync/track_padding.rb b/lib/wavesync/track_padding.rb new file mode 100644 index 0000000..e509dc9 --- /dev/null +++ b/lib/wavesync/track_padding.rb @@ -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 diff --git a/lib/wavesync/ui.rb b/lib/wavesync/ui.rb index 004146c..3b6332e 100644 --- a/lib/wavesync/ui.rb +++ b/lib/wavesync/ui.rb @@ -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 diff --git a/test/wavesync/track_padding_test.rb b/test/wavesync/track_padding_test.rb new file mode 100644 index 0000000..89559a1 --- /dev/null +++ b/test/wavesync/track_padding_test.rb @@ -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