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 0000000..c3998c3 Binary files /dev/null and b/test/fixtures/click_120bpm_2_5bars.wav differ 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