Compare commits
No commits in common. 'b6ab373801ae008ec19a6be4a8aae50e633744dc' and '0ffcc4c4e071ceaa61ead080ce5272b7876b0285' have entirely different histories.
b6ab373801
...
0ffcc4c4e0
2 changed files with 0 additions and 379 deletions
@ -1,373 +0,0 @@ |
||||
@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