Files
HaloKeymind/scripts/generate_button_voice.py
T

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()