From d7711cb7c852dcf1f973d765cb882b615e374d8b Mon Sep 17 00:00:00 2001 From: Andreas Zecher Date: Mon, 9 Mar 2026 08:21:11 +0100 Subject: [PATCH] Add Octatrack export command Adds `wavesync set export --device ` command that generates an Octatrack DPS-1 project.work file with static sample slots mapped to tracks in the set. Co-Authored-By: Claude Sonnet 4.6 --- Steepfile | 1 + lib/wavesync.rb | 1 + lib/wavesync/commands/set.rb | 39 ++- lib/wavesync/config.rb | 15 +- lib/wavesync/octatrack_exporter.rb | 318 ++++++++++++++++++ .../octatrack_project_files/arr_template.gz | Bin 0 -> 68 bytes .../octatrack_project_files/bank_template.gz | Bin 0 -> 2745 bytes .../markers_template.gz | Bin 0 -> 280 bytes rakelib/checksum.rake | 178 ++++++++++ sig/generated/wavesync/config.rbs | 2 +- sig/generated/wavesync/file_sync_throttle.rbs | 42 +++ sig/generated/wavesync/octatrack_exporter.rbs | 95 ++++++ test/wavesync/config_test.rb | 14 + test/wavesync/octatrack_exporter_test.rb | 185 ++++++++++ 14 files changed, 884 insertions(+), 6 deletions(-) create mode 100644 lib/wavesync/octatrack_exporter.rb create mode 100644 lib/wavesync/octatrack_project_files/arr_template.gz create mode 100644 lib/wavesync/octatrack_project_files/bank_template.gz create mode 100644 lib/wavesync/octatrack_project_files/markers_template.gz create mode 100644 rakelib/checksum.rake create mode 100644 sig/generated/wavesync/file_sync_throttle.rbs create mode 100644 sig/generated/wavesync/octatrack_exporter.rbs create mode 100644 test/wavesync/octatrack_exporter_test.rb diff --git a/Steepfile b/Steepfile index 48a488d..d72bc29 100644 --- a/Steepfile +++ b/Steepfile @@ -13,6 +13,7 @@ target :wavesync do library 'yaml' library 'psych' library 'fileutils' + library 'zlib' library 'pathname' library 'shellwords' library 'open3' diff --git a/lib/wavesync.rb b/lib/wavesync.rb index 2721211..f58e2f0 100644 --- a/lib/wavesync.rb +++ b/lib/wavesync.rb @@ -21,5 +21,6 @@ module Wavesync require 'wavesync/analyzer' require 'wavesync/set' require 'wavesync/set_editor' +require 'wavesync/octatrack_exporter' require 'wavesync/commands' require 'wavesync/cli' diff --git a/lib/wavesync/commands/set.rb b/lib/wavesync/commands/set.rb index f5bc618..e00099a 100644 --- a/lib/wavesync/commands/set.rb +++ b/lib/wavesync/commands/set.rb @@ -10,6 +10,7 @@ class Set < Command self.subcommands = [ Subcommand.new(usage: 'set create NAME', description: 'Create a new track set'), Subcommand.new(usage: 'set edit NAME', description: 'Edit an existing track set'), + Subcommand.new(usage: 'set export NAME --device DEVICE', description: 'Export a track set to a device'), Subcommand.new(usage: 'set list', description: 'List all track sets') ].freeze @@ -17,7 +18,11 @@ class Set < Command def run subcommand = ARGV.shift - _options, config = parse_options(banner: 'Usage: wavesync set [options]') + options, config = parse_options(banner: 'Usage: wavesync set [options]') do |opts, opts_hash| + opts.on('-d', '--device NAME', 'Name of device (used by export)') do |value| + opts_hash[:device] = value + end + end case subcommand when 'create' @@ -43,9 +48,39 @@ def run else sets.each { |set| puts "#{set.name} (#{set.tracks.size} tracks)" } end + when 'export' + name = require_name('export') + unless options[:device] + puts 'Usage: wavesync set export --device ' + exit 1 + end + device_config = config.device_configs.find { |device_config| device_config[:name] == options[:device] } + unless device_config + known = config.device_configs.map { |device_config| device_config[:name] }.join(', ') + puts "Unknown device \"#{options[:device]}\". Devices in config: #{known}" + exit 1 + end + unless Wavesync::Set.exists?(config.library, name) + puts "Set '#{name}' not found. Use 'wavesync set create #{name}' to create it." + exit 1 + end + unless device_config[:projects_path] + puts "Device \"#{options[:device]}\" has no 'projects_path' configured in wavesync.yml." + exit 1 + end + set = Wavesync::Set.load(config.library, name) + device = Wavesync::Device.find_by(name: device_config[:model]) + exporter = Wavesync::OctatrackExporter.new(set, config.library, device_config[:path], device_config[:projects_path], device: device) + if Dir.exist?(exporter.project_dir) && !Wavesync::UI.new.confirm("'#{File.basename(exporter.project_dir)}' already exists on device. Overwrite? [y/N] ") + puts 'Export cancelled.' + exit 0 + end + project_path = exporter.export + puts "Exported '#{name}' to #{project_path}" + puts "#{set.tracks.size} track(s) assigned to static slots 1-#{set.tracks.size}" else puts "Unknown subcommand: #{subcommand || '(none)'}" - puts 'Available subcommands: create, edit, list' + puts 'Available subcommands: create, edit, export, list' exit 1 end end diff --git a/lib/wavesync/config.rb b/lib/wavesync/config.rb index 78ed760..f26625f 100644 --- a/lib/wavesync/config.rb +++ b/lib/wavesync/config.rb @@ -10,11 +10,11 @@ class Config DEFAULT_PATH = File.join(Dir.home, 'wavesync.yml') SUPPORTED_KEYS = %w[library devices].freeze - DEVICE_SUPPORTED_KEYS = %w[name model path].freeze + DEVICE_SUPPORTED_KEYS = %w[name model path projects_path].freeze DEVICE_REQUIRED_KEYS = %w[name model path].freeze attr_reader :library #: String - attr_reader :device_configs #: Array[{ name: String, model: String, path: String }] + attr_reader :device_configs #: Array[{ name: String, model: String, path: String, projects_path: String? }] #: (?String path) -> Config def self.load(path = DEFAULT_PATH) @@ -35,7 +35,12 @@ def initialize(data) @library = File.expand_path(data['library']) @device_configs = data['devices'].each_with_index.map do |device, i| validate_device!(device, i) - { name: device['name'], model: device['model'], path: File.expand_path(device['path']) } + { + name: device['name'], + model: device['model'], + path: File.expand_path(device['path']), + projects_path: device['projects_path'] && File.expand_path(device['projects_path']) + } end end @@ -71,6 +76,10 @@ def validate_device!(device, index) %w[name model path].each do |key| raise ConfigError, "Device #{index + 1} '#{key}' must be a string" unless device[key].is_a?(String) end + + projects_path = device['projects_path'] + return unless projects_path + raise ConfigError, "Device #{index + 1} 'projects_path' must be a string" unless projects_path.is_a?(String) end end end diff --git a/lib/wavesync/octatrack_exporter.rb b/lib/wavesync/octatrack_exporter.rb new file mode 100644 index 0000000..3dc6439 --- /dev/null +++ b/lib/wavesync/octatrack_exporter.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true +# rbs_inline: enabled + +require 'fileutils' +require 'pathname' +require 'zlib' +require_relative 'audio' + +module Wavesync + class OctatrackExporter + PROJECT_FILE = 'project.work' + CRLF = "\r\n" + + TEMPLATES_DIR = File.join(__dir__.to_s, 'octatrack_project_files') + + # Offsets of the 8 track assignment blocks within each bank file. + # Each block is 40 bytes: 8 × [0x8N, slot, slot, 0x00, 0x00] + TRACK_ASSIGNMENT_OFFSETS = [ + 0x8f1ad, 0x90a68, 0x92323, 0x93bde, + 0x95499, 0x96d54, 0x9860f, 0x99eca + ].freeze + + #: (Set set, String library_path, String device_audio_path, String projects_path, ?device: Device?) -> void + def initialize(set, library_path, device_audio_path, projects_path, device: nil) + @set = set #: Set + @library_path = File.expand_path(library_path) #: String + @device_audio_path = File.expand_path(device_audio_path) #: String + @projects_path = File.expand_path(projects_path) #: String + @device = device #: Device? + end + + #: () -> String + def project_dir + File.join(@projects_path, @set.name.upcase) + end + + #: () -> String + def export + FileUtils.mkdir_p(project_dir) + write_project_work + write_bank_files + write_arr_files + write_markers_file + system('sync') + project_dir + end + + private + + #: () -> void + def write_project_work + File.write(File.join(project_dir, PROJECT_FILE), generate_project_work) + end + + #: () -> void + def write_bank_files + bank_template = load_template('bank') + bank01 = apply_track_assignments(bank_template) + File.binwrite(File.join(project_dir, 'bank01.work'), bank01) + 2.upto(16) do |n| + File.binwrite(File.join(project_dir, "bank#{n.to_s.rjust(2, '0')}.work"), bank_template) + end + end + + #: () -> void + def write_arr_files + arr_template = load_template('arr') + 1.upto(8) do |n| + File.binwrite(File.join(project_dir, "arr#{n.to_s.rjust(2, '0')}.work"), arr_template) + end + end + + #: () -> void + def write_markers_file + File.binwrite(File.join(project_dir, 'markers.work'), load_template('markers')) + end + + #: (String name) -> String + def load_template(name) + Zlib::GzipReader.open(File.join(TEMPLATES_DIR, "#{name}_template.gz"), &:read) #: String + end + + #: (String bank_data) -> String + def apply_track_assignments(bank_data) + data = bank_data.dup.b + TRACK_ASSIGNMENT_OFFSETS.each do |offset| + write_track_assignments(data, offset) + end + data.setbyte(data.bytesize - 1, bank_checksum(data)) + data + end + + #: (String data, Integer offset) -> void + def write_track_assignments(data, offset) + slot_for_track = { + 0 => 1, # Track 1 → song 1 + 4 => 2 # Track 5 → song 2 + } + 8.times do |track| + pos = offset + (track * 5) + slot = slot_for_track.fetch(track, 0) + data.setbyte(pos, 0x80 | track) + data.setbyte(pos + 1, slot) + data.setbyte(pos + 2, slot) + data.setbyte(pos + 3, 0) + data.setbyte(pos + 4, 0) + end + end + + #: (String data) -> Integer + def bank_checksum(data) + ((data.byteslice(0, data.bytesize - 1) || '').bytes.sum - 1) % 256 + end + + #: () -> String + def generate_project_work + parts = [ + section_header('Project Settings'), + meta_section, + settings_section, + section_header('Project States'), + states_section, + section_header('Samples'), + flex_sample_sections, + static_sample_sections, + divider + ] + parts.join(CRLF) + end + + #: () -> String + def divider + '############################' + end + + #: (String title) -> String + def section_header(title) + [divider, "# #{title}", divider, ''].join(CRLF) + end + + #: (String tag, Array[String] lines) -> String + def block(tag, lines) + ([open_tag(tag)] + lines + [close_tag(tag), '']).join(CRLF) + end + + #: (String tag) -> String + def open_tag(tag) + "[#{tag}]" + end + + #: (String tag) -> String + def close_tag(tag) + "[/#{tag}]" + end + + #: () -> String + def meta_section + block('META', [ + 'TYPE=OCTATRACK DPS-1 PROJECT', + 'VERSION=19', + 'OS_VERSION=R0175 1.40A' + ]) + end + + #: () -> String + def settings_section + block('SETTINGS', [ + 'WRITEPROTECTED=0', + 'TEMPOx24=2880', + 'PATTERN_TEMPO_ENABLED=0', + 'MIDI_CLOCK_SEND=0', + 'MIDI_CLOCK_RECEIVE=0', + 'MIDI_TRANSPORT_SEND=0', + 'MIDI_TRANSPORT_RECEIVE=0', + 'MIDI_PROGRAM_CHANGE_SEND=0', + 'MIDI_PROGRAM_CHANGE_SEND_CH=-1', + 'MIDI_PROGRAM_CHANGE_RECEIVE=0', + 'MIDI_PROGRAM_CHANGE_RECEIVE_CH=-1', + 'MIDI_TRIG_CH1=0', + 'MIDI_TRIG_CH2=1', + 'MIDI_TRIG_CH3=2', + 'MIDI_TRIG_CH4=3', + 'MIDI_TRIG_CH5=4', + 'MIDI_TRIG_CH6=5', + 'MIDI_TRIG_CH7=6', + 'MIDI_TRIG_CH8=7', + 'MIDI_AUTO_CHANNEL=10', + 'MIDI_SOFT_THRU=0', + 'MIDI_AUDIO_TRK_CC_IN=1', + 'MIDI_AUDIO_TRK_CC_OUT=3', + 'MIDI_AUDIO_TRK_NOTE_IN=1', + 'MIDI_AUDIO_TRK_NOTE_OUT=3', + 'MIDI_MIDI_TRK_CC_IN=1', + 'PATTERN_CHANGE_CHAIN_BEHAVIOR=0', + 'PATTERN_CHANGE_AUTO_SILENCE_TRACKS=0', + 'PATTERN_CHANGE_AUTO_TRIG_LFOS=0', + 'LOAD_24BIT_FLEX=0', + 'DYNAMIC_RECORDERS=0', + 'RECORD_24BIT=0', + 'RESERVED_RECORDER_COUNT=8', + 'RESERVED_RECORDER_LENGTH=16', + 'INPUT_DELAY_COMPENSATION=0', + 'GATE_AB=127', + 'GATE_CD=127', + 'GAIN_AB=64', + 'GAIN_CD=64', + 'DIR_AB=0', + 'DIR_CD=0', + 'PHONES_MIX=64', + 'MAIN_TO_CUE=0', + 'MASTER_TRACK=0', + 'CUE_STUDIO_MODE=0', + 'MAIN_LEVEL=64', + 'CUE_LEVEL=64', + 'METRONOME_TIME_SIGNATURE=3', + 'METRONOME_TIME_SIGNATURE_DENOMINATOR=2', + 'METRONOME_PREROLL=0', + 'METRONOME_CUE_VOLUME=32', + 'METRONOME_MAIN_VOLUME=0', + 'METRONOME_PITCH=12', + 'METRONOME_TONAL=1', + 'METRONOME_ENABLED=0', + *Array.new(8, 'TRIG_MODE_MIDI=0') + ]) + end + + #: () -> String + def states_section + block('STATES', [ + 'BANK=0', + 'PATTERN=0', + 'ARRANGEMENT=0', + 'ARRANGEMENT_MODE=0', + 'PART=0', + 'TRACK=4', + 'TRACK_OTHERMODE=0', + 'SCENE_A_MUTE=0', + 'SCENE_B_MUTE=0', + 'TRACK_CUE_MASK=0', + 'TRACK_MUTE_MASK=0', + 'TRACK_SOLO_MASK=0', + 'MIDI_TRACK_MUTE_MASK=0', + 'MIDI_TRACK_SOLO_MASK=0', + 'MIDI_MODE=0' + ]) + end + + #: () -> String + def flex_sample_sections + (129..136).map do |slot| + block('SAMPLE', [ + 'TYPE=FLEX', + "SLOT=#{slot}", + 'PATH=', + 'BPMx24=2880', + 'TSMODE=2', + 'LOOPMODE=0', + 'GAIN=72', + 'TRIGQUANTIZATION=-1' + ]) + end.join(CRLF) + end + + #: () -> String + def static_sample_sections + @set.tracks.each_with_index.map do |track_path, index| + slot = index + 1 + path = relative_audio_path(track_path) + static_sample_block(slot, path) + end.join(CRLF) + end + + #: (Integer slot, String path) -> String + def static_sample_block(slot, path) + block('SAMPLE', [ + 'TYPE=STATIC', + "SLOT=#{slot.to_s.rjust(3, '0')}", + "PATH=#{path}", + 'TSMODE=2', + 'LOOPMODE=0', + 'GAIN=48', + 'TRIGQUANTIZATION=-1' + ]) + end + + #: (String track_path) -> String + def relative_audio_path(track_path) + audio_folder = File.basename(@device_audio_path) + relative_to_library = track_path.delete_prefix("#{@library_path}/") + relative_to_library = apply_device_path_rules(track_path, relative_to_library) if @device + File.join('..', audio_folder, relative_to_library) + end + + #: (String track_path, String relative_to_library) -> String + def apply_device_path_rules(track_path, relative_to_library) + target_type = @device.target_file_type(track_path) + relative_path = Pathname(relative_to_library) + relative_path = relative_path.sub_ext(".#{target_type}") if target_type + + if @device.bpm_source == :filename + bpm = read_bpm(track_path) + if bpm + bpm_basename = "#{relative_path.basename(relative_path.extname)} #{bpm} bpm#{relative_path.extname}" + relative_path = relative_path.dirname.join(bpm_basename) + end + end + + relative_path.to_s + end + + #: (String track_path) -> (String | Integer)? + def read_bpm(track_path) + Audio.new(track_path).bpm + rescue StandardError + nil + end + end +end diff --git a/lib/wavesync/octatrack_project_files/arr_template.gz b/lib/wavesync/octatrack_project_files/arr_template.gz new file mode 100644 index 0000000000000000000000000000000000000000..c348f4df0ec4dbcc3afd54469af4025b8fd0b8e0 GIT binary patch literal 68 zcmb2|=3oE;rvGn`ZRBK76kyn}`p$7dmY;r8ivP#fT%WMylzH)y_3LA!({}d@ft7-Z R1G`T#9-k~;?xDlL003XY7)SsB literal 0 HcmV?d00001 diff --git a/lib/wavesync/octatrack_project_files/bank_template.gz b/lib/wavesync/octatrack_project_files/bank_template.gz new file mode 100644 index 0000000000000000000000000000000000000000..6905dd94ca6baecdfdf5c5fccd7ffc06949ee38d GIT binary patch literal 2745 zcmb2|=3oE;rvGp6`c9IL6m7Uz^YiwC&NQF1Tp3{wQU#YU+>Pu~;WRri!FI~ec;@JY z%$!A@maDBq${kuJSWVe(kshEp^^~x^T7RYWni~|GqqbzkL0<{?+nx zr&U_nM?Cw!f5p3p@%nM|kREgT|5okG)Pz3?e}qOB@v$8Ms~^HP2T1>;Lre3r2?@Hd--V z1&j=vTD)~*7#egQUg8jEQ1ITdE*-Uj;8CcI{!~%&%ak2SIt|!{O#+X zCx86D#@(-re2(dGhn;g*9>YNo$VH4|&cWxvzfT*SKSEvuo7%)cx1pTlMeP z#x?8zt&F?>v_5avs$br>!>-2fUbpJsweO){zbEE?tKakI&*`9sqJR5eJ+)lJQQ^J5 z?q7SI%<;FeM)TN@{@t<9dSCVC{Xeg-?w4QPf8X}zzpG|bezoua_38TlpRcm*gRj{K z*=x-ISNm(P%{Os-V2kcUhUovR_v-e?xr-ltaR>=A0VOMD^Qaf~>18eEMutF%2f0Rb zPFU{B=+Q%lK#7L!CkvFGzc`d0D}e-o5)AoM9=4p{Wq<-f;$=q)&{Rvrg2b(pEFNw~ zF}_C+DBK`xIOl}vt{+b_e+Ah7-4Rvyzoc$kjb;6hfY5lGzj^EG!v21~|DUmF(>2A5 HTR0g2Z`3`l literal 0 HcmV?d00001 diff --git a/lib/wavesync/octatrack_project_files/markers_template.gz b/lib/wavesync/octatrack_project_files/markers_template.gz new file mode 100644 index 0000000000000000000000000000000000000000..0606102957ffbc042de14433f73591b70ff54e0d GIT binary patch literal 280 zcmb2|=3oE;rvGm*Z{%z+5OKXIU%;%sal;0YBYF~Nnr5ENeG^i^VtsyPT@3;@d6B98z7 literal 0 HcmV?d00001 diff --git a/rakelib/checksum.rake b/rakelib/checksum.rake new file mode 100644 index 0000000..f5ab04a --- /dev/null +++ b/rakelib/checksum.rake @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +namespace :checksum do + # Compute the expected checksum for a .work file based on its type. + # + # bank*.work — (sum of all bytes except last) - 1, mod 256 + # arr*.work — XOR of all bytes from offset 12 to second-to-last + # markers.work — (sum of bytes from offset 16 to second-to-last) - 1, mod 256 + # project.work — no checksum (plain text) + # + # Returns nil for files with no checksum. + def expected_checksum(file_path, data) + basename = File.basename(file_path) + payload = data.byteslice(0, data.bytesize - 1) + + case basename + when /\Abank\d+\.work\z/ + (payload.bytes.sum - 1) % 256 + when /\Aarr\d+\.work\z/ + payload.bytes[12..].reduce(0, :^) + when 'markers.work' + (payload.bytes[16..].sum - 1) % 256 + end + end + + desc 'Hex-dump a .work file and show the checksum byte location' + task :dump, [:file] do |_task, args| + file_path = args[:file] + abort 'Usage: rake checksum:dump[file.work]' unless file_path + abort "File not found: #{file_path}" unless File.exist?(file_path) + + data = File.binread(file_path) + file_size = data.bytesize + stored_checksum = data.getbyte(file_size - 1) + computed = expected_checksum(file_path, data) + + puts "File: #{file_path}" + puts "Size: #{file_size} bytes (0x#{file_size.to_s(16).upcase})" + if computed + match = stored_checksum == computed ? 'OK' : 'MISMATCH' + puts "Last byte (checksum): 0x#{stored_checksum.to_s(16).rjust(2, '0').upcase} computed: 0x#{computed.to_s(16).rjust(2, '0').upcase} #{match}" + else + puts "Last byte: 0x#{stored_checksum.to_s(16).rjust(2, '0').upcase} (no checksum for this file type)" + end + puts + + puts 'Header (first 32 bytes):' + 0.step([32, file_size].min - 1, 16) do |offset| + row = data.byteslice(offset, 16) || '' + hex = row.bytes.map { |byte| byte.to_s(16).rjust(2, '0') }.join(' ') + ascii = row.bytes.map { |byte| byte.between?(0x20, 0x7e) ? byte.chr : '.' }.join + puts " #{offset.to_s(16).rjust(8, '0')}: #{hex.ljust(47)} #{ascii}" + end + puts + + tail_start = [file_size - 32, 0].max + puts 'Tail (last 32 bytes, checksum is the final byte):' + tail_start.step(file_size - 1, 16) do |offset| + row = data.byteslice(offset, 16) || '' + hex = row.bytes.each_with_index.map do |byte, index| + offset + index == file_size - 1 ? "[#{byte.to_s(16).rjust(2, '0')}]" : byte.to_s(16).rjust(2, '0') + end.join(' ') + ascii = row.bytes.map { |byte| byte.between?(0x20, 0x7e) ? byte.chr : '.' }.join + puts " #{offset.to_s(16).rjust(8, '0')}: #{hex.ljust(53)} #{ascii}" + end + end + + desc 'Try common checksum algorithms and show which matches the stored value' + task :brute, [:file] do |_task, args| + file_path = args[:file] + abort 'Usage: rake checksum:brute[file.work]' unless file_path + abort "File not found: #{file_path}" unless File.exist?(file_path) + + data = File.binread(file_path) + stored_checksum = data.getbyte(data.bytesize - 1) + payload = data.byteslice(0, data.bytesize - 1).bytes + + puts "File: #{file_path}" + puts "Stored checksum: 0x#{stored_checksum.to_s(16).rjust(2, '0').upcase} (#{stored_checksum})" + puts "Payload size: #{payload.size} bytes" + puts + + byte_sum = payload.sum + + candidates = { + 'sum % 256' => byte_sum % 256, + '(sum - 1) % 256' => (byte_sum - 1) % 256, + '(sum + 1) % 256' => (byte_sum + 1) % 256, + '(256 - sum % 256) % 256' => (256 - (byte_sum % 256)) % 256, + '(-sum) % 256' => (-byte_sum) % 256, + 'XOR of all bytes' => payload.reduce(0, :^), + 'XOR ^ 0xFF' => payload.reduce(0, :^) ^ 0xFF + } + + [12, 16, 20].each do |offset| + next unless payload.size > offset + + slice = payload[offset..] + candidates["offset #{offset}: (sum-1) % 256"] = (slice.sum - 1) % 256 + candidates["offset #{offset}: sum % 256"] = slice.sum % 256 + candidates["offset #{offset}: XOR"] = slice.reduce(0, :^) + end + + matches, misses = candidates.partition { |_, computed| computed == stored_checksum } + + if matches.any? + puts '=== MATCHES ===' + matches.each { |label, computed| puts " MATCH #{label.ljust(45)} => 0x#{computed.to_s(16).rjust(2, '0').upcase}" } + else + puts '=== NO MATCHES ===' + end + puts + + puts '=== ALL RESULTS ===' + (matches + misses).each do |label, computed| + prefix = computed == stored_checksum ? ' MATCH' : ' miss ' + puts "#{prefix} #{label.ljust(45)} => 0x#{computed.to_s(16).rjust(2, '0').upcase}" + end + end + + desc 'Compare expected vs actual checksum for every .work file in a project dir' + task :compare, [:dir] do |_task, args| + dir = args[:dir] + abort 'Usage: rake checksum:compare[dir]' unless dir + abort "Directory not found: #{dir}" unless Dir.exist?(dir) + + work_files = Dir[File.join(dir, '*.work')] + abort "No .work files found in #{dir}" if work_files.empty? + + puts 'File Stored Expected Match?' + puts '-' * 65 + + work_files.each do |file_path| + data = File.binread(file_path) + stored = data.getbyte(data.bytesize - 1) + computed = expected_checksum(file_path, data) + + stored_hex = format('0x%02X', value: stored) + if computed.nil? + puts format('%-30s %10s %10s %s', + file: File.basename(file_path), stored: stored_hex, expected: 'n/a', match: '(no checksum)') + else + computed_hex = format('0x%02X', value: computed) + match = stored == computed ? 'OK' : 'MISMATCH' + puts format('%-30s %10s %10s %s', + file: File.basename(file_path), stored: stored_hex, expected: computed_hex, match: match) + end + end + end + + desc 'Verify all .work files in a project dir pass their checksums' + task :verify, [:dir] do |_task, args| + dir = args[:dir] + abort 'Usage: rake checksum:verify[dir]' unless dir + abort "Directory not found: #{dir}" unless Dir.exist?(dir) + + work_files = Dir[File.join(dir, '*.work')] + abort "No .work files found in #{dir}" if work_files.empty? + + all_ok = true + work_files.each do |file_path| + data = File.binread(file_path) + stored = data.getbyte(data.bytesize - 1) + computed = expected_checksum(file_path, data) + + next if computed.nil? + + if stored == computed + puts "OK #{File.basename(file_path)}" + else + puts "MISMATCH #{File.basename(file_path)} stored=0x#{stored.to_s(16).rjust(2, '0').upcase} expected=0x#{computed.to_s(16).rjust(2, '0').upcase}" + all_ok = false + end + end + + exit 1 unless all_ok + end +end diff --git a/sig/generated/wavesync/config.rbs b/sig/generated/wavesync/config.rbs index 020cd0d..5db027c 100644 --- a/sig/generated/wavesync/config.rbs +++ b/sig/generated/wavesync/config.rbs @@ -15,7 +15,7 @@ module Wavesync attr_reader library: String - attr_reader device_configs: Array[{ name: String, model: String, path: String }] + attr_reader device_configs: Array[{ name: String, model: String, path: String, projects_path: String? }] # : (?String path) -> Config def self.load: (?String path) -> Config diff --git a/sig/generated/wavesync/file_sync_throttle.rbs b/sig/generated/wavesync/file_sync_throttle.rbs new file mode 100644 index 0000000..bc4ec5f --- /dev/null +++ b/sig/generated/wavesync/file_sync_throttle.rbs @@ -0,0 +1,42 @@ +# Generated from lib/wavesync/file_sync_throttle.rb with RBS::Inline + +module Wavesync + class FileSyncThrottle + MODES: untyped + + POLL_INTERVAL_SECONDS: ::Integer + + TIMEOUT_SECONDS: ::Integer + + FREE_SPACE_THRESHOLD_BYTES: untyped + + FIXED_DELAY_SECONDS: ::Integer + + attr_reader mode: Symbol + + # : (?mode: Symbol) -> void + def initialize: (?mode: Symbol) -> void + + # : (Pathname target_path) -> void + def wait_for_sync: (Pathname target_path) -> void + + def wait: (untyped seconds) -> untyped + + private + + # : (Pathname target_path) -> void + def wait_for_file_size_stable: (Pathname target_path) -> void + + # : (Pathname target_path) -> void + def wait_for_disk_space: (Pathname target_path) -> void + + # : (Pathname target_path) -> void + def wait_for_lsof: (Pathname target_path) -> void + + # : (Pathname path) -> Integer + def free_space_bytes: (Pathname path) -> Integer + + # : (Pathname path) -> bool + def file_open?: (Pathname path) -> bool + end +end diff --git a/sig/generated/wavesync/octatrack_exporter.rbs b/sig/generated/wavesync/octatrack_exporter.rbs new file mode 100644 index 0000000..b071417 --- /dev/null +++ b/sig/generated/wavesync/octatrack_exporter.rbs @@ -0,0 +1,95 @@ +# Generated from lib/wavesync/octatrack_exporter.rb with RBS::Inline + +module Wavesync + class OctatrackExporter + PROJECT_FILE: ::String + + CRLF: ::String + + TEMPLATES_DIR: untyped + + # Offsets of the 8 track assignment blocks within each bank file. + # Each block is 40 bytes: 8 × [0x8N, slot, slot, 0x00, 0x00] + TRACK_ASSIGNMENT_OFFSETS: untyped + + # : (Set set, String library_path, String device_audio_path, String projects_path, ?device: Device?) -> void + def initialize: (Set set, String library_path, String device_audio_path, String projects_path, ?device: Device?) -> void + + # : () -> String + def project_dir: () -> String + + # : () -> String + def export: () -> String + + private + + # : () -> void + def write_project_work: () -> void + + # : () -> void + def write_bank_files: () -> void + + # : () -> void + def write_arr_files: () -> void + + # : () -> void + def write_markers_file: () -> void + + # : (String name) -> String + def load_template: (String name) -> String + + # : (String bank_data) -> String + def apply_track_assignments: (String bank_data) -> String + + # : (String data, Integer offset) -> void + def write_track_assignments: (String data, Integer offset) -> void + + # : (String data) -> Integer + def bank_checksum: (String data) -> Integer + + # : () -> String + def generate_project_work: () -> String + + # : () -> String + def divider: () -> String + + # : (String title) -> String + def section_header: (String title) -> String + + # : (String tag, Array[String] lines) -> String + def block: (String tag, Array[String] lines) -> String + + # : (String tag) -> String + def open_tag: (String tag) -> String + + # : (String tag) -> String + def close_tag: (String tag) -> String + + # : () -> String + def meta_section: () -> String + + # : () -> String + def settings_section: () -> String + + # : () -> String + def states_section: () -> String + + # : () -> String + def flex_sample_sections: () -> String + + # : () -> String + def static_sample_sections: () -> String + + # : (Integer slot, String path) -> String + def static_sample_block: (Integer slot, String path) -> String + + # : (String track_path) -> String + def relative_audio_path: (String track_path) -> String + + # : (String track_path, String relative_to_library) -> String + def apply_device_path_rules: (String track_path, String relative_to_library) -> String + + # : (String track_path) -> (String | Integer)? + def read_bpm: (String track_path) -> (String | Integer)? + end +end diff --git a/test/wavesync/config_test.rb b/test/wavesync/config_test.rb index 70c6997..5e8bf89 100644 --- a/test/wavesync/config_test.rb +++ b/test/wavesync/config_test.rb @@ -156,6 +156,20 @@ class ConfigTest < Wavesync::TestCase assert_equal 'My Device', config.device_configs.first[:name] assert_equal 'TP-7', config.device_configs.first[:model] assert_equal File.expand_path('/tmp/device'), config.device_configs.first[:path] + assert_nil config.device_configs.first[:projects_path] + end + + test 'initializes projects_path when provided' do + device = { 'name' => 'My Device', 'model' => 'Octatrack', 'path' => '/tmp/audio', 'projects_path' => '/tmp/projects' } + config = Config.new(VALID_CONFIG.merge('devices' => [device])) + assert_equal File.expand_path('/tmp/projects'), config.device_configs.first[:projects_path] + end + + test 'raises ConfigError when projects_path is not a string' do + device = { 'name' => 'My Device', 'model' => 'Octatrack', 'path' => '/tmp/audio', 'projects_path' => 123 } + data = VALID_CONFIG.merge('devices' => [device]) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "Device 1 'projects_path' must be a string", error.message end test 'initializes with multiple devices' do diff --git a/test/wavesync/octatrack_exporter_test.rb b/test/wavesync/octatrack_exporter_test.rb new file mode 100644 index 0000000..ab63ee3 --- /dev/null +++ b/test/wavesync/octatrack_exporter_test.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'tmpdir' +require_relative 'test_case' +require_relative '../../lib/wavesync/set' +require_relative '../../lib/wavesync/device' +require_relative '../../lib/wavesync/octatrack_exporter' + +module Wavesync + class OctatrackExporterTest < Wavesync::TestCase + def setup + @tmp = Dir.mktmpdir + @library = File.join(@tmp, 'library') + @device_audio_path = File.join(@tmp, 'device', 'AUDIO') + @projects_path = File.join(@tmp, 'device', 'LIBRARY') + FileUtils.mkdir_p(@library) + FileUtils.mkdir_p(@device_audio_path) + FileUtils.mkdir_p(@projects_path) + @track1 = File.join(@library, 'Artist A', 'track1.wav') + @track2 = File.join(@library, 'Artist B', 'track2.wav') + end + + def teardown + FileUtils.rm_rf(@tmp) + end + + def exporter(tracks = [@track1]) + set = Set.new(@library, 'my_set', tracks) + OctatrackExporter.new(set, @library, @device_audio_path, @projects_path) + end + + def project_dir + File.join(@projects_path, 'MY_SET') + end + + test 'export creates a project directory named after the set in uppercase' do + exporter.export + assert Dir.exist?(project_dir) + end + + test 'export returns the project directory path' do + result = exporter.export + assert_equal project_dir, result + end + + test 'export creates project.work' do + exporter.export + assert File.exist?(File.join(project_dir, 'project.work')) + end + + test 'export creates bank01.work through bank16.work' do + exporter.export + 1.upto(16) { |n| assert File.exist?(File.join(project_dir, "bank#{n.to_s.rjust(2, '0')}.work")) } + end + + test 'export creates arr01.work through arr08.work' do + exporter.export + 1.upto(8) { |n| assert File.exist?(File.join(project_dir, "arr#{n.to_s.rjust(2, '0')}.work")) } + end + + test 'export creates markers.work' do + exporter.export + assert File.exist?(File.join(project_dir, 'markers.work')) + end + + test 'project.work contains the Octatrack project type header' do + exporter.export + assert_includes project_work, 'TYPE=OCTATRACK DPS-1 PROJECT' + end + + test 'project.work uses CRLF line endings' do + exporter.export + assert_includes File.binread(File.join(project_dir, 'project.work')), "\r\n" + end + + test 'project.work includes FLEX slots 129 through 136' do + exporter.export + assert_includes project_work, "TYPE=FLEX\r\nSLOT=129" + assert_includes project_work, "TYPE=FLEX\r\nSLOT=136" + end + + test 'project.work assigns tracks to sequential STATIC slots starting at 1' do + exporter([@track1, @track2]).export + assert_includes project_work, "TYPE=STATIC\r\nSLOT=001" + assert_includes project_work, "TYPE=STATIC\r\nSLOT=002" + end + + test 'project.work slot numbers are zero-padded to 3 digits' do + exporter.export + assert_includes project_work, 'SLOT=001' + end + + test 'project.work PATH is relative from project dir to the audio folder' do + exporter.export + assert_includes project_work, 'PATH=../AUDIO/Artist A/track1.wav' + end + + test 'project.work PATH uses the device audio folder name' do + custom_audio = File.join(@tmp, 'device', 'SAMPLES') + FileUtils.mkdir_p(custom_audio) + set = Set.new(@library, 'my_set', [@track1]) + OctatrackExporter.new(set, @library, custom_audio, @projects_path).export + content = File.read(File.join(project_dir, 'project.work')) + assert_includes content, 'PATH=../SAMPLES/Artist A/track1.wav' + end + + test 'project.work preserves subdirectory structure in PATH' do + track = File.join(@library, 'Artist B', 'Album C', 'track.wav') + exporter([track]).export + assert_includes project_work, 'PATH=../AUDIO/Artist B/Album C/track.wav' + end + + test 'bank02 through bank16 are identical to the blank template' do + exporter.export + bank02 = File.binread(File.join(project_dir, 'bank02.work')) + 3.upto(16) do |n| + assert_equal bank02, File.binread(File.join(project_dir, "bank#{n.to_s.rjust(2, '0')}.work")) + end + end + + test 'arr files are all identical' do + exporter.export + arr01 = File.binread(File.join(project_dir, 'arr01.work')) + 2.upto(8) do |n| + assert_equal arr01, File.binread(File.join(project_dir, "arr#{n.to_s.rjust(2, '0')}.work")) + end + end + + test 'bank01 track 1 is assigned to static slot 1' do + exporter.export + bank01 = File.binread(File.join(project_dir, 'bank01.work')) + OctatrackExporter::TRACK_ASSIGNMENT_OFFSETS.each do |offset| + assert_equal 0x80, bank01.getbyte(offset), "track 1 tag at 0x#{offset.to_s(16)}" + assert_equal 1, bank01.getbyte(offset + 1), "track 1 slot at 0x#{offset.to_s(16)}" + end + end + + test 'bank01 track 5 is assigned to static slot 2' do + exporter.export + bank01 = File.binread(File.join(project_dir, 'bank01.work')) + OctatrackExporter::TRACK_ASSIGNMENT_OFFSETS.each do |offset| + track5_offset = offset + (4 * 5) + assert_equal 0x84, bank01.getbyte(track5_offset), "track 5 tag at 0x#{track5_offset.to_s(16)}" + assert_equal 2, bank01.getbyte(track5_offset + 1), "track 5 slot at 0x#{track5_offset.to_s(16)}" + end + end + + test 'project.work converts mp3 path to wav extension when device is provided' do + track = File.join(@library, 'Artist A', 'track.mp3') + octatrack = Device.find_by(name: 'Octatrack') + set = Set.new(@library, 'my_set', [track]) + OctatrackExporter.new(set, @library, @device_audio_path, @projects_path, device: octatrack).export + assert_includes project_work, 'PATH=../AUDIO/Artist A/track.wav' + end + + test 'project.work appends bpm to path when track has bpm' do + octatrack = Device.find_by(name: 'Octatrack') + set = Set.new(@library, 'my_set', [@track1]) + OctatrackExporter.any_instance.stubs(:read_bpm).returns(120) + OctatrackExporter.new(set, @library, @device_audio_path, @projects_path, device: octatrack).export + assert_includes project_work, 'PATH=../AUDIO/Artist A/track1 120 bpm.wav' + end + + test 'project.work does not append bpm when track has no bpm' do + octatrack = Device.find_by(name: 'Octatrack') + set = Set.new(@library, 'my_set', [@track1]) + OctatrackExporter.any_instance.stubs(:read_bpm).returns(nil) + OctatrackExporter.new(set, @library, @device_audio_path, @projects_path, device: octatrack).export + assert_includes project_work, 'PATH=../AUDIO/Artist A/track1.wav' + end + + test 'bank01 checksum is valid' do + exporter.export + bank01 = File.binread(File.join(project_dir, 'bank01.work')).bytes + expected = (bank01[0..-2].sum - 1) % 256 + assert_equal expected, bank01.last + end + + private + + def project_work + File.read(File.join(project_dir, 'project.work')) + end + end +end