|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +""" |
| 4 | +A script for converting .WAV files to wavetables for Mozzi. |
| 5 | +
|
| 6 | +Reads bitness, sample format, and sample rate from the WAV header automatically. |
| 7 | +Supports 8-bit unsigned, 16-bit signed, 24-bit signed, and 32-bit signed PCM WAV files, |
| 8 | +as well as 32-bit IEEE float WAV files (samples in -1.0..1.0 range). |
| 9 | +
|
| 10 | +All sample data is converted to signed 8-bit or 16-bit values. |
| 11 | +Output values are centered around 0 and can be negated without overflow. |
| 12 | +If audio file is stereo, only the first channel is used. |
| 13 | +
|
| 14 | +Requires Python 3.9+, no dependencies. |
| 15 | +
|
| 16 | +NOTE: Using Audacity to prepare sound files: |
| 17 | + |
| 18 | +For generated waveforms like sine or sawtooth, set the project |
| 19 | +rate to the size of the wavetable you wish to create, which must |
| 20 | +be a power of two (eg. 8192), and set the selection format |
| 21 | +(beneath the editing window) to samples. Then you can generate |
| 22 | +and save 1 second of a waveform and it will fit your table |
| 23 | +length. |
| 24 | +
|
| 25 | +For a recorded audio sample, set the project rate to the |
| 26 | +MOZZI_AUDIO_RATE (16384 in the current version). |
| 27 | +Samples can be any length, as long as they fit in your Arduino. |
| 28 | + |
| 29 | +Save the file by "Export" -> "Export as WAV". |
| 30 | +To keep all the details, choose "32-bit float" encoding. |
| 31 | +Other supported encodings are 8,16,24,32-bit PCM. |
| 32 | + |
| 33 | +Now use the file you just exported, as the "infile" to convert. |
| 34 | +
|
| 35 | +
|
| 36 | +Author: Paul Melnikov, 2026-04 |
| 37 | +""" |
| 38 | + |
| 39 | +# Usage: |
| 40 | +# >>>wav2mozzi.py infile [-t tablename] [-o outfile] [-b {8,16}] [--no-symmetric-output] |
| 41 | +# Arguments: |
| 42 | +# * infile The .WAV file to convert. |
| 43 | +# * -t tablename (Optional) The name to give the table. Default: uppercase input filename. |
| 44 | +# * -o outfile (Optional) The output .h file. Default: derived from input filename. |
| 45 | +# * -b, --output-bits |
| 46 | +# (Optional) Output sample size in bits. Allowed: 8 or 16. Default: 8. |
| 47 | +# * --symmetric-output, --no-symmetric-output |
| 48 | +# (Optional) Generate symmetric signed output range. Default: enabled. |
| 49 | + |
| 50 | + |
| 51 | +import sys, os, textwrap, struct, random, argparse, re |
| 52 | + |
| 53 | +def read_wav(infile): |
| 54 | + """Read a WAV file, supporting both PCM and IEEE float formats. |
| 55 | + Returns (nchannels, sample_size, samplerate, nframes, raw_bytes, is_float). |
| 56 | + |
| 57 | + Technically, python has built-in "wave" library for this, |
| 58 | + but it doesn't support IEEE floats, so decode headers manually. |
| 59 | + """ |
| 60 | + with open(infile, 'rb') as f: |
| 61 | + # Parse RIFF header |
| 62 | + riff = f.read(4) |
| 63 | + if riff != b'RIFF': |
| 64 | + raise IOError("Not a valid WAV file (missing RIFF header)") |
| 65 | + f.read(4) # file size |
| 66 | + wave_id = f.read(4) |
| 67 | + if wave_id != b'WAVE': |
| 68 | + raise IOError("Not a valid WAV file (missing WAVE identifier)") |
| 69 | + |
| 70 | + fmt_found = False |
| 71 | + data_raw = None |
| 72 | + audio_format = None |
| 73 | + n_channels = None |
| 74 | + sample_rate = None |
| 75 | + sample_size = None |
| 76 | + n_frames = None |
| 77 | + |
| 78 | + while True: |
| 79 | + chunk_header = f.read(8) |
| 80 | + if len(chunk_header) < 8: |
| 81 | + break |
| 82 | + chunk_id = chunk_header[:4] |
| 83 | + chunk_size = struct.unpack('<I', chunk_header[4:8])[0] |
| 84 | + |
| 85 | + if chunk_id == b'fmt ': |
| 86 | + fmt_data = f.read(chunk_size) |
| 87 | + audio_format = struct.unpack('<H', fmt_data[0:2])[0] |
| 88 | + n_channels = struct.unpack('<H', fmt_data[2:4])[0] |
| 89 | + sample_rate = struct.unpack('<I', fmt_data[4:8])[0] |
| 90 | + bits_per_sample = struct.unpack('<H', fmt_data[14:16])[0] |
| 91 | + sample_size = bits_per_sample // 8 |
| 92 | + fmt_found = True |
| 93 | + elif chunk_id == b'data': |
| 94 | + data_raw = f.read(chunk_size) |
| 95 | + n_frames = len(data_raw) // (n_channels * sample_size) |
| 96 | + break |
| 97 | + else: |
| 98 | + f.read(chunk_size) |
| 99 | + if chunk_size % 2 == 1: |
| 100 | + f.read(1) # padding byte |
| 101 | + |
| 102 | + if not fmt_found or data_raw is None: |
| 103 | + raise IOError("Could not find fmt/data chunks in WAV file") |
| 104 | + |
| 105 | + # audio_format: 1 = PCM, 3 = IEEE float |
| 106 | + is_float = (audio_format == 3) |
| 107 | + if audio_format not in (1, 3): |
| 108 | + raise ValueError(f"Unsupported WAV format code {audio_format} (only PCM=1 and IEEE float=3 are supported)") |
| 109 | + |
| 110 | + return n_channels, sample_size, sample_rate, n_frames, data_raw, is_float |
| 111 | + |
| 112 | + |
| 113 | +def wav2mozzi(infile, outfile, tablename, output_bytes=1, symmetric_output=True): |
| 114 | + """ |
| 115 | + Convert a WAV file to a Mozzi wavetable header. |
| 116 | + - infile: input WAV file path |
| 117 | + - outfile: output .h file path |
| 118 | + - tablename: name to use for the generated variables (e.g. "MYTABLE") |
| 119 | + - output_bytes: number of bytes for output samples (1 or 2 supported) |
| 120 | + - symmetric_output: if True, negative range is the same as positive (e.g. -128 becomes -127 for 1 byte) |
| 121 | + """ |
| 122 | + n_channels, sample_size, sample_rate, n_frames, data_bytes, is_float = read_wav(infile) |
| 123 | + print("opened", infile) |
| 124 | + format_str = "float" if is_float else "PCM" |
| 125 | + print(f" channels: {n_channels}, rate: {sample_rate} Hz, sample size: {sample_size * 8}bit, samples: {n_frames}, format: {format_str}") |
| 126 | + |
| 127 | + # for clarity, convert input to -1.0...1.0 first |
| 128 | + |
| 129 | + input_midpoint = 0.0 |
| 130 | + input_max = 1.0 |
| 131 | + |
| 132 | + if is_float: |
| 133 | + if sample_size != 4: |
| 134 | + raise ValueError(f"Unsupported float sample size: {sample_size} B") |
| 135 | + else: |
| 136 | + if sample_size == 1: |
| 137 | + # 8-bit WAV is unsigned 0..255 |
| 138 | + # by definition, mid point is 128 |
| 139 | + input_midpoint = 128 |
| 140 | + input_max = 255 |
| 141 | + elif sample_size == 2: |
| 142 | + # 16-bit little-endian signed |
| 143 | + input_midpoint = 0 |
| 144 | + input_max = 2**15 - 1 |
| 145 | + elif sample_size == 3: |
| 146 | + # 24-bit little-endian signed |
| 147 | + input_midpoint = 0 |
| 148 | + input_max = 2**23 - 1 |
| 149 | + elif sample_size == 4: |
| 150 | + # 32-bit little-endian signed |
| 151 | + input_midpoint = 0 |
| 152 | + input_max = 2**31 - 1 |
| 153 | + else: |
| 154 | + raise ValueError(f"Unsupported sample size: {sample_size} B") |
| 155 | + |
| 156 | + # Decode raw bytes into samples (mono only - take first channel) |
| 157 | + values = [] |
| 158 | + |
| 159 | + frame_size = n_channels * sample_size |
| 160 | + for i in range(n_frames): |
| 161 | + offset = i * frame_size |
| 162 | + sample_bytes = data_bytes[offset:offset + sample_size] # first channel only |
| 163 | + if is_float: |
| 164 | + if sample_size == 4: |
| 165 | + val = struct.unpack('<f', sample_bytes)[0] |
| 166 | + else: |
| 167 | + if sample_size == 1: |
| 168 | + # 8-bit WAV is unsigned 0..255 |
| 169 | + val = struct.unpack('B', sample_bytes)[0] |
| 170 | + elif sample_size == 2: |
| 171 | + # 16-bit little-endian signed |
| 172 | + val = struct.unpack('<h', sample_bytes)[0] |
| 173 | + elif sample_size == 3: |
| 174 | + # 24-bit little-endian signed |
| 175 | + b = sample_bytes |
| 176 | + val = b[0] | (b[1] << 8) | (b[2] << 16) |
| 177 | + if val >= 0x800000: # convert to signed |
| 178 | + val -= 0x1000000 |
| 179 | + elif sample_size == 4: |
| 180 | + val = struct.unpack('<i', sample_bytes)[0] |
| 181 | + values.append(val) |
| 182 | + |
| 183 | + # Scale to -1.0...1.0 range |
| 184 | + if is_float: |
| 185 | + scaled_values = values # already in -1.0...1.0 range |
| 186 | + else: |
| 187 | + input_max = input_max - input_midpoint # for uint8, max should be is 127 |
| 188 | + scaled_values = [(v - input_midpoint) / input_max for v in values] |
| 189 | + |
| 190 | + # Convert to signed 8-bit or 16-bit range for output |
| 191 | + c_type = 'int8_t' if output_bytes == 1 else 'int16_t' |
| 192 | + out_range = [-128, 127] if output_bytes == 1 else [-2**15, 2**15-1] |
| 193 | + if symmetric_output: |
| 194 | + out_range[0] += 1 # e.g. -128 becomes -127 for symmetric range |
| 195 | + |
| 196 | + out_values = [ |
| 197 | + max(out_range[0], min(out_range[1], int(v * out_range[1]))) |
| 198 | + for v in scaled_values |
| 199 | + ] |
| 200 | + |
| 201 | + # Dither triple-33 sequences (taken from char2mozzi.py) |
| 202 | + for i in range(len(out_values) - 2): |
| 203 | + if out_values[i] == 33 and out_values[i+1] == 33 and out_values[i+2] == 33: |
| 204 | + out_values[i+2] = random.choice([32, 34]) |
| 205 | + |
| 206 | + with open(outfile, "w") as fout: |
| 207 | + fout.write(f'#ifndef {tablename}_H_\n') |
| 208 | + fout.write(f'#define {tablename}_H_\n\n') |
| 209 | + fout.write('// Generated by wav2mozzi.py"\n') |
| 210 | + fout.write('// Arguments: "' + ' '.join(sys.argv[1:]) + '"\n\n') |
| 211 | + fout.write('#include <Arduino.h>\n') |
| 212 | + fout.write('#include "mozzi_pgmspace.h"\n\n') |
| 213 | + fout.write(f'#define {tablename}_NUM_CELLS {len(out_values)}\n') |
| 214 | + fout.write(f'#define {tablename}_SAMPLERATE {sample_rate}\n\n') |
| 215 | + fout.write(f'CONSTTABLE_STORAGE({c_type}) {tablename}_DATA [] = {{\n') |
| 216 | + outstring = '' |
| 217 | + for v in out_values: |
| 218 | + outstring += str(v) + ", " |
| 219 | + outstring = textwrap.fill(outstring, width=80, initial_indent=' ', subsequent_indent=' ') |
| 220 | + fout.write(outstring) |
| 221 | + fout.write('\n};\n') |
| 222 | + fout.write(f'\n#endif /* {tablename}_H_ */\n') |
| 223 | + |
| 224 | + print("wrote", outfile) |
| 225 | + sym_str = ", symmetric" if symmetric_output else "" |
| 226 | + print(f" table name: {tablename}, type: {c_type}{sym_str}") |
| 227 | + |
| 228 | + |
| 229 | +if __name__ == "__main__": |
| 230 | + parser = argparse.ArgumentParser( |
| 231 | + description=__doc__, |
| 232 | + formatter_class=argparse.RawDescriptionHelpFormatter) |
| 233 | + parser.add_argument('infile', help='Input .WAV file') |
| 234 | + parser.add_argument('-t', '--tablename', help='Table name for the generated header (default: uppercase input filename)') |
| 235 | + parser.add_argument('-o', '--outfile', help='Output .h file (default: derived from input filename)') |
| 236 | + parser.add_argument('-b', '--output-bits', type=int, choices=(8, 16), default=8, |
| 237 | + help='Output sample size in bits (default: 8)') |
| 238 | + parser.add_argument('-s', '--symmetric-output', action=argparse.BooleanOptionalAction, default=True, |
| 239 | + help='Generate symmetric signed output range (default: enabled)') |
| 240 | + args = parser.parse_args() |
| 241 | + |
| 242 | + infile = os.path.expanduser(args.infile) |
| 243 | + if args.tablename: |
| 244 | + tablename = args.tablename |
| 245 | + else: |
| 246 | + # derive from filename: strip extension, keep only alnum/underscore, uppercase |
| 247 | + tablename = re.sub(r'[^A-Za-z0-9_]', '_', os.path.splitext(os.path.basename(infile))[0]).upper() |
| 248 | + outfile = os.path.expanduser(args.outfile) if args.outfile else os.path.splitext(infile)[0] + '.h' |
| 249 | + |
| 250 | + try: |
| 251 | + wav2mozzi(infile, outfile, tablename, |
| 252 | + output_bytes=args.output_bits // 8, |
| 253 | + symmetric_output=args.symmetric_output) |
| 254 | + except (IOError, ValueError) as e: |
| 255 | + print(f"Error: {e}", file=sys.stderr) |
0 commit comments