You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
374 lines
13 KiB
374 lines
13 KiB
|
1 week ago
|
@page "/relay-drum"
|
||
|
|
@rendermode InteractiveServer
|
||
|
|
@implements IDisposable
|
||
|
|
|
||
|
|
@using IOModuleTestBlazor.Services
|
||
|
|
@using Peak.Can.Basic
|
||
|
|
|
||
|
|
@inject ICanService CanService
|
||
|
|
|
||
|
|
<PageTitle>Relay Drum Machine</PageTitle>
|
||
|
|
|
||
|
|
<div class="container-fluid">
|
||
|
|
<h1 class="mb-3">Relay Drum Machine</h1>
|
||
|
|
|
||
|
|
@if (!CanService.IsConnected)
|
||
|
|
{
|
||
|
|
<div class="alert alert-warning mb-3">CAN not connected — connect on the Home page first.</div>
|
||
|
|
}
|
||
|
|
|
||
|
|
@* ── Transport ───────────────────────────────────────────────────────────── *@
|
||
|
|
<div class="card mb-3">
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="d-flex align-items-center gap-4 flex-wrap">
|
||
|
|
<button class="btn @(_playing ? "btn-danger" : "btn-success") btn-lg px-4"
|
||
|
|
@onclick="TogglePlay"
|
||
|
|
disabled="@(!CanService.IsConnected)">
|
||
|
|
@(_playing ? "⏹ Stop" : "▶ Play")
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<div class="d-flex align-items-center gap-2">
|
||
|
|
<label class="fw-semibold mb-0">BPM</label>
|
||
|
|
<input type="range" class="form-range" style="width:160px"
|
||
|
|
min="40" max="240" step="1"
|
||
|
|
@bind="_bpm" @bind:event="oninput" />
|
||
|
|
<span class="font-monospace fw-bold" style="min-width:3ch">@_bpm</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="d-flex align-items-center gap-2">
|
||
|
|
<label class="fw-semibold mb-0">Pulse</label>
|
||
|
|
<input type="range" class="form-range" style="width:120px"
|
||
|
|
min="20" max="80" step="5"
|
||
|
|
@bind="_pulseDurationMs" @bind:event="oninput" />
|
||
|
|
<span class="font-monospace" style="min-width:5ch">@_pulseDurationMs ms</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<button class="btn btn-outline-secondary" @onclick="ClearPattern">Clear</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
@* ── Presets ─────────────────────────────────────────────────────────────── *@
|
||
|
|
<div class="card mb-3">
|
||
|
|
<div class="card-header fw-semibold">Presets</div>
|
||
|
|
<div class="card-body d-flex gap-2 flex-wrap">
|
||
|
|
@foreach (var preset in _presets)
|
||
|
|
{
|
||
|
|
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoadPreset(preset.Value)">
|
||
|
|
@preset.Key
|
||
|
|
</button>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
@* ── Pattern grid ────────────────────────────────────────────────────────── *@
|
||
|
|
<div class="card mb-3">
|
||
|
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||
|
|
<span class="fw-semibold">Pattern — @_barCount bar@(_barCount != 1 ? "s" : "") (@StepCount steps, 4/4 16th-note grid)</span>
|
||
|
|
<div class="d-flex align-items-center gap-2">
|
||
|
|
<button class="btn btn-outline-secondary btn-sm"
|
||
|
|
@onclick="RemoveBar" disabled="@(_barCount <= 1)">− Bar</button>
|
||
|
|
<span class="font-monospace px-1">@_barCount</span>
|
||
|
|
<button class="btn btn-outline-secondary btn-sm"
|
||
|
|
@onclick="AddBar" disabled="@(_barCount >= 8)">+ Bar</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="card-body">
|
||
|
|
|
||
|
|
@* Beat header — shown once above the first bar *@
|
||
|
|
<div class="beat-header">
|
||
|
|
<div class="bar-label-col"></div>
|
||
|
|
<div class="step-grid-col beat-header-grid">
|
||
|
|
@for (int b = 0; b < 4; b++)
|
||
|
|
{
|
||
|
|
<div class="beat-header-cell">@(b + 1)</div>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
@* One row per bar *@
|
||
|
|
@for (int bar = 0; bar < _barCount; bar++)
|
||
|
|
{
|
||
|
|
var barIndex = bar;
|
||
|
|
<div class="bar-row @(barIndex == _currentBar ? "bar-row-active" : "")">
|
||
|
|
<div class="bar-label-col">
|
||
|
|
<span class="bar-label">B@(barIndex + 1)</span>
|
||
|
|
</div>
|
||
|
|
<div class="step-grid-col">
|
||
|
|
<div class="step-grid">
|
||
|
|
@for (int s = 0; s < 16; s++)
|
||
|
|
{
|
||
|
|
var globalStep = barIndex * 16 + s;
|
||
|
|
var localStep = s;
|
||
|
|
bool isGroupStart = localStep % 4 == 0;
|
||
|
|
<div class="step-cell @GetStepClass(globalStep) @(isGroupStart ? "beat-start" : "")"
|
||
|
|
@onclick="() => ToggleStep(globalStep)">
|
||
|
|
<span class="step-num">@(localStep + 1)</span>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
@* ── Status bar ──────────────────────────────────────────────────────────── *@
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-body py-2 d-flex align-items-center gap-3">
|
||
|
|
@if (_playing && _currentStep >= 0)
|
||
|
|
{
|
||
|
|
<span class="badge bg-success">● Playing</span>
|
||
|
|
<span class="font-monospace">Bar @(_currentBar + 1) · Beat @(_currentBeat + 1) · Step @(_currentStepInBar + 1)</span>
|
||
|
|
<span class="text-muted">(global step @(_currentStep + 1) / @StepCount)</span>
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
<span class="badge bg-secondary">■ Stopped</span>
|
||
|
|
}
|
||
|
|
<span class="text-muted ms-auto">@(_steps.Count(s => s)) / @StepCount steps active</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.beat-header {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
margin-bottom: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.beat-header-grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(4, 1fr);
|
||
|
|
}
|
||
|
|
|
||
|
|
.beat-header-cell {
|
||
|
|
text-align: center;
|
||
|
|
font-size: 0.7rem;
|
||
|
|
font-weight: bold;
|
||
|
|
color: #6c757d;
|
||
|
|
padding-left: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.bar-label-col {
|
||
|
|
min-width: 36px;
|
||
|
|
flex-shrink: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.bar-label {
|
||
|
|
font-size: 0.7rem;
|
||
|
|
font-weight: bold;
|
||
|
|
color: #6c757d;
|
||
|
|
}
|
||
|
|
|
||
|
|
.step-grid-col {
|
||
|
|
flex: 1;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.bar-row {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
margin-bottom: 6px;
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 2px 4px;
|
||
|
|
transition: background-color 0.1s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.bar-row-active {
|
||
|
|
background-color: rgba(255, 193, 7, 0.08);
|
||
|
|
}
|
||
|
|
|
||
|
|
.step-grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(16, 1fr);
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.step-cell {
|
||
|
|
aspect-ratio: 1;
|
||
|
|
border: 2px solid #495057;
|
||
|
|
border-radius: 5px;
|
||
|
|
cursor: pointer;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
transition: background-color 0.1s, box-shadow 0.1s;
|
||
|
|
user-select: none;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.step-cell:hover { border-color: #0d6efd; }
|
||
|
|
|
||
|
|
.step-num {
|
||
|
|
font-size: 0.6rem;
|
||
|
|
font-weight: bold;
|
||
|
|
line-height: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.beat-start { border-left: 2px solid #6c757d; }
|
||
|
|
|
||
|
|
/* States */
|
||
|
|
.step-off { background-color: #343a40; color: #6c757d; }
|
||
|
|
.step-on { background-color: #0d6efd; border-color: #0d6efd; color: white; }
|
||
|
|
.step-cursor { box-shadow: 0 0 0 2px #ffc107; border-color: #ffc107 !important; }
|
||
|
|
.step-cursor-on { background-color: #fd7e14; border-color: #ffc107 !important; color: white;
|
||
|
|
box-shadow: 0 0 10px rgba(253,126,20,0.8), 0 0 0 2px #ffc107; }
|
||
|
|
</style>
|
||
|
|
|
||
|
|
@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<string, bool[]> _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();
|
||
|
|
}
|
||
|
|
}
|