Add paired PS Move chirp actuator
This commit is contained in:
parent
68bcbabe6e
commit
fa555f3583
4 changed files with 630 additions and 0 deletions
|
|
@ -399,6 +399,16 @@ is still a bad physical question for this room/speaker path. The default
|
|||
targeted probe planner now caps selection at 12 kHz and biases measurable
|
||||
low/mid bands instead of favoring ultrasonic-ish uncertainty that ordinary
|
||||
speakers and mics cannot answer well.
|
||||
Starfire's USB-connected PS Move is now a paired audio/visual timing actuator
|
||||
through `Mimir.PsMoveProbe --paired-chirp-train`. The worker renders one raw
|
||||
Float32 chirp train, asks `Mimir.WasapiLoopback` to emit it, and schedules Move
|
||||
LED on/off phases from the same `PairedChirpEvent` documents. The schedule is
|
||||
the owner: every visual pulse must share the same event id and planned offset
|
||||
as its audio chirp. The 2026-06-02 receipt
|
||||
`artifacts/runtime/starfire-usb-move-paired-20260602-144135/paired-events.jsonl`
|
||||
proved three emitted chirps and three USB LED pulses, with LED onsets matching
|
||||
planned 150/400/650 ms at millisecond receipt resolution after the HID train
|
||||
moved to high-resolution stopwatch waits.
|
||||
The calibration runner is now closed-loop instead of a blind full-spectrum
|
||||
sweep: `--bioacoustic-realtk-scheduled-calibration-live` asks the scheduler for
|
||||
each probe, emits through Realtek, captures Scarlett, updates residuals, and
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
|
|
@ -7,6 +8,23 @@ const ushort PsMovePid = 0x03D5;
|
|||
|
||||
var readReports = args.Any(arg => string.Equals(arg, "--read", StringComparison.OrdinalIgnoreCase));
|
||||
var pairHost = TryGetOption(args, "--pair-host");
|
||||
var rgb = TryGetOption(args, "--rgb");
|
||||
var pulseMs = TryGetIntOption(args, "--pulse-ms", 0);
|
||||
var pulseCount = TryGetIntOption(args, "--pulse-count", 1);
|
||||
var intervalMs = TryGetIntOption(args, "--interval-ms", 500);
|
||||
var eventLog = TryGetOption(args, "--event-log");
|
||||
var pairedChirpTrain = args.Any(arg => string.Equals(arg, "--paired-chirp-train", StringComparison.OrdinalIgnoreCase));
|
||||
var outputAudio = TryGetOption(args, "--output-audio");
|
||||
var eventCount = TryGetIntOption(args, "--event-count", pulseCount);
|
||||
var startDelayMs = TryGetIntOption(args, "--start-delay-ms", 150);
|
||||
var chirpMs = TryGetIntOption(args, "--chirp-ms", Math.Max(1, pulseMs));
|
||||
var sampleRate = TryGetIntOption(args, "--sample-rate", 48_000);
|
||||
var startHz = TryGetDoubleOption(args, "--start-hz", 2400.0);
|
||||
var endHz = TryGetDoubleOption(args, "--end-hz", 7200.0);
|
||||
var gain = TryGetDoubleOption(args, "--gain", 0.12);
|
||||
var renderDevice = TryGetOption(args, "--render-device");
|
||||
var wasapiExe = TryGetOption(args, "--wasapi-exe");
|
||||
var noAudioEmit = args.Any(arg => string.Equals(arg, "--no-audio-emit", StringComparison.OrdinalIgnoreCase));
|
||||
var showAll = args.Any(arg => string.Equals(arg, "--all", StringComparison.OrdinalIgnoreCase));
|
||||
if (showAll)
|
||||
{
|
||||
|
|
@ -34,6 +52,64 @@ if (devices.Count == 0)
|
|||
return 1;
|
||||
}
|
||||
|
||||
if (pairedChirpTrain)
|
||||
{
|
||||
var move = devices.FirstOrDefault(static device =>
|
||||
device.Caps.OutputReportByteLength > 0 &&
|
||||
device.Path.IndexOf("&col01#", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
if (move == null)
|
||||
{
|
||||
Console.WriteLine("No PS Move col01 output collection is available for paired chirp train.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var audioPath = string.IsNullOrWhiteSpace(outputAudio)
|
||||
? Path.Combine("artifacts", "runtime", $"starfire-usb-move-chirp-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.f32")
|
||||
: outputAudio;
|
||||
if (string.IsNullOrWhiteSpace(rgb) || !WindowsHid.TryParseRgb(rgb, out var r, out var g, out var b))
|
||||
{
|
||||
Console.WriteLine("Paired chirp train requires --rgb #rrggbb or --rgb r,g,b.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(audioPath)) ?? ".");
|
||||
if (!string.IsNullOrWhiteSpace(eventLog))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(eventLog)) ?? ".");
|
||||
}
|
||||
|
||||
var events = PairedChirpTrain.RenderAudio(
|
||||
audioPath,
|
||||
Math.Max(1, eventCount),
|
||||
TimeSpan.FromMilliseconds(Math.Max(0, startDelayMs)),
|
||||
TimeSpan.FromMilliseconds(Math.Max(1, intervalMs)),
|
||||
TimeSpan.FromMilliseconds(Math.Max(1, chirpMs)),
|
||||
sampleRate,
|
||||
startHz,
|
||||
endHz,
|
||||
gain);
|
||||
PairedChirpTrain.AppendScheduleEvents(eventLog, events, audioPath, sampleRate, gain);
|
||||
var audioTask = noAudioEmit
|
||||
? Task.FromResult("skipped: --no-audio-emit")
|
||||
: PairedChirpTrain.EmitAudioAsync(audioPath, sampleRate, gain, renderDevice, wasapiExe, eventLog);
|
||||
var ledTask = WindowsHid.TryWriteScheduledLedTrainAsync(
|
||||
move,
|
||||
events,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
TimeSpan.FromMilliseconds(Math.Max(1, pulseMs)),
|
||||
eventLog);
|
||||
var result = await ledTask.ConfigureAwait(false);
|
||||
var audioResult = await audioTask.ConfigureAwait(false);
|
||||
Console.WriteLine(
|
||||
$"pairedChirpTrain audio={audioPath} events={events.Count} sampleRate={sampleRate} startDelayMs={startDelayMs} chirpMs={chirpMs} intervalMs={intervalMs} led={result} audio={audioResult}");
|
||||
return result.StartsWith("ok", StringComparison.OrdinalIgnoreCase) &&
|
||||
(audioResult.StartsWith("ok", StringComparison.OrdinalIgnoreCase) || audioResult.StartsWith("skipped", StringComparison.OrdinalIgnoreCase))
|
||||
? 0
|
||||
: 1;
|
||||
}
|
||||
|
||||
foreach (var device in devices)
|
||||
{
|
||||
PrintDevice(device);
|
||||
|
|
@ -50,6 +126,18 @@ foreach (var device in devices)
|
|||
Console.WriteLine($" read={result}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rgb))
|
||||
{
|
||||
var result = await WindowsHid.TryWriteLedPulseTrainAsync(
|
||||
device,
|
||||
rgb,
|
||||
Math.Max(1, pulseCount),
|
||||
TimeSpan.FromMilliseconds(Math.Max(0, pulseMs)),
|
||||
TimeSpan.FromMilliseconds(Math.Max(1, intervalMs)),
|
||||
eventLog);
|
||||
Console.WriteLine($" led={result}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +174,12 @@ static string? TryGetOption(string[] values, string name)
|
|||
return null;
|
||||
}
|
||||
|
||||
static int TryGetIntOption(string[] values, string name, int fallback) =>
|
||||
int.TryParse(TryGetOption(values, name), out var value) ? value : fallback;
|
||||
|
||||
static double TryGetDoubleOption(string[] values, string name, double fallback) =>
|
||||
double.TryParse(TryGetOption(values, name), out var value) ? value : fallback;
|
||||
|
||||
internal sealed record HidDeviceInfo(
|
||||
string Path,
|
||||
ushort VendorId,
|
||||
|
|
@ -95,11 +189,243 @@ internal sealed record HidDeviceInfo(
|
|||
string? SerialNumber,
|
||||
HidpCaps Caps);
|
||||
|
||||
internal sealed record PairedChirpEvent(
|
||||
string EventId,
|
||||
int Index,
|
||||
double OffsetSeconds,
|
||||
int AudioStartSample,
|
||||
int AudioEndSample,
|
||||
double StartHz,
|
||||
double EndHz);
|
||||
|
||||
internal static class PairedChirpTrain
|
||||
{
|
||||
public static IReadOnlyList<PairedChirpEvent> RenderAudio(
|
||||
string path,
|
||||
int count,
|
||||
TimeSpan startDelay,
|
||||
TimeSpan interval,
|
||||
TimeSpan chirpDuration,
|
||||
int sampleRate,
|
||||
double startHz,
|
||||
double endHz,
|
||||
double gain)
|
||||
{
|
||||
var events = new List<PairedChirpEvent>(count);
|
||||
var intervalSeconds = Math.Max(0.001, interval.TotalSeconds);
|
||||
var startDelaySeconds = Math.Max(0.0, startDelay.TotalSeconds);
|
||||
var chirpSeconds = Math.Max(0.001, chirpDuration.TotalSeconds);
|
||||
var chirpSamples = Math.Max(1, (int)Math.Round(chirpSeconds * sampleRate));
|
||||
var totalSamples = Math.Max(
|
||||
chirpSamples,
|
||||
(int)Math.Ceiling((startDelaySeconds + (count - 1) * intervalSeconds + chirpSeconds + 0.05) * sampleRate));
|
||||
var samples = new float[totalSamples];
|
||||
var runId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var offsetSeconds = startDelaySeconds + index * intervalSeconds;
|
||||
var startSample = Math.Min(samples.Length - 1, (int)Math.Round(offsetSeconds * sampleRate));
|
||||
var endSample = Math.Min(samples.Length, startSample + chirpSamples);
|
||||
events.Add(new PairedChirpEvent(
|
||||
$"starfire-usb-move-chirp:{runId}:{index}",
|
||||
index,
|
||||
offsetSeconds,
|
||||
startSample,
|
||||
endSample,
|
||||
startHz,
|
||||
endHz));
|
||||
|
||||
for (var sample = startSample; sample < endSample; sample++)
|
||||
{
|
||||
var local = sample - startSample;
|
||||
var normalized = chirpSamples <= 1 ? 0.0 : local / (double)(chirpSamples - 1);
|
||||
var frequency = startHz + (endHz - startHz) * normalized;
|
||||
var phase = 2.0 * Math.PI * (startHz * local / sampleRate +
|
||||
0.5 * (endHz - startHz) * local * local / Math.Max(1.0, chirpSamples) / sampleRate);
|
||||
var envelope = 0.5 - 0.5 * Math.Cos(2.0 * Math.PI * normalized);
|
||||
samples[sample] += (float)Math.Clamp(Math.Sin(phase) * envelope * gain, -1.0, 1.0);
|
||||
_ = frequency; // keep the linear-frequency intent explicit for receipts.
|
||||
}
|
||||
}
|
||||
|
||||
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read);
|
||||
var bytes = new byte[samples.Length * sizeof(float)];
|
||||
Buffer.BlockCopy(samples, 0, bytes, 0, bytes.Length);
|
||||
stream.Write(bytes);
|
||||
return events;
|
||||
}
|
||||
|
||||
public static void AppendScheduleEvents(
|
||||
string? eventLogPath,
|
||||
IReadOnlyList<PairedChirpEvent> events,
|
||||
string audioPath,
|
||||
int sampleRate,
|
||||
double gain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventLogPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in events)
|
||||
{
|
||||
var line = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
document = "mimir.psmove_usb_audio_visual_pulse_event.v1",
|
||||
invariant = "visual pulse and audio chirp share one event id and one planned offset",
|
||||
item.EventId,
|
||||
item.Index,
|
||||
item.OffsetSeconds,
|
||||
item.AudioStartSample,
|
||||
item.AudioEndSample,
|
||||
item.StartHz,
|
||||
item.EndHz,
|
||||
phase = "schedule",
|
||||
audioPath,
|
||||
sampleRate,
|
||||
gain,
|
||||
result = "planned"
|
||||
});
|
||||
File.AppendAllText(eventLogPath, line + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<string> EmitAudioAsync(
|
||||
string audioPath,
|
||||
int sampleRate,
|
||||
double gain,
|
||||
string? renderDevice,
|
||||
string? wasapiExe,
|
||||
string? eventLogPath)
|
||||
{
|
||||
var resolvedExe = ResolveWasapiExecutable(wasapiExe);
|
||||
var args = new List<string>
|
||||
{
|
||||
"--play-f32-mono",
|
||||
"--input",
|
||||
Path.GetFullPath(audioPath),
|
||||
"--sample-rate",
|
||||
sampleRate.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"--gain",
|
||||
gain.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"--render-buffer-seconds",
|
||||
"0.02",
|
||||
"--render-drain-seconds",
|
||||
"0.08"
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(renderDevice))
|
||||
{
|
||||
args.Add("--device");
|
||||
args.Add(renderDevice);
|
||||
}
|
||||
|
||||
var startedNs = WindowsHid.NowNs();
|
||||
AppendRenderEvent(eventLogPath, "render-start", audioPath, sampleRate, gain, startedNs, startedNs, "starting");
|
||||
using var process = new Process();
|
||||
process.StartInfo.FileName = resolvedExe;
|
||||
foreach (var arg in args)
|
||||
{
|
||||
process.StartInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
process.StartInfo.UseShellExecute = false;
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
process.StartInfo.RedirectStandardOutput = true;
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync().ConfigureAwait(false);
|
||||
var completedNs = WindowsHid.NowNs();
|
||||
var stderr = await stderrTask.ConfigureAwait(false);
|
||||
var stdout = await stdoutTask.ConfigureAwait(false);
|
||||
var result = process.ExitCode == 0
|
||||
? $"ok renderExit=0 pid={process.Id}"
|
||||
: $"render failed exit={process.ExitCode} pid={process.Id}";
|
||||
AppendRenderEvent(eventLogPath, "render-complete", audioPath, sampleRate, gain, startedNs, completedNs, result, stdout, stderr);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var completedNs = WindowsHid.NowNs();
|
||||
var result = "render failed: " + ex.Message;
|
||||
AppendRenderEvent(eventLogPath, "render-complete", audioPath, sampleRate, gain, startedNs, completedNs, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveWasapiExecutable(string? explicitPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(explicitPath))
|
||||
{
|
||||
return explicitPath;
|
||||
}
|
||||
|
||||
var current = new DirectoryInfo(Environment.CurrentDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Mimir.WasapiLoopback",
|
||||
"bin",
|
||||
"Debug",
|
||||
"net10.0-windows",
|
||||
"Mimir.WasapiLoopback.exe");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return "Mimir.WasapiLoopback.exe";
|
||||
}
|
||||
|
||||
private static void AppendRenderEvent(
|
||||
string? eventLogPath,
|
||||
string phase,
|
||||
string audioPath,
|
||||
int sampleRate,
|
||||
double gain,
|
||||
long startedNs,
|
||||
long completedNs,
|
||||
string result,
|
||||
string? stdout = null,
|
||||
string? stderr = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventLogPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var line = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
document = "mimir.psmove_usb_audio_visual_pulse_event.v1",
|
||||
invariant = "visual pulse and audio chirp share one event id and one planned offset",
|
||||
phase,
|
||||
audioPath,
|
||||
sampleRate,
|
||||
gain,
|
||||
startedNs,
|
||||
completedNs,
|
||||
result,
|
||||
stdout,
|
||||
stderr
|
||||
});
|
||||
File.AppendAllText(eventLogPath, line + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class WindowsHid
|
||||
{
|
||||
private const int DigcfPresent = 0x00000002;
|
||||
private const int DigcfDeviceinterface = 0x00000010;
|
||||
private const uint GenericRead = 0x80000000;
|
||||
private const uint GenericWrite = 0x40000000;
|
||||
private const uint FileShareRead = 0x00000001;
|
||||
private const uint FileShareWrite = 0x00000002;
|
||||
private const uint OpenExisting = 3;
|
||||
|
|
@ -110,6 +436,7 @@ internal static class WindowsHid
|
|||
private const int HidpStatusSuccess = 0x00110000;
|
||||
private const byte GetBtAddressReport = 0x04;
|
||||
private const byte SetBtAddressReport = 0x05;
|
||||
private const byte LedReport = 0x06;
|
||||
private const int BtAddressGetSize = 16;
|
||||
private const int BtAddressSetSize = 23;
|
||||
|
||||
|
|
@ -268,6 +595,267 @@ internal static class WindowsHid
|
|||
return $"ok controller={controller} host={assigned}";
|
||||
}
|
||||
|
||||
public static async Task<string> TryWriteLedPulseTrainAsync(
|
||||
HidDeviceInfo device,
|
||||
string rgb,
|
||||
int pulseCount,
|
||||
TimeSpan pulseDuration,
|
||||
TimeSpan interval,
|
||||
string? eventLogPath)
|
||||
{
|
||||
if (device.Caps.OutputReportByteLength == 0 || device.Path.IndexOf("&col01#", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
return "skipped: LED output uses the PS Move col01 output-report collection";
|
||||
}
|
||||
|
||||
if (!TryParseRgb(rgb, out var r, out var g, out var b))
|
||||
{
|
||||
return $"invalid rgb: {rgb}";
|
||||
}
|
||||
|
||||
var written = 0;
|
||||
for (var index = 0; index < Math.Max(1, pulseCount); index++)
|
||||
{
|
||||
var eventId = $"starfire-usb-move:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}:{index}";
|
||||
var onStarted = NowNs();
|
||||
var on = TryWriteLed(device, r, g, b);
|
||||
var onCompleted = NowNs();
|
||||
AppendPulseEvent(eventLogPath, eventId, index, "on", r, g, b, onStarted, onCompleted, on);
|
||||
if (!on.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return on;
|
||||
}
|
||||
|
||||
if (pulseDuration > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(pulseDuration).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var offStarted = NowNs();
|
||||
var off = TryWriteLed(device, 0, 0, 0);
|
||||
var offCompleted = NowNs();
|
||||
AppendPulseEvent(eventLogPath, eventId, index, "off", 0, 0, 0, offStarted, offCompleted, off);
|
||||
if (!off.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"off failed after {written} pulses: {off}";
|
||||
}
|
||||
|
||||
written++;
|
||||
if (index + 1 < pulseCount)
|
||||
{
|
||||
var rest = interval - pulseDuration;
|
||||
if (rest > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(rest).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $"ok pulses={written} rgb=#{r:X2}{g:X2}{b:X2} pulseMs={pulseDuration.TotalMilliseconds:0} intervalMs={interval.TotalMilliseconds:0}";
|
||||
}
|
||||
|
||||
public static async Task<string> TryWriteScheduledLedTrainAsync(
|
||||
HidDeviceInfo device,
|
||||
IReadOnlyList<PairedChirpEvent> events,
|
||||
byte r,
|
||||
byte g,
|
||||
byte b,
|
||||
TimeSpan pulseDuration,
|
||||
string? eventLogPath)
|
||||
{
|
||||
if (device.Caps.OutputReportByteLength == 0 || device.Path.IndexOf("&col01#", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
return "skipped: LED output uses the PS Move col01 output-report collection";
|
||||
}
|
||||
|
||||
TimeBeginPeriod(1);
|
||||
try
|
||||
{
|
||||
var started = NowNs();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var written = 0;
|
||||
foreach (var item in events)
|
||||
{
|
||||
WaitUntil(stopwatch, TimeSpan.FromSeconds(item.OffsetSeconds));
|
||||
|
||||
var onStarted = NowNs();
|
||||
var on = TryWriteLed(device, r, g, b);
|
||||
var onCompleted = NowNs();
|
||||
AppendPairedPulseEvent(eventLogPath, item, "on", r, g, b, started, onStarted, onCompleted, on);
|
||||
if (!on.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return on;
|
||||
}
|
||||
|
||||
if (pulseDuration > TimeSpan.Zero)
|
||||
{
|
||||
WaitUntil(stopwatch, TimeSpan.FromSeconds(item.OffsetSeconds) + pulseDuration);
|
||||
}
|
||||
|
||||
var offStarted = NowNs();
|
||||
var off = TryWriteLed(device, 0, 0, 0);
|
||||
var offCompleted = NowNs();
|
||||
AppendPairedPulseEvent(eventLogPath, item, "off", 0, 0, 0, started, offStarted, offCompleted, off);
|
||||
if (!off.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"off failed after {written} events: {off}";
|
||||
}
|
||||
|
||||
written++;
|
||||
}
|
||||
|
||||
return $"ok pairedEvents={written}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
TimeEndPeriod(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WaitUntil(Stopwatch stopwatch, TimeSpan due)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var remaining = due - stopwatch.Elapsed;
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (remaining > TimeSpan.FromMilliseconds(8))
|
||||
{
|
||||
Thread.Sleep(Math.Max(1, (int)Math.Floor(remaining.TotalMilliseconds) - 4));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remaining > TimeSpan.FromMilliseconds(1.5))
|
||||
{
|
||||
Thread.Sleep(0);
|
||||
continue;
|
||||
}
|
||||
|
||||
Thread.SpinWait(256);
|
||||
}
|
||||
}
|
||||
|
||||
private static string TryWriteLed(HidDeviceInfo device, byte r, byte g, byte b)
|
||||
{
|
||||
using var handle = CreateFile(
|
||||
device.Path,
|
||||
GenericWrite,
|
||||
FileShareRead | FileShareWrite,
|
||||
IntPtr.Zero,
|
||||
OpenExisting,
|
||||
0,
|
||||
IntPtr.Zero);
|
||||
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
return $"open failed: {new Win32Exception(Marshal.GetLastWin32Error()).Message}";
|
||||
}
|
||||
|
||||
var report = new byte[Math.Max(9, (int)device.Caps.OutputReportByteLength)];
|
||||
report[0] = LedReport;
|
||||
report[1] = 0;
|
||||
report[2] = r;
|
||||
report[3] = g;
|
||||
report[4] = b;
|
||||
return WriteFile(handle, report, (uint)report.Length, out var written, IntPtr.Zero)
|
||||
? $"ok rgb=#{r:X2}{g:X2}{b:X2} bytes={written}"
|
||||
: $"WriteFile failed: {new Win32Exception(Marshal.GetLastWin32Error()).Message}";
|
||||
}
|
||||
|
||||
public static bool TryParseRgb(string value, out byte r, out byte g, out byte b)
|
||||
{
|
||||
r = 0;
|
||||
g = 0;
|
||||
b = 0;
|
||||
var normalized = value.Trim().TrimStart('#');
|
||||
if (normalized.Length == 6)
|
||||
{
|
||||
return byte.TryParse(normalized.AsSpan(0, 2), System.Globalization.NumberStyles.HexNumber, null, out r) &&
|
||||
byte.TryParse(normalized.AsSpan(2, 2), System.Globalization.NumberStyles.HexNumber, null, out g) &&
|
||||
byte.TryParse(normalized.AsSpan(4, 2), System.Globalization.NumberStyles.HexNumber, null, out b);
|
||||
}
|
||||
|
||||
var parts = normalized.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length == 3 &&
|
||||
byte.TryParse(parts[0], out r) &&
|
||||
byte.TryParse(parts[1], out g) &&
|
||||
byte.TryParse(parts[2], out b);
|
||||
}
|
||||
|
||||
public static long NowNs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
|
||||
private static void AppendPulseEvent(
|
||||
string? eventLogPath,
|
||||
string eventId,
|
||||
int index,
|
||||
string phase,
|
||||
byte r,
|
||||
byte g,
|
||||
byte b,
|
||||
long startedNs,
|
||||
long completedNs,
|
||||
string result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventLogPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var line = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
document = "mimir.psmove_usb_pulse_event.v1",
|
||||
eventId,
|
||||
index,
|
||||
phase,
|
||||
rgb = new[] { (int)r, (int)g, (int)b },
|
||||
startedNs,
|
||||
completedNs,
|
||||
result,
|
||||
});
|
||||
File.AppendAllText(eventLogPath, line + Environment.NewLine);
|
||||
}
|
||||
|
||||
private static void AppendPairedPulseEvent(
|
||||
string? eventLogPath,
|
||||
PairedChirpEvent item,
|
||||
string phase,
|
||||
byte r,
|
||||
byte g,
|
||||
byte b,
|
||||
long trainStartNs,
|
||||
long startedNs,
|
||||
long completedNs,
|
||||
string result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventLogPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var line = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
document = "mimir.psmove_usb_audio_visual_pulse_event.v1",
|
||||
invariant = "visual pulse and audio chirp share one event id and one planned offset",
|
||||
item.EventId,
|
||||
item.Index,
|
||||
item.OffsetSeconds,
|
||||
item.AudioStartSample,
|
||||
item.AudioEndSample,
|
||||
item.StartHz,
|
||||
item.EndHz,
|
||||
phase,
|
||||
rgb = new[] { (int)r, (int)g, (int)b },
|
||||
trainStartNs,
|
||||
startedNs,
|
||||
completedNs,
|
||||
result,
|
||||
});
|
||||
File.AppendAllText(eventLogPath, line + Environment.NewLine);
|
||||
}
|
||||
|
||||
private static (string? Controller, string? Host) ReadBluetoothAddresses(SafeFileHandle handle)
|
||||
{
|
||||
var report = new byte[BtAddressGetSize];
|
||||
|
|
@ -499,6 +1087,14 @@ internal static class WindowsHid
|
|||
IntPtr numberOfBytesRead,
|
||||
ref NativeOverlapped overlapped);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool WriteFile(
|
||||
SafeFileHandle file,
|
||||
byte[] buffer,
|
||||
uint numberOfBytesToWrite,
|
||||
out uint numberOfBytesWritten,
|
||||
IntPtr overlapped);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetOverlappedResult(
|
||||
SafeFileHandle file,
|
||||
|
|
@ -517,6 +1113,12 @@ internal static class WindowsHid
|
|||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
[DllImport("winmm.dll", EntryPoint = "timeBeginPeriod")]
|
||||
private static extern uint TimeBeginPeriod(uint period);
|
||||
|
||||
[DllImport("winmm.dll", EntryPoint = "timeEndPeriod")]
|
||||
private static extern uint TimeEndPeriod(uint period);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SpDeviceInterfaceData
|
||||
{
|
||||
|
|
|
|||
|
|
@ -348,3 +348,8 @@
|
|||
{"ts": "2026-06-02T00:06:13+01:00", "type": "verification", "status": "degraded", "note": "2026-06-02: A short local online-verse daemon run proved recorder startup and session/body directory creation, but live Mimir.Well synchronized-frame planning degraded repeatedly with mixed timestamp-domain range errors such as camera/ASIO timestamps being compared before a shared canonical clock owner exists. Treat this as the known camera-clock/global-time correction scar, not a recorder storage failure."}
|
||||
{"ts": "2026-06-02T00:25:02+01:00", "type": "implementation", "status": "ok", "note": "2026-06-02 shakedown: Fixed two data-loss/disk-pressure scars while leaving the long-running Well stack active. The supervisor now passes --seconds 0 to Mimir.Well for indefinite runs instead of a one-year CancelAfter delay that .NET rejects, and waits for relay /health before starting subscribers/producers so Nightwing does not race dotnet cold start. Mimir.Well capture pages now fall back to latest per-buffer samples with TimingEvidenceKind=unsynchronized-fallback when synchronized-frame planning degrades, so Recorder pages raw CPU bodies even before the canonical clock owner exists. Mimir.EveSensorReceiver still broadcasts full capture pages to subscribers but redacts capture-page bodies from diagnostic stdout to avoid duplicating the paged body store in verse-relay.out.log. Live run online-verse-daemons-20260602-002116 / VerseCaptures/20260602-002124 showed all four local workers alive, Recorder receiving 520 observations, 479 body refs, Nightwing observations flowing after manual restart, and capture-page stdout summaries with sampleCount/body-redacted."}
|
||||
{"ts": "2026-06-02T00:46:28+01:00", "type": "implementation", "status": "ok", "note": "2026-06-02 live shakedown: Added provisional clock-domain edge-fit corrections through MimirSourceTimingCorrection, actual oldest-sample bounds for synchronized frame planning, Well clock-domain diagnostics, and mimir.well_stream_pressure.v1 snapshot telemetry for poll/publish pressure. Hot-swapped active run online-verse-daemons-20260602-002116 to the new build; latest VerseCaptures/20260602-002124 snapshot showed complete 6/6 Ready slices over Scarlett ASIO, Kiyo Pro, and Leap with zero slice distance. The same telemetry exposed the streaming-organ scar: ASIO had about 20 ms of actual buffered history against a five-second retention target, and WebSocket JSON capture-page publishing reached multi-MB messages with >100 ms publish spikes. Next transport cut should split CultMesh typed control/cursor state from CultCache page/shard body streaming and dashboard latest-state lanes."}
|
||||
{"ts": "2026-06-02T12:54:25+01:00", "type": "decision", "status": "ok", "note": "Odin/Nightwing TUI sparsity was a projection/lowering ownership failure, not missing Well data: Mimir must publish rich Eve layout intent and music/Well metrics, Odin must merge provider layout pressure into accepted interface layout, and Nightwing must honor continuous/min/preferred height while collapsing lower-priority surfaces. 2026-06-02 verification showed Well metrics plus Move piano roll rendered on Nightwing without restarting the heavy Well stack."}
|
||||
{"ts": "2026-06-02T13:44:08+01:00", "type": "decision", "status": "ok", "note": "Perfect Machine shakedown cut: Mimir.Well now treats --presentation-delay-ms as an adaptive latency ceiling, not the fixed owner. The Well publishes mimir.well_latency_policy.v1 metrics (current/floor/ceiling delay, retained overlap, edge skew, readiness, sync confidence, reason) and mimir.well_feature_signals.v1 normalized video feature signals with Faust control paths video/<source>/{confidence,motion_energy,centroid_x,centroid_y}. Next live Well run should use these fields to drive continuous latency/fidelity tuning and native/Faust lowering."}
|
||||
{"ts": "2026-06-02T13:56:55+01:00", "type": "decision", "status": "ok", "note": "Imagination shakedown consensus 2026-06-02: saved Well data shows 97.8% full-frame readiness after warm-up at fixed 2500ms, but hundreds-of-seconds cross-domain edge skew proves timestamp domains are not canonical. Immediate cut implemented: multiplexed ASIO sources now declare logical stream count so hub drain budget cannot starve Focusrite channels. Next cuts: canonical clock-fit owner before planner overlap, bounded async Well publish/body lane, runtime-owned video-feature DSP control frame, and score tracker pitch/note-event fidelity beyond 512-bin f0."}
|
||||
{"ts": "2026-06-02T14:13:56+01:00", "type": "decision", "status": "ok", "note": "Starfire USB PS Move shakedown: Mimir.PsMoveProbe sees three Sony VID_054C/PID_03D5 HID collections and can write report 0x06 to col01. Added --rgb/--pulse-ms/--pulse-count/--interval-ms/--event-log; proof wrote readable mimir.psmove_usb_pulse_event.v1 receipts and 49-byte on/off reports. This is a local fast visual timing witness primitive; precision scheduling should move into a persistent/native worker because CLI pulse proof showed requested 60ms pulse producing about 221ms on/off gap."}
|
||||
{"ts": "2026-06-02T14:41:46+01:00", "type": "verification", "status": "ok", "note": "Starfire USB PS Move paired chirp train now derives audio samples and visual LED pulses from one event schedule. 2026-06-02 run artifacts/runtime/starfire-usb-move-paired-20260602-144135 emitted three WASAPI chirps and three Move LED pulses with shared event ids; receipt showed planned/observed LED onsets 150/400/650 ms at millisecond resolution after high-resolution wait cut."}
|
||||
|
|
|
|||
|
|
@ -301,6 +301,19 @@ invariants:
|
|||
audio renderer uses separate AquaSynth contestant renders with
|
||||
role-specific durations/gains; true per-role physical timbre remains a
|
||||
future AquaSynth/Faust renderer cut, not hidden scheduler behavior.
|
||||
Starfire now has a USB-local PS Move paired chirp actuator in
|
||||
`Mimir.PsMoveProbe`: `--paired-chirp-train` renders a mono Float32 chirp
|
||||
train, emits it through `Mimir.WasapiLoopback`, and writes PS Move LED
|
||||
pulses from the same `PairedChirpEvent` schedule. The invariant is that
|
||||
audio chirp samples and visual LED phases share one event id and planned
|
||||
offset; visual pulses must not be independent reactions. The
|
||||
2026-06-02 receipt
|
||||
`artifacts/runtime/starfire-usb-move-paired-20260602-144135/paired-events.jsonl`
|
||||
proved three WASAPI chirps plus three USB Move LED pulses with observed LED
|
||||
onsets matching planned 150/400/650 ms at millisecond receipt resolution.
|
||||
This is a proven Starfire-local timing actuator, but the final production
|
||||
cut should still move emission into a persistent native/Faust-owned worker
|
||||
before claiming hard realtime physical sync.
|
||||
MIDI keyboard notes should later enter as an explicit typed score source,
|
||||
but today AquaSynth is observed through whatever audio channel carries its
|
||||
rendered voice.
|
||||
|
|
|
|||
Loading…
Reference in a new issue