diff --git a/extras/python/wav2mozzi.py b/extras/python/wav2mozzi.py new file mode 100755 index 000000000..c95c2f2d5 --- /dev/null +++ b/extras/python/wav2mozzi.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 + +""" +A script for converting .WAV files to wavetables for Mozzi. + +Reads bitness, sample format, and sample rate from the WAV header automatically. +Supports 8-bit unsigned, 16-bit signed, 24-bit signed, and 32-bit signed PCM WAV files, +as well as 32-bit IEEE float WAV files (samples in -1.0..1.0 range). + +All sample data is converted to signed 8-bit or 16-bit values. +Output values are centered around 0 and can be negated without overflow. +If audio file is stereo, only the first channel is used. + +Requires Python 3.9+, no dependencies. + +NOTE: Using Audacity to prepare sound files: + +For generated waveforms like sine or sawtooth, set the project +rate to the size of the wavetable you wish to create, which must +be a power of two (eg. 8192), and set the selection format +(beneath the editing window) to samples. Then you can generate +and save 1 second of a waveform and it will fit your table +length. + +For a recorded audio sample, set the project rate to the +MOZZI_AUDIO_RATE (16384 in the current version). +Samples can be any length, as long as they fit in your Arduino. + +Save the file by "Export" -> "Export as WAV". +To keep all the details, choose "32-bit float" encoding. +Other supported encodings are 8,16,24,32-bit PCM. + +Now use the file you just exported, as the "infile" to convert. + + +Author: Paul Melnikov, 2026-04 +""" + +# Usage: +# >>>wav2mozzi.py infile [-t tablename] [-o outfile] [-b {8,16}] [--no-symmetric-output] +# Arguments: +# * infile The .WAV file to convert. +# * -t tablename (Optional) The name to give the table. Default: uppercase input filename. +# * -o outfile (Optional) The output .h file. Default: derived from input filename. +# * -b, --output-bits +# (Optional) Output sample size in bits. Allowed: 8 or 16. Default: 8. +# * --symmetric-output, --no-symmetric-output +# (Optional) Generate symmetric signed output range. Default: enabled. + + +import sys, os, textwrap, struct, random, argparse, re + +def read_wav(infile): + """Read a WAV file, supporting both PCM and IEEE float formats. + Returns (nchannels, sample_size, samplerate, nframes, raw_bytes, is_float). + + Technically, python has built-in "wave" library for this, + but it doesn't support IEEE floats, so decode headers manually. + """ + with open(infile, 'rb') as f: + # Parse RIFF header + riff = f.read(4) + if riff != b'RIFF': + raise IOError("Not a valid WAV file (missing RIFF header)") + f.read(4) # file size + wave_id = f.read(4) + if wave_id != b'WAVE': + raise IOError("Not a valid WAV file (missing WAVE identifier)") + + fmt_found = False + data_raw = None + audio_format = None + n_channels = None + sample_rate = None + sample_size = None + n_frames = None + + while True: + chunk_header = f.read(8) + if len(chunk_header) < 8: + break + chunk_id = chunk_header[:4] + chunk_size = struct.unpack('= 0x800000: # convert to signed + val -= 0x1000000 + elif sample_size == 4: + val = struct.unpack('\n') + fout.write('#include "mozzi_pgmspace.h"\n\n') + fout.write(f'#define {tablename}_NUM_CELLS {len(out_values)}\n') + fout.write(f'#define {tablename}_SAMPLERATE {sample_rate}\n\n') + fout.write(f'CONSTTABLE_STORAGE({c_type}) {tablename}_DATA [] = {{\n') + outstring = '' + for v in out_values: + outstring += str(v) + ", " + outstring = textwrap.fill(outstring, width=80, initial_indent=' ', subsequent_indent=' ') + fout.write(outstring) + fout.write('\n};\n') + fout.write(f'\n#endif /* {tablename}_H_ */\n') + + print("wrote", outfile) + sym_str = ", symmetric" if symmetric_output else "" + print(f" table name: {tablename}, type: {c_type}{sym_str}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('infile', help='Input .WAV file') + parser.add_argument('-t', '--tablename', help='Table name for the generated header (default: uppercase input filename)') + parser.add_argument('-o', '--outfile', help='Output .h file (default: derived from input filename)') + parser.add_argument('-b', '--output-bits', type=int, choices=(8, 16), default=8, + help='Output sample size in bits (default: 8)') + parser.add_argument('-s', '--symmetric-output', action=argparse.BooleanOptionalAction, default=True, + help='Generate symmetric signed output range (default: enabled)') + args = parser.parse_args() + + infile = os.path.expanduser(args.infile) + if args.tablename: + tablename = args.tablename + else: + # derive from filename: strip extension, keep only alnum/underscore, uppercase + tablename = re.sub(r'[^A-Za-z0-9_]', '_', os.path.splitext(os.path.basename(infile))[0]).upper() + outfile = os.path.expanduser(args.outfile) if args.outfile else os.path.splitext(infile)[0] + '.h' + + try: + wav2mozzi(infile, outfile, tablename, + output_bytes=args.output_bits // 8, + symmetric_output=args.symmetric_output) + except (IOError, ValueError) as e: + print(f"Error: {e}", file=sys.stderr)