Skip to content

Instantly share code, notes, and snippets.

@userx14
Last active September 14, 2025 12:15
Show Gist options
  • Save userx14/664f5e74cc7ced8c29d4a0434ab7be98 to your computer and use it in GitHub Desktop.
Save userx14/664f5e74cc7ced8c29d4a0434ab7be98 to your computer and use it in GitHub Desktop.
Circuit tracks firmware reverse engineering
import numpy as np
from pathlib import Path
import crcmod
import sys
def extract_sysex_messages(syx_path):
commands_list = dict([
(0x71, "UPDATE_INIT"),
(0x72, "UPDATE_WRITE"),
(0x73, "UPDATE_FINISH"),
(0x76, "UPDATE_FOOTER"),
(0x7c, "UPDATE_HEADER"),
])
def parse_nibble(data):
result = 0
for byte_value in data:
result = result << 4
result |= byte_value
return result
with open(syx_path, 'rb') as syx_file:
data = syx_file.read()
finalFirmwareFile = np.empty([0],dtype=np.uint8)
i = 0
while i < len(data):
if data[i] == 0xF0: #SysEx start byte
end_index = data.find(0xF7, i) #SysEx end byte
if end_index == -1:
raise ValueError("Missing SysEx end byte")
novation_header = bytes([0x00, 0x20, 0x29, 0x00])
if data[i+1:i+5] != novation_header:
raise ValueError("File is missing novation header")
command = commands_list[data[i+5]]
cropped_data = data[i+6:end_index]
if command == "UPDATE_INIT":
version = parse_nibble(cropped_data[2:8])
if cropped_data[1] == 0x64:
print("target: circuit tracks")
elif cropped_data[1] == 0x63:
print("target: circuit rhythm")
else:
print(hex(cropped_data))
print(0x1d)
print(f"init version: {version}")
elif command == "UPDATE_HEADER":
version = parse_nibble(cropped_data[1:7])
print(f"header version: {version}")
filesize = parse_nibble(cropped_data[7:15])
print(f"header filesize: {hex(filesize)}")
checksum = parse_nibble(cropped_data[15:23])
print(f"header checksum: {hex(checksum)}")
elif command in ["UPDATE_WRITE", "UPDATE_FINISH"]:
#need to tightly pack 7bit MIDI bytes into 8bit firmware file
packed = np.frombuffer(cropped_data, dtype=np.uint8)
unpacked = np.unpackbits(packed[:, np.newaxis], axis=1)
unpacked = unpacked[:, 1:].reshape(-1) #discard first bit of each byte and make continuous array
repacked = np.packbits(unpacked[:-3]) #last three bits are just padding
if command == "UPDATE_FINISH":
finalFirmwareFile = np.concatenate([repacked, finalFirmwareFile])
break
else:
finalFirmwareFile = np.concatenate([finalFirmwareFile, repacked])
i = end_index + 1
else:
i += 1
finalFirmwareFile = bytes(finalFirmwareFile[:filesize])
crc32_non_reflected = crcmod.mkCrcFun(0x104C11DB7, rev=False, initCrc=0xFFFFFFFF, xorOut=0x00000000)
calculated_checksum = crc32_non_reflected(finalFirmwareFile)
if calculated_checksum != checksum:
raise ValueError(f"File checksum {calculated_checksum} does not match header checksum {checksum}")
with open(syx_path.with_suffix(".bin"), 'wb') as f:
f.write(finalFirmwareFile)
if len(sys.argv)!=2:
print("need to give a path to a .syx file")
extract_sysex_messages(Path(sys.argv[1]))
@Ondrysak
Copy link

Ondrysak commented Aug 24, 2025

Reversing the wasm blob

The web version of novation components uses the wasm blob for validation of the ncs files before uploading them to the device

https://components.novationmusic.com/vendor/circuit-tracks-project-validator-e83caa525f3f586024af78cebcb33ad4.wasm

We need two things ghidra and a plugin for wasm dissasembly ghidra-wasm-plugin

Just looking for the defined strings we are definitely at the right place to understand more about the structure of the file since the strings include stuff like --- Success Log : ---, --- Error Log : ---, VALIDATION LOG FOR SESSION FILE @, Session colour out of range, Tempo out of range, Scene pattern chain padding not set to 0, has an invalid drum choice value

This is a piece of code that is validating the ncs format, we can try to infer how ncs looks by checking how this code works

Finding entrypoint

We look for exported function and eyeball their decompilation output in ghidra eventually we find a function matching what ncs validator should be doing in the exports, we traverse the function call tree until we find a function at ram:8002e424 which very much looks like its running some validators over the ncs file in sequence, after some renaming it looks like this

void run_validators(undefined4 param1,undefined4 *param2)

{
  int *piVar1;
  undefined4 *puVar2;
  int *piVar3;
  int iVar4;
  uint uVar5;
  undefined4 *puVar6;
  undefined4 *puVar7;
  undefined1 auStack_27430 [160816];
  
  uVar5 = (int)DAT_ram_00008594._4_4_ - (int)(int *)DAT_ram_00008594 >> 2;
  if (uVar5 < 0x81) {
    unnamed_function_257(&DAT_ram_00008594,0x81 - uVar5);
code_r0x8002e4ba:
    piVar1 = DAT_ram_00008594._4_4_;
    if (DAT_ram_00008594._4_4_ != (int *)DAT_ram_00008594) goto code_r0x8002e4c2;
  }
  else {
    if ((int)DAT_ram_00008594._4_4_ - (int)(int *)DAT_ram_00008594 == 0x204) goto code_r0x8002e4ba;
    DAT_ram_00008594._4_4_ = (int *)DAT_ram_00008594 + 0x81;
code_r0x8002e4c2:
    piVar1 = DAT_ram_00008594._4_4_;
    iVar4 = 0;
    piVar3 = (int *)DAT_ram_00008594;
    do {
      *piVar3 = iVar4;
      iVar4 = iVar4 + 1;
      piVar3 = piVar3 + 1;
    } while (piVar3 != piVar1);
  }
  piVar1[-1] = 0xff;
  uVar5 = (int)DAT_ram_000085a0._4_4_ - (int)(int *)DAT_ram_000085a0 >> 2;
  if (uVar5 < 0x41) {
    unnamed_function_257(&DAT_ram_000085a0,0x41 - uVar5);
code_r0x8002e551:
    piVar1 = DAT_ram_000085a0._4_4_;
    if (DAT_ram_000085a0._4_4_ == (int *)DAT_ram_000085a0) goto code_r0x8002e57b;
  }
  else {
    if ((int)DAT_ram_000085a0._4_4_ - (int)(int *)DAT_ram_000085a0 == 0x104) goto code_r0x8002e551;
    DAT_ram_000085a0._4_4_ = (int *)DAT_ram_000085a0 + 0x41;
  }
  piVar1 = DAT_ram_000085a0._4_4_;
  iVar4 = 0;
  piVar3 = (int *)DAT_ram_000085a0;
  do {
    *piVar3 = iVar4;
    iVar4 = iVar4 + 1;
    piVar3 = piVar3 + 1;
  } while (piVar3 != piVar1);
code_r0x8002e57b:
  piVar1[-1] = 0xff;
  iVar4 = memcpy(auStack_27430,*param2,&DAT_ram_0002740c);
  validate_header_and_feature_flags(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_timing_section(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_scenes_table(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_scene_chain_bounds_and_padding(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4)
  ;
  validate_pattern_chain_table(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_synth_patterns_chunkA(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_synth_patterns_chunkB(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_synth_patterns_chunkC(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_synth_patterns_chunkD(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_synth_patterns_chunkE(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_synth_track_info_table(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_drum_patterns_chunkA(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_drum_patterns_chunkB(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_drum_patterns_chunkC(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_drum_patterns_chunkD(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_drum_patterns_chunkE(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_drum_mute_state_table(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_default_drum_choices(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_midi_patterns_chunkA(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_midi_patterns_chunkB(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_midi_patterns_chunkC(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_midi_patterns_chunkD(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_midi_patterns_chunkE(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_midi_track_info_table(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_scale_settings(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_fx_presets(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_midi_keyboard_octaves(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  report_label_actualValue_helperA(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  report_label_actualValue_helperB(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  report_label_actualValue_helperC(iVar4,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  validate_root_orchestrator_stub(param1,&DAT_ram_00027420 + iVar4,&DAT_ram_00027410 + iVar4);
  puVar6 = *(undefined4 **)(&DAT_ram_00027410 + iVar4);
  if (puVar6 != (undefined4 *)0x0) {
    puVar2 = *(undefined4 **)(&DAT_ram_00027414 + iVar4);
    puVar7 = puVar6;
    if (puVar6 != *(undefined4 **)(&DAT_ram_00027414 + iVar4)) {
      do {
        if (*(char *)((int)puVar2 + -9) < '\0') {
          free(puVar2[-5]);
        }
        puVar7 = puVar2 + -8;
        if (*(char *)((int)puVar2 + -0x15) < '\0') {
          free(*puVar7);
        }
        puVar2 = puVar7;
      } while (puVar6 != puVar7);
      puVar7 = *(undefined4 **)(&DAT_ram_00027410 + iVar4);
    }
    *(undefined4 **)(&DAT_ram_00027414 + iVar4) = puVar6;
    free(puVar7);
  }
  puVar6 = *(undefined4 **)(&DAT_ram_00027420 + iVar4);
  if (puVar6 != (undefined4 *)0x0) {
    puVar2 = *(undefined4 **)(&DAT_ram_00027424 + iVar4);
    puVar7 = puVar6;
    if (puVar6 != *(undefined4 **)(&DAT_ram_00027424 + iVar4)) {
      do {
        if (*(char *)((int)puVar2 + -9) < '\0') {
          free(puVar2[-5]);
        }
        puVar7 = puVar2 + -8;
        if (*(char *)((int)puVar2 + -0x15) < '\0') {
          free(*puVar7);
        }
        puVar2 = puVar7;
      } while (puVar6 != puVar7);
      puVar7 = *(undefined4 **)(&DAT_ram_00027420 + iVar4);
    }
    *(undefined4 **)(&DAT_ram_00027424 + iVar4) = puVar6;
    free(puVar7);
  }
  return;
}

decompiling the above I managed to put together first version of a tool able to decode some of the project data in

https://github.com/Ondrysak/ncstool

// TODO ADD renamed functions addresses

@Ondrysak
Copy link

Circuit Tracks Pitch Shifting Implementation Analysis

Executive Summary

ANSWER: Pitch-shifted samples are PRE-STORED in packs, NOT computed on device

The Circuit Tracks firmware uses a sample bank approach where each drum type contains up to 140 pre-recorded WAV files at different pitches. The device selects which pre-recorded sample to play based on MIDI input - no real-time pitch shifting occurs.

Key Discovery: Sample Bank Architecture

Core Concept

  • No real-time pitch shifting: The device uses pre-recorded samples at different pitches stored in pack files
  • Sample banks: Each drum type has a bank of up to 140 pre-recorded WAV samples covering different pitches
  • Chromatic mapping: 2-bit encoding allows 12 semitones to be encoded in 24 bits for sample selection
  • Efficient lookup: O(1) sample selection using bit manipulation - no DSP processing

Key Functions with Addresses

1. DrumSample_GetPitchAdjustedIndex (0x80408C6)

Purpose: Core sample selection function - maps MIDI notes to pre-recorded sample indices

Key Insight: This function does NOT generate pitch-shifted audio. It selects which pre-recorded sample to play from the bank.

unsigned int DrumSample_GetPitchAdjustedIndex(SampleBank *bank, uint8_t midi_note) {
    // Extract 2-bit pitch adjustment from chromatic lookup table
    int pitch_adjustment = (bank->pitch_table >> (2 * (midi_note % 12))) & 3;

    uint8_t adjusted_note = midi_note;

    // Apply pitch adjustment: 0=no change, 1=down semitone, 2/3=up semitone
    if (pitch_adjustment) {
        if (pitch_adjustment == 1) {
            adjusted_note = midi_note - 1;  // Select sample one semitone down
        } else {
            adjusted_note = midi_note + 1;  // Select sample one semitone up
        }
    }

    // Clamp to valid sample range [min_sample, max_sample]
    if (adjusted_note < bank->min_sample) {
        return bank->min_sample;
    }
    if (adjusted_note > bank->max_sample) {
        return bank->max_sample;
    }

    return adjusted_note;  // Returns sample INDEX, not processed audio
}

2. SampleBank_ProcessPitchTable (0x804093C)

Purpose: Builds min/max sample range for up to 140 pre-recorded samples per bank

Key Insight: Processes metadata for existing samples, doesn't generate new ones.

int SampleBank_ProcessPitchTable(SampleBank *bank) {
    uint8_t note = 1;

    // Find first valid sample by checking which pre-recorded samples exist
    do {
        uint32_t pitch_bits = (bank->pitch_table >> (2 * (note % 12))) & 3;
        uint8_t adjusted_note;

        if (pitch_bits == 0) {
            adjusted_note = note;           // Use sample at base pitch
        } else if (pitch_bits == 1) {
            adjusted_note = note - 1;       // Use sample one semitone down
        } else {
            adjusted_note = note + 1;       // Use sample one semitone up
        }

        bank->min_sample = adjusted_note;
        note++;
    } while (bank->min_sample == 0);

    // Find maximum valid sample (limit: 140 pre-recorded samples per bank)
    for (int sample_id = 139; sample_id >= 0; sample_id--) {
        uint8_t adjusted = SampleBank_CalculatePitchOffset(bank, sample_id);
        bank->max_sample = adjusted;
        if (adjusted < 140) break;  // Sample limit check
    }

    return bank->max_sample;
}

3. SampleBank_SetPitchTable (0x80408BC)

Purpose: Initialize sample bank with predefined pitch table from ROM

int SampleBank_SetPitchTable(SampleBank *bank, int drum_type_index) {
    // Load predefined pitch table from lookup table at 0x805A3C4
    bank->pitch_table = dword_805A3C4[drum_type_index];
    return SampleBank_ProcessPitchTable(bank);
}

4. Sample_LoadToDSP (0x8049a80) - CRITICAL EVIDENCE

Purpose: Loads pre-recorded WAV samples directly from pack files to DSP memory

Key Insight: This function proves samples are pre-stored. It loads complete WAV files with headers, validates format, and transfers to DSP. NO pitch processing occurs.

int Sample_LoadToDSP(uint8_t sample_id, uint32_t sample_offset) {
    // Read 64-byte WAV header from pack file
    FileRequest request = {4, sample_id, 0xFF};
    FS_ReadFile(&request, 0, &sample_header_buffer, 64, 0, 0);

    // Validate standard WAV format (RIFF/WAVE, 48kHz, 16-bit PCM)
    if (Sample_ValidateWAVFormat(&sample_header_buffer)) {
        uint32_t data_offset = Sample_GetDataOffset(&sample_header_buffer, 0);
        uint32_t sample_size = Sample_GetDataSize(&sample_header_buffer);

        // Load sample data in 2KB chunks directly to DSP
        while (bytes_loaded < sample_size) {
            // Check 15MB memory limit
            if ((chunk_size >> 1) + current_offset >= 0xE80000) break;

            // Read chunk from pack file
            FS_ReadFile(&request, data_offset, current_buffer, chunk_size, 0, 0);

            // Convert endianness and transfer to DSP
            DSP_WriteSampleData(0, current_buffer, chunk_size);
        }
    }
    return buffer_size;
}

Pitch Table Data Structure (Address: 0x805A3C4)

Encoding Format - Sample Selection, NOT Pitch Processing

  • 24-bit pitch table: Encodes 12 semitones using 2 bits each
  • 2-bit values map to PRE-RECORDED samples:
    • 00 (0): Use sample at base pitch (no sample change)
    • 01 (1): Use sample recorded one semitone down
    • 10 (2): Use sample recorded one semitone up
    • 11 (3): Use sample recorded one semitone up (same as 2)

Example Pitch Table Analysis

From dword_805A3C4[0] = 0x441108 (Kick drum sample mapping):

Binary: 0100 0100 0001 0001 0000 1000
Semitones: C  C# D  D# E  F  F# G  G# A  A# B
Bits:     00 10 00 01 00 01 00 00 10 00 00 00
Sample:    C  D  D  D  E  E  F# G  A  A  A  B

Translation: When MIDI note comes in:

  • C note: Play pre-recorded C sample
  • C# note: Play pre-recorded D sample (+1 semitone)
  • D note: Play pre-recorded D sample
  • D# note: Play pre-recorded D sample (-1 semitone)
  • E note: Play pre-recorded E sample
  • F note: Play pre-recorded E sample (-1 semitone)
  • etc.

Each "sample" is a complete WAV file stored in the pack.

Pack File Organization - THE SMOKING GUN

Sample Pack Structure (Evidence from FS_ReadFile calls)

Sample Pack File Layout:
├── Sample 0: Kick_C3.wav (48kHz, 16-bit, complete WAV file)
├── Sample 1: Kick_C#3.wav (48kHz, 16-bit, complete WAV file)
├── Sample 2: Kick_D3.wav (48kHz, 16-bit, complete WAV file)
├── Sample 3: Kick_D#3.wav (48kHz, 16-bit, complete WAV file)
├── ...
├── Sample 139: Kick_B8.wav (48kHz, 16-bit, complete WAV file)
└── Metadata: Pitch tables, voice mappings, etc.

Sample Bank Memory Layout

struct SampleBank {
    uint8_t min_sample;      // Minimum valid sample index (e.g., 36)
    uint8_t max_sample;      // Maximum valid sample index (e.g., 96)
    uint8_t padding[2];
    uint32_t pitch_table;    // 24-bit sample selection table (NOT pitch processing)
    // ... additional bank data
    uint8_t voice_mapping[244 + 140];  // Maps sample index → audio voice ID
};

Sample Memory Management (Addresses from firmware)

  • 15MB total sample memory: 0xE80000 bytes maximum (enforced at 0x8049B18)
  • 140 samples per bank: Hard limit enforced at 0x804098A
  • 2KB chunks: Samples loaded in 2048-byte blocks for efficiency
  • Dual buffer system: Uses alternating buffers (0x20003D04, 0x20004504)
  • WAV validation: Sample_ValidateWAVFormat (0x8051BA0) ensures 48kHz/16-bit format

Complete Sample Selection Process (Function Addresses)

Step-by-Step Process

  1. MIDI Note Input: Note 0-63 received on drum track channels 2-7
  2. Sample Selection: DrumSample_GetPitchAdjustedIndex (0x80408C6)
    • midi_note % 12 maps to chromatic scale
    • Extract 2-bit value from pitch table at 0x805A3C4
    • Returns index of pre-recorded sample to play
  3. Voice Allocation: Get voice ID from voice_mapping[adjusted_sample + 244]
  4. Sample Loading: Sample_LoadToDSP (0x8049a80) loads complete WAV file
  5. Audio Trigger: DrumTrack_TriggerNote (0x804B07A) plays pre-recorded sample

Performance Characteristics

  • Lookup Time: O(1) - Direct bit manipulation, no DSP processing
  • Memory Usage: ~700 bytes per drum track sample bank metadata
  • CPU Usage: Minimal - no floating-point operations or pitch algorithms
  • Latency: Sub-millisecond sample selection (vs. milliseconds for real-time pitch shifting)
  • Audio Quality: Perfect - no pitch shifting artifacts since samples are pre-recorded

Why Pre-Recorded Samples Instead of Real-Time Pitch Shifting?

Technical Advantages

  1. Audio Quality: Zero pitch shifting artifacts - each sample recorded at correct pitch
  2. Performance: No CPU overhead for DSP algorithms - just sample selection
  3. Latency: Instant response - no processing delay
  4. Memory Bandwidth: Efficient - no real-time sample rate conversion
  5. Predictable: Drum sounds typically need ±12 semitones maximum

Trade-offs

  • Pack Size: Larger files due to multiple sample variations (140 samples vs. 1)
  • Storage: Higher storage requirements for sample packs
  • Flexibility: Limited to pre-recorded variations (can't pitch shift arbitrarily)
  • Quality: Excellent - professional studio-recorded samples at each pitch

Evidence Summary - Function Addresses

Core Sample Selection Functions

  • 0x80408C6: DrumSample_GetPitchAdjustedIndex - Selects pre-recorded sample index
  • 0x804093C: SampleBank_ProcessPitchTable - Processes sample bank metadata
  • 0x80408BC: SampleBank_SetPitchTable - Loads pitch tables from ROM (0x805A3C4)

Sample Loading Functions (Proof of Pre-Storage)

  • 0x8049A80: Sample_LoadToDSP - Loads complete WAV files from pack
  • 0x8051BA0: Sample_ValidateWAVFormat - Validates 48kHz/16-bit WAV headers
  • 0x8051B80: Sample_GetDataSize - Gets size of pre-recorded WAV data
  • 0x8051B8C: Sample_GetDataOffset - Gets offset to WAV audio data

File System Functions

  • 0x80185DE: FS_ReadSector - Reads sectors from pack files
  • 0x801F1A2: Pack_GetStructure - Gets pack file structure

Memory Limits (Firmware Constraints)

  • 0x8049B18: 15MB sample memory limit check (0xE80000 bytes)
  • 0x804098A: 140 samples per bank limit check
  • 0x8051BEA: 19MB max sample size check (0x124F801 bytes)

Conclusion

The Circuit Tracks does NOT compute pitch-shifted samples on the device. All pitch variations are pre-recorded WAV files stored in the sample packs. The firmware simply selects which pre-recorded sample to play based on MIDI input using efficient lookup tables.

This approach provides professional audio quality with zero latency, at the cost of larger pack files containing multiple sample variations.

@userx14
Copy link
Author

userx14 commented Aug 25, 2025

Which binary firmware file version are you analyzing?
When I look at the firmware file for most recent "1.2.1" / 4486 for circuit tracks,
the addresses 0x08051B8C where you find the max sample size check,
it is at the position larger then total size of the firmware file (last byte 0x0804CDDB). Also applies to some other functions.

Also I'm really unsure about the conclusion here, for the behavior of the pitch shifting of the drum tracks.
From hardware testing with a single sample, when adjusting the pitch with midi cc command or with the knob, the samples are not chromatically played and do not fall onto semitones. So either this behavior is non used code or something funky is going on when it is played with the DSP.

@userx14
Copy link
Author

userx14 commented Aug 26, 2025

I analyzed the strings in 4486, the most relevant places will likely be:

  • SysEx message parsing 49f6c Warning: SysEx Data Bytes out of range
  • Midi transmission buffer 25dc0 MIDI Buffer Overrun
  • Usb handling 2acd8 USBD_GetDescriptor: 0x%x
  • DSP interaction f5f0 >> DSP Uploading...

Here is an overview over all the strings:

   //Hex address of string in firmware 4486, some strings might have invalid data pre- or appended.
    1cd dGB1.1.5143.4486
   ad3c Card Detected
   ad4c No card Detected
   ad60 Sd Card Removed...
   ad74 SD:Read Fail
   ad84 Caching @ 0x%X, Size: %d
   ada0 SD:Write Fail
   adb0 SDWrite @ 0x%X, SecCount: %d
   af48 Init FS: %d
   af58 Mount Fail: %d
   af68 Disk [%d] Mounted
   af7c FAT Sync: %d
   af8c GET_BLOCK_SIZE: %d
   afa0 IoCtl: CMD:%d, BUF:%d
   c7b4 SDIO Error! Result: 0x%X
   cf9c tasks.c
   d2c0 port.c
   d76c Jitter: %d
   d778 Starting %d.%d.%d - %s
   d790 GB1.1.5143.4486
   d7a0 Complete
   d7ac Waiting...
   d7b8 Iterations per sec %d
   dc1f  >> Power Button IRQ %d
   dea3  Main
   deac Storage
   deb4 Content
   f5f0 >> DSP Uploading...
   f608 << DSP Uploaded
  10da8 Warning: Transmit Error
  10dc4 Warning, malformed SysEx
  10de0 Emptying Midi SysEx Queue[%d]
  12e28 Req Queue is full!
  1312e pGFlashWrite @ 0x%X, Size: %d
  14214 Media Queue is full!
  15369  pGUPN not valid
  1537c Updating UPN
  1559c Availability changed [%d]=%d
  155bc eSourceChanged =%d
  155d0 eChargingChanged =%d
  15824 Low battery state: %d
  1583c Crit battery state: %d
  15854 Requesting App shutdown
  15870 Fullcharge battery state: %d
  15a54 Circuit Tracks
  15a64 Focusrite A.E
  15c00 Automount: State changed %d
  15e50 FatFs_File: Opening %s
  15e68 FatFs_File: Closing %s
  15e80 No File Open!
  15e90 Read: Seeking
  15ea0 Interface::Read Failed %d
  16357 G%d:/Tracks
  16364 Volume[%d]::Indexing failed
  16384 Warning: Pack Load failed
  163a0 %02d%s
  163a8 %s/%s
  163b0 createDir loop: %s
  163c4 Creating Dir: %s
  163d8 Failed create Dir (%d)
  16793  Sessions
  167a0 Patches
  167a8 meta
  167b0 GridFx
  167b8 DeleteItem: %s
  167c8 Directory not empty: %s
  167e4 %s/%s/%s
  16f13  USBC
  16f1b @USBS
  20190 USERDEMO0q
  204fc DEMO
  25dc0 MIDI Buffer Overrun
  27288 Warning: INVALID_PACK Access
  272a8 Warning: LoadPack Failed Id:%d
  272c8 Content::Write: Failed
  272e0 Copying to: %s
  272f0 mv Pack %s to: %s
  27304 mv to: %s
  28198 Setup Stage %d
  2979c Transfer failed
  297b0 Data Packet: %d of %d
  2a57c Bytes Read = 0
  2a58c Cache Data Packet: %d, %d, %d
  2a5ac Write Data Packet: %d, %d, %d
  2a5cc End Cmd Write @%d, N=%d, %d
  2acbe pGUSBD_StdDevReq: 0x%x
  2acd8 USBD_GetDescriptor: 0x%x
  2acf4 USB_DESC_TYPE_STRING: 0x%x
  2aeff  Indexing: [%d]=%s
  2b0eb GStarting Index Container: %s
  304e4 USERDEMO
  39424 printf_s: bad %s argument
  39c54 Uploading PCM:%d Len=%d bytes
  39c74 Drum PCM not valid
  419bb  constraint handler: bad message
  47130 Random Decay    
  47282 P=Saw Pad         
  47fc0 MSD Wr=%d %d
  47fd0 <SERIALUPN123>
  484f0 Initial Patch   
  4999f  Fault! 0x%08X -> 0x%08X
  499c7  Heap Size: %d	 Heap Used: %d	 Heap Avail: %d bytes 
  49a00 Total RAM Used: %d	 Remaining: %d bytes 
  49c6c zsmfWrite: Seeking to offset %d from %d
  49c98 Interface::Write Failed %d (%d expected %d)
  49f6c Warning: SysEx Data Bytes out of range
  49f94 TransmitSysEx: Msg too long %d, Max:%d
  4a0ac CreateDirectoryPath: Could not chdrive
  4a0d4 Content::Write: Creating Dir: %s
  4a7f6 ".:FR^Input message too small!, Expected: %d, Received: %d
  4b07c USER
  4b0b0 WARNING: GetPackContentType (%d) Out of bounds!!!
  4b0e4 WARNING: GetHalContentType (%d) Out of bounds!!!
  4b118 Transmitter: SysEx in progress, emptying Queue
  4b1fc Warning: MIDI Transmitter Queue is FULL!
  4b254 Application may only set an invalid UPN
  4b280 Copy failed %d! From [%d][%d][%d] to [%d]
  4b388 Battery Voltage Update: %d%% (%d mV)
  4b400 Volume[%d]::Populate %d Packs found
  4b64c FatFs_File: Open %s, Failed: %d
  4b6c0 NovationCircuit         1.00Sample Memory Exceeded 0x%X + 0x%X
  4b878 User Session                    
  4bedc Novation MSD
  4befc _SESSION.ncs
  4bf0c _PATCHBANK.cpb
  4c058 _PCM.wav
  4c064 _META.ncm
  4c070 _GRIDFX.ncg
  4c130 _GLOB.cg
  4c1d4 MIDI
  4c214 _PACK
  4c21c Globals

@userx14
Copy link
Author

userx14 commented Aug 28, 2025

Doing the same for the strings in circuitrhythm-firmware-5706.bin returns basically identical strings at slightly different offsets. There is even some "Circuit Tracks" string still in the firmware for rhythm. Some additional strings for rhythm that are not present for tracks are:

1f678 Sample Save Error
1f6c4 RIFF
1f6cc WAVE
56fb0 DSP Filesystem reports Sample Memory Exhausted.

Even more hint that the hardware platform between rhythm and tracks is shared.

@Ondrysak
Copy link

Ondrysak commented Sep 4, 2025

Good finds :)

Which binary firmware file version are you analyzing? When I look at the firmware file for most recent "1.2.1" / 4486 for circuit tracks,

Using the same version

the addresses 0x08051B8C where you find the max sample size check,

honestly I dont know why, but seems like when loaded in ida base address is actually 0x08010000 not 0x08000000

Also I'm really unsure about the conclusion here, for the behavior of the pitch shifting of the drum tracks. From hardware testing with a single sample, when adjusting the pitch with midi cc command or with the knob, the samples are not chromatically played and do not fall onto semitones. So either this behavior is non used code or something funky is going on when it is played with the DSP.

Let me check again :)

@userx14
Copy link
Author

userx14 commented Sep 6, 2025

I did a dump of the flash memory and your offset of 0x08010000 is correct. Before that there is a bootloader, that is not included in the firmware update file. The function locations make sense with this.

Is it possible that you found the place that handles the different musical scales that the synth supports, instead of some sample playback selection? You identified that the semitone offset are stored at 0x0805a4c4 and beyond. If I check there I see 15 similar entries followed by an entry with just zeros, which would match nicely the 16 scales that circuit tracks supports and the zeros would be major scale.

Is the order of notes maybe flipped (B A# A...) instead of (C C# D ...) for the pitch table analysis? Just from the shift in the function I would have guessed that e.g. note C corresponds to the two lowest value bits.

Also I think you are correct that Sample_LoadToDSP does not modify the pitch. My guess is that they just change the playback speed on the DSP, after upload. Unfortunately, I did not find where they do this in the firmware.

Anyway I'm impressed with how fast you did the analysis of these functions, the assembly code that does the modulo 12 part took me quiet a while to understand.

Together with pr0ximaMusic, I started a github repo with a ghidra project and collected some other resources like the debug header pinout for stlink connection. If you like to be added as collaborator to this one, please let me know.

@userx14
Copy link
Author

userx14 commented Sep 6, 2025

@Ondrysak
Copy link

Ondrysak commented Sep 6, 2025

Together with pr0ximaMusic, I started a github repo with a ghidra project and collected some other resources like the debug header pinout for stlink connection. If you like to be added as collaborator to this one, please let me know.

Sure add me

I did a dump of the flash memory and your offset of 0x08010000 is correct. Before that there is a bootloader, that is not included in the firmware update file. The function locations make sense with this.

I will try to look into pitch shifting again, honestly I have very little experience with all the DSP stuff so most of the heavy lifting was done by using this method https://wilgibbs.com/blog/defcon-finals-mcp/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment