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