454 lines
15 KiB
C#
454 lines
15 KiB
C#
using System.Buffers.Binary;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Mimir.Runtime.Synchronization;
|
|
|
|
var options = WellObsOptions.Parse(args);
|
|
if (string.IsNullOrWhiteSpace(options.WellLogPath))
|
|
{
|
|
Console.Error.WriteLine("Mimir.WellObsDaemon requires --well-log <path> for this cut.");
|
|
return 2;
|
|
}
|
|
|
|
using var stopping = new CancellationTokenSource();
|
|
Console.CancelKeyPress += (_, eventArgs) =>
|
|
{
|
|
eventArgs.Cancel = true;
|
|
stopping.Cancel();
|
|
};
|
|
|
|
using var publisher = new MimirObsStemSharedMemoryPublisher(options.MapName);
|
|
var state = new WellLogTailState(options.WellLogPath, options.FromStart);
|
|
var lastPublishedCaptureSequence = -1L;
|
|
var lastStatus = DateTimeOffset.MinValue;
|
|
Console.Error.WriteLine($"Mimir Well OBS daemon drinking {options.WellLogPath}");
|
|
Console.Error.WriteLine($"Mimir Well OBS daemon publishing stems to {options.MapName}");
|
|
|
|
while (!stopping.IsCancellationRequested)
|
|
{
|
|
foreach (var document in state.ReadNewDocuments())
|
|
{
|
|
using var _ = document;
|
|
var publication = WellObsPublication.TryBuild(document, options);
|
|
if (publication == null || publication.CaptureSequence <= lastPublishedCaptureSequence)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
lastPublishedCaptureSequence = publication.CaptureSequence;
|
|
publisher.Publish(publication.Snapshot);
|
|
Console.Error.WriteLine(
|
|
$"mimir-well-obs published capture={publication.CaptureSequence} stems={publication.Snapshot.ReadyStems.Count} compositeFrames={publication.CompositeFrameCount} sources={string.Join(",", publication.SourceIds)}");
|
|
if (options.Once)
|
|
{
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
if (DateTimeOffset.UtcNow - lastStatus >= TimeSpan.FromSeconds(options.StatusSeconds))
|
|
{
|
|
lastStatus = DateTimeOffset.UtcNow;
|
|
Console.Error.WriteLine($"mimir-well-obs status lastCapture={lastPublishedCaptureSequence} offset={state.Position}");
|
|
}
|
|
|
|
try
|
|
{
|
|
await Task.Delay(options.PollMs, stopping.Token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
|
|
internal sealed record WellObsOptions(
|
|
string WellLogPath,
|
|
string MapName,
|
|
string CompositeStemId,
|
|
string PerSourceStemPrefix,
|
|
int PollMs,
|
|
int StatusSeconds,
|
|
bool FromStart,
|
|
bool Once)
|
|
{
|
|
public static WellObsOptions Parse(IReadOnlyList<string> args) => new(
|
|
ParseString(args, "--well-log", ""),
|
|
ParseString(args, "--map", MimirObsStemSharedMemoryPublisher.DefaultMapName),
|
|
ParseString(args, "--composite-stem-id", "well_composite"),
|
|
ParseString(args, "--source-stem-prefix", "well_"),
|
|
Math.Max(5, ParseInt(args, "--poll-ms", 50)),
|
|
Math.Max(1, ParseInt(args, "--status-seconds", 5)),
|
|
ParseBool(args, "--from-start", false),
|
|
ParseBool(args, "--once", false));
|
|
|
|
private static string ParseString(IReadOnlyList<string> args, string name, string fallback)
|
|
{
|
|
for (var index = 0; index < args.Count; index++)
|
|
{
|
|
if (string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase) && index + 1 < args.Count)
|
|
{
|
|
return args[index + 1];
|
|
}
|
|
|
|
if (args[index].StartsWith(name + "=", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return args[index][(name.Length + 1)..];
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
private static int ParseInt(IReadOnlyList<string> args, string name, int fallback) =>
|
|
int.TryParse(ParseString(args, name, ""), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
|
|
|
|
private static bool ParseBool(IReadOnlyList<string> args, string name, bool fallback)
|
|
{
|
|
for (var index = 0; index < args.Count; index++)
|
|
{
|
|
if (!string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return bool.TryParse(args[index + 1], out var value) ? value : fallback;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
internal sealed class WellLogTailState
|
|
{
|
|
private readonly string path;
|
|
private long position;
|
|
|
|
public WellLogTailState(string path, bool fromStart)
|
|
{
|
|
this.path = path;
|
|
position = !fromStart && File.Exists(path) ? new FileInfo(path).Length : 0;
|
|
}
|
|
|
|
public long Position => position;
|
|
|
|
public IEnumerable<JsonDocument> ReadNewDocuments()
|
|
{
|
|
if (!File.Exists(path))
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
if (position > stream.Length)
|
|
{
|
|
position = 0;
|
|
}
|
|
|
|
stream.Seek(position, SeekOrigin.Begin);
|
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
|
string? line;
|
|
while ((line = reader.ReadLine()) != null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
JsonDocument? parsed = null;
|
|
try
|
|
{
|
|
parsed = JsonDocument.Parse(line);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
}
|
|
|
|
if (parsed != null)
|
|
{
|
|
yield return parsed;
|
|
}
|
|
}
|
|
|
|
position = stream.Position;
|
|
}
|
|
}
|
|
|
|
internal sealed record WellObsPublication(
|
|
long CaptureSequence,
|
|
int CompositeFrameCount,
|
|
IReadOnlyList<string> SourceIds,
|
|
MimirObsStemPublicationSnapshot Snapshot)
|
|
{
|
|
public static WellObsPublication? TryBuild(JsonDocument document, WellObsOptions options)
|
|
{
|
|
var root = document.RootElement;
|
|
var kind = JsonText(root, "document");
|
|
if (kind is not "mimir.well_capture_page.v1")
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var captureSequence = (long)JsonNumber(root, "captureSequence");
|
|
var feeds = ParseAudioFeeds(root).ToArray();
|
|
var samples = ParseAudioSamples(root).ToArray();
|
|
if (feeds.Length == 0 || samples.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var selectedFeeds = SelectFeeds(feeds);
|
|
var selectedSamples = selectedFeeds
|
|
.Select(feed => (Feed: feed, Sample: samples.LastOrDefault(sample => string.Equals(sample.SourceId, feed.SourceId, StringComparison.Ordinal))))
|
|
.Where(pair => pair.Sample != null)
|
|
.Select(pair => (pair.Feed, Sample: pair.Sample!))
|
|
.ToArray();
|
|
if (selectedSamples.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var sampleRate = selectedSamples[0].Sample.SampleRate;
|
|
selectedSamples = selectedSamples
|
|
.Where(pair => pair.Sample.SampleRate == sampleRate)
|
|
.ToArray();
|
|
if (selectedSamples.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var frameCount = selectedSamples.Min(pair => pair.Sample.Samples.Length);
|
|
if (frameCount <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var stems = new List<MimirObsPublishedAudioStem>();
|
|
var mix = new float[frameCount];
|
|
var mixWeight = 0.0;
|
|
var sequence = 0L;
|
|
foreach (var (feed, sample) in selectedSamples)
|
|
{
|
|
var sourceSamples = sample.Samples.AsSpan(0, frameCount).ToArray();
|
|
var gain = Math.Clamp(feed.Gain, 0.0, 8.0);
|
|
for (var index = 0; index < sourceSamples.Length; index++)
|
|
{
|
|
sourceSamples[index] = (float)Math.Clamp(sourceSamples[index] * gain, -1.0, 1.0);
|
|
mix[index] += sourceSamples[index];
|
|
}
|
|
|
|
mixWeight += Math.Max(0.000001, gain);
|
|
sequence = Math.Max(sequence, sample.Sequence);
|
|
stems.Add(new MimirObsPublishedAudioStem(
|
|
options.PerSourceStemPrefix + StableStemId(sample.SourceId),
|
|
feed.DisplayName,
|
|
sample.SourceId,
|
|
0,
|
|
frameCount,
|
|
sampleRate,
|
|
sample.Sequence,
|
|
Configured: true,
|
|
sourceSamples));
|
|
}
|
|
|
|
if (mixWeight > 0.0)
|
|
{
|
|
for (var index = 0; index < mix.Length; index++)
|
|
{
|
|
mix[index] = (float)Math.Clamp(mix[index] / mixWeight, -1.0, 1.0);
|
|
}
|
|
}
|
|
|
|
stems.Insert(0, new MimirObsPublishedAudioStem(
|
|
options.CompositeStemId,
|
|
"Well configured composite",
|
|
string.Join("+", selectedSamples.Select(static pair => pair.Sample.SourceId)),
|
|
0,
|
|
frameCount,
|
|
sampleRate,
|
|
sequence,
|
|
Configured: true,
|
|
mix));
|
|
|
|
return new WellObsPublication(
|
|
captureSequence,
|
|
frameCount,
|
|
selectedSamples.Select(static pair => pair.Sample.SourceId).ToArray(),
|
|
new MimirObsStemPublicationSnapshot(
|
|
"well-tightest-configured-composite",
|
|
MimirObsAudioPublicationKind.FaustStemBus,
|
|
stems,
|
|
[],
|
|
[],
|
|
sequence));
|
|
}
|
|
|
|
private static IEnumerable<WellAudioFeed> ParseAudioFeeds(JsonElement root)
|
|
{
|
|
var audio = JsonArray(JsonGet(JsonGet(root, "configuredComposite"), "audio"));
|
|
foreach (var item in audio)
|
|
{
|
|
yield return new WellAudioFeed(
|
|
JsonText(item, "SourceId", "sourceId") ?? "unknown",
|
|
JsonText(item, "DisplayName", "displayName") ?? JsonText(item, "SourceId", "sourceId") ?? "unknown",
|
|
JsonBool(item, "Muted", "muted"),
|
|
JsonBool(item, "Solo", "solo"),
|
|
JsonNumber(item, "Gain", "gain", fallback: 1.0));
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<WellAudioSample> ParseAudioSamples(JsonElement root)
|
|
{
|
|
foreach (var item in JsonArray(JsonGet(root, "samples")))
|
|
{
|
|
var slice = JsonGet(item, "slice");
|
|
var sample = JsonGet(item, "sample");
|
|
if (!string.Equals(JsonText(slice, "Kind", "kind"), "Audio", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!string.Equals(JsonText(slice, "Status", "status"), "Ready", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var audio = JsonGet(sample, "audio");
|
|
var body = JsonGet(item, "body");
|
|
if (!string.Equals(JsonText(body, "status"), "inline", StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(JsonText(body, "encoding"), "base64", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var bytes = Convert.FromBase64String(JsonText(body, "data") ?? "");
|
|
var sourceId = JsonText(sample, "SourceId", "sourceId") ?? JsonText(slice, "SourceId", "sourceId") ?? "unknown";
|
|
var sampleRate = (int)JsonNumber(audio, "SampleRate", "sampleRate");
|
|
var channels = Math.Max(1, (int)JsonNumber(audio, "Channels", "channels", fallback: 1.0));
|
|
var format = JsonText(audio, "SampleFormat", "sampleFormat") ?? "Float32";
|
|
var decoded = DecodeMono(bytes, format, channels);
|
|
if (sampleRate > 0 && decoded.Length > 0)
|
|
{
|
|
yield return new WellAudioSample(
|
|
sourceId,
|
|
sampleRate,
|
|
(long)JsonNumber(sample, "Sequence", "sequence"),
|
|
decoded);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<WellAudioFeed> SelectFeeds(IReadOnlyList<WellAudioFeed> feeds)
|
|
{
|
|
var unmuted = feeds.Where(static feed => !feed.Muted).ToArray();
|
|
var solo = unmuted.Where(static feed => feed.Solo).ToArray();
|
|
return solo.Length > 0 ? solo : unmuted;
|
|
}
|
|
|
|
private static float[] DecodeMono(byte[] bytes, string format, int channels)
|
|
{
|
|
var sampleSize = format switch
|
|
{
|
|
"Float32" => 4,
|
|
"Int32" or "Int32LSB" => 4,
|
|
"Int16" => 2,
|
|
_ => 0,
|
|
};
|
|
if (sampleSize == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var frameCount = bytes.Length / sampleSize / Math.Max(1, channels);
|
|
var output = new float[frameCount];
|
|
for (var frame = 0; frame < frameCount; frame++)
|
|
{
|
|
var sum = 0.0;
|
|
for (var channel = 0; channel < channels; channel++)
|
|
{
|
|
var offset = (frame * channels + channel) * sampleSize;
|
|
sum += format switch
|
|
{
|
|
"Float32" => BitConverter.ToSingle(bytes, offset),
|
|
"Int32" or "Int32LSB" => BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset, 4)) / 2147483648.0,
|
|
"Int16" => BinaryPrimitives.ReadInt16LittleEndian(bytes.AsSpan(offset, 2)) / 32768.0,
|
|
_ => 0.0,
|
|
};
|
|
}
|
|
|
|
output[frame] = (float)Math.Clamp(sum / channels, -1.0, 1.0);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
private static string StableStemId(string sourceId)
|
|
{
|
|
var builder = new StringBuilder(sourceId.Length);
|
|
foreach (var ch in sourceId)
|
|
{
|
|
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '_');
|
|
}
|
|
|
|
return builder.ToString().Trim('_');
|
|
}
|
|
|
|
private static JsonElement JsonGet(JsonElement element, params string[] names)
|
|
{
|
|
if (element.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return default;
|
|
}
|
|
|
|
foreach (var name in names)
|
|
{
|
|
if (element.TryGetProperty(name, out var value))
|
|
{
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return default;
|
|
}
|
|
|
|
private static IEnumerable<JsonElement> JsonArray(JsonElement element) =>
|
|
element.ValueKind == JsonValueKind.Array ? element.EnumerateArray() : [];
|
|
|
|
private static string? JsonText(JsonElement element, params string[] names)
|
|
{
|
|
var value = names.Length == 0 ? element : JsonGet(element, names);
|
|
return value.ValueKind == JsonValueKind.String ? value.GetString() : null;
|
|
}
|
|
|
|
private static double JsonNumber(JsonElement element, params string[] names) =>
|
|
JsonNumber(element, names, 0.0);
|
|
|
|
private static double JsonNumber(JsonElement element, string name1, string name2, double fallback) =>
|
|
JsonNumber(element, [name1, name2], fallback);
|
|
|
|
private static double JsonNumber(JsonElement element, string name, double fallback) =>
|
|
JsonNumber(element, [name], fallback);
|
|
|
|
private static double JsonNumber(JsonElement element, string[] names, double fallback)
|
|
{
|
|
var value = names.Length == 0 ? element : JsonGet(element, names);
|
|
return value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number) ? number : fallback;
|
|
}
|
|
|
|
private static bool JsonBool(JsonElement element, params string[] names)
|
|
{
|
|
var value = JsonGet(element, names);
|
|
return value.ValueKind == JsonValueKind.True || (value.ValueKind == JsonValueKind.String && bool.TryParse(value.GetString(), out var parsed) && parsed);
|
|
}
|
|
}
|
|
|
|
internal sealed record WellAudioFeed(string SourceId, string DisplayName, bool Muted, bool Solo, double Gain);
|
|
|
|
internal sealed record WellAudioSample(string SourceId, int SampleRate, long Sequence, float[] Samples);
|