Skip to content

Commit 02fde2d

Browse files
authored
Merge pull request #328 from positron96/master
Add wav2mozzi.py to convert .WAV files to Mozzi headers
2 parents 2aa0842 + 46fcad7 commit 02fde2d

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

extras/python/wav2mozzi.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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

Comments
 (0)