From 1d4734dbad71c30474d57cb986fc9b95514dc48d Mon Sep 17 00:00:00 2001 From: Andreas Zecher Date: Fri, 27 Mar 2026 23:16:46 +0100 Subject: [PATCH] Add integration tests for device sync and click track fixture --- Rakefile | 13 ++- rakelib/fixtures.rake | 58 ++++++++++ test/fixtures/click_120bpm_2_5bars.wav | Bin 0 -> 441110 bytes test/integration/integration_test_case.rb | 112 +++++++++++++++++++ test/integration/octatrack_sync_test.rb | 125 ++++++++++++++++++++++ test/integration/tp7_sync_test.rb | 93 ++++++++++++++++ test/wavesync/scanner_test.rb | 7 +- test/wavesync/test_case.rb | 13 +++ 8 files changed, 414 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/click_120bpm_2_5bars.wav create mode 100644 test/integration/integration_test_case.rb create mode 100644 test/integration/octatrack_sync_test.rb create mode 100644 test/integration/tp7_sync_test.rb diff --git a/Rakefile b/Rakefile index 97b7739..eaf92d1 100644 --- a/Rakefile +++ b/Rakefile @@ -3,12 +3,21 @@ require 'rake/testtask' require_relative 'lib/wavesync/version' -Rake::TestTask.new do |t| +Rake::TestTask.new(:test) do |t| t.libs << 'test' - t.pattern = 'test/**/*_test.rb' + t.pattern = 'test/wavesync/**/*_test.rb' t.verbose = false end +namespace :test do + desc 'Run integration tests against connected devices' + Rake::TestTask.new(:integration) do |t| + t.libs << 'test' + t.pattern = 'test/integration/**/*_test.rb' + t.verbose = false + end +end + desc 'Run rubocop, steep check, and tests' task default: %i[rubocop steep test] diff --git a/rakelib/fixtures.rake b/rakelib/fixtures.rake index 8979fa8..6da81f2 100644 --- a/rakelib/fixtures.rake +++ b/rakelib/fixtures.rake @@ -49,5 +49,63 @@ namespace :fixtures do "#{FIXTURES_PATH}/44100_#{bit_depth}.#{ext}" end end + + Rake::Task['fixtures:generate_click_track'].invoke + end + + desc 'Generate click track fixture: 120 BPM, 2.5 bars, 44100Hz 16-bit WAV with ACID BPM' + task :generate_click_track do + require_relative '../lib/wavesync/acid_chunk' + + click_bpm = 120 + click_bars = 2.5 + click_sample_rate = 44_100 + click_ms = 30 + downbeat_freq = 880 + beat_freq = 440 + output_path = "#{FIXTURES_PATH}/click_120bpm_2_5bars.wav" + + beat_duration_ms = (60_000.0 / click_bpm).round + total_beats = (click_bars * 4).to_i + total_duration_s = click_bars * 4 * 60.0 / click_bpm + + beat_times_ms = Array.new(total_beats) { |i| i * beat_duration_ms } + downbeat_times_ms = beat_times_ms.select.with_index { |_, i| (i % 4).zero? } + other_beat_times_ms = beat_times_ms - downbeat_times_ms + + num_downbeats = downbeat_times_ms.size + num_other_beats = other_beat_times_ms.size + + filter_parts = [] + filter_parts << "[0]asplit=#{num_downbeats}#{(0...num_downbeats).map { |i| "[d#{i}]" }.join}" + filter_parts << "[1]asplit=#{num_other_beats}#{(0...num_other_beats).map { |i| "[b#{i}]" }.join}" + + output_labels = [] + + downbeat_times_ms.each_with_index do |time_ms, i| + label = "od#{i}" + filter_parts << "[d#{i}]adelay=#{time_ms},apad=whole_dur=#{total_duration_s}[#{label}]" + output_labels << "[#{label}]" + end + + other_beat_times_ms.each_with_index do |time_ms, i| + label = "ob#{i}" + filter_parts << "[b#{i}]adelay=#{time_ms},apad=whole_dur=#{total_duration_s}[#{label}]" + output_labels << "[#{label}]" + end + + filter_parts << "#{output_labels.join}amix=inputs=#{total_beats}:normalize=0,volume=0.5" + + sh 'ffmpeg', '-y', + '-f', 'lavfi', '-i', "sine=frequency=#{downbeat_freq}:sample_rate=#{click_sample_rate}:duration=#{click_ms / 1000.0}", + '-f', 'lavfi', '-i', "sine=frequency=#{beat_freq}:sample_rate=#{click_sample_rate}:duration=#{click_ms / 1000.0}", + '-filter_complex', filter_parts.join(';'), + '-acodec', 'pcm_s16le', + '-ar', click_sample_rate.to_s, + '-t', total_duration_s.to_s, + output_path + + Wavesync::AcidChunk.write_bpm_in_place(output_path, click_bpm) + puts "Generated #{output_path} (#{click_bpm} BPM, #{click_bars} bars, #{total_duration_s}s)" end end diff --git a/test/fixtures/click_120bpm_2_5bars.wav b/test/fixtures/click_120bpm_2_5bars.wav new file mode 100644 index 0000000000000000000000000000000000000000..c3998c36eb825940f595aaeceba1645876d63352 GIT binary patch literal 441110 zcmeFzk8@Y$l|S%%pI-+MAtC~%h}lRf&@^J2#)!y*vYCi6B1XP!L_``>3K&zch>_uo z7%2rz5wny*z=$lS5s_vX#HcZi*%Vnsi-BSceA$TEh&0V&nmx~td++ywI{QED%;7VW zWRe-~b6)4XpL1v5otHP{T^0PF>2v<6^zkRtQi31|{b$C$AXq;)3|Np76x{tlQL6v_ z-GzDg-Tgpb(bfLvf=5@B=4565IP;cClY*e+(I+3RZBYLALFmF>cAEcQJSBgi8r5Xj z4((vU!5P>E)1g&OQfuY=;wv7rL2PI^G#HW$i^oLSuGm(aPCZi}!zDNY6>?0&`S=cI z=~n%u$+bh=-&}Xp8FwaM2Is<)tdVaQ3*~UNUuD8(Xafx~bi(U!FPu_$sjae2#Qb_b zj%9=yK}M1p-xd|R<+jfB>tg*mK8cw)02z+P2XQ~%qz~#xOq%Vp?e19A8n-5`K}*=c z_V8y#feh6yl?LnJIQ08xbirFN7tX69wNG9YqeM2pljVnZ2l>f8aZ$9$ZM02hfLW}& za5eq}ufh;V;=Q;VhwI&XrWt5E><8{ZR2SDKwZYzS7u&#pE^=g#tWwv)DmVgt5DfSR z-i8wBQ>Cg&GI@(A)<02JgT{|BOq}3Kft7>s7a$Bo~V3_*S+ntO;t8SL52~uxqo-&M-T5ir$4YafG)T zjJM)iyns*WA*R)AvE^=FR21Kz%noLSGuiF@MsZ2h%VPDlng{jJ4TZ-YxE`Bo=bE?ljk*qJW2*1@K%9gtu>O=St-sopn;Y2LMPq9#U>+NQto#Mttsqx5UWH2%u#uUHETf};qqmHT>Py=Uu zJ`9|O-@>iXpwd;jtP_9Y-K;P4>cwI0& z85Q3YO?C_HHq)i=)yMI1U%}r~I1dlu&AM5an=Cuf^}5dJOx%&23EIO?Sp%;Y^JR*9 zL;cVTedeWN_!4U2J~*xN)XVa7A;cJ-!Lq`vAS=m^r$$9?rQL6gepq+l3g3x=z9Xqv zgl}M)KA?-uXxnSsTx--6HziF$V|b9&@KqvTM)GAf#&`N--|@tE{s24#-D<8nAbZ6P z;x?Yo3d8$?!emxl9F@7vw#5uKWx5;JV2+=v&`(zZ*5Ebz6`f~0OoxB#* zgxlEjyhLQl3vz=>@e|t$m;Cfzgu_q@{c5plksxzK5iem&!ll8|q%2+$t#`Gy-K3e- zx*s?CWDNDK8iq5l8kw%rS*FJ{+E-jv^sD&!WL;1hu3}4gf%u+0C7)Id%He}6DeU!W z`~?VjMxB;pWT7bIYuNg5L$E&C5Wg7hc1^a|WSh-e=&k<$zMR4#KJgpyZ@5a2F{jO5 zTj`cXi{nMfqF_<@D4WH9EQD;9i&UR_%%A^q3crOv`kQ4KY*H6xw)~k`&v≺oe|x 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 Q|C#;2Fhn5yzxwa_e*{6)WB>pF literal 0 HcmV?d00001 diff --git a/test/integration/integration_test_case.rb b/test/integration/integration_test_case.rb new file mode 100644 index 0000000..362f504 --- /dev/null +++ b/test/integration/integration_test_case.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'securerandom' +require_relative '../wavesync/test_case' +require_relative '../../lib/wavesync' + +module Wavesync + class IntegrationTestCase < Wavesync::TestCase + def self.device_model + raise NotImplementedError, "#{self} must define .device_model" + end + + def setup + silence_output + + config = begin + Wavesync::Config.load + rescue Wavesync::ConfigError + skip 'No wavesync config found (~/.wavesync.yml)' + end + + device_config = config.device_configs.find { |dc| dc[:model] == self.class.device_model } + skip "No #{self.class.device_model} device configured in ~/wavesync.yml" unless device_config + skip "Device path not accessible: #{device_config[:path]}" unless File.exist?(device_config[:path]) + + @device = Wavesync::Device.find_by(name: device_config[:model]) + @device_test_path = File.join(device_config[:path], 'wavesync_test', SecureRandom.hex(8)) + @source_dir = Dir.mktmpdir + + FileUtils.mkdir_p(@device_test_path) + end + + def teardown + restore_output + FileUtils.rm_rf(@source_dir) if @source_dir + delete_from_device(@device_test_path) if @device_test_path + end + + private + + def source_file(dest_name, fixture:, bpm: nil, cue_points: nil) + dest_path = File.join(@source_dir, dest_name) + FileUtils.cp(File.join(FIXTURES_PATH, fixture), dest_path) + audio = Wavesync::Audio.new(dest_path) + audio.write_bpm(bpm) if bpm + audio.write_cue_points(cue_points) if cue_points + dest_path + end + + def sync(pad: false) + Wavesync::Scanner.new(@source_dir).sync(@device_test_path, @device, pad: pad) + end + + def device_file(relative_path) + File.join(@device_test_path, relative_path) + end + + def assert_file_on_device(relative_path) + assert File.exist?(device_file(relative_path)), "Expected file on device: #{relative_path}" + end + + def assert_file_not_on_device(relative_path) + refute File.exist?(device_file(relative_path)), "Expected no file on device: #{relative_path}" + end + + def assert_acid_bpm(relative_path, expected_bpm) + actual_bpm = Wavesync::AcidChunk.read_bpm(device_file(relative_path)) + assert_in_delta expected_bpm, actual_bpm.to_f, 0.01, "Expected ACID BPM #{expected_bpm} in #{relative_path}, got #{actual_bpm}" + end + + def assert_no_acid_bpm(relative_path) + actual_bpm = Wavesync::AcidChunk.read_bpm(device_file(relative_path)) + assert_nil actual_bpm, "Expected no ACID BPM in #{relative_path}, got #{actual_bpm}" + end + + def assert_cue_points_on_device(relative_path, expected_cue_points) + actual_cue_points = Wavesync::CueChunk.read(device_file(relative_path)) + assert_equal expected_cue_points.size, actual_cue_points.size, + "Expected #{expected_cue_points.size} cue point(s) in #{relative_path}, got #{actual_cue_points.size}" + expected_cue_points.zip(actual_cue_points).each do |expected, actual| + assert_equal expected[:sample_offset], actual[:sample_offset] + assert_equal expected[:label], actual[:label] if expected.key?(:label) + end + end + + def assert_sample_rate_on_device(relative_path, expected_hz) + actual_hz = Wavesync::Audio.new(device_file(relative_path)).sample_rate + assert_equal expected_hz, actual_hz, "Expected sample rate #{expected_hz}Hz in #{relative_path}, got #{actual_hz}" + end + + def assert_duration_on_device(relative_path, expected_seconds, tolerance: 0.1) + actual_seconds = Wavesync::Audio.new(device_file(relative_path)).duration + assert_in_delta expected_seconds, actual_seconds, tolerance, + "Expected duration #{expected_seconds}s in #{relative_path}, got #{actual_seconds}s" + end + + def delete_from_device(dir_path) + return unless File.exist?(dir_path) + + Dir.glob(File.join(dir_path, '**', '*')) + .reverse + .each do |entry| + File.file?(entry) ? File.delete(entry) : Dir.rmdir(entry) + rescue SystemCallError + nil + end + Dir.rmdir(dir_path) + rescue SystemCallError + nil + end + end +end diff --git a/test/integration/octatrack_sync_test.rb b/test/integration/octatrack_sync_test.rb new file mode 100644 index 0000000..8595060 --- /dev/null +++ b/test/integration/octatrack_sync_test.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require_relative 'integration_test_case' + +module Wavesync + class OctatrackSyncTest < IntegrationTestCase + def self.device_model = 'Octatrack' + + test 'copies wav without bpm using original filename' do + source_file('track.wav', fixture: '44100_16.wav') + sync + assert_file_on_device 'track.wav' + end + + test 'copies wav with bpm, bpm appended to filename' do + source_file('track.wav', fixture: '44100_16.wav', bpm: 120) + sync + assert_file_on_device 'track 120 bpm.wav' + assert_file_not_on_device 'track.wav' + end + + test 'copies aif without bpm using original filename' do + source_file('track.aif', fixture: '44100_16.aif') + sync + assert_file_on_device 'track.aif' + end + + test 'copies aif with bpm, bpm appended to filename' do + source_file('track.aif', fixture: '44100_16.aif', bpm: 130) + sync + assert_file_on_device 'track 130 bpm.aif' + assert_file_not_on_device 'track.aif' + end + + test 'converts wav at 48000hz to 44100hz' do + source_file('track.wav', fixture: '48000_16.wav') + sync + assert_file_on_device 'track.wav' + assert_sample_rate_on_device 'track.wav', 44_100 + end + + test 'converts wav at 48000hz to 44100hz with bpm appended to filename' do + source_file('track.wav', fixture: '48000_16.wav', bpm: 128) + sync + assert_file_on_device 'track 128 bpm.wav' + assert_sample_rate_on_device 'track 128 bpm.wav', 44_100 + end + + test 'converts wav at 48000hz to 44100hz, rescaling cue point sample offsets' do + cue_points = [{ identifier: 1, sample_offset: 48_000, label: 'Drop' }] + source_file('track.wav', fixture: '48000_16.wav', cue_points: cue_points) + sync + rescaled_offset = (48_000 * 44_100 / 48_000.0).round + assert_cue_points_on_device 'track.wav', [{ identifier: 1, sample_offset: rescaled_offset, label: 'Drop' }] + end + + test 'converts mp3 to wav' do + source_file('track.mp3', fixture: '44100.mp3') + sync + assert_file_on_device 'track.wav' + assert_file_not_on_device 'track.mp3' + end + + test 'converts mp3 to wav with bpm appended to filename' do + source_file('track.mp3', fixture: '44100.mp3', bpm: 130) + sync + assert_file_on_device 'track 130 bpm.wav' + end + + test 'converts m4a to wav' do + source_file('track.m4a', fixture: '44100.m4a') + sync + assert_file_on_device 'track.wav' + assert_file_not_on_device 'track.m4a' + end + + test 'converts m4a to wav with bpm appended to filename' do + source_file('track.m4a', fixture: '44100.m4a', bpm: 95) + sync + assert_file_on_device 'track 95 bpm.wav' + end + + test 'pads track to 64-bar boundary when bpm is present' do + source_file('click.wav', fixture: 'click_120bpm_2_5bars.wav') + sync(pad: true) + seconds_per_bar = 4 * 60.0 / 120 + expected_duration = 64 * seconds_per_bar + assert_file_on_device 'click 120 bpm.wav' + assert_duration_on_device 'click 120 bpm.wav', expected_duration, tolerance: 0.5 + end + + test 'does not pad track when bpm is absent' do + source_file('track.wav', fixture: '44100_16.wav') + sync(pad: true) + assert_file_on_device 'track.wav' + assert_duration_on_device 'track.wav', 1.0, tolerance: 0.2 + end + + test 'replaces stale bpm filename on device when source bpm changes' do + source_file('track.wav', fixture: '44100_16.wav', bpm: 120) + sync + assert_file_on_device 'track 120 bpm.wav' + + Wavesync::Audio.new(File.join(@source_dir, 'track.wav')).write_bpm(130) + sync + + assert_file_on_device 'track 130 bpm.wav' + assert_file_not_on_device 'track 120 bpm.wav' + end + + test 'writes cue points from device wav to source wav when source has none' do + cue_points = [{ identifier: 1, sample_offset: 44_100, label: 'Marker' }] + source_file('track.wav', fixture: '44100_16.wav', cue_points: cue_points) + sync + + FileUtils.cp(File.join(FIXTURES_PATH, '44100_16.wav'), File.join(@source_dir, 'track.wav')) + sync + + source_cue_points = Wavesync::CueChunk.read(File.join(@source_dir, 'track.wav')) + assert_equal 1, source_cue_points.size + assert_equal 44_100, source_cue_points[0][:sample_offset] + assert_equal 'Marker', source_cue_points[0][:label] + end + end +end diff --git a/test/integration/tp7_sync_test.rb b/test/integration/tp7_sync_test.rb new file mode 100644 index 0000000..6738f5c --- /dev/null +++ b/test/integration/tp7_sync_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative 'integration_test_case' + +module Wavesync + class Tp7SyncTest < IntegrationTestCase + def self.device_model = 'TP-7' + + test 'copies wav without bpm, no acid bpm on device' do + source_file('track.wav', fixture: '44100_16.wav') + sync + assert_file_on_device 'track.wav' + assert_no_acid_bpm 'track.wav' + end + + test 'copies wav with bpm, acid bpm injected on device' do + source_file('track.wav', fixture: '44100_16.wav', bpm: 120) + sync + assert_file_on_device 'track.wav' + assert_acid_bpm 'track.wav', 120 + end + + test 'copies wav with cue points, cue points preserved on device' do + cue_points = [ + { identifier: 1, sample_offset: 11_025, label: 'A' }, + { identifier: 2, sample_offset: 22_050, label: 'B' } + ] + source_file('track.wav', fixture: '44100_16.wav', cue_points: cue_points) + sync + assert_cue_points_on_device 'track.wav', cue_points + end + + test 'copies wav with bpm and cue points, both preserved on device' do + cue_points = [{ identifier: 1, sample_offset: 11_025, label: 'Drop' }] + source_file('track.wav', fixture: '44100_16.wav', bpm: 140, cue_points: cue_points) + sync + assert_acid_bpm 'track.wav', 140 + assert_cue_points_on_device 'track.wav', cue_points + end + + test 'copies mp3 without converting it' do + source_file('track.mp3', fixture: '44100.mp3') + sync + assert_file_on_device 'track.mp3' + end + + test 'converts m4a to wav' do + source_file('track.m4a', fixture: '44100.m4a') + sync + assert_file_on_device 'track.wav' + assert_file_not_on_device 'track.m4a' + end + + test 'converts m4a to wav and injects bpm into acid chunk' do + source_file('track.m4a', fixture: '44100.m4a', bpm: 140) + sync + assert_file_on_device 'track.wav' + assert_acid_bpm 'track.wav', 140 + end + + test 'converts aif to wav and injects bpm into acid chunk' do + source_file('track.aif', fixture: '44100_16.aif', bpm: 100) + sync + assert_file_on_device 'track.wav' + assert_acid_bpm 'track.wav', 100 + end + + test 'skips wav on second sync when file already exists on device' do + source_file('track.wav', fixture: '44100_16.wav', bpm: 120) + sync + assert_acid_bpm 'track.wav', 120 + + Wavesync::Audio.new(File.join(@source_dir, 'track.wav')).write_bpm(130) + sync + + assert_acid_bpm 'track.wav', 120 + end + + test 'writes cue points from device wav to source wav when source has none' do + cue_points = [{ identifier: 1, sample_offset: 44_100, label: 'Marker' }] + source_file('track.wav', fixture: '44100_16.wav', cue_points: cue_points) + sync + + FileUtils.cp(File.join(FIXTURES_PATH, '44100_16.wav'), File.join(@source_dir, 'track.wav')) + sync + + source_cue_points = Wavesync::CueChunk.read(File.join(@source_dir, 'track.wav')) + assert_equal 1, source_cue_points.size + assert_equal 44_100, source_cue_points[0][:sample_offset] + assert_equal 'Marker', source_cue_points[0][:label] + end + end +end diff --git a/test/wavesync/scanner_test.rb b/test/wavesync/scanner_test.rb index 9a97b95..ae4c2a9 100644 --- a/test/wavesync/scanner_test.rb +++ b/test/wavesync/scanner_test.rb @@ -6,17 +6,14 @@ module Wavesync class ScannerTest < Wavesync::TestCase def setup + silence_output @source_dir = Dir.mktmpdir @target_dir = Dir.mktmpdir @device = Device.find_by(name: 'TP-7') - @original_stdout = $stdout - @null_out = File.open(File::NULL, 'w') # rubocop:disable Style/FileOpen - $stdout = @null_out end def teardown - $stdout = @original_stdout - @null_out.close + restore_output FileUtils.rm_rf(@source_dir) FileUtils.rm_rf(@target_dir) end diff --git a/test/wavesync/test_case.rb b/test/wavesync/test_case.rb index c15edcf..c4d8dc0 100644 --- a/test/wavesync/test_case.rb +++ b/test/wavesync/test_case.rb @@ -9,5 +9,18 @@ class TestCase < Minitest::Test def self.test(name, &) define_method("test_#{name.gsub(/\s+/, '_')}", &) end + + private + + def silence_output + @original_stdout = $stdout + @null_out = File.open(File::NULL, 'w') # rubocop:disable Style/FileOpen + $stdout = @null_out + end + + def restore_output + $stdout = @original_stdout + @null_out&.close + end end end