/* Data model for BrayerSound */

// id and defaultMidi are 0-indexed internally. Display uses id+1 / defaultMidi+1.
const CHANNELS = [
  { id: 0, name: 'Sd1drums',       kind: 'DRUMS',      map: 'Acoustic Kit',  color: '#ffb84c', defaultMidi: 9  },
  { id: 1, name: 'Perc Ethnic',    kind: 'PERCUSSION', map: 'Darbuka Set',   color: '#ffb84c', defaultMidi: 10 },
  { id: 2, name: 'Sd1bass',        kind: 'BASS',       map: 'Bass FingerX',  color: '#ff6acb', defaultMidi: 8  },
  { id: 3, name: 'ilaySidiLead',   kind: 'ACCOMP',     map: 'Lead Strings',  color: '#22d3ee', defaultMidi: 11 },
  { id: 4, name: 'ilaySidiPad',    kind: 'ACCOMP',     map: 'Warm Pad',      color: '#22d3ee', defaultMidi: 12 },
  { id: 5, name: 'ilaySidiClassic',kind: 'ACCOMP',     map: 'Classic Keys',  color: '#22d3ee', defaultMidi: 13 },
  { id: 6, name: 'ilaySidiSynth',  kind: 'ACCOMP',     map: 'Analog Stack',  color: '#9b5cff', defaultMidi: 14 },
  { id: 7, name: 'BrayerPerc',     kind: 'ACCOMP',     map: 'Ethnic Perc',   color: '#9b5cff', defaultMidi: 15 },
];

const SAMPLE_MAPS = {
  'Acoustic Kit': [
    { note:'C2', name:'Kick_Hard' }, { note:'D2', name:'Kick_Soft' },
    { note:'E2', name:'Snare_Tight' }, { note:'F2', name:'Snare_Rim' },
    { note:'G2', name:'HH_Closed' }, { note:'A2', name:'HH_Open' },
    { note:'B2', name:'Ride_Bell' }, { note:'C3', name:'Crash_1' },
    { note:'D3', name:'Tom_Low' }, { note:'E3', name:'Tom_Mid' },
    { note:'F3', name:'Tom_Hi' }, { note:'G3', name:'Cowbell' },
    { note:'C#2', name:'Kick_Layer'}, { note:'D#2', name:'Clap' },
    { note:'F#2', name:'Snare_Rim2'}, { note:'G#2', name:'HH_Pedal' },
    { note:'A#2', name:'Tamb' }, { note:'C#3', name:'Crash_2' },
    { note:'D#3', name:'Tom_FlrLo'}, { note:'F#3', name:'Tom_FlrHi' },
  ],
  'Bass FingerX': [
    { note:'E1', name:'E_fing_p' }, { note:'F1', name:'F_fing_p' },
    { note:'G1', name:'G_fing_p' }, { note:'A1', name:'A_fing_p' },
    { note:'B1', name:'B_fing_p' }, { note:'C2', name:'C_fing_p' },
    { note:'D2', name:'D_fing_p' }, { note:'E2', name:'E_fing_f' },
    { note:'F2', name:'F_fing_f' }, { note:'G2', name:'G_fing_f' },
  ],
  'Lead Strings': [
    { note:'C3', name:'Strings_C'}, { note:'D3', name:'Strings_D'},
    { note:'E3', name:'Strings_E'}, { note:'F3', name:'Strings_F'},
    { note:'G3', name:'Strings_G'}, { note:'A3', name:'Strings_A'},
    { note:'B3', name:'Strings_B'}, { note:'C4', name:'Strings_C4'},
    { note:'D4', name:'Strings_D4'},{ note:'E4', name:'Strings_E4'},
  ],
  'Warm Pad': [
    { note:'C3', name:'Pad_Soft_C'}, { note:'D3', name:'Pad_Soft_D'},
    { note:'E3', name:'Pad_Soft_E'}, { note:'F3', name:'Pad_Soft_F'},
    { note:'G3', name:'Pad_Soft_G'}, { note:'A3', name:'Pad_Soft_A'},
  ],
  'Ethnic Perc': [
    { note:'C2', name:'Darbuka_Dum'}, { note:'D2', name:'Darbuka_Tak'},
    { note:'E2', name:'Bendir_Lo'}, { note:'F2', name:'Bendir_Hi'},
    { note:'G2', name:'Riq_Shake'}, { note:'A2', name:'Riq_Hit'},
    { note:'B2', name:'Tabla_Na'}, { note:'C3', name:'Tabla_Tin'},
  ],
  'Classic Keys': [
    { note:'C3', name:'Rhodes_C'}, { note:'E3', name:'Rhodes_E'},
    { note:'G3', name:'Rhodes_G'}, { note:'C4', name:'Rhodes_C4'},
  ],
  'Darbuka Set': [
    { note:'C2', name:'Dum_1'}, { note:'D2', name:'Dum_2'},
    { note:'E2', name:'Tak_Hi'}, { note:'F2', name:'Tak_Lo'},
    { note:'G2', name:'Slap'},  { note:'A2', name:'Roll'},
  ],
  'Analog Stack': [
    { note:'C3', name:'Stack_C'}, { note:'D3', name:'Stack_D'},
    { note:'E3', name:'Stack_E'}, { note:'F3', name:'Stack_F'},
  ],
};

const MAP_LIBRARY = [
  { name: 'Acoustic Kit',    kind: 'DRUMS',      count: 20 },
  { name: 'Hip Hop Kit',     kind: 'DRUMS',      count: 18 },
  { name: 'Electronic Kit',  kind: 'DRUMS',      count: 24 },
  { name: 'Bass FingerX',    kind: 'BASS',       count: 10 },
  { name: 'Bass Slap',       kind: 'BASS',       count: 12 },
  { name: 'Bass Sub',        kind: 'BASS',       count: 8 },
  { name: 'Darbuka Set',     kind: 'PERCUSSION', count: 6 },
  { name: 'Ethnic Perc',     kind: 'PERCUSSION', count: 8 },
  { name: 'Lead Strings',    kind: 'MELODIC',    count: 10 },
  { name: 'Warm Pad',        kind: 'MELODIC',    count: 6 },
  { name: 'Classic Keys',    kind: 'MELODIC',    count: 4 },
  { name: 'Analog Stack',    kind: 'MELODIC',    count: 4 },
];

const FX_PRE = [
  { id:'gate',  name:'Noise Gate', preset:'Soft -40dB', enabled:false,
    params:[
      {k:'THRESH', v:-40, unit:'dB', min:-80, max:0},
      {k:'ATTACK', v:2, unit:'ms', min:0, max:100},
      {k:'HOLD',   v:10, unit:'ms', min:0, max:500},
      {k:'RELEASE',v:80, unit:'ms', min:0, max:1000},
    ]},
  { id:'comp',  name:'Compressor', preset:'Warm Glue',   enabled:true,
    params:[
      {k:'THRESH', v:-18, unit:'dB', min:-60, max:0},
      {k:'RATIO',  v:4, unit:':1', min:1, max:20},
      {k:'ATTACK', v:12, unit:'ms', min:0, max:200},
      {k:'RELEASE',v:120, unit:'ms', min:10, max:2000},
      {k:'KNEE',   v:3, unit:'dB', min:0, max:12},
      {k:'MAKEUP', v:3, unit:'dB', min:0, max:24},
    ]},
  { id:'satur', name:'Saturator',  preset:'Tape Sat',    enabled:false,
    params:[
      {k:'DRIVE',  v:28, unit:'%',  min:0, max:100},
      {k:'TONE',   v:50, unit:'%',  min:0, max:100},
      {k:'MIX',    v:80, unit:'%',  min:0, max:100},
    ]},
];

const FX_POST = [
  { id:'delay', name:'Delay',      preset:'1/4 Plate',   enabled:false,
    params:[
      {k:'TIME',   v:375, unit:'ms', min:1, max:2000},
      {k:'FEEDBK', v:35, unit:'%', min:0, max:100},
      {k:'TONE',   v:65, unit:'%', min:0, max:100},
      {k:'SPREAD', v:80, unit:'%', min:0, max:100},
      {k:'MIX',    v:22, unit:'%', min:0, max:100},
      // Delay v2 overhaul params (backend IDs 107-112).
      {k:'MODE',      v:0,   unit:'',   min:0,     max:3,   type:'enum',
        options:['Digital','Tape','BBD','Ambient'], paramKey:'mode_dl'},
      {k:'MIDI_SYNC', v:0,   unit:'',   min:0,     max:1,   type:'toggle', paramKey:'midi_sync_dl'},
      {k:'BASE_NOTE', v:4,   unit:'',   min:0,     max:14,  type:'enum',
        options:['1/1','1/2','1/2T','1/2.','1/4','1/4T','1/4.','1/8','1/8T','1/8.','1/16','1/16T','1/16.','1/32','1/32T'],
        paramKey:'base_note_dl'},
      {k:'TIMES',     v:1,   unit:'×',  min:0.001, max:32,  paramKey:'times_dl'},
      {k:'XFADE',     v:20,  unit:'ms', min:1,     max:200, paramKey:'crossfade_ms_dl'},
      {k:'TONE_DL',   v:50,  unit:'%',  min:0,     max:100, paramKey:'tone_dl'},
    ]},
  { id:'reverb', name:'Reverb',    preset:'Medium Hall', enabled:false,
    params:[
      {k:'SIZE',   v:68, unit:'%', min:0, max:100},
      {k:'PREDLY', v:24, unit:'ms', min:0, max:250},
      {k:'DAMP',   v:40, unit:'%', min:0, max:100},
      {k:'WIDTH',  v:90, unit:'%', min:0, max:100},
      {k:'MIX',    v:18, unit:'%', min:0, max:100},
      // Reverb v2 (Jot FDN) overhaul params (backend IDs 101-106).
      {k:'DECAY',  v:50, unit:'%', min:0, max:100, paramKey:'decay_rv'},
      {k:'BANDW',  v:50, unit:'%', min:0, max:100, paramKey:'bandwidth_rv'},
      {k:'MODUL',  v:50, unit:'%', min:0, max:100, paramKey:'modulation_rv'},
      {k:'DIFFUS', v:50, unit:'%', min:0, max:100, paramKey:'diffusion_rv'},
      {k:'ER/LATE',v:50, unit:'%', min:0, max:100, paramKey:'er_late_rv'},
      {k:'LO CUT', v:50, unit:'%', min:0, max:100, paramKey:'lo_cut_rv'},
    ]},
  { id:'chorus', name:'Chorus',    preset:'Stereo Wide', enabled:false,
    params:[
      {k:'RATE',   v:0.4, unit:'Hz', min:0.05, max:8},
      {k:'DEPTH',  v:35, unit:'%', min:0, max:100},
      {k:'VOICES', v:4, unit:'', min:2, max:8},
      {k:'MIX',    v:30, unit:'%', min:0, max:100},
      // Chorus v2 overhaul params (backend IDs 118-127). RATE/DEPTH/VOICES/MIX
      // above already map to the chorus's rate/depth/voices/mix accessors, so
      // they're not re-added here — only the genuinely-new controls are.
      {k:'MODE',      v:0,  unit:'',  min:0,    max:3,   type:'enum',
        options:['Classic','Juno','Dimension','Microshift'], paramKey:'mode_ch'},
      {k:'WAVEFORM',  v:0,  unit:'',  min:0,    max:4,   type:'enum',
        options:['Triangle','Sine','Vintage','Up','Down'], paramKey:'waveform_ch'},
      {k:'FEEDBK',    v:0,  unit:'%', min:-95,  max:95,  paramKey:'feedback_ch'},
      {k:'WIDTH',     v:100,unit:'%', min:0,    max:100, paramKey:'width_ch'},
      {k:'TILT',      v:0,  unit:'',  min:-100, max:100, paramKey:'tilt_ch'},
      {k:'MIDI_SYNC', v:0,  unit:'',  min:0,    max:1,   type:'toggle', paramKey:'midi_sync_ch'},
      {k:'BASE_NOTE', v:4,  unit:'',  min:0,    max:14,  type:'enum',
        options:['1/1','1/2','1/2T','1/2.','1/4','1/4T','1/4.','1/8','1/8T','1/8.','1/16','1/16T','1/16.','1/32','1/32T'],
        paramKey:'base_note_ch'},
      {k:'TIMES',     v:1,  unit:'×', min:1,    max:32,  paramKey:'times_ch'},
    ]},
  { id:'tremolo', name:'Stereo Tremolo', preset:'Default', enabled:false,
    params:[
      {k:'WAVEFORM',  v:1,   unit:'',   min:0,    max:4,    type:'enum',
        options:['Triangle','Sine','Vintage','Up','Down'], paramKey:'waveform'},
      {k:'SHAPE',     v:0,   unit:'',   min:-100, max:100,  paramKey:'shape'},
      {k:'PHASE',     v:90,  unit:'°',  min:-180, max:180,  paramKey:'phase'},
      {k:'SPEED',     v:4.5, unit:'Hz', min:0.02, max:20,   paramKey:'speed_hz'},
      {k:'MIDI_SYNC', v:0,   unit:'',   min:0,    max:1,    type:'toggle', paramKey:'midi_sync'},
      {k:'BASE_NOTE', v:4,   unit:'',   min:0,    max:14,   type:'enum',
        options:['1/1','1/2','1/2T','1/2.','1/4','1/4T','1/4.','1/8','1/8T','1/8.','1/16','1/16T','1/16.','1/32','1/32T'],
        paramKey:'base_note'},
      {k:'TIMES',     v:1,   unit:'×',  min:1,    max:32,   paramKey:'times'},
      {k:'DEPTH',     v:50,  unit:'%',  min:0,    max:100,  paramKey:'depth_tremolo'},
      {k:'MIX',       v:100, unit:'%',  min:0,    max:100,  paramKey:'mix_tremolo'},
      // Tremolo Phase-1 overhaul params (backend IDs 128-129).
      {k:'MODE',       v:0,  unit:'',   min:0,    max:1,    type:'enum',
        options:['Classic','Harmonic'], paramKey:'mode_tr'},
      {k:'LEVEL_COMP', v:0,  unit:'%',  min:0,    max:100,  paramKey:'level_comp_tr'},
    ]},
  { id:'autowah', name:'Auto-Wah', preset:'Default', enabled:false,
    params:[
      {k:'MODE',       v:0,   unit:'',   min:0,    max:1,    type:'enum',
        options:['Envelope','LFO'], paramKey:'mode'},
      {k:'MIN_FREQ',   v:200, unit:'Hz', min:100,  max:2000, paramKey:'min_freq'},
      {k:'MAX_FREQ',   v:3000,unit:'Hz', min:500,  max:8000, paramKey:'max_freq'},
      {k:'Q',          v:4,   unit:'',   min:0.5,  max:15,   paramKey:'q'},
      {k:'DEPTH',      v:80,  unit:'%',  min:0,    max:100,  paramKey:'depth_wah'},
      {k:'MIX',        v:100, unit:'%',  min:0,    max:100,  paramKey:'mix_wah'},
      {k:'SENS',       v:60,  unit:'%',  min:0,    max:100,  paramKey:'sensitivity'},
      {k:'ATTACK_MS',  v:8,   unit:'ms', min:1,    max:200,  paramKey:'attack_ms'},
      {k:'RELEASE_MS', v:120, unit:'ms', min:10,   max:1000, paramKey:'release_ms'},
      {k:'WAVEFORM',   v:1,   unit:'',   min:0,    max:4,    type:'enum',
        options:['Triangle','Sine','Vintage','Up','Down'], paramKey:'waveform_wah'},
      {k:'RATE',       v:2.0, unit:'Hz', min:0.02, max:20,   paramKey:'rate_hz_wah'},
      {k:'MIDI_SYNC',  v:0,   unit:'',   min:0,    max:1,    type:'toggle', paramKey:'midi_sync_wah'},
      {k:'BASE_NOTE',  v:4,   unit:'',   min:0,    max:14,   type:'enum',
        options:['1/1','1/2','1/2T','1/2.','1/4','1/4T','1/4.','1/8','1/8T','1/8.','1/16','1/16T','1/16.','1/32','1/32T'],
        paramKey:'base_note_wah'},
      {k:'TIMES',      v:1,   unit:'×',  min:1,    max:32,   paramKey:'times_wah'},
      // Auto-Wah v2 overhaul params (backend IDs 138-142).
      {k:'FILTER',     v:1,   unit:'',   min:0,    max:3,    type:'enum',
        options:['LP','BP','HP','Notch'], paramKey:'filter_type_wah'},
      {k:'DIRECTION',  v:0,   unit:'',   min:0,    max:1,    type:'enum',
        options:['Up','Down'], paramKey:'direction_wah'},
      {k:'WAH_DRIVE',  v:1,   unit:'',   min:1,    max:8,    paramKey:'drive_wah'},
      {k:'Q_TRACK',    v:0,   unit:'%',  min:0,    max:100,  paramKey:'q_track_wah'},
      {k:'AUTO_GAIN',  v:0,   unit:'',   min:0,    max:1,    type:'toggle', paramKey:'auto_gain_wah'},
    ]},
  { id:'phaser', name:'Phaser', preset:'Default', enabled:false,
    params:[
      {k:'STAGES',     v:1,   unit:'',   min:0,    max:2,    type:'enum',
        options:['4','6','8'], paramKey:'stages'},
      {k:'MIN_FREQ',   v:200, unit:'Hz', min:80,   max:2000, paramKey:'min_freq_ph'},
      {k:'MAX_FREQ',   v:2000,unit:'Hz', min:500,  max:8000, paramKey:'max_freq_ph'},
      {k:'FEEDBK',     v:50,  unit:'%',  min:-95,  max:95,   paramKey:'feedback_ph'},
      {k:'DEPTH',      v:80,  unit:'%',  min:0,    max:100,  paramKey:'depth_ph'},
      {k:'MIX',        v:50,  unit:'%',  min:0,    max:100,  paramKey:'mix_ph'},
      {k:'PHASE',      v:90,  unit:'°',  min:-180, max:180,  paramKey:'phase_ph'},
      {k:'WAVEFORM',   v:1,   unit:'',   min:0,    max:4,    type:'enum',
        options:['Triangle','Sine','Vintage','Up','Down'], paramKey:'waveform_ph'},
      {k:'RATE',       v:0.5, unit:'Hz', min:0.02, max:20,   paramKey:'rate_hz_ph'},
      {k:'MIDI_SYNC',  v:0,   unit:'',   min:0,    max:1,    type:'toggle', paramKey:'midi_sync_ph'},
      {k:'BASE_NOTE',  v:4,   unit:'',   min:0,    max:14,   type:'enum',
        options:['1/1','1/2','1/2T','1/2.','1/4','1/4T','1/4.','1/8','1/8T','1/8.','1/16','1/16T','1/16.','1/32','1/32T'],
        paramKey:'base_note_ph'},
      {k:'TIMES',      v:1,   unit:'×',  min:1,    max:32,   paramKey:'times_ph'},
      // Phaser v2 overhaul params (backend IDs 148-149).
      {k:'PH_DRIVE',   v:0,   unit:'%',  min:0,    max:100,  paramKey:'drive_ph'},
      {k:'STEREO',     v:0,   unit:'',   min:0,    max:2,    type:'enum',
        options:['Quadrature','Independent','M-S'], paramKey:'stereo_mode_ph'},
    ]},
  { id:'flanger', name:'Flanger', preset:'Default', enabled:false,
    params:[
      {k:'MIN_DLY',    v:0.5, unit:'ms', min:0.1,  max:10,   paramKey:'min_delay_ms'},
      {k:'MAX_DLY',    v:5,   unit:'ms', min:0.5,  max:15,   paramKey:'max_delay_ms'},
      {k:'FEEDBK',     v:50,  unit:'%',  min:-95,  max:95,   paramKey:'feedback_fl'},
      {k:'DEPTH',      v:80,  unit:'%',  min:0,    max:100,  paramKey:'depth_fl'},
      {k:'MIX',        v:50,  unit:'%',  min:0,    max:100,  paramKey:'mix_fl'},
      {k:'PHASE',      v:90,  unit:'°',  min:-180, max:180,  paramKey:'phase_fl'},
      {k:'WAVEFORM',   v:1,   unit:'',   min:0,    max:4,    type:'enum',
        options:['Triangle','Sine','Vintage','Up','Down'], paramKey:'waveform_fl'},
      {k:'RATE',       v:0.3, unit:'Hz', min:0.02, max:20,   paramKey:'rate_hz_fl'},
      {k:'MIDI_SYNC',  v:0,   unit:'',   min:0,    max:1,    type:'toggle', paramKey:'midi_sync_fl'},
      {k:'BASE_NOTE',  v:4,   unit:'',   min:0,    max:14,   type:'enum',
        options:['1/1','1/2','1/2T','1/2.','1/4','1/4T','1/4.','1/8','1/8T','1/8.','1/16','1/16T','1/16.','1/32','1/32T'],
        paramKey:'base_note_fl'},
      {k:'TIMES',      v:1,   unit:'×',  min:1,    max:32,   paramKey:'times_fl'},
      // Flanger v2 overhaul params (backend IDs 158-160). Only Modern/TapeTZF
      // exist in the Rust FlangerMode enum (Phase 2 modes deferred).
      {k:'MODE',       v:0,   unit:'',   min:0,    max:1,    type:'enum',
        options:['Modern','TapeTZF'], paramKey:'mode_fl'},
      {k:'REF_MS',     v:5,   unit:'ms', min:0.1,  max:15,   paramKey:'reference_ms_fl'},
      {k:'DAMP',       v:8000,unit:'Hz', min:200,  max:20000,paramKey:'damp_fl'},
    ]},
];

const EQ_DEFAULT = {
  low:  { gain: 0, freq: 120 },
  mid:  { gain: 0, freq: 1000 },
  high: { gain: 0, freq: 6000 },
};

// Master chain
const MASTER_FX = [
  { id:'meq',  name:'Master EQ',       preset:'Flat',      enabled:true },
  { id:'mcomp',name:'Bus Compressor',  preset:'Glue',      enabled:true,
    params:[
      {k:'THRESH', v:-12, unit:'dB', min:-60, max:0},
      {k:'RATIO',  v:2, unit:':1', min:1, max:10},
      {k:'ATTACK', v:30, unit:'ms', min:0, max:200},
      {k:'RELEASE',v:150, unit:'ms', min:10, max:2000},
      {k:'MIX',    v:60, unit:'%', min:0, max:100},
    ]},
  { id:'mlim', name:'Peak Limiter',    preset:'Transparent', enabled:true,
    params:[
      {k:'CEILING',v:-0.3, unit:'dB', min:-6, max:0},
      {k:'RELEASE',v:50, unit:'ms', min:0.5, max:500},
      {k:'GAIN',   v:2, unit:'dB', min:0, max:18},
    ]},
];

/* ---- Live State Sync: command → UI state mapper ---- */

// Set of commands that carry a display-relevant state change. A receiving
// screen replays each through applyCommandToState so its UI mirrors the
// editing screen WITHOUT re-invoking the engine.
const STATE_SYNC_CMDS = {
  set_volume:1, set_pan:1, set_mute:1, set_solo:1, set_eq_band:1, set_midi_channel:1,
  set_effect_param:1, toggle_effect:1,
  set_master_volume:1, set_master_pan:1, set_master_eq:1, set_master_fx_param:1, toggle_master_effect:1,
  load_soundmap_to_channel:1, set_guitar_mode:1,
  set_sample_param:1, set_sample_params_snapshot:1,
  transport_play:1, transport_stop:1, set_master_bpm_manual:1,
  load_loop_bank:1, set_active_loop_pc:1, set_loop_address:1, update_loop_metadata:1, set_loop_armed:1,
  // UI-only mirror: which screen is shown + which edit-map note is selected.
  set_view:1, set_edit_note:1,
};
// Commands that change the soundmap LIBRARY (the list of available soundmaps).
// After one runs, the other screen must re-fetch its list.
const LIBRARY_CMDS = {
  import_soundfont:1, import_uploaded_soundfont:1,
  create_empty_soundmap:1, delete_soundmap:1, rename_soundmap:1,
  add_sample_to_soundmap:1, save_note_adjustments:1, set_soundmap_program:1,
};
const SYNC_BAND_KEYS = ['low','mid','high'];

// Apply a {cmd,args} change to UI state. DISPLAY ONLY — never invokes the engine.
// Returns a NEW state (immutable). Unknown/irrelevant cmd → returns state unchanged.
// Every arm mirrors the corresponding inline mutation handler's patch shape so
// the receiving screen ends up byte-identical to the editing screen.
function applyCommandToState(state, cmd, args){
  args = args || {};
  function patchCh(id, patch){
    if(state.channels == null || state.channels[id] == null) return state;
    return {...state, channels: {...state.channels, [id]: {...state.channels[id], ...patch}}};
  }
  switch(cmd){
    case 'set_volume':       return patchCh(args.channelId, { volume: args.db });
    case 'set_pan':          return patchCh(args.channelId, { pan: args.value });
    case 'set_mute':         return patchCh(args.channelId, { mute: args.muted });
    case 'set_solo':         return patchCh(args.channelId, { solo: args.soloed });
    case 'set_midi_channel': return patchCh(args.channelId, { midiCh: args.midiCh });
    case 'set_eq_band': {
      var ch = state.channels && state.channels[args.channelId]; if(!ch) return state;
      var key = SYNC_BAND_KEYS[args.band] || 'low';
      return patchCh(args.channelId, { eq: {...ch.eq, [key]: {...ch.eq[key], gain: args.gain, freq: args.freq}} });
    }

    // ---- Channel FX (mirror channel-edit.jsx updateFx / updateFxParam) ----
    // `chain` is the numeric CHAIN_INDEX (0=pre, 1=post); `effectId` is the
    // FX_ID_INDEX, i.e. the index into that chain array. updateFx sets `.enabled`;
    // updateFxParam finds the matching param by its backend key and sets `.v`.
    case 'toggle_effect': {
      var cf = state.channels && state.channels[args.channelId]; if(!cf) return state;
      var chainKey = args.chain === 1 ? 'post' : 'pre';
      var arr = cf[chainKey]; if(!Array.isArray(arr) || arr[args.effectId] == null) return state;
      var nextArr = arr.map(function(fx, i){ return i === args.effectId ? {...fx, enabled: args.enabled} : fx; });
      return patchCh(args.channelId, { [chainKey]: nextArr });
    }
    case 'set_effect_param': {
      var cfp = state.channels && state.channels[args.channelId]; if(!cfp) return state;
      var chKey = args.chain === 1 ? 'post' : 'pre';
      var fxArr = cfp[chKey]; if(!Array.isArray(fxArr) || fxArr[args.effectId] == null) return state;
      var fxArr2 = fxArr.map(function(fx, i){
        if(i !== args.effectId || !Array.isArray(fx.params)) return fx;
        // The handler sends p.paramKey (override, e.g. Tremolo) or p.k; match either.
        var params = fx.params.map(function(p){
          return (p.paramKey === args.paramKey || p.k === args.paramKey) ? {...p, v: args.value} : p;
        });
        return {...fx, params: params};
      });
      return patchCh(args.channelId, { [chKey]: fxArr2 });
    }

    case 'set_master_volume': return {...state, master:{...state.master, volume: args.db}};
    case 'set_master_pan':    return {...state, master:{...state.master, pan: args.value}};
    case 'set_master_eq': {
      var mkey = SYNC_BAND_KEYS[args.band] || 'low';
      return {...state, master:{...state.master, eq:{...state.master.eq, [mkey]:{...state.master.eq[mkey], gain:args.gain, freq:args.freq}}}};
    }

    // ---- Master FX (mirror master.jsx updateFx / updateFxParam) ----
    // `effectId` is the FX_ID_INDEX of the master fx (meq=0, mcomp=1, mlim=2).
    // The handlers key by fx.id, so resolve the index back to an id first.
    case 'toggle_master_effect': {
      if(state.master == null || !Array.isArray(state.master.fx)) return state;
      var mfxId = SYNC_MASTER_FX_IDS[args.effectId];
      if(mfxId == null) return state;
      var mfx = state.master.fx.map(function(f){ return f.id === mfxId ? {...f, enabled: args.enabled} : f; });
      return {...state, master:{...state.master, fx: mfx}};
    }
    case 'set_master_fx_param': {
      if(state.master == null || !Array.isArray(state.master.fx)) return state;
      var mfxId2 = SYNC_MASTER_FX_IDS[args.effectId];
      if(mfxId2 == null) return state;
      // Master updateFxParam matches params by p.k (master fx has no paramKey overrides).
      var mfx2 = state.master.fx.map(function(f){
        if(f.id !== mfxId2 || !Array.isArray(f.params)) return f;
        return {...f, params: f.params.map(function(p){ return p.k === args.paramKey ? {...p, v: args.value} : p; })};
      });
      return {...state, master:{...state.master, fx: mfx2}};
    }

    // ---- Soundmap load (mirror app.jsx midi-program-change handler) ----
    // Resets per-sample tweaks + cached sample list, exactly as the PG path does.
    case 'load_soundmap_to_channel':
      return patchCh(args.channelId, { map: args.soundmapName, mapCategory: args.category, sampleParams: {}, samples: [] });

    // Guitar mode only mutates MapBrowser-local component state (guitarOn /
    // guitarPreset keyed by "category/name"); it never patches the channel
    // state object, so there is nothing in the shared UI state to mirror.
    case 'set_guitar_mode': return state;

    // ---- Per-sample params (mirror channel-edit.jsx updateSampleParam) ----
    // sampleParams is keyed by NOTE NAME (e.g. 'C4'); the command carries the
    // raw MIDI number, so convert it back. Merge into any existing note entry.
    case 'set_sample_param': {
      var csp = state.channels && state.channels[args.channelId]; if(!csp) return state;
      var noteName = midiToNoteName(args.note);
      if(noteName == null) return state;
      var sp = csp.sampleParams || {};
      return patchCh(args.channelId, {
        sampleParams: {...sp, [noteName]: {...(sp[noteName] || {}), [args.paramKey]: convertSampleParamFromBackend(args.paramKey, args.value)}}
      });
    }
    // Snapshot replaces the whole table. `params` is a list of canonical-unit
    // entries (the buildSampleParamsSnapshot shape); convert each back to the
    // UI-friendly per-note shape the sliders read.
    case 'set_sample_params_snapshot': {
      var css = state.channels && state.channels[args.channelId]; if(!css) return state;
      if(!Array.isArray(args.params)) return state;
      var rebuilt = {};
      args.params.forEach(function(e){
        if(e == null) return;
        var nm = midiToNoteName(e.note);
        if(nm == null) return;
        rebuilt[nm] = {
          volume:  e.volume    != null ? e.volume            : 0,
          pan:     e.pan       != null ? e.pan * 50          : 0,     // -1..1 → -50..50
          eqLo:    e.eq_lo     != null ? e.eq_lo             : 0,
          eqMid:   e.eq_mid    != null ? e.eq_mid            : 0,
          eqHi:    e.eq_hi     != null ? e.eq_hi             : 0,
          attack:  e.attack    != null ? e.attack * 1000     : 5,     // s → ms
          decay:   e.decay     != null ? e.decay * 1000      : 120,
          sustain: e.sustain   != null ? e.sustain * 100     : 70,    // 0..1 → %
          release: e.release   != null ? e.release * 1000    : 300,
          cutoff:  e.cutoff    != null ? e.cutoff            : 20000,
          lfo:     e.lfo_depth != null ? e.lfo_depth * 100   : 0,     // 0..1 → %
          reverse: !!e.reverse,
          one_shot: !!e.one_shot,
        };
      });
      return patchCh(args.channelId, { sampleParams: rebuilt });
    }

    // ---- Transport (NEW state.transport field; no local mixer handler — the
    // TransportWidget tracks these optimistically with its own component state).
    case 'transport_play':        return {...state, transport: {...(state.transport||{}), playing:true}};
    case 'transport_stop':        return {...state, transport: {...(state.transport||{}), playing:false}};
    case 'set_master_bpm_manual': return {...state, transport: {...(state.transport||{}), bpm: args.bpm}};

    // ---- UI-only mirror: move the OTHER screen to the same view/selection ----
    case 'set_view':
      return {...state, view: args.view,
        editingChannel: (args.view === 'edit' ? args.channelId : state.editingChannel),
        selectedChannel: (args.view === 'edit' ? args.channelId : state.selectedChannel)};
    case 'set_edit_note':
      // The edit-map note selection lives in global state so it mirrors; the
      // SampleMapView reads it (channel-edit.jsx).
      return {...state, editNote: args.note != null ? { ch: args.channelId, note: args.note } : null};

    // ---- Loops (mirror channel-edit.jsx + app.jsx loop handlers) ----
    // Loop commands address the channel via `channel` (not `channelId`).
    case 'load_loop_bank': {
      if(args.channel == null) return state;
      var lc = state.channels && state.channels[args.channel]; if(!lc) return state;
      // The full reload patch (activeLoopPc from the bank's first loop, etc.)
      // is not derivable from args alone — args only carry {channel, bankName}.
      // Mirror what IS knowable: set the bank name and clear pending. Active PC
      // is left as-is (a following set_active_loop_pc / PC event sets it).
      return patchCh(args.channel, { loopBank: args.bankName, pendingLoopPc: null });
    }
    case 'set_active_loop_pc': {
      // channel-edit.jsx optimistically marks the requested PC as pending.
      if(args.channel == null || args.pc == null) return state;
      return patchCh(args.channel, { pendingLoopPc: args.pc });
    }
    // set_loop_address / update_loop_metadata edit bank.json (and refresh the
    // bank list); neither patches per-channel UI state from args alone, so they
    // are conservative no-ops here.
    case 'set_loop_address':    return state;
    case 'update_loop_metadata': return state;
    case 'set_loop_armed': {
      if(args.channel == null || args.armed == null) return state;
      return patchCh(args.channel, { loopArmed: args.armed });
    }

    default: return state;
  }
}

// Master fx index → id, inverse of FX_ID_INDEX's master entries. Used by the
// master-fx sync arms (the handlers key by id, the command carries an index).
const SYNC_MASTER_FX_IDS = ['meq','mcomp','mlim'];

window.applyCommandToState = applyCommandToState;
window.STATE_SYNC_CMDS = STATE_SYNC_CMDS;
window.LIBRARY_CMDS = LIBRARY_CMDS;
window.convertSampleParamFromBackend = convertSampleParamFromBackend;

/* ---- Tauri IPC Helper Layer ---- */

const BAND_INDEX = { low: 0, mid: 1, high: 2 };
const CHAIN_INDEX = { pre: 0, post: 1 };
const FX_ID_INDEX = { gate:0, comp:1, satur:2, delay:0, reverb:1, chorus:2, tremolo:3, autowah:4, phaser:5, flanger:6, guitar:7, meq:0, mcomp:1, mlim:2 };

// Shared note-name → MIDI converter (used by the piano keyboard, syncStateToEngine, etc.).
const NOTE_SEMITONES_LUT = { 'C':0,'C#':1,'D':2,'D#':3,'E':4,'F':5,'F#':6,'G':7,'G#':8,'A':9,'A#':10,'B':11 };
function noteToMidi(noteStr){
  if(typeof noteStr !== 'string') return -1;
  const match = noteStr.match(/^([A-G]#?)(-?\d+)$/);
  if(!match) return -1;
  const midi = (parseInt(match[2],10)+1)*12 + (NOTE_SEMITONES_LUT[match[1]] || 0);
  if(midi < 0 || midi > 127) return -1;
  return midi;
}

// Inverse of noteToMidi. 60 → "C4" (matches noteToMidi("C4") === 60).
const MIDI_NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
function midiToNoteName(midi){
  const n = Number(midi);
  if(!Number.isFinite(n) || n < 0 || n > 127) return null;
  const oct = Math.floor(n/12) - 1;
  return MIDI_NOTE_NAMES[n%12] + oct;
}

// Convert a UI-unit per-sample param value to the backend's canonical unit.
// Backend expects: attack/decay/release in seconds, sustain/lfo in 0..1,
// pan in -1..1, cutoff in Hz, EQ bands in dB, volume as-is.
function convertSampleParamForBackend(key, value){
  switch(key){
    case 'attack': case 'decay': case 'release': return value / 1000;  // ms → s
    case 'sustain': case 'lfo': case 'lfo_depth': return value / 100;  // % → 0..1
    case 'pan': return value / 50;                                     // -50..50 → -1..1
    case 'reverse': case 'one_shot': return value ? 1 : 0;             // bool → 0/1
    default: return value;                                             // cutoff, volume, eqLo/Mid/Hi
  }
}

// Inverse of convertSampleParamForBackend. Used when the UI loads per-note
// data from the backend (canonical units) and wants to display it on the
// sliders (UI-friendly units). Booleans are passed through unchanged.
function convertSampleParamFromBackend(key, value){
  switch(key){
    case 'attack': case 'decay': case 'release': return value * 1000;  // s → ms
    case 'sustain': case 'lfo': case 'lfo_depth': return value * 100;  // 0..1 → %
    case 'pan': return value * 50;                                     // -1..1 → -50..50
    default: return value;                                             // cutoff, volume, eqLo/Mid/Hi, one_shot
  }
}

// Build a canonical-unit snapshot list from the UI's `cs.sampleParams` so the
// backend can replace a channel's full per-note params table in a single ring
// message. Mirrors what convertSampleParamForBackend does on the live setSampleParam
// path, but emits all fields per note in one go. Defaults match SampleParams::default
// on the Rust side so notes the user never touched stay aligned with the engine.
//
// Used by syncStateToEngine (boot / snapshot restore / audio-device swap). Without
// this coalesce, those paths fired one Tauri invoke per (note, key) — ~700 per
// channel — and overflowed the command ring while audio was paused on Pi.
function buildSampleParamsSnapshot(sampleParams){
  const out = [];
  if (!sampleParams || typeof sampleParams !== 'object') return out;
  Object.keys(sampleParams).forEach(function(noteName){
    const midi = noteToMidi(noteName);
    if (midi == null || midi < 0 || midi > 127) return;
    const p = sampleParams[noteName] || {};
    out.push({
      note: midi,
      volume:    p.volume    != null ? p.volume        : 0.0,
      pan:       p.pan       != null ? p.pan / 50      : 0.0,        // -50..50 → -1..1
      eq_lo:     p.eqLo      != null ? p.eqLo          : 0.0,
      eq_mid:    p.eqMid     != null ? p.eqMid         : 0.0,
      eq_hi:     p.eqHi      != null ? p.eqHi          : 0.0,
      attack:    p.attack    != null ? p.attack / 1000 : 0.005,      // ms → s
      decay:     p.decay     != null ? p.decay / 1000  : 0.1,
      sustain:   p.sustain   != null ? p.sustain / 100 : 0.7,        // % → 0..1
      release:   p.release   != null ? p.release / 1000: 0.3,
      cutoff:    p.cutoff    != null ? p.cutoff        : 20000.0,
      lfo_depth: p.lfo       != null ? p.lfo / 100     : 0.0,
      reverse:   !!p.reverse,
      one_shot:  !!p.one_shot,
    });
  });
  return out;
}

const TauriAPI = {
  _tauri: null,
  _ready: false,

  init() {
    if (window.__TAURI__ && window.__TAURI__.core) {
      this._tauri = window.__TAURI__.core;
      this._ready = true;
    }
    return this._ready;
  },

  get available() {
    if (!this._ready) this.init();
    return this._ready;
  },

  _invoke(cmd, args) {
    if (!this.available) return Promise.resolve();
    return BSInvoke(cmd, args).catch(function(err) {
      if (typeof err === 'string' && err.indexOf('not yet implemented') !== -1) return;
      throw err;
    });
  },

  // Channel commands
  setVolume(channelId, db) {
    return this._invoke('set_volume', { channelId: channelId, db: db });
  },
  setPan(channelId, value) {
    return this._invoke('set_pan', { channelId: channelId, value: value });
  },
  setMute(channelId, muted) {
    return this._invoke('set_mute', { channelId: channelId, muted: muted });
  },
  setSolo(channelId, soloed) {
    return this._invoke('set_solo', { channelId: channelId, soloed: soloed });
  },
  setEqBand(channelId, band, gain, freq) {
    return this._invoke('set_eq_band', {
      channelId: channelId, band: BAND_INDEX[band] || 0, gain: gain, freq: freq
    });
  },
  setEffectParam(channelId, chain, effectId, paramKey, value) {
    return this._invoke('set_effect_param', {
      channelId: channelId,
      chain: CHAIN_INDEX[chain] || 0,
      effectId: FX_ID_INDEX[effectId] || 0,
      paramKey: paramKey,
      value: value
    });
  },
  toggleEffect(channelId, chain, effectId, enabled) {
    return this._invoke('toggle_effect', {
      channelId: channelId,
      chain: CHAIN_INDEX[chain] || 0,
      effectId: FX_ID_INDEX[effectId] || 0,
      enabled: enabled
    });
  },
  setGuitarMode(channelId, category, soundmapName, enabled, preset) {
    return this._invoke('set_guitar_mode', {
      channelId: channelId, category: category, soundmapName: soundmapName,
      enabled: enabled, preset: (preset | 0)
    });
  },
  loadSampleMap(channelId, mapName) {
    return this._invoke('load_sample_map', { channelId: channelId, mapName: mapName });
  },
  setMidiChannel(channelId, midiCh) {
    return this._invoke('set_midi_channel', { channelId: channelId, midiCh: midiCh });
  },
  setSampleParam(channelId, note, paramKey, value) {
    return this._invoke('set_sample_param', {
      channelId: channelId, note: note, paramKey: paramKey, value: value
    });
  },
  // Replace a channel's full per-note params table in a single ring message.
  // Used by syncStateToEngine instead of firing one setSampleParam per (note, key)
  // pair — the latter overflowed the 256-slot command ring during boot / snapshot
  // restore / audio-device swap on Pi (audio thread is paused during the EBUSY
  // retry window so it can't drain in time). `paramsList` is an array of canonical-
  // unit entries: [{note, volume, pan, eq_lo, eq_mid, eq_hi, attack, decay,
  // sustain, release, cutoff, lfo_depth, reverse, one_shot}, ...].
  setSampleParamsSnapshot(channelId, paramsList) {
    return this._invoke('set_sample_params_snapshot', {
      channelId: channelId, params: paramsList
    });
  },

  // Master commands
  setMasterVolume(db) {
    return this._invoke('set_master_volume', { db: db });
  },
  setMasterPan(value) {
    return this._invoke('set_master_pan', { value: value });
  },
  setMasterEq(band, gain, freq) {
    return this._invoke('set_master_eq', { band: BAND_INDEX[band] || 0, gain: gain, freq: freq });
  },
  setMasterFxParam(effectId, paramKey, value) {
    return this._invoke('set_master_fx_param', {
      effectId: FX_ID_INDEX[effectId] || 0, paramKey: paramKey, value: value
    });
  },
  toggleMasterEffect(effectId, enabled) {
    return this._invoke('toggle_master_effect', {
      effectId: FX_ID_INDEX[effectId] || 0, enabled: enabled
    });
  },

  // Session commands
  saveSession(name, channels, master) {
    return this._invoke('save_session', { name: name, channels: channels, master: master });
  },
  loadSession(name) {
    return this._invoke('load_session', { name: name });
  },
  listSessions() {
    return this._invoke('list_sessions', {});
  },
  deleteSession(name) {
    return this._invoke('delete_session', { name: name });
  },

  // System
  getAudioInfo() {
    return this._invoke('get_audio_info', {});
  },
  getMidiPorts() {
    return this._invoke('get_midi_ports', {});
  },
  listAudioDevices() {
    return this._invoke('list_audio_devices', {});
  },
  selectAudioDevice(deviceName) {
    return this._invoke('select_audio_device', { deviceName: deviceName });
  },
  selectMidiPort(portName) {
    return this._invoke('select_midi_port', { portName: portName });
  },
  getMeterLevels() {
    return this._invoke('get_meter_levels', {});
  },
  getMidiStatus() {
    return this._invoke('get_midi_status', {});
  },
  getDspLoad() {
    return this._invoke('get_dsp_load', {});
  },
  /** Returns a 4-element array, one entry per Pi core, each 0–100.
   *  On non-Linux dev builds returns all zeros. */
  getPerCoreCpu() {
    return this._invoke('get_per_core_cpu', {});
  },

  // Note playback
  playNote(channelId, note, velocity) {
    return this._invoke('play_note', { channelId: channelId, note: note, velocity: velocity });
  },
  stopNote(channelId, note) {
    return this._invoke('stop_note', { channelId: channelId, note: note });
  },

  // File browser / soundfont management
  browseSoundfonts() {
    return this._invoke('browse_soundfonts', {});
  },
  browseFolder() {
    return this._invoke('browse_folder', {});
  },
  importSoundfont(sf2Path, presetIndex, category, nameOverride) {
    return this._invoke('import_soundfont', { sf2Path: sf2Path, presetIndex: presetIndex, category: category, nameOverride: nameOverride || null });
  },

  // Soundmap management
  listSoundmaps() {
    return this._invoke('list_soundmaps', {});
  },
  deleteSoundmap(category, name) {
    return this._invoke('delete_soundmap', { category: category, name: name });
  },
  renameSoundmap(category, oldName, newName) {
    return this._invoke('rename_soundmap', { category: category, oldName: oldName, newName: newName });
  },
  getSoundmapConfig() {
    return this._invoke('get_soundmap_config', {});
  },
  setSoundmapConfig(config) {
    return this._invoke('set_soundmap_config', { config: config });
  },
  saveNoteAdjustments(category, name, adjustments) {
    return this._invoke('save_note_adjustments', { category: category, soundmapName: name, adjustments: adjustments });
  },
  loadSoundmap(channelId, category, name) {
    return this._invoke('load_soundmap_to_channel', { channelId: channelId, category: category, soundmapName: name });
  },
  // Returns [{note:'C4', name:'kick_01', volume, pan, attack, decay, sustain,
  // release, cutoff, one_shot}, ...] built from the soundmap's index.json.
  // UI uses this to populate the piano AND to mirror the engine's per-sample
  // params on the sliders (so what the engine actually loaded matches what
  // the user sees — see channel-edit.jsx useEffect for the merge).
  // The note name is precomputed here from `midi_note` so callers don't have
  // to. Other fields are passed through in canonical units; the SampleParams
  // panel converts to UI-friendly units via convertSampleParamFromBackend.
  getSoundmapSamples(category, name) {
    return this._invoke('get_soundmap_samples', { category: category, soundmapName: name })
      .then(function(list){
        if(!Array.isArray(list)) return [];
        return list.map(function(s){
          const note = midiToNoteName(s.midi_note);
          if(!note) return null;
          return {
            note: note,
            name: s.name,
            midi_note: s.midi_note,
            volume: s.volume,
            pan: s.pan,
            attack: s.attack,
            decay: s.decay,
            sustain: s.sustain,
            release: s.release,
            cutoff: s.cutoff,
            one_shot: s.one_shot,
          };
        }).filter(function(s){ return s; });
      });
  },
  createEmptySoundmap(category, name) {
    return this._invoke('create_empty_soundmap', { category: category, name: name });
  },
  addSampleToSoundmap(category, name, midiNote, wavPath) {
    return this._invoke('add_sample_to_soundmap', { category: category, name: name, midiNote: midiNote, wavPath: wavPath });
  },
  setSoundmapProgram(category, name, newProgram) {
    return this._invoke('set_soundmap_program', { category: category, name: name, newProgram: newProgram });
  },

  // Push the UI's current channels + master state to the audio engine.
  // Call on app boot, after session load, and after audio-device swap so the
  // engine matches what the user sees (the engine starts from its own defaults
  // and has no way to infer UI state).
  syncStateToEngine(uiState) {
    if (!this.available || !uiState) return Promise.resolve();
    const channels = uiState.channels || {};
    const channelIds = Object.keys(channels)
      .map(function(k){ return parseInt(k, 10); })
      .filter(function(n){ return !isNaN(n); })
      .sort(function(a,b){ return a-b; });

    channelIds.forEach(function(cid){
      const cs = channels[cid];
      if (!cs) return;
      // Reload the soundmap first — other params only matter once samples are present.
      // We only replay maps the user has explicitly picked (both name + category stored).
      if (cs.map && cs.mapCategory) {
        this.loadSoundmap(cid, cs.mapCategory, cs.map);
      }
      this.setVolume(cid, cs.volume);
      this.setPan(cid, cs.pan);
      this.setMute(cid, !!cs.mute);
      this.setSolo(cid, !!cs.solo);
      if (cs.midiCh != null) this.setMidiChannel(cid, cs.midiCh);
      if (cs.eq) {
        if (cs.eq.low)  this.setEqBand(cid, 'low',  cs.eq.low.gain,  cs.eq.low.freq);
        if (cs.eq.mid)  this.setEqBand(cid, 'mid',  cs.eq.mid.gain,  cs.eq.mid.freq);
        if (cs.eq.high) this.setEqBand(cid, 'high', cs.eq.high.gain, cs.eq.high.freq);
      }
      ['pre','post'].forEach(function(chain){
        const list = cs[chain];
        if (!Array.isArray(list)) return;
        list.forEach(function(fx){
          this.toggleEffect(cid, chain, fx.id, !!fx.enabled);
          if (Array.isArray(fx.params)) {
            fx.params.forEach(function(p){
              // paramKey override (for Tremolo's lowercase backend keys) > UI label `k`.
              var sendKey = p.paramKey || p.k;
              this.setEffectParam(cid, chain, fx.id, sendKey, p.v);
            }.bind(this));
          }
        }.bind(this));
      }.bind(this));
      // Per-sample params: one snapshot message replaces all 128 entries in one
      // shot. The previous code fired one Tauri invoke per (note, key) pair,
      // which on Pi overflowed the 256-slot command ring during the audio-device
      // swap retry window (~5 s with no consumer). Coalescing into one message
      // per channel keeps us safely under the ring capacity even when audio is
      // paused.
      const snap = buildSampleParamsSnapshot(cs.sampleParams);
      if (snap.length > 0) {
        this.setSampleParamsSnapshot(cid, snap);
      }
    }.bind(this));

    const m = uiState.master;
    if (m) {
      if (m.volume != null) this.setMasterVolume(m.volume);
      if (m.pan != null)    this.setMasterPan(m.pan);
      if (m.eq) {
        if (m.eq.low)  this.setMasterEq('low',  m.eq.low.gain,  m.eq.low.freq);
        if (m.eq.mid)  this.setMasterEq('mid',  m.eq.mid.gain,  m.eq.mid.freq);
        if (m.eq.high) this.setMasterEq('high', m.eq.high.gain, m.eq.high.freq);
      }
      if (Array.isArray(m.fx)) {
        m.fx.forEach(function(fx){
          this.toggleMasterEffect(fx.id, !!fx.enabled);
          if (Array.isArray(fx.params)) {
            fx.params.forEach(function(p){
              this.setMasterFxParam(fx.id, p.k, p.v);
            }.bind(this));
          }
        }.bind(this));
      }
    }
    return Promise.resolve();
  },
};

Object.assign(window, { CHANNELS, SAMPLE_MAPS, MAP_LIBRARY, FX_PRE, FX_POST, EQ_DEFAULT, MASTER_FX, TauriAPI, applyCommandToState, STATE_SYNC_CMDS });
