// BeatTime API client + local @beat clock — C# (single file, no dependencies).
// https://beattime.live
//
// BeatTime is one universal time: 1000 .beats/day, anchored to UTC, no timezones.
// Public, free, read-only API + proof-of-existence.
//
// Works in Unity (2019.4+, Mono & IL2CPP), .NET 5+/Core/Framework, Godot C#, Xamarin.
// Target: .NET Standard 2.0. Drop the file into your project — that's the install.
//
// // Local clock — no network needed, @beat is derived from the UTC clock:
// label.text = BeatTime.LocalNow(); // "@523.45" (call every frame, it's cheap)
// double b = BeatTime.LocalBeats(); // 523.4567...
//
// // Optional server sync (SNTP-style drift correction), then keep ticking locally:
// var bt = new BeatTime();
// await bt.SyncClockAsync(); // sets the shared clock offset
//
// // Open API (methods return the raw JSON string — parse with your JSON lib
// // of choice: JsonUtility, Newtonsoft, System.Text.Json):
// string json = await bt.NowAsync(); // {"beat":"@523","beat_centi":...}
// string beat = await bt.BeatNowAsync(); // "@523"
//
// // Proof of existence (only the SHA-256 hash is sent — your file stays local):
// string digest = BeatTime.Sha256File("contract.pdf");
// await bt.StampAsync(digest);
// string cert = bt.CertUrl(digest); // downloadable PDF certificate
//
// Unity WebGL note: HttpClient is unavailable there — use UnityWebRequest for the
// API calls if you need them. The local clock (the usual use) needs no network.
//
// No API key, no limits beyond fair-use rate limiting. MIT-style: use freely.
using System;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
public sealed class BeatTime
{
public const string DefaultBaseUrl = "https://beattime.live";
private const double MsPerDay = 86400000.0;
private const double MsPerBeat = 86400.0; // 86400000 ms/day / 1000 beats
/// Clock offset (server - local) in ms, set by SyncClockAsync(). Shared.
public static double OffsetMs = 0.0;
private readonly HttpClient _http;
private readonly string _base;
public BeatTime(string baseUrl = DefaultBaseUrl, double timeoutSeconds = 10.0)
{
_base = baseUrl.TrimEnd('/');
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(timeoutSeconds) };
}
// --- local clock (no network) ----------------------------------------
/// .beat for a unix-ms instant: ms-of-day (UTC) / 86400.
public static double BeatsAt(long unixMs)
{
double d = ((unixMs % (long)MsPerDay) + (long)MsPerDay) % (long)MsPerDay;
return d / MsPerBeat;
}
/// Current .beat [0,1000) from the device clock (+ sync offset).
public static double LocalBeats()
=> BeatsAt(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + (long)OffsetMs);
/// Format beats: "@523" or "@523.45" (centibeats, the default).
public static string Format(double beats, bool centi = true)
{
int whole = (int)Math.Floor(beats);
string s = "@" + whole.ToString("000");
if (centi) s += "." + ((int)Math.Floor((beats - whole) * 100)).ToString("00");
return s;
}
/// Current formatted @beat from the device clock, e.g. "@523.45".
public static string LocalNow(bool centi = true) => Format(LocalBeats(), centi);
// --- open API (raw JSON strings) -------------------------------------
/// Current time: {"beat":"@523","beat_centi":"@523.45","beats":..,"utc":..}.
public Task NowAsync() => GetAsync("/api/now/");
/// Just the current "@NNN" (extracted from /api/now/).
public async Task BeatNowAsync()
=> ExtractString(await NowAsync().ConfigureAwait(false), "beat");
/// Convert an instant (ISO 8601; no timezone = UTC) to its @beat.
public Task TimeToBeatAsync(string iso8601)
=> GetAsync("/api/convert/?at=" + Uri.EscapeDataString(iso8601));
/// Convert a beat [0,1000) to UTC time. Optional date (YYYY-MM-DD) and
/// tzOffset (minutes, e.g. 120 for UTC+2) to also get the local time.
public Task BeatToTimeAsync(double beat, string date = null, int? tzOffset = null)
{
var q = "/api/convert/?beat=" + beat.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (date != null) q += "&date=" + Uri.EscapeDataString(date);
if (tzOffset.HasValue) q += "&tz_offset=" + tzOffset.Value;
return GetAsync(q);
}
/// Server time for clock sync: {"server_unix_ms":..,...}.
public Task SyncAsync() => GetAsync("/api/sync/");
public Task HealthAsync() => GetAsync("/api/health/");
/// Fetch /api/sync/ and set OffsetMs (SNTP-style, halves the round-trip).
/// After this, LocalBeats()/LocalNow() tick on the corrected clock.
public async Task SyncClockAsync()
{
long t0 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
string json = await SyncAsync().ConfigureAwait(false);
long t1 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
double server = ExtractNumber(json, "server_unix_ms");
OffsetMs = server + (t1 - t0) / 2.0 - t1;
return OffsetMs;
}
// --- proof of existence ---------------------------------------------
/// Record a 64-char lowercase hex SHA-256 in the public proof log.
/// Idempotent: the first stamp wins. The file never leaves your machine.
public Task StampAsync(string digest)
=> PostAsync("/api/proof/stamp", "{\"digest\":\"" + digest + "\"}");
/// Look up a digest: timestamp, inclusion proof, signature, anchors.
public Task VerifyAsync(string digest)
=> GetAsync("/api/proof/verify?digest=" + Uri.EscapeDataString(digest));
/// URL of the downloadable PDF certificate for a stamped digest.
public string CertUrl(string digest) => _base + "/api/proof/cert/" + digest;
/// URL of the public verification page for a digest.
public string VerifyUrl(string digest) => _base + "/proof/?h=" + digest;
/// SHA-256 of a file (streamed) — feed it to StampAsync()/VerifyAsync().
public static string Sha256File(string path)
{
using (var sha = SHA256.Create())
using (var f = File.OpenRead(path))
{
var hash = sha.ComputeHash(f);
var sb = new StringBuilder(64);
foreach (byte b in hash) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
// --- plumbing --------------------------------------------------------
private async Task GetAsync(string path)
{
var r = await _http.GetAsync(_base + path).ConfigureAwait(false);
r.EnsureSuccessStatusCode();
return await r.Content.ReadAsStringAsync().ConfigureAwait(false);
}
private async Task PostAsync(string path, string jsonBody)
{
var content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
var r = await _http.PostAsync(_base + path, content).ConfigureAwait(false);
r.EnsureSuccessStatusCode();
return await r.Content.ReadAsStringAsync().ConfigureAwait(false);
}
// Minimal flat-key JSON field extraction — avoids a JSON dependency for the
// two fields the SDK itself needs. For everything else, parse the raw JSON.
private static string ExtractString(string json, string key)
{
int i = json.IndexOf("\"" + key + "\"", StringComparison.Ordinal);
if (i < 0) return null;
i = json.IndexOf(':', i) + 1;
int a = json.IndexOf('"', i) + 1;
int b = json.IndexOf('"', a);
return json.Substring(a, b - a);
}
private static double ExtractNumber(string json, string key)
{
int i = json.IndexOf("\"" + key + "\"", StringComparison.Ordinal);
if (i < 0) return double.NaN;
i = json.IndexOf(':', i) + 1;
int e = i;
while (e < json.Length && (char.IsDigit(json[e]) || json[e] == '.' ||
json[e] == '-' || json[e] == '+' || json[e] == 'e' || json[e] == 'E' ||
char.IsWhiteSpace(json[e]))) e++;
return double.Parse(json.Substring(i, e - i).Trim(),
System.Globalization.CultureInfo.InvariantCulture);
}
}