Skip to content

Instantly share code, notes, and snippets.

@matthewryanscott
Last active September 25, 2025 06:06
Show Gist options
  • Select an option

  • Save matthewryanscott/d648a6462d555b0ebdbf83fd352f3008 to your computer and use it in GitHub Desktop.

Select an option

Save matthewryanscott/d648a6462d555b0ebdbf83fd352f3008 to your computer and use it in GitHub Desktop.
A tool for importing stems into SunVox.
# wav2sunvox
# by Queries
#
# A tool for importing stems into SunVox.
#
# License: MIT
#
# Proper repo: some day maybe...
# For now I'll keep the gist up-to-date with my local copy:
# https://gist.github.com/matthewryanscott/d648a6462d555b0ebdbf83fd352f3008
#
# Disclaimer: Vibe coded & intentionally left messy for now.
#
# Greets:
# - vekoN for the technical insight: https://warmplace.ru/forum/viewtopic.php?p=26040
# - NightRadio for pouring your heart and soul into SunVox
from pathlib import Path
from typing import NamedTuple
import numpy as np
import soundfile as sf
import typer
from rv.api import NOTE, Pattern, Project, m
app = typer.Typer()
class AudioFileInfo(NamedTuple):
"""Container for audio file information."""
filename: str
duration_seconds: float
sample_rate: int
channels: int
audio_data: np.ndarray
num_measures: int
class LayoutPosition(NamedTuple):
"""Container for X/Y positioning information."""
x: int
y: int
class MeasureChunk(NamedTuple):
"""Container for a power-of-2 chunk of measures."""
start_measure: int
num_measures: int # Always a power of 2
chunk_size_bits: int # log2(num_measures)
def decompose_measures_to_power_of_2_chunks(total_measures: int) -> list[MeasureChunk]:
"""Decompose total measures into power-of-2 chunks for optimal sampler count."""
chunks = []
remaining = total_measures
start_measure = 0
while remaining > 0:
# Find the largest power of 2 that fits in remaining measures
chunk_size_bits = remaining.bit_length() - 1 # log2 of largest power of 2 <= remaining
chunk_size = 1 << chunk_size_bits # 2^chunk_size_bits
chunks.append(
MeasureChunk(start_measure=start_measure, num_measures=chunk_size, chunk_size_bits=chunk_size_bits)
)
start_measure += chunk_size
remaining -= chunk_size
return chunks
def validate_audio_file(filename: str) -> Path:
"""Validate that the audio file exists and has a supported format."""
path = Path(filename)
if path.suffix.lower() not in [".wav", ".aiff", ".aif"]:
typer.echo(f"Error: Unsupported file format '{path.suffix}'. Supported formats: .wav, .aiff, .aif", err=True)
raise typer.Exit(1)
if not path.exists():
typer.echo(f"Error: File '{filename}' does not exist", err=True)
raise typer.Exit(1)
return path
def read_audio_file(filename: str, bpm: int) -> AudioFileInfo:
"""Read and validate audio file, ensuring it contains complete measures."""
try:
info = sf.info(filename)
duration_seconds = info.duration
sample_rate = info.samplerate
channels = info.channels
# Read the actual audio data
audio_data, _ = sf.read(filename)
if channels == 1:
# Convert mono to stereo by duplicating the channel
audio_data = np.column_stack([audio_data, audio_data])
elif channels > 2:
# Convert multi-channel to stereo by taking first two channels
audio_data = audio_data[:, :2]
except Exception as e:
typer.echo(f"Error reading audio file: {e}", err=True)
raise typer.Exit(1) from None
# Calculate measures
beats_per_second = bpm / 60.0
total_beats = duration_seconds * beats_per_second
measures_4beat = total_beats / 4.0
# Check if measures is an integer (within floating point precision)
measures_int = round(measures_4beat)
if abs(measures_4beat - measures_int) > 0.02: # Allow for small floating point errors
typer.echo("Audio File Analysis:")
typer.echo(f" File: {filename}")
typer.echo(f" Duration: {duration_seconds:.2f} seconds")
typer.echo(f" Sample Rate: {sample_rate} Hz")
typer.echo(f" Channels: {channels}")
typer.echo(f" BPM: {bpm}")
typer.echo(f" Total Beats: {total_beats:.2f}")
typer.echo(f" 4-Beat Measures: {measures_4beat:.2f}")
typer.echo("", err=True)
typer.echo("Error: Audio file does not contain a precise integer number of 4-beat measures.", err=True)
typer.echo(f"Expected: {measures_int} or {measures_int + 1} measures", err=True)
typer.echo(f"Calculated: {measures_4beat:.3f} measures", err=True)
typer.echo("Please trim the audio file to align with measure boundaries.", err=True)
raise typer.Exit(1)
# Print file information
typer.echo(f"Audio File: {filename}")
typer.echo(f" Duration: {duration_seconds:.2f} seconds")
typer.echo(f" Sample Rate: {sample_rate} Hz")
typer.echo(f" Channels: {channels} → 2 (stereo)")
typer.echo(f" Measures: {measures_int}")
return AudioFileInfo(
filename=filename,
duration_seconds=duration_seconds,
sample_rate=sample_rate,
channels=channels,
audio_data=audio_data,
num_measures=measures_int,
)
def calculate_layout_positions(
file_sampler_counts: list[int],
) -> tuple[list[LayoutPosition], list[LayoutPosition], LayoutPosition]:
"""Calculate X/Y positions for sampler groups, amplifiers, and output."""
group_spacing_x = 192 # Horizontal spacing between file groups
# Calculate sampler positions - each group stacked vertically, groups separated horizontally
sampler_positions = []
amp_positions = []
num_file_groups = len(file_sampler_counts)
for file_idx, sampler_count in enumerate(file_sampler_counts):
group_x = file_idx * group_spacing_x
# All samplers in a stack have the same Y coordinate (like a stack of cards)
# Only X changes between file groups
sampler_y = 0 # All samplers in this stack at Y=0
for _sampler_idx in range(sampler_count):
sampler_positions.append(LayoutPosition(group_x, sampler_y))
# Amplifier positioned 192 units below the sampler stack
amp_y = 0 + 192 # Samplers are all at Y=0, so amp is at Y=192
amp_positions.append(LayoutPosition(group_x, amp_y))
# Output centered between all groups
if num_file_groups > 1:
output_x = (num_file_groups - 1) * group_spacing_x // 2
else:
output_x = 0
# Position output 192 units below the amplifiers
amp_y = 192 # All amps are at Y=192
output_y = amp_y + 192 # Output at Y=384
output_position = LayoutPosition(output_x, output_y)
return sampler_positions, amp_positions, output_position
def create_sampler_chain_with_chunk(
project: Project,
chunk: MeasureChunk,
file_idx: int,
chunk_audio: np.ndarray,
sample_rate: int,
position: LayoutPosition,
) -> tuple[m.Sampler, m.MultiSynth, m.Glide]:
"""Create Glide → MultiSynth → Sampler chain with multi-measure chunk audio data."""
# Create sampler module (rightmost in chain)
sampler = project.attach_module(m.Sampler())
sampler.name = f"F{file_idx:02d}C{chunk.num_measures:03d}M{chunk.start_measure:03d}" # F00C128M000, etc.
sampler.volume = 256 # Set volume to maximum
sampler.polyphony = 8 # Set polyphony to 8 for smooth transitions (matches envelope-sampler)
sampler.sample_interpolation = m.Sampler.SampleInterpolation.linear # Linear interpolation
sampler.tick_length = 64 # Tick length (user-specified)
# Configure volume envelope (removing first point, adjusting sustain point)
sampler.volume_envelope.points = [(1, 32768), (2, 0)] # Peak-release curve (no initial attack)
sampler.volume_envelope.sustain = True # Enable sustain
sampler.volume_envelope.sustain_point = 0 # Sustain at point 0 (peak, now first point)
sampler.x = position.x
sampler.y = position.y
# Ensure audio data is in the correct format (float32)
if chunk_audio.dtype != np.float32:
chunk_audio = chunk_audio.astype(np.float32)
# Ensure stereo format
if len(chunk_audio.shape) == 1:
# Mono - duplicate to stereo
chunk_audio = np.column_stack([chunk_audio, chunk_audio])
# Create a Sample object using Sampler's inner class
sample = m.Sampler.Sample()
# Set basic sample properties
sample.rate = sample_rate
sample.channels = m.Sampler.Channels.stereo
sample.format = m.Sampler.Format.float32
sample.loop_start = 0
sample.loop_len = 0
sample.loop_type = m.Sampler.LoopType.off # No looping
sample.loop_sustain = False
sample.volume = 64 # Default volume
sample.finetune = 100 # Fixed finetune value (user-specified)
sample.panning = 0 # Center panning
sample.relative_note = 16 # Fixed relative note value (user-specified)
sample.name = f"F{file_idx:02d}C{chunk.num_measures:03d}M{chunk.start_measure:03d}".encode()
sample.start_pos = 0
# Set sample data - must be last after all properties are set
sample.data = chunk_audio.tobytes()
# Set the sample in slot 0
sampler.samples[0] = sample
# Map middle C to sample slot 0
sampler.note_samples[NOTE.C5] = 0
# Create MultiSynth module (middle of chain) - stacked above Glides
multisynth = project.attach_module(m.MultiSynth())
multisynth.name = f"F{file_idx:02d}C{chunk.num_measures:03d}M{chunk.start_measure:03d}_Phase"
multisynth.x = position.x # Same X as sampler (stacked)
multisynth.y = position.y - 384 # 384 units above sampler (top stack)
# TODO: Configure MultiSynth controllers when we understand the API better
# Create Glide module (leftmost in chain) - stacked above Samplers but below MultiSynths
glide = project.attach_module(m.Glide())
glide.name = f"F{file_idx:02d}C{chunk.num_measures:03d}M{chunk.start_measure:03d}_Trig"
glide.x = position.x # Same X as sampler (stacked)
glide.y = position.y - 192 # 192 units above sampler (middle stack)
glide.reset_on_first_note = True
glide.polyphony = False
# Connect the chain: Glide → MultiSynth → Sampler
project.connect(glide, multisynth)
project.connect(multisynth, sampler)
return sampler, multisynth, glide
def create_pattern_for_chunk(
project: Project, chunk: MeasureChunk, file_idx: int, multisynth_module: m.MultiSynth, glide_module: m.Glide
) -> Pattern:
"""Create a pattern that sends phase control to MultiSynth and notes to Glide for a power-of-2 chunk."""
pattern = Pattern()
pattern.lines = chunk.num_measures * 16 # 16 lines per measure
pattern.tracks = 3 # 3 tracks: phase control + 2 note tracks for smoothing (matches multitrack.pixi)
pattern.name = f"F{file_idx:02d}C{chunk.num_measures:03d}M{chunk.start_measure:03d}"
pattern.x = chunk.start_measure * 16 # Position patterns based on start measure
pattern.y = file_idx * 32 # Patterns for each file at different Y positions (keep this spacing for pattern view)
project.attach_pattern(pattern)
# Get module numbers for MultiSynth (phase control) and Glide (note triggers)
if multisynth_module.index is not None:
multisynth_num = multisynth_module.index + 1 # SunVox uses 1-based indexing
else:
multisynth_num = 1 # Fallback
if glide_module.index is not None:
glide_num = glide_module.index + 1 # SunVox uses 1-based indexing
else:
glide_num = 1 # Fallback
# Add 3-track pattern data (corrected routing):
# Track 0: Phase control effects to MultiSynth
# Track 1: Note triggers to Glide (not MultiSynth!)
# Track 2: Duplicate note triggers to Glide (for smoothing)
for line_idx in range(pattern.lines):
# Track 0: Phase control (0x0700) - matches multitrack.pixi line 109
track0_note = pattern.data[line_idx][0]
track0_note.note = 0 # No note, just effect
track0_note.vel = 0 # No velocity
track0_note.module = multisynth_num # Target MultiSynth for phase control
track0_note.ctl = 0x0700 # Phase control effect
if pattern.lines > 1:
# Calculate phase value: 0x0000 to 0x8000 - matches multitrack.pixi formula
phase_val = (line_idx * 0x8000) // pattern.lines
track0_note.val = phase_val & 0xFFFF
else:
track0_note.val = 0x0000
# Track 1: Note triggers - corrected to target Glide
track1_note = pattern.data[line_idx][1]
track1_note.note = NOTE.C5 # Note 61 (C5)
track1_note.vel = 0 # No velocity (let Glide smooth it)
track1_note.module = glide_num # Target Glide for note triggers
track1_note.ctl = 0x0000 # No effect
track1_note.val = 0x0000 # No effect value
# Track 2: Duplicate note triggers - corrected to target Glide
track2_note = pattern.data[line_idx][2]
track2_note.note = NOTE.C5 # Note 61 (C5)
track2_note.vel = 0 # No velocity (let Glide smooth it)
track2_note.module = glide_num # Target Glide for note triggers
track2_note.ctl = 0x0000 # No effect
track2_note.val = 0x0000 # No effect value
return pattern
def create_amplifier(project: Project, file_idx: int, position: LayoutPosition, name: str | None = None) -> m.Amplifier:
"""Create an amplifier module for a file group."""
amp = project.attach_module(m.Amplifier())
if name:
amp.name = name
else:
amp.name = f"F{file_idx:02d}_AMP"
amp.x = position.x
amp.y = position.y
return amp
def extract_filename_suffixes(filenames: list[str], common_prefix: str) -> list[str]:
"""Extract the suffix of each filename after removing the common prefix."""
suffixes = []
for filename in filenames:
stem = Path(filename).stem
if stem.startswith(common_prefix):
suffix = stem[len(common_prefix) :]
# Remove leading separators
suffix = suffix.lstrip(" -_")
# Use the suffix if it's meaningful, otherwise use the full stem
if suffix and len(suffix) >= 1:
suffixes.append(suffix)
else:
suffixes.append(stem)
else:
# Fallback to full stem if prefix doesn't match
suffixes.append(stem)
return suffixes
def find_common_filename_stem(filenames: list[str]) -> str:
"""Find the largest common stem among filenames, or combine with hyphens if no common stem."""
if len(filenames) == 1:
return Path(filenames[0]).stem
# Get all stems
stems = [Path(filename).stem for filename in filenames]
# Find the longest common prefix
if not stems:
return "untitled"
# Start with the first stem and find common prefix with each subsequent stem
common_prefix = stems[0]
for stem in stems[1:]:
# Find longest common prefix between common_prefix and stem
prefix_len = 0
min_len = min(len(common_prefix), len(stem))
for i in range(min_len):
if common_prefix[i] == stem[i]:
prefix_len = i + 1
else:
break
common_prefix = common_prefix[:prefix_len]
# Only use common prefix if it's meaningful (at least 2 characters)
if len(common_prefix) >= 2:
# Strip trailing separators for cleaner filenames
cleaned_prefix = common_prefix.rstrip(" -_")
if len(cleaned_prefix) >= 2:
return cleaned_prefix
# No meaningful common prefix, combine with hyphens
return "-".join(stems)
def process_audio_file(
project: Project,
audio_info: AudioFileInfo,
file_idx: int,
bpm: int,
sampler_positions: list[LayoutPosition],
amp_position: LayoutPosition,
amp_name: str | None = None,
) -> tuple[list[m.Sampler], m.Amplifier]:
"""Process a single audio file, creating power-of-2 chunk samplers, patterns, and amplifier."""
# Calculate samples per measure
beats_per_second = bpm / 60.0
samples_per_beat = audio_info.sample_rate / beats_per_second
samples_per_measure = int(samples_per_beat * 4) # 4 beats per measure
# Decompose measures into power-of-2 chunks
chunks = decompose_measures_to_power_of_2_chunks(audio_info.num_measures)
samplers = []
for chunk_idx, chunk in enumerate(chunks):
# Calculate sample range for this chunk
start_sample = chunk.start_measure * samples_per_measure
end_sample = min((chunk.start_measure + chunk.num_measures) * samples_per_measure, len(audio_info.audio_data))
# Extract audio data for this chunk (multiple measures)
chunk_audio = audio_info.audio_data[start_sample:end_sample]
# Get position for this sampler
sampler_pos = sampler_positions[chunk_idx]
# Create sampler chain (Glide → MultiSynth → Sampler) with chunk
sampler, multisynth, glide = create_sampler_chain_with_chunk(
project, chunk, file_idx, chunk_audio, audio_info.sample_rate, sampler_pos
)
samplers.append(sampler)
# Create pattern for this chunk (phase to MultiSynth, notes to Glide)
create_pattern_for_chunk(project, chunk, file_idx, multisynth, glide)
# Create amplifier for this file group
amplifier = create_amplifier(project, file_idx, amp_position, amp_name)
# Connect all samplers to the amplifier
for sampler in samplers:
project.connect(sampler, amplifier)
return samplers, amplifier
@app.command()
def main(
filenames: list[str] = typer.Argument(..., help="Paths to WAV or AIFF files"), # noqa
bpm: int = typer.Argument(..., help="BPM as an integer (SunVox format)"),
output: str = typer.Option(None, help="Output filename (auto-generated if not specified)"),
) -> None:
"""Convert multiple WAV or AIFF files to SunVox format.
Each file will have its own set of samplers and patterns positioned in separate groups.
All groups route through amplifiers to a centered output.
For development testing, use these 130 BPM examples:
- Single track:
config/queries-performance/projects/test.aif
- Multi-track (stems):
config/queries-performance/projects/test2-drums.aif
config/queries-performance/projects/test2-bass.aif
config/queries-performance/projects/test2-other.aif
config/queries-performance/projects/test2-vocals.aif
⚠️ DO NOT OVERWRITE the test files - they contain important test data!
"""
if not filenames:
typer.echo("Error: At least one audio file must be specified", err=True)
raise typer.Exit(1)
# Validate all files first
typer.echo("Validating audio files...")
validated_paths = []
for filename in filenames:
path = validate_audio_file(filename)
validated_paths.append(path)
# Read and analyze all audio files
typer.echo("\nAnalyzing audio files...")
audio_files = []
max_measures = 0
for filename in filenames:
audio_info = read_audio_file(filename, bpm)
audio_files.append(audio_info)
max_measures = max(max_measures, audio_info.num_measures)
# Validate that all stems have the same number of measures (for multi-file projects)
if len(audio_files) > 1:
first_measures = audio_files[0].num_measures
mismatched_files = []
for audio_info in audio_files[1:]:
if audio_info.num_measures != first_measures:
mismatched_files.append((audio_info.filename, audio_info.num_measures))
if mismatched_files:
typer.echo("", err=True)
typer.echo(
"Error: All stems must have the same number of measures when processing multiple files.", err=True
)
typer.echo(f"Expected: {first_measures} measures (from {Path(audio_files[0].filename).name})", err=True)
typer.echo("Found:", err=True)
for filename, measures in mismatched_files:
typer.echo(f" {Path(filename).name}: {measures} measures", err=True)
typer.echo("Please ensure all stem files are the same length before processing.", err=True)
raise typer.Exit(1)
# Create SunVox project
typer.echo(f"\nCreating SunVox project for {len(audio_files)} files...")
# Determine project name and output filename
if output:
output_filename = output
if not output_filename.endswith(".sunvox"):
output_filename += ".sunvox"
project_name = Path(output_filename).stem
else:
# Find common stem or combine stems with hyphens
project_name = find_common_filename_stem(filenames)
first_file = Path(filenames[0])
output_filename = first_file.with_name(project_name).with_suffix(".sunvox")
# Create new SunVox project
project = Project()
project.name = project_name
project.initial_bpm = bpm
# Calculate sampler counts per file based on power-of-2 decomposition
file_sampler_counts = []
for audio_info in audio_files:
chunks = decompose_measures_to_power_of_2_chunks(audio_info.num_measures)
file_sampler_counts.append(len(chunks))
# Calculate layout positions
sampler_positions, amp_positions, output_position = calculate_layout_positions(file_sampler_counts)
# Calculate amplifier names from filename suffixes (for multi-file projects)
amp_names = None
if len(audio_files) > 1:
amp_names = extract_filename_suffixes(filenames, project_name)
# Process each audio file
all_samplers = []
all_amplifiers = []
sampler_position_offset = 0
for file_idx, audio_info in enumerate(audio_files):
typer.echo(f" Processing file {file_idx + 1}/{len(audio_files)}: {Path(audio_info.filename).name}")
# Get the number of chunks (samplers) for this file
chunks = decompose_measures_to_power_of_2_chunks(audio_info.num_measures)
num_chunks = len(chunks)
typer.echo(
f" {audio_info.num_measures} measures → "
f"{num_chunks} samplers: {[chunk.num_measures for chunk in chunks]}"
)
# Get positions for this file's samplers and amplifier
file_sampler_positions = sampler_positions[sampler_position_offset : sampler_position_offset + num_chunks]
file_amp_position = amp_positions[file_idx]
sampler_position_offset += num_chunks
# Get amplifier name for multi-file projects
amp_name = amp_names[file_idx] if amp_names else None
samplers, amplifier = process_audio_file(
project, audio_info, file_idx, bpm, file_sampler_positions, file_amp_position, amp_name
)
all_samplers.extend(samplers)
all_amplifiers.append(amplifier)
typer.echo(f" Created {len(samplers)} samplers and patterns")
# Position output module and connect all amplifiers to it
project.output.x = output_position.x
project.output.y = output_position.y
for amplifier in all_amplifiers:
project.connect(amplifier, project.output)
typer.echo(f" Created {len(all_amplifiers)} amplifiers")
typer.echo(f" Total samplers: {len(all_samplers)}")
# Save the project
try:
with open(output_filename, "wb") as f:
project.write_to(f)
typer.echo(f"\n✅ SunVox project created: {output_filename}")
typer.echo(f" Project name: {project_name}")
typer.echo(f" BPM: {bpm}")
typer.echo(f" Files processed: {len(audio_files)}")
typer.echo(f" Total measures: {sum(info.num_measures for info in audio_files)}")
typer.echo(" Channels: 2 (stereo)")
except Exception as e:
typer.echo(f"Error saving SunVox project: {e}", err=True)
raise typer.Exit(1) from None
if __name__ == "__main__":
app()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment