@page "/relay-drum" @rendermode InteractiveServer @implements IDisposable @using IOModuleTestBlazor.Services @using Peak.Can.Basic @inject ICanService CanService Relay Drum Machine

Relay Drum Machine

@if (!CanService.IsConnected) {
CAN not connected — connect on the Home page first.
} @* ── Transport ───────────────────────────────────────────────────────────── *@
@_bpm
@_pulseDurationMs ms
@* ── Presets ─────────────────────────────────────────────────────────────── *@
Presets
@foreach (var preset in _presets) { }
@* ── Pattern grid ────────────────────────────────────────────────────────── *@
Pattern — @_barCount bar@(_barCount != 1 ? "s" : "") (@StepCount steps, 4/4 16th-note grid)
@_barCount
@* Beat header — shown once above the first bar *@
@for (int b = 0; b < 4; b++) {
@(b + 1)
}
@* One row per bar *@ @for (int bar = 0; bar < _barCount; bar++) { var barIndex = bar;
B@(barIndex + 1)
@for (int s = 0; s < 16; s++) { var globalStep = barIndex * 16 + s; var localStep = s; bool isGroupStart = localStep % 4 == 0;
@(localStep + 1)
}
}
@* ── Status bar ──────────────────────────────────────────────────────────── *@
@if (_playing && _currentStep >= 0) { ● Playing Bar @(_currentBar + 1) · Beat @(_currentBeat + 1) · Step @(_currentStepInBar + 1) (global step @(_currentStep + 1) / @StepCount) } else { ■ Stopped } @(_steps.Count(s => s)) / @StepCount steps active
@code { private const int StepsPerBar = 16; private int _barCount = 1; private int StepCount => _barCount * StepsPerBar; private bool[] _steps = new bool[StepsPerBar]; private int _currentStep = -1; private int _currentBar => _currentStep < 0 ? -1 : _currentStep / StepsPerBar; private int _currentStepInBar => _currentStep < 0 ? -1 : _currentStep % StepsPerBar; private int _currentBeat => _currentStep < 0 ? -1 : (_currentStep % StepsPerBar) / 4; private bool _playing; private int _bpm = 120; private int _pulseDurationMs = 30; private CancellationTokenSource? _cts; private static readonly Dictionary _presets = new() { ["Four-on-Floor"] = [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false], ["Kick 1+3"] = [true, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false], ["Snare 2+4"] = [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false], ["Hi-Hat 8th"] = [true, false, true, false, true, false, true, false, true, false, true, false, true, false, true, false], ["Hi-Hat 16th"] = [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true], ["Off-Beat"] = [false, false, true, false, false, false, true, false, false, false, true, false, false, false, true, false], ["Clave 3-2"] = [true, false, false, true, false, false, true, false, false, false, true, false, false, true, false, false], }; private string GetStepClass(int step) { bool isCurrent = step == _currentStep; bool isOn = _steps[step]; return (isCurrent, isOn) switch { (true, true) => "step-cursor-on", (true, false) => "step-off step-cursor", (false, true) => "step-on", _ => "step-off", }; } private void ToggleStep(int step) => _steps[step] = !_steps[step]; private void ClearPattern() { Array.Clear(_steps); StateHasChanged(); } private void LoadPreset(bool[] pattern) { // Tile the 16-step preset across however many bars are active var next = new bool[StepCount]; for (int i = 0; i < StepCount; i++) next[i] = pattern[i % StepsPerBar]; _steps = next; StateHasChanged(); } private async Task AddBar() { await StopSequencer(); Array.Resize(ref _steps, (_barCount + 1) * StepsPerBar); _barCount++; } private async Task RemoveBar() { if (_barCount <= 1) return; await StopSequencer(); _barCount--; Array.Resize(ref _steps, _barCount * StepsPerBar); } private async Task TogglePlay() { if (_playing) await StopSequencer(); else StartSequencer(); } private void StartSequencer() { _cts?.Dispose(); _cts = new CancellationTokenSource(); _playing = true; var steps = _steps; // snapshot so resize mid-play doesn't affect the running loop _ = Task.Run(() => RunSequencer(steps, _cts.Token)); } private async Task StopSequencer() { _cts?.Cancel(); // Yield briefly to let the background task react before we return await Task.Delay(50); } private async Task RunSequencer(bool[] steps, CancellationToken ct) { int total = steps.Length; try { while (!ct.IsCancellationRequested) { for (int step = 0; step < total && !ct.IsCancellationRequested; step++) { _currentStep = step; try { await InvokeAsync(StateHasChanged); } catch { return; } int stepMs = Math.Max(30, (int)(60_000.0 / _bpm / 4)); int pulseMs = Math.Min(_pulseDurationMs, stepMs / 2); if (steps[step]) { SendRelay(true); await Task.Delay(pulseMs, ct); SendRelay(false); int rest = stepMs - pulseMs; if (rest > 0) await Task.Delay(rest, ct); } else { await Task.Delay(stepMs, ct); } } } } catch (OperationCanceledException) { } finally { _currentStep = -1; _playing = false; SendRelay(false); try { await InvokeAsync(StateHasChanged); } catch { } } } private void SendRelay(bool on) { var data = new byte[8]; data[0] = on ? (byte)0x01 : (byte)0x02; // 0x01=TERM_ON(SET), 0x02=TERM_OFF(RESET) CanService.Write(new PcanMessage(0x240, MessageType.Standard, 8, data, false)); } public void Dispose() { _cts?.Cancel(); _cts?.Dispose(); } }