Last active
September 25, 2025 06:06
-
-
Save matthewryanscott/d648a6462d555b0ebdbf83fd352f3008 to your computer and use it in GitHub Desktop.
A tool for importing stems into SunVox.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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