From fa555f3583bcd5027571cf8c8eacd769c14dfd92 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 2 Jun 2026 14:42:53 +0100 Subject: [PATCH] Add paired PS Move chirp actuator --- notes/current-system-map.md | 10 + src/Mimir.PsMoveProbe/Program.cs | 602 +++++++++++++++++++++++++++++++ state/evidence.jsonl | 5 + state/map.yaml | 13 + 4 files changed, 630 insertions(+) diff --git a/notes/current-system-map.md b/notes/current-system-map.md index 593a98b..6467c81 100644 --- a/notes/current-system-map.md +++ b/notes/current-system-map.md @@ -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 diff --git a/src/Mimir.PsMoveProbe/Program.cs b/src/Mimir.PsMoveProbe/Program.cs index 3cc10ca..5703065 100644 --- a/src/Mimir.PsMoveProbe/Program.cs +++ b/src/Mimir.PsMoveProbe/Program.cs @@ -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 RenderAudio( + string path, + int count, + TimeSpan startDelay, + TimeSpan interval, + TimeSpan chirpDuration, + int sampleRate, + double startHz, + double endHz, + double gain) + { + var events = new List(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 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 EmitAudioAsync( + string audioPath, + int sampleRate, + double gain, + string? renderDevice, + string? wasapiExe, + string? eventLogPath) + { + var resolvedExe = ResolveWasapiExecutable(wasapiExe); + var args = new List + { + "--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 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 TryWriteScheduledLedTrainAsync( + HidDeviceInfo device, + IReadOnlyList 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 { diff --git a/state/evidence.jsonl b/state/evidence.jsonl index c28cc0c..bf3b57f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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//{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."} diff --git a/state/map.yaml b/state/map.yaml index 666bb2f..4e0198c 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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.