mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 19:41:59 +00:00
119 lines
5.0 KiB
Python
119 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Encode the fixed button-confirmation WAV catalog as independent IMA clips."""
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
from generate_gps_voice import encode, read, shared_prefix, emit_array
|
|
|
|
PHRASES = (
|
|
('ready', 'Ready'),
|
|
('notificationCleared', 'Notification cleared'),
|
|
('advertQueued', 'Advert queued'),
|
|
('advertFailed', 'Advert failed'),
|
|
('soundOn', 'Sound on'),
|
|
('soundOff', 'Sound off'),
|
|
('actionFailed', 'Action failed'),
|
|
('usbSetup', 'USB setup'),
|
|
('shuttingDown', 'Shutting down'),
|
|
('restarting', 'Restarting'),
|
|
('alertsSoundVibration', 'Sound and vibration'),
|
|
('alertsSoundOnly', 'Sound only'),
|
|
('alertsVibrationOnly', 'Vibration only'),
|
|
('alertsSilent', 'Silent'),
|
|
)
|
|
|
|
# Only meaningful byte-identical prefixes are shared. These retain the exact
|
|
# approved phrase prosody; adaptive compressed tails are not standalone words.
|
|
PREFIX_GROUPS = (
|
|
('advertPrefix', 'advertQueued', 'advertFailed'),
|
|
('soundPrefix', 'soundOff', 'alertsSoundOnly'),
|
|
)
|
|
|
|
def split_catalog(encoded):
|
|
tails = dict(encoded)
|
|
prefixes = {}
|
|
references = {}
|
|
for prefix_name, first, second in PREFIX_GROUPS:
|
|
prefix, one, two = shared_prefix(encoded[first], encoded[second])
|
|
# Tiny shared silence does not justify additional metadata/storage.
|
|
if len(prefix) < 64:
|
|
continue
|
|
prefixes[prefix_name] = prefix
|
|
tails[first], tails[second] = one, two
|
|
references[first] = references[second] = prefix_name
|
|
return prefixes, tails, references
|
|
|
|
def emit_clip(lines, name, data, count, rate=8000, *, prefix=None, codec=None):
|
|
emit_array(lines, name, data)
|
|
fields = f' {name}Data, sizeof({name}Data), {count}, {rate}'
|
|
if codec:
|
|
fields += f', VoiceCodec::{codec}'
|
|
if prefix:
|
|
fields += f', {prefix}Data, sizeof({prefix}Data)'
|
|
lines.extend([f'static constexpr VoiceClip {name}Clip = {{', fields, '};'])
|
|
|
|
def emit_catalog(lines, samples, encoded, *, codec=None):
|
|
prefixes, tails, references = split_catalog(encoded)
|
|
for prefix_name, data in prefixes.items():
|
|
if prefix_name == 'soundPrefix':
|
|
lines.append('#ifdef HAS_DRV2605')
|
|
lines.append('// Shared encoded prefix; keep adaptive state at the seam.')
|
|
emit_array(lines, prefix_name, data)
|
|
if prefix_name == 'soundPrefix':
|
|
lines.append('#endif // HAS_DRV2605')
|
|
for name, phrase in PHRASES:
|
|
if name == 'alertsSoundVibration':
|
|
lines.append('#ifdef HAS_DRV2605')
|
|
lines.append(f'// "{phrase}"')
|
|
if name == 'soundOff' and name in references:
|
|
# Non-vibration boards have no second consumer for this prefix.
|
|
# Keep their original contiguous phrase, without extra span data.
|
|
lines.append('#ifdef HAS_DRV2605')
|
|
emit_clip(lines, name, tails[name], len(samples[name]),
|
|
prefix=references[name], codec=codec)
|
|
lines.append('#else')
|
|
emit_clip(lines, name, encoded[name], len(samples[name]), codec=codec)
|
|
lines.append('#endif // HAS_DRV2605')
|
|
else:
|
|
emit_clip(lines, name, tails[name], len(samples[name]),
|
|
prefix=references.get(name), codec=codec)
|
|
lines.append('#endif // HAS_DRV2605')
|
|
return sum(map(len, prefixes.values()))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--directory', type=Path, required=True)
|
|
parser.add_argument('--output', type=Path, required=True)
|
|
parser.add_argument('--voice', help='Speech voice used to synthesize the source WAVs')
|
|
args = parser.parse_args()
|
|
if args.voice is not None and (not args.voice.strip() or '\n' in args.voice or '\r' in args.voice):
|
|
parser.error('--voice must be a nonempty single-line label')
|
|
lines = [
|
|
'// Generated by scripts/generate_button_voice.py. 8 kHz IMA ADPCM.',
|
|
'// Fixed spoken confirmations; no runtime speech engine.',
|
|
'#pragma once', '#include <helpers/ui/GpsVoice.h>',
|
|
'namespace mesh { namespace audio {',
|
|
]
|
|
if args.voice is not None:
|
|
lines.insert(2, f'// Voice: {args.voice}')
|
|
samples_by_name = {}
|
|
encoded_by_name = {}
|
|
for name, phrase in PHRASES:
|
|
samples = read(args.directory / (name + '.wav'))
|
|
# Shutdown drains audio nonblockingly for at most five seconds.
|
|
if not samples or len(samples) > 32000:
|
|
raise ValueError(f'{phrase}: expected a nonempty clip under four seconds')
|
|
encoded = encode(samples)
|
|
samples_by_name[name] = samples
|
|
encoded_by_name[name] = encoded
|
|
print(f'{phrase}: {len(samples) / 8000:.3f}s, {len(encoded)} bytes')
|
|
saved = emit_catalog(lines, samples_by_name, encoded_by_name)
|
|
lines.append('}} // namespace mesh::audio')
|
|
args.output.write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
|
print(f'Button confirmations: {sum(map(len, encoded_by_name.values())) - saved} audio bytes')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|