parent
ba40821bd5
commit
65be28065c
2 changed files with 379 additions and 0 deletions
@ -0,0 +1,373 @@ |
|||||||
|
@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(); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue