MicrophoneArrayProfileCodec.java
package org.hammer.audio.acquisition;
import java.io.IOException;
import java.io.StringReader;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.hammer.audio.core.AudioFormatDescriptor;
import org.hammer.audio.geometry.Vector2;
/** Deterministic text codec for reusable microphone-array profiles. */
public final class MicrophoneArrayProfileCodec {
private static final String SCHEMA_VERSION = "1";
/** Encode a profile without timestamps or platform-dependent ordering. */
public String encode(MicrophoneArrayProfile profile) {
Map<String, String> values = new ConcurrentHashMap<>();
put(values, "schema", SCHEMA_VERSION);
put(values, "profile.id", profile.profileId());
put(values, "profile.name", profile.displayName());
put(values, "profile.layout", profile.layout().name());
put(
values,
"profile.modes",
profile.supportedModes().stream()
.map(Enum::name)
.sorted()
.collect(Collectors.joining(",")));
writeArray(values, profile.array());
writeCapture(values, profile.liveCapture());
writeCalibration(values, profile.calibration());
return values.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + '=' + entry.getValue())
.collect(Collectors.joining("\n", "", "\n"));
}
/** Decode one profile produced by {@link #encode(MicrophoneArrayProfile)}. */
public MicrophoneArrayProfile decode(String encoded) {
if (encoded == null || encoded.isBlank()) {
throw new IllegalArgumentException("encoded profile must not be blank");
}
Properties properties = new Properties();
try {
properties.load(new StringReader(encoded));
} catch (IOException exception) {
throw new IllegalArgumentException("profile text could not be read", exception);
}
requireValue(properties, "schema", SCHEMA_VERSION);
MicrophoneArray array = readArray(properties);
CaptureDeviceConfiguration capture = readCapture(properties);
MicrophoneArrayCalibration calibration = readCalibration(properties, array);
return new MicrophoneArrayProfile(
value(properties, "profile.id"),
value(properties, "profile.name"),
MicrophoneArrayLayout.valueOf(value(properties, "profile.layout")),
array,
readModes(properties),
capture,
calibration);
}
private static void writeArray(Map<String, String> values, MicrophoneArray array) {
put(values, "array.channels", Integer.toString(array.channels()));
for (int index = 0; index < array.channels(); index++) {
Microphone microphone = array.microphone(index);
String prefix = "array.microphone." + index + '.';
put(values, prefix + "id", microphone.id());
put(values, prefix + "channel", Integer.toString(microphone.channel()));
put(values, prefix + "x", Double.toString(microphone.positionMeters().x()));
put(values, prefix + "y", Double.toString(microphone.positionMeters().y()));
}
}
private static MicrophoneArray readArray(Properties properties) {
int channels = integer(properties, "array.channels");
List<Microphone> microphones = new ArrayList<>(channels);
for (int index = 0; index < channels; index++) {
String prefix = "array.microphone." + index + '.';
microphones.add(
new Microphone(
value(properties, prefix + "id"),
new Vector2(decimal(properties, prefix + "x"), decimal(properties, prefix + "y")),
integer(properties, prefix + "channel")));
}
return new MicrophoneArray(microphones);
}
private static void writeCapture(
Map<String, String> values, CaptureDeviceConfiguration configuration) {
put(values, "capture.present", Boolean.toString(configuration != null));
if (configuration == null) {
return;
}
CaptureDeviceDescriptor device = configuration.device();
put(values, "capture.device.id", device.deviceId());
put(values, "capture.device.name", device.name());
put(values, "capture.device.vendor", device.vendor());
put(values, "capture.device.description", device.description());
put(values, "capture.device.version", device.version());
put(values, "capture.sampleRate", Float.toString(configuration.format().sampleRate()));
put(values, "capture.channels", Integer.toString(configuration.format().channels()));
put(
values,
"capture.sampleBits",
Integer.toString(configuration.format().sourceSampleSizeInBits()));
put(values, "capture.signed", Boolean.toString(configuration.signed()));
put(values, "capture.bigEndian", Boolean.toString(configuration.bigEndian()));
}
private static CaptureDeviceConfiguration readCapture(Properties properties) {
if (!flag(properties, "capture.present")) {
return null;
}
CaptureDeviceDescriptor device =
new CaptureDeviceDescriptor(
value(properties, "capture.device.id"),
value(properties, "capture.device.name"),
value(properties, "capture.device.vendor"),
value(properties, "capture.device.description"),
value(properties, "capture.device.version"));
AudioFormatDescriptor format =
new AudioFormatDescriptor(
floating(properties, "capture.sampleRate"),
integer(properties, "capture.channels"),
integer(properties, "capture.sampleBits"));
return new CaptureDeviceConfiguration(
device, format, flag(properties, "capture.signed"), flag(properties, "capture.bigEndian"));
}
private static void writeCalibration(
Map<String, String> values, MicrophoneArrayCalibration calibration) {
put(values, "calibration.present", Boolean.toString(calibration != null));
if (calibration == null) {
return;
}
put(values, "calibration.id", calibration.profileId());
put(values, "calibration.referenceChannel", Integer.toString(calibration.referenceChannel()));
put(values, "calibration.calibratedAt", calibration.calibratedAt().toString());
put(values, "calibration.validUntil", calibration.validUntil().toString());
for (ChannelTimingCalibration channel : calibration.channels()) {
String prefix = "calibration.channel." + channel.channel() + '.';
put(values, prefix + "referenceFrame", Long.toString(channel.referenceFrame()));
put(values, prefix + "offsetSamples", Double.toString(channel.offsetSamples()));
put(values, prefix + "driftPpm", Double.toString(channel.driftPpm()));
put(values, prefix + "residualRmsSamples", Double.toString(channel.residualRmsSamples()));
put(values, prefix + "jitterRmsSamples", Double.toString(channel.jitterRmsSamples()));
put(values, prefix + "gainLinear", Double.toString(channel.gainLinear()));
put(values, prefix + "invertedPolarity", Boolean.toString(channel.invertedPolarity()));
}
}
private static MicrophoneArrayCalibration readCalibration(
Properties properties, MicrophoneArray array) {
if (!flag(properties, "calibration.present")) {
return null;
}
List<ChannelTimingCalibration> channels = new ArrayList<>(array.channels());
for (int channel = 0; channel < array.channels(); channel++) {
String prefix = "calibration.channel." + channel + '.';
channels.add(
new ChannelTimingCalibration(
channel,
longValue(properties, prefix + "referenceFrame"),
decimal(properties, prefix + "offsetSamples"),
decimal(properties, prefix + "driftPpm"),
decimal(properties, prefix + "residualRmsSamples"),
decimal(properties, prefix + "jitterRmsSamples"),
decimal(properties, prefix + "gainLinear"),
flag(properties, prefix + "invertedPolarity")));
}
return new MicrophoneArrayCalibration(
value(properties, "calibration.id"),
array,
integer(properties, "calibration.referenceChannel"),
channels,
Instant.parse(value(properties, "calibration.calibratedAt")),
Instant.parse(value(properties, "calibration.validUntil")));
}
private static Set<LocalizationInputMode> readModes(Properties properties) {
String encodedModes = value(properties, "profile.modes");
Set<LocalizationInputMode> modes = EnumSet.noneOf(LocalizationInputMode.class);
Arrays.stream(encodedModes.split(","))
.filter(mode -> !mode.isBlank())
.map(LocalizationInputMode::valueOf)
.forEach(modes::add);
if (modes.isEmpty()) {
throw new IllegalArgumentException("profile.modes must not be empty");
}
return modes;
}
private static void put(Map<String, String> values, String key, String value) {
values.put(key, URLEncoder.encode(value, StandardCharsets.UTF_8));
}
private static String value(Properties properties, String key) {
String value = properties.getProperty(key);
if (value == null) {
throw new IllegalArgumentException("missing profile property: " + key);
}
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
private static void requireValue(Properties properties, String key, String expected) {
String actual = value(properties, key);
if (!expected.equals(actual)) {
throw new IllegalArgumentException(
"unsupported " + key + ": expected " + expected + " but was " + actual);
}
}
private static boolean flag(Properties properties, String key) {
return Boolean.parseBoolean(value(properties, key));
}
private static int integer(Properties properties, String key) {
return Integer.parseInt(value(properties, key));
}
private static long longValue(Properties properties, String key) {
return Long.parseLong(value(properties, key));
}
private static float floating(Properties properties, String key) {
return Float.parseFloat(value(properties, key));
}
private static double decimal(Properties properties, String key) {
return Double.parseDouble(value(properties, key));
}
}