@pro-gram/flux 1.6.1 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/flux.js +1 -1
- package/dependencies.json +15 -15
- package/package.json +60 -60
- package/payload/DrumBuddy-Setup.exe +2 -2
- package/source/AssetBootstrap.cs +1 -1
- package/source/Audio Decoders.txt +1 -1
- package/source/Audio.cs +27 -5
- package/source/Build Instructions.txt +4 -0
- package/source/Build.ps1 +6 -1
- package/source/EditorRecording.cs +106 -0
- package/source/EditorView.cs +13 -7
- package/source/Main.cs +100 -47
- package/source/Main.xaml +1 -1
- package/source/Quick Start.txt +9 -3
- package/source/RecordingSession.cs +59 -0
- package/source/RecordingTests.cs +141 -0
- package/source/Setup.cs +6 -5
- package/source/TempoGrid.cs +1 -1
- package/source/TempoGridTests.cs +2 -0
- package/source/Third Party Notices.txt +1 -1
- package/source/references/R16 Format Research.txt +0 -16
package/README.md
CHANGED
|
@@ -62,7 +62,9 @@ The visible **- / + buttons and slider** zoom the timeline from a full-project o
|
|
|
62
62
|
|
|
63
63
|
**Ctrl+Plus / Ctrl+Minus** zoom (including Ctrl+= and numeric-keypad keys), **Ctrl+0** fits the project and **Ctrl+Shift+0** fits the selection. These shortcuts are editable in Settings > Keybinds. At close zoom, waveform detail is read from decoded audio in the background, preserving positive/negative peaks and float headroom. The ruler displays fractional seconds when a close-up contains no labeled beat. Zoom changes the view without changing audio, the playhead or undo history.
|
|
64
64
|
|
|
65
|
-
Record options contains microphone/input channels, sample rate and PCM/float format.
|
|
65
|
+
Record options contains microphone/input channels, sample rate and PCM/float format. **Record** or **R** reserves the selected track (consecutive tracks for separate input files) and shows a one-bar countdown at the project tempo and time signature. The countdown displays the destination tracks and selected input. Press Record again, Stop, or Escape to cancel it before capture starts.
|
|
66
|
+
|
|
67
|
+
After the final beat, capture starts through the selected input. A red playhead, growing recording highlights and elapsed timer follow the audio actually received. **Stop & save** preserves the original take and inserts it at the registered starting point; the playhead stays at the end of the take. Bar lines display **1, 2, 3**; detailed beat labels remain available when zoomed in. Use hardware direct monitoring. Software overdub latency alignment is not included.
|
|
66
68
|
|
|
67
69
|
Recording controls, converter output settings and Media Player gain have draggable dividers, Collapse/Show and Side/Bottom positioning.
|
|
68
70
|
|
package/bin/flux.js
CHANGED
|
@@ -10,7 +10,7 @@ const { needsUpdate } = require('../scripts/version.js');
|
|
|
10
10
|
const arg = process.argv[2];
|
|
11
11
|
if (arg === '--version' || arg === '-v') { console.log(pkg.version); return; }
|
|
12
12
|
if (arg === '--help' || arg === '-h') {
|
|
13
|
-
console.log('Flux ' + pkg.version + ' for Windows\n\nflux Open Flux\nflux --setup Install or repair Flux\nflux --audio-diagnostics Save an audio device report\nflux --version Show the npm package version\n\nAudio: H5studio native 32-bit float recording; 16/24-bit PCM recording and export.\nWindows/Realtek/USB microphones use 16-bit PCM input.\nWorkspaces: PCM/float Media Player and 16-track timeline Editor with recording.\nTempo grid: BPM, time signatures, 1/1 to 1/256 subdivisions, triplets and independent grid/edge snapping.\nCtrl+C copies; Backspace deletes selection; Ctrl+V pastes at the playhead.\nDark/light themes, Converter and editable keybind settings.\nUpdate: close Flux, then npm.cmd install -g @pro-gram/flux@latest\nRun flux.cmd afterward to finish setup if npm skipped the install script.'); return;
|
|
13
|
+
console.log('Flux ' + pkg.version + ' for Windows\n\nflux Open Flux\nflux --setup Install or repair Flux\nflux --audio-diagnostics Save an audio device report\nflux --version Show the npm package version\n\nAudio: H5studio native 32-bit float recording; 16/24-bit PCM recording and export.\nWindows/Realtek/USB microphones use 16-bit PCM input.\nWorkspaces: PCM/float Media Player and 16-track timeline Editor with recording.\nRecording: one-bar visual count-in, reserved input tracks and a moving recording playhead.\nTempo grid: BPM, time signatures, 1/1 to 1/256 subdivisions, triplets and independent grid/edge snapping.\nCtrl+C copies; Backspace deletes selection; Ctrl+V pastes at the playhead.\nDark/light themes, Converter and editable keybind settings.\nUpdate: close Flux, then npm.cmd install -g @pro-gram/flux@latest\nRun flux.cmd afterward to finish setup if npm skipped the install script.'); return;
|
|
14
14
|
}
|
|
15
15
|
if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('Flux needs Windows x64.');
|
|
16
16
|
const root = installRoot();
|
package/dependencies.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
{
|
|
2
|
-
"ffmpeg": {
|
|
3
|
-
"url": "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-9.0.2-essentials_build.zip",
|
|
4
|
-
"file": "ffmpeg-9.0.2.zip",
|
|
5
|
-
"sha256": "60f467265b1e312373dbcd92200c2618a74850f98d3d078e94296bb3fa2047ba"
|
|
6
|
-
},
|
|
7
|
-
"panel": {
|
|
8
|
-
"url": "https://zoomcorp.com/media/original_images/R16_list_0.png",
|
|
9
|
-
"file": "r16-panel.png",
|
|
10
|
-
"sha256": "315a71e8942a445c903b2d1aedb703b64aab30f054e46199a2bac447d3d03cb2",
|
|
11
|
-
"alternateSha256": "5564fa622f101dc27ea402c399c5b9b1e1e039b8771daf40b185dab9772adf38"
|
|
12
|
-
},
|
|
13
|
-
"installerSha256": "
|
|
14
|
-
"bundledAssets": true
|
|
15
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"ffmpeg": {
|
|
3
|
+
"url": "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-9.0.2-essentials_build.zip",
|
|
4
|
+
"file": "ffmpeg-9.0.2.zip",
|
|
5
|
+
"sha256": "60f467265b1e312373dbcd92200c2618a74850f98d3d078e94296bb3fa2047ba"
|
|
6
|
+
},
|
|
7
|
+
"panel": {
|
|
8
|
+
"url": "https://zoomcorp.com/media/original_images/R16_list_0.png",
|
|
9
|
+
"file": "r16-panel.png",
|
|
10
|
+
"sha256": "315a71e8942a445c903b2d1aedb703b64aab30f054e46199a2bac447d3d03cb2",
|
|
11
|
+
"alternateSha256": "5564fa622f101dc27ea402c399c5b9b1e1e039b8771daf40b185dab9772adf38"
|
|
12
|
+
},
|
|
13
|
+
"installerSha256": "1d54666d43c987d0200d70634cec75da7e7cc269aaee224603c5cc7139a2cb81",
|
|
14
|
+
"bundledAssets": true
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@pro-gram/flux",
|
|
3
|
-
"version": "1.6.
|
|
4
|
-
"description": "Flux: a 16-track Windows audio studio with native shared playback, float/PCM recording, clip editing tools, tempo grids and bundled audio codecs.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"author": "pro-gram",
|
|
7
|
-
"os": [
|
|
8
|
-
"win32"
|
|
9
|
-
],
|
|
10
|
-
"cpu": [
|
|
11
|
-
"x64"
|
|
12
|
-
],
|
|
13
|
-
"engines": {
|
|
14
|
-
"node": ">=18"
|
|
15
|
-
},
|
|
16
|
-
"bin": {
|
|
17
|
-
"flux": "bin/flux.js"
|
|
18
|
-
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"postinstall": "echo If npm skips setup, run flux --setup && node scripts/install.js",
|
|
21
|
-
"test": "node --test test/*.test.js"
|
|
22
|
-
},
|
|
23
|
-
"files": [
|
|
24
|
-
"bin/flux.js",
|
|
25
|
-
"scripts/",
|
|
26
|
-
"test/",
|
|
27
|
-
"payload/DrumBuddy-Setup.exe",
|
|
28
|
-
"dependencies.json",
|
|
29
|
-
"source/",
|
|
30
|
-
"LICENSE",
|
|
31
|
-
"NOTICE.txt"
|
|
32
|
-
],
|
|
33
|
-
"keywords": [
|
|
34
|
-
"audio-editor",
|
|
35
|
-
"media-player",
|
|
36
|
-
"multitrack",
|
|
37
|
-
"zoom-h5studio",
|
|
38
|
-
"32-bit-float",
|
|
39
|
-
"pcm",
|
|
40
|
-
"zoom-r16",
|
|
41
|
-
"drums",
|
|
42
|
-
"audio",
|
|
43
|
-
"midi",
|
|
44
|
-
"wav",
|
|
45
|
-
"windows",
|
|
46
|
-
"recording",
|
|
47
|
-
"fluxtempo",
|
|
48
|
-
"realtek",
|
|
49
|
-
"audio-workstation",
|
|
50
|
-
"flux",
|
|
51
|
-
"tempo-grid",
|
|
52
|
-
"beat-grid",
|
|
53
|
-
"snap-to-grid"
|
|
54
|
-
],
|
|
55
|
-
"publishConfig": {
|
|
56
|
-
"access": "public",
|
|
57
|
-
"registry": "https://registry.npmjs.org/"
|
|
58
|
-
},
|
|
59
|
-
"homepage": "https://fluxtempo.ca"
|
|
60
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@pro-gram/flux",
|
|
3
|
+
"version": "1.6.2",
|
|
4
|
+
"description": "Flux: a 16-track Windows audio studio with native shared playback, float/PCM recording, clip editing tools, tempo grids and bundled audio codecs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "pro-gram",
|
|
7
|
+
"os": [
|
|
8
|
+
"win32"
|
|
9
|
+
],
|
|
10
|
+
"cpu": [
|
|
11
|
+
"x64"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"flux": "bin/flux.js"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"postinstall": "echo If npm skips setup, run flux --setup && node scripts/install.js",
|
|
21
|
+
"test": "node --test test/*.test.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin/flux.js",
|
|
25
|
+
"scripts/",
|
|
26
|
+
"test/",
|
|
27
|
+
"payload/DrumBuddy-Setup.exe",
|
|
28
|
+
"dependencies.json",
|
|
29
|
+
"source/",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"NOTICE.txt"
|
|
32
|
+
],
|
|
33
|
+
"keywords": [
|
|
34
|
+
"audio-editor",
|
|
35
|
+
"media-player",
|
|
36
|
+
"multitrack",
|
|
37
|
+
"zoom-h5studio",
|
|
38
|
+
"32-bit-float",
|
|
39
|
+
"pcm",
|
|
40
|
+
"zoom-r16",
|
|
41
|
+
"drums",
|
|
42
|
+
"audio",
|
|
43
|
+
"midi",
|
|
44
|
+
"wav",
|
|
45
|
+
"windows",
|
|
46
|
+
"recording",
|
|
47
|
+
"fluxtempo",
|
|
48
|
+
"realtek",
|
|
49
|
+
"audio-workstation",
|
|
50
|
+
"flux",
|
|
51
|
+
"tempo-grid",
|
|
52
|
+
"beat-grid",
|
|
53
|
+
"snap-to-grid"
|
|
54
|
+
],
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public",
|
|
57
|
+
"registry": "https://registry.npmjs.org/"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://fluxtempo.ca"
|
|
60
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/payload/DrumBuddy-Setup.exe
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 41726976 bytes
|
|
4
|
+
sha256: 1d54666d43c987d0200d70634cec75da7e7cc269aaee224603c5cc7139a2cb81
|
package/source/AssetBootstrap.cs
CHANGED
|
@@ -21,7 +21,7 @@ namespace DrumBuddySetup
|
|
|
21
21
|
if(File.Exists(target) && expected.Contains(Hash(target),StringComparer.OrdinalIgnoreCase))return target;
|
|
22
22
|
string temporary=target+"."+Guid.NewGuid().ToString("N")+".partial";
|
|
23
23
|
try {
|
|
24
|
-
using(var client=new WebClient()) { client.Headers[HttpRequestHeader.UserAgent]="DrumBuddy/1.6.
|
|
24
|
+
using(var client=new WebClient()) { client.Headers[HttpRequestHeader.UserAgent]="DrumBuddy/1.6.2";client.DownloadFile(url,temporary); }
|
|
25
25
|
if(!expected.Contains(Hash(temporary),StringComparer.OrdinalIgnoreCase))throw new IOException("A downloaded component failed its integrity check. Please retry setup or obtain a newer Drum Buddy release.");
|
|
26
26
|
File.Copy(temporary,target,true);return target;
|
|
27
27
|
}finally { if(File.Exists(temporary))File.Delete(temporary); }
|
package/source/Audio.cs
CHANGED
|
@@ -282,6 +282,7 @@ namespace DrumBuddy
|
|
|
282
282
|
private Task writing;
|
|
283
283
|
private readonly object gate = new object();
|
|
284
284
|
private bool accepting;
|
|
285
|
+
private Action beginCapture;
|
|
285
286
|
private int captureChannels, selectedInput, savedChannels;
|
|
286
287
|
private bool separate, requireFloatInput;
|
|
287
288
|
private readonly List<IRecordingWriter> writers = new List<IRecordingWriter>();
|
|
@@ -300,18 +301,27 @@ namespace DrumBuddy
|
|
|
300
301
|
|
|
301
302
|
public void Start(InputChoice input, int mode, int bits, string folder, int rate)
|
|
302
303
|
{
|
|
303
|
-
|
|
304
|
+
Prepare(input, mode, bits, folder, rate); BeginCapture();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Prepare drivers on the UI thread before the count-in, without capturing audio
|
|
308
|
+
// or creating a take. ASIO COM objects must stay on this thread.
|
|
309
|
+
public void Prepare(InputChoice input, int mode, int bits, string folder, int rate)
|
|
310
|
+
{
|
|
311
|
+
if (input == null) throw new ArgumentNullException("input");
|
|
312
|
+
if (device != null || windowsInput != null || queue != null || beginCapture != null) throw new InvalidOperationException("This recorder is already prepared.");
|
|
313
|
+
if (input.Driver != null) { PrepareAsio(input.Driver, mode, bits, folder, rate); return; }
|
|
304
314
|
try {
|
|
305
315
|
windowsChannels = AudioInputs.Channels(input);
|
|
306
316
|
var layout = CaptureLayout.Create(windowsChannels, mode);
|
|
317
|
+
captureChannels = layout.Channels;
|
|
307
318
|
windowsInput = new WaveInEvent { DeviceNumber = AudioInputs.Device(input), WaveFormat = new WaveFormat(rate, 16, windowsChannels), BufferMilliseconds = 50, NumberOfBuffers = 3 };
|
|
308
|
-
StartBuffered(layout, bits, folder, rate, false);
|
|
309
319
|
SourceFormat = "Windows 16-bit PCM"; SourceIsFloat = false;
|
|
310
320
|
windowsInput.DataAvailable += delegate(object sender, WaveInEventArgs e) { ProcessWindowsPcm(e.Buffer, e.BytesRecorded, windowsChannels); };
|
|
311
321
|
windowsInput.RecordingStopped += delegate(object sender, StoppedEventArgs e) {
|
|
312
322
|
lock (gate) { if (accepting && Error == null) Error = e.Exception ?? new IOException("The microphone stopped. Check its connection and Windows microphone permissions."); }
|
|
313
323
|
};
|
|
314
|
-
windowsInput.StartRecording();
|
|
324
|
+
beginCapture = delegate { StartBuffered(layout, bits, folder, rate, false); windowsInput.StartRecording(); };
|
|
315
325
|
}
|
|
316
326
|
catch { Stop(); throw; }
|
|
317
327
|
}
|
|
@@ -341,19 +351,30 @@ namespace DrumBuddy
|
|
|
341
351
|
}
|
|
342
352
|
|
|
343
353
|
public void Start(string driver, int mode, int bits, string folder, int rate)
|
|
354
|
+
{ Start(new InputChoice { Driver = driver, Name = driver }, mode, bits, folder, rate); }
|
|
355
|
+
|
|
356
|
+
public void BeginCapture()
|
|
357
|
+
{
|
|
358
|
+
var start = beginCapture;
|
|
359
|
+
if (start == null) throw new InvalidOperationException("Prepare a recording input before starting capture.");
|
|
360
|
+
beginCapture = null;
|
|
361
|
+
try { start(); } catch { Stop(); throw; }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private void PrepareAsio(string driver, int mode, int bits, string folder, int rate)
|
|
344
365
|
{
|
|
345
366
|
try {
|
|
346
367
|
device = new AsioOut(driver);
|
|
347
368
|
var layout = CaptureLayout.Create(device.DriverInputChannelCount, mode);
|
|
369
|
+
captureChannels = layout.Channels;
|
|
348
370
|
if (!device.IsSampleRateSupported(rate)) throw new IOException("This device does not support " + rate + " Hz in its current mode. Choose a supported rate, or set the recorder to Audio I/F Only.");
|
|
349
371
|
device.InputChannelOffset = layout.FirstInput;
|
|
350
372
|
device.InitRecordAndPlayback(null, layout.Channels, rate);
|
|
351
|
-
StartBuffered(layout, bits, folder, rate, bits == 32 && CaptureLayout.IsH5studio(driver));
|
|
352
373
|
device.AudioAvailable += Captured;
|
|
353
374
|
device.PlaybackStopped += delegate(object sender, StoppedEventArgs e) {
|
|
354
375
|
lock (gate) { if (accepting && Error == null) Error = e.Exception ?? new IOException("The recording device stopped sending audio. Check its USB connection."); }
|
|
355
376
|
};
|
|
356
|
-
device.Play();
|
|
377
|
+
beginCapture = delegate { StartBuffered(layout, bits, folder, rate, bits == 32 && CaptureLayout.IsH5studio(driver)); device.Play(); };
|
|
357
378
|
}
|
|
358
379
|
catch { Stop(); throw; }
|
|
359
380
|
}
|
|
@@ -427,6 +448,7 @@ namespace DrumBuddy
|
|
|
427
448
|
// Call on the UI thread: ASIO COM objects have thread affinity.
|
|
428
449
|
public void Stop()
|
|
429
450
|
{
|
|
451
|
+
beginCapture = null;
|
|
430
452
|
lock (gate) accepting = false;
|
|
431
453
|
if (windowsInput != null) {
|
|
432
454
|
try { windowsInput.StopRecording(); } catch (Exception ex) { if (Error == null) Error = ex; }
|
|
@@ -18,6 +18,7 @@ Verification:
|
|
|
18
18
|
DrumBuddy.exe --tools-self-test "C:\path\to\new-tool-test-folder"
|
|
19
19
|
DrumBuddy.exe --workspace-self-test "C:\path\to\new-editor-test-folder"
|
|
20
20
|
DrumBuddy.exe --float-self-test "C:\path\to\new-float-test-folder"
|
|
21
|
+
DrumBuddy.exe --recording-self-test "C:\path\to\new-recording-test-folder"
|
|
21
22
|
DrumBuddy.exe --output-self-test "C:\path\to\new-output-test-folder"
|
|
22
23
|
DrumBuddy.exe --editor-playback-self-test "C:\path\to\new-playback-test-folder"
|
|
23
24
|
DrumBuddy.exe --interface-self-test "C:\path\to\new-interface-test-folder"
|
|
@@ -40,6 +41,9 @@ exposed. The output suite queries microphone formats without opening capture.
|
|
|
40
41
|
The editor-playback suite imports float/PCM files and exercises the actual
|
|
41
42
|
editor transport with muted listening, plus native pause/resume/drain checks.
|
|
42
43
|
Windows microphone buffer tests cover routing, saved samples and queue draining.
|
|
44
|
+
The recording suite covers count-in timing/cancellation, frozen input and track
|
|
45
|
+
routing, captured-frame playhead progress, recording visuals, take insertion
|
|
46
|
+
and preserved originals using generated input buffers.
|
|
43
47
|
The interface suite checks keybind capture, saved/unsaved settings, theme
|
|
44
48
|
switching, integrated recording insertion and dark/light/compact layouts.
|
|
45
49
|
The general-conversion suite checks original-rate float/PCM WAV, FLAC,
|
package/source/Build.ps1
CHANGED
|
@@ -100,6 +100,7 @@ $appArgs += Join-Path $PSScriptRoot 'EditorSelectionTests.cs'
|
|
|
100
100
|
$appArgs += @('EditorTools.cs','EditorToolInteractions.cs') | ForEach-Object { Join-Path $PSScriptRoot $_ }
|
|
101
101
|
$appArgs += Join-Path $PSScriptRoot 'EditorToolsTests.cs'
|
|
102
102
|
$appArgs += Join-Path $PSScriptRoot 'EditorCompactLayout.cs'
|
|
103
|
+
$appArgs += @('RecordingSession.cs','EditorRecording.cs','RecordingTests.cs') | ForEach-Object { Join-Path $PSScriptRoot $_ }
|
|
103
104
|
& $compiler @appArgs
|
|
104
105
|
if ($LASTEXITCODE -ne 0) { throw 'App compilation failed.' }
|
|
105
106
|
$iconVersion = [Reflection.AssemblyName]::GetAssemblyName((Join-Path $package 'DrumBuddy.exe')).Version.ToString(3)
|
|
@@ -119,7 +120,11 @@ if (Test-Path -LiteralPath $payload) { Remove-Item -LiteralPath $payload }
|
|
|
119
120
|
[IO.Compression.ZipFile]::CreateFromDirectory($package,$payload,[IO.Compression.CompressionLevel]::Optimal,$false)
|
|
120
121
|
$manifest = Get-ChildItem -LiteralPath $package -Recurse -File | ForEach-Object {
|
|
121
122
|
$relative = $_.FullName.Substring($package.Length + 1).Replace('\','/')
|
|
122
|
-
|
|
123
|
+
$payloadStream = [IO.File]::OpenRead($_.FullName)
|
|
124
|
+
$payloadHasher = [Security.Cryptography.SHA256]::Create()
|
|
125
|
+
try { $payloadHash = [BitConverter]::ToString($payloadHasher.ComputeHash($payloadStream)).Replace('-','') }
|
|
126
|
+
finally { $payloadStream.Dispose(); $payloadHasher.Dispose() }
|
|
127
|
+
$payloadHash + ' ' + $relative
|
|
123
128
|
}
|
|
124
129
|
$manifestPath = Join-Path $BuildFolder 'payload.sha256'
|
|
125
130
|
[IO.File]::WriteAllLines($manifestPath,[string[]]$manifest,(New-Object Text.UTF8Encoding($false)))
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Generic;
|
|
3
|
+
using System.Linq;
|
|
4
|
+
using System.Windows;
|
|
5
|
+
using System.Windows.Controls;
|
|
6
|
+
using System.Windows.Media;
|
|
7
|
+
using System.Windows.Shapes;
|
|
8
|
+
|
|
9
|
+
namespace DrumBuddy
|
|
10
|
+
{
|
|
11
|
+
public sealed partial class EditorView
|
|
12
|
+
{
|
|
13
|
+
private Border recordingBanner;
|
|
14
|
+
private TextBlock countdownText, recordingHeadline, recordingRouting;
|
|
15
|
+
private RecordingPlan recordingPlan;
|
|
16
|
+
private bool captureRunning;
|
|
17
|
+
private readonly List<Rectangle> recordingOverlays = new List<Rectangle>();
|
|
18
|
+
|
|
19
|
+
private void InitializeRecordingFeedback(Panel tools)
|
|
20
|
+
{
|
|
21
|
+
var body = new DockPanel();
|
|
22
|
+
countdownText = WorkspaceUi.Text("", 30); countdownText.FontWeight = FontWeights.Bold;
|
|
23
|
+
countdownText.Foreground = WorkspaceUi.Color("#B52C36"); countdownText.MinWidth = 72;
|
|
24
|
+
countdownText.Margin = new Thickness(0, 0, 12, 0); countdownText.VerticalAlignment = VerticalAlignment.Center;
|
|
25
|
+
DockPanel.SetDock(countdownText, Dock.Left); body.Children.Add(countdownText);
|
|
26
|
+
var details = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; body.Children.Add(details);
|
|
27
|
+
recordingHeadline = WorkspaceUi.Text("", 15); recordingHeadline.FontWeight = FontWeights.SemiBold; details.Children.Add(recordingHeadline);
|
|
28
|
+
recordingRouting = WorkspaceUi.Text("", 12); recordingRouting.Margin = new Thickness(0, 3, 0, 0); details.Children.Add(recordingRouting);
|
|
29
|
+
recordingBanner = new Border { Child = body, BorderBrush = WorkspaceUi.Color("#B52C36"), BorderThickness = new Thickness(2),
|
|
30
|
+
Background = WorkspaceUi.Color("#F3F5F5"), Padding = new Thickness(10, 6, 10, 6), Margin = new Thickness(0, 0, 0, 6), Visibility = Visibility.Collapsed };
|
|
31
|
+
tools.Children.Add(recordingBanner);
|
|
32
|
+
}
|
|
33
|
+
public RecordingPlan PlanRecording(InputChoice input, int mode, int bits, int rate, int availableInputs)
|
|
34
|
+
{
|
|
35
|
+
if (!CommitTempo()) throw new System.IO.IOException("Enter a valid project tempo before recording.");
|
|
36
|
+
return new RecordingPlan(input, mode, bits, rate, availableInputs, track, playhead, session.Grid);
|
|
37
|
+
}
|
|
38
|
+
public void BeginRecordingCountdown(RecordingPlan plan)
|
|
39
|
+
{
|
|
40
|
+
recordingPlan = plan; captureRunning = false; playhead = plan.AnchorFrame;
|
|
41
|
+
SetRecordingState(true); recordButton.Content = "Cancel count-in";
|
|
42
|
+
recordingBanner.Visibility = Visibility.Visible; countdownText.Text = "...";
|
|
43
|
+
recordingHeadline.Text = "Preparing recording input"; recordingRouting.Text = plan.Routing;
|
|
44
|
+
BuildHeaders(); DrawCanvas(); EnsurePlayheadVisible();
|
|
45
|
+
double top = RulerHeight + plan.FirstTrack * RowHeight, bottom = top + plan.TrackCount * RowHeight;
|
|
46
|
+
if (scroll.ViewportHeight > 0) {
|
|
47
|
+
if (top < scroll.VerticalOffset) scroll.ScrollToVerticalOffset(Math.Max(0, top - RowHeight));
|
|
48
|
+
else if (bottom > scroll.VerticalOffset + scroll.ViewportHeight) scroll.ScrollToVerticalOffset(Math.Max(0, bottom - scroll.ViewportHeight));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
public void ShowCountdownBeat(int remaining)
|
|
52
|
+
{
|
|
53
|
+
countdownText.Text = remaining.ToString();
|
|
54
|
+
recordingHeadline.Text = "Recording in " + remaining + (remaining == 1 ? " beat" : " beats");
|
|
55
|
+
}
|
|
56
|
+
public void BeginRecordingCapture()
|
|
57
|
+
{
|
|
58
|
+
captureRunning = true; recordButton.Content = "Stop recording"; countdownText.Text = "REC";
|
|
59
|
+
recordingHeadline.Text = "Recording | 00:00.00"; BuildHeaders(); DrawCanvas();
|
|
60
|
+
}
|
|
61
|
+
public void UpdateRecordingProgress(long capturedFrames, int sampleRate)
|
|
62
|
+
{
|
|
63
|
+
if (!recording || recordingPlan == null) return;
|
|
64
|
+
playhead = recordingPlan.TimelineFrame(capturedFrames, sampleRate);
|
|
65
|
+
timeline.Width = Math.Max(timeline.Width, X(Math.Min(EditorSession.MaxFrame, playhead + Audio.Rate * 15L)));
|
|
66
|
+
recordingHeadline.Text = "Recording | " + WorkspaceUi.Clock(capturedFrames / (double)sampleRate);
|
|
67
|
+
UpdateRecordingOverlays(); UpdatePlayLine(); UpdateTransport();
|
|
68
|
+
if (DateTime.UtcNow >= manualViewUntil) EnsurePlayheadVisible();
|
|
69
|
+
}
|
|
70
|
+
public void ShowRecordingSaving()
|
|
71
|
+
{ recordButton.Content = "Saving..."; recordButton.IsEnabled = false; countdownText.Text = "..."; recordingHeadline.Text = "Saving recording"; }
|
|
72
|
+
public void ShowRecordingError(string message)
|
|
73
|
+
{
|
|
74
|
+
recordingBanner.Visibility = Visibility.Visible; countdownText.Text = "!";
|
|
75
|
+
recordingHeadline.Text = "Could not record"; recordingRouting.Text = message;
|
|
76
|
+
}
|
|
77
|
+
private bool RecordingTrack(int index)
|
|
78
|
+
{ return recording && recordingPlan != null && index >= recordingPlan.FirstTrack && index < recordingPlan.FirstTrack + recordingPlan.TrackCount; }
|
|
79
|
+
private void DrawRecordingOverlays()
|
|
80
|
+
{
|
|
81
|
+
recordingOverlays.Clear(); if (recordingPlan == null || !recording) return;
|
|
82
|
+
for (int i = 0; i < recordingPlan.TrackCount; i++) {
|
|
83
|
+
var rectangle = new Rectangle { Fill = WorkspaceUi.Color("#B52C36"), Opacity = .24, Stroke = WorkspaceUi.Color("#B52C36"),
|
|
84
|
+
StrokeThickness = 2, Height = RowHeight - 6, IsHitTestVisible = false, Tag = "RecordingPreview" };
|
|
85
|
+
Canvas.SetTop(rectangle, RulerHeight + (recordingPlan.FirstTrack + i) * RowHeight + 3);
|
|
86
|
+
timeline.Children.Add(rectangle); recordingOverlays.Add(rectangle);
|
|
87
|
+
}
|
|
88
|
+
UpdateRecordingOverlays();
|
|
89
|
+
}
|
|
90
|
+
private void UpdateRecordingOverlays()
|
|
91
|
+
{
|
|
92
|
+
if (recordingPlan == null) return;
|
|
93
|
+
double left = Math.Max(scroll.HorizontalOffset, X(recordingPlan.AnchorFrame));
|
|
94
|
+
double right = Math.Min(scroll.HorizontalOffset + Math.Max(900, scroll.ViewportWidth), X(playhead));
|
|
95
|
+
foreach (var rectangle in recordingOverlays) {
|
|
96
|
+
Canvas.SetLeft(rectangle, left); rectangle.Width = Math.Max(2, right - left);
|
|
97
|
+
rectangle.Visibility = right >= left ? Visibility.Visible : Visibility.Collapsed;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
internal string CountdownForTest { get { return countdownText.Text; } }
|
|
101
|
+
internal string RecordingHeadlineForTest { get { return recordingHeadline.Text; } }
|
|
102
|
+
internal string RoutingForTest { get { return recordingRouting.Text; } }
|
|
103
|
+
internal int[] ArmedTracksForTest { get { return Enumerable.Range(0, 16).Where(RecordingTrack).ToArray(); } }
|
|
104
|
+
internal int RecordingOverlaysForTest { get { return recordingOverlays.Count; } }
|
|
105
|
+
}
|
|
106
|
+
}
|
package/source/EditorView.cs
CHANGED
|
@@ -119,6 +119,7 @@ namespace DrumBuddy
|
|
|
119
119
|
separate = new CheckBox { Content = "Import files on separate tracks", IsChecked = true, FontSize = 14, Margin = new Thickness(4, 0, 0, 8), VerticalAlignment = VerticalAlignment.Center }; editing.Children.Add(separate);
|
|
120
120
|
compactOptionsButton = WorkspaceUi.Button("Audio / export options", delegate { compactOptions = !compactOptions; UpdateCompactLayout(); }); editing.Children.Add(compactOptionsButton);
|
|
121
121
|
|
|
122
|
+
InitializeRecordingFeedback(tools);
|
|
122
123
|
gridControls = new WrapPanel { Margin = new Thickness(0, 0, 0, 3) }; tools.Children.Add(gridControls);
|
|
123
124
|
gridControls.Children.Add(WorkspaceUi.Text("BPM ", 12));
|
|
124
125
|
tempoBox = new TextBox { Width = 60, MinHeight = 28, Padding = new Thickness(5, 3, 5, 3), Margin = new Thickness(0, 0, 9, 5) }; gridControls.Children.Add(tempoBox);
|
|
@@ -202,7 +203,7 @@ namespace DrumBuddy
|
|
|
202
203
|
var transport = new WrapPanel(); bottom.Children.Add(transport);
|
|
203
204
|
play = WorkspaceUi.Button("Play", delegate { PlayPause(); }); play.MinWidth = 90; transport.Children.Add(play);
|
|
204
205
|
transport.Children.Add(WorkspaceUi.Button("Stop", delegate { if (recording && StopRequested != null) StopRequested(); else { Stop(); UpdateTransport(); } }));
|
|
205
|
-
transport.Children.Add(WorkspaceUi.Button("To start", delegate { Stop(); playhead = 0; DrawCanvas(); EnsurePlayheadVisible(); }));
|
|
206
|
+
transport.Children.Add(WorkspaceUi.Button("To start", delegate { if (IsBusy) return; Stop(); playhead = 0; DrawCanvas(); EnsurePlayheadVisible(); }));
|
|
206
207
|
positionText = WorkspaceUi.Text("00:00.00 / 00:00.00", 16); positionText.FontFamily = new FontFamily("Consolas"); positionText.Margin = new Thickness(0, 0, 14, 8); transport.Children.Add(positionText);
|
|
207
208
|
transport.Children.Add(WorkspaceUi.Text("Listen volume ", 14));
|
|
208
209
|
volume = new Slider { Minimum = 0, Maximum = 100, Value = 75, Width = 120, Margin = new Thickness(0, 0, 16, 8), VerticalAlignment = VerticalAlignment.Center };
|
|
@@ -413,7 +414,8 @@ namespace DrumBuddy
|
|
|
413
414
|
for (int i = 0; i < 16; i++) {
|
|
414
415
|
int index = i; var state = session.Tracks[i];
|
|
415
416
|
var panel = new StackPanel { Margin = new Thickness(7, 3, 7, 3) };
|
|
416
|
-
var name = WorkspaceUi.Text((i + 1).ToString("00") + " " + state.Name, 14); name.TextWrapping = TextWrapping.NoWrap; name.TextTrimming = TextTrimming.CharacterEllipsis; name.FontWeight = FontWeights.SemiBold; panel.Children.Add(name);
|
|
417
|
+
var name = WorkspaceUi.Text((i + 1).ToString("00") + (RecordingTrack(i) ? (captureRunning ? " REC " : " ARM ") : " ") + state.Name, 14); name.TextWrapping = TextWrapping.NoWrap; name.TextTrimming = TextTrimming.CharacterEllipsis; name.FontWeight = FontWeights.SemiBold; panel.Children.Add(name);
|
|
418
|
+
if (RecordingTrack(i)) name.Foreground = WorkspaceUi.Color("#B52C36");
|
|
417
419
|
var strip = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) }; panel.Children.Add(strip);
|
|
418
420
|
var mute = new ToggleButton { Content = "M", IsChecked = state.Mute, Width = 28, Height = 26, FontSize = 12, Margin = new Thickness(0, 0, 4, 0), ToolTip = "Mute track " + (i + 1) };
|
|
419
421
|
mute.Click += delegate { if (!updating) Change(delegate { session.Tracks[index].Mute = mute.IsChecked == true; return true; }, "Track mute changed."); }; strip.Children.Add(mute);
|
|
@@ -422,7 +424,7 @@ namespace DrumBuddy
|
|
|
422
424
|
var levels = new[] { -60.0, -24, -18, -12, -6, 0, 3, 6, 12, state.GainDb }.Distinct().OrderBy(v => v).ToArray();
|
|
423
425
|
var level = new ComboBox { ItemsSource = levels.Select(v => v.ToString("0.#", CultureInfo.InvariantCulture) + " dB").ToArray(), SelectedIndex = Array.IndexOf(levels, state.GainDb), Width = 102, MinHeight = 26, Height = 26, FontSize = 12, Padding = new Thickness(3, 0, 3, 0), ToolTip = "Track playback / export level" };
|
|
424
426
|
level.SelectionChanged += delegate { if (!updating && level.SelectedIndex >= 0) { double value = levels[level.SelectedIndex]; Change(delegate { session.Tracks[index].GainDb = value; return true; }, "Track level changed."); } }; strip.Children.Add(level);
|
|
425
|
-
var border = new Border { Height = RowHeight, BorderThickness = new Thickness(0, 0, 1, 1), BorderBrush = WorkspaceUi.Color("#D6E1DF"), Background = WorkspaceUi.Color(i == track ? "#DCEFEB" : "#EDF3F1"), Child = panel };
|
|
427
|
+
var border = new Border { Height = RowHeight, BorderThickness = new Thickness(RecordingTrack(i) ? 3 : 0, 0, 1, 1), BorderBrush = WorkspaceUi.Color(RecordingTrack(i) ? "#B52C36" : "#D6E1DF"), Background = WorkspaceUi.Color(i == track ? "#DCEFEB" : "#EDF3F1"), Child = panel };
|
|
426
428
|
border.PreviewMouseLeftButtonDown += delegate { if (!IsBusy && track != index) { track = index; UpdateSelectionText(); } };
|
|
427
429
|
border.MouseLeftButtonUp += delegate { BuildHeaders(); DrawCanvas(); };
|
|
428
430
|
headers.Children.Add(border);
|
|
@@ -451,6 +453,7 @@ namespace DrumBuddy
|
|
|
451
453
|
if (tick.Label != null) { if (x >= left && x < left + ViewportWidth) visibleLabel = true; var label = WorkspaceUi.Text(tick.Label, 11); label.IsHitTestVisible = false; Canvas.SetLeft(label, x + 4); Canvas.SetTop(label, 3); timeline.Children.Add(label); }
|
|
452
454
|
}
|
|
453
455
|
if (!session.Grid.MusicalRuler || !visibleLabel) DrawTimeRuler(left, right);
|
|
456
|
+
DrawRecordingOverlays();
|
|
454
457
|
timeline.Children.Add(selectionOverlay); timeline.Children.Add(selectionStartLine); timeline.Children.Add(selectionEndLine); timeline.Children.Add(selectionStartHandle); timeline.Children.Add(selectionEndHandle); UpdateSelectionOverlay();
|
|
455
458
|
playLine.Y1 = 0; playLine.Y2 = timeline.Height; timeline.Children.Add(playLine); UpdatePlayLine(); UpdateTransport();
|
|
456
459
|
undoButton.IsEnabled = !IsBusy && undo.Count > 0; redoButton.IsEnabled = !IsBusy && redo.Count > 0;
|
|
@@ -517,7 +520,7 @@ namespace DrumBuddy
|
|
|
517
520
|
}
|
|
518
521
|
private void UpdatePlayLine() { playLine.X1 = playLine.X2 = X(playhead); }
|
|
519
522
|
private void UpdateTransport()
|
|
520
|
-
{ positionText.Text = WorkspaceUi.Clock(playhead / (double)Audio.Rate) + " / " + WorkspaceUi.Clock(session.End / (double)Audio.Rate); }
|
|
523
|
+
{ positionText.Text = WorkspaceUi.Clock(playhead / (double)Audio.Rate) + " / " + WorkspaceUi.Clock(Math.Max(session.End, recording ? playhead : 0) / (double)Audio.Rate); }
|
|
521
524
|
private void EnsurePlayheadVisible()
|
|
522
525
|
{ double x = X(playhead); if (x < scroll.HorizontalOffset || x > scroll.HorizontalOffset + scroll.ViewportWidth - 15) scroll.ScrollToHorizontalOffset(Math.Max(0, x - 30)); }
|
|
523
526
|
|
|
@@ -685,12 +688,14 @@ namespace DrumBuddy
|
|
|
685
688
|
internal DockablePane RecordingPaneForTest { get { return recordingPane; } }
|
|
686
689
|
public void SetRecordingState(bool active)
|
|
687
690
|
{
|
|
688
|
-
recording = active; recordButton.Content = active ? "Stop recording" : "Record";
|
|
691
|
+
recording = active; recordButton.Content = active ? "Stop recording" : "Record"; recordButton.IsEnabled = true; play.IsEnabled = !active;
|
|
692
|
+
playLine.Stroke = WorkspaceUi.Color(active ? "#B52C36" : "#D0772C");
|
|
689
693
|
RefreshGridToolbar();
|
|
690
694
|
foreach (var button in fileButtons) button.IsEnabled = !active && operation == null;
|
|
691
695
|
if (active) { Stop(); ShowRecordingOptions(true); }
|
|
696
|
+
else { recordingPlan = null; captureRunning = false; recordingBanner.Visibility = Visibility.Collapsed; BuildHeaders(); DrawCanvas(); }
|
|
692
697
|
}
|
|
693
|
-
public async Task AddRecordedFiles(IEnumerable<string> paths, long at, int firstTrack)
|
|
698
|
+
public async Task AddRecordedFiles(IEnumerable<string> paths, long at, int firstTrack, bool keepPlayhead = false)
|
|
694
699
|
{
|
|
695
700
|
if (IsBusy) throw new IOException("Finish the editor operation before adding the take.");
|
|
696
701
|
var files = paths.Where(File.Exists).ToArray();
|
|
@@ -706,7 +711,8 @@ namespace DrumBuddy
|
|
|
706
711
|
if (session.Tracks[index].Name == "Track " + (index + 1)) session.Tracks[index].Name = Path.GetFileNameWithoutExtension(clip.Name);
|
|
707
712
|
}
|
|
708
713
|
try { session.Validate(); } catch { session = before; throw; }
|
|
709
|
-
undo.Push(before); redo.Clear(); dirty = true; track = firstTrack;
|
|
714
|
+
undo.Push(before); redo.Clear(); dirty = true; track = firstTrack;
|
|
715
|
+
playhead = keepPlayhead ? Math.Min(EditorSession.MaxFrame, at + decoded.Max(c => (long)Math.Round(c.Duration * Audio.Rate))) : at;
|
|
710
716
|
}
|
|
711
717
|
finally { EndWork(); BuildHeaders(); DrawCanvas(); }
|
|
712
718
|
}
|
package/source/Main.cs
CHANGED
|
@@ -20,7 +20,7 @@ using NAudio.Wave.SampleProviders;
|
|
|
20
20
|
|
|
21
21
|
[assembly: AssemblyTitle("Flux")]
|
|
22
22
|
[assembly: AssemblyDescription("PCM/float media player, 16-track audio editor, H5studio recording and R16 control")]
|
|
23
|
-
[assembly: AssemblyVersion("1.6.
|
|
23
|
+
[assembly: AssemblyVersion("1.6.2.0")]
|
|
24
24
|
|
|
25
25
|
namespace DrumBuddy
|
|
26
26
|
{
|
|
@@ -79,6 +79,9 @@ namespace DrumBuddy
|
|
|
79
79
|
private CancellationTokenSource cancel;
|
|
80
80
|
private bool busy, closeWhenIdle, stopping;
|
|
81
81
|
private Recorder recorder;
|
|
82
|
+
private CancellationTokenSource countIn;
|
|
83
|
+
private Task recordingStartup;
|
|
84
|
+
private bool captureActive;
|
|
82
85
|
private readonly ProgressBar[] meters = new ProgressBar[8];
|
|
83
86
|
private readonly TextBlock[] meterLabels = new TextBlock[8];
|
|
84
87
|
private readonly DispatcherTimer timer;
|
|
@@ -161,7 +164,7 @@ namespace DrumBuddy
|
|
|
161
164
|
C<RadioButton>("RecordFloat").Checked += delegate { ShowRecordingFormat(); };
|
|
162
165
|
C<RadioButton>("Record16").Checked += delegate { ShowRecordingFormat(); };
|
|
163
166
|
C<RadioButton>("Record24").Checked += delegate { ShowRecordingFormat(); };
|
|
164
|
-
C<Button>("StartRecordButton").Click += delegate { StartRecording(); };
|
|
167
|
+
C<Button>("StartRecordButton").Click += async delegate { await StartRecording(); };
|
|
165
168
|
C<Button>("StopRecordButton").Click += async delegate { await StopRecording(true); };
|
|
166
169
|
C<Button>("HelpButton").Click += delegate { ShowHelp(); };
|
|
167
170
|
C<Button>("InstallDriversButton").Click += delegate { DriverSetup.ShowGuide(window); };
|
|
@@ -184,6 +187,7 @@ namespace DrumBuddy
|
|
|
184
187
|
e.Handled = true;
|
|
185
188
|
};
|
|
186
189
|
window.Closing += async delegate(object sender, System.ComponentModel.CancelEventArgs e) {
|
|
190
|
+
if (countIn != null) { e.Cancel = true; await StopRecording(false); window.Close(); return; }
|
|
187
191
|
if (recorder != null) {
|
|
188
192
|
e.Cancel = true;
|
|
189
193
|
if (stopping) return;
|
|
@@ -244,6 +248,11 @@ namespace DrumBuddy
|
|
|
244
248
|
public void Show() { window.Show(); }
|
|
245
249
|
public Window NativeWindow { get { return window; } }
|
|
246
250
|
internal void OpenEditorForTest() { OpenWorkspace(true); }
|
|
251
|
+
internal Task StartRecordingForTest() { return StartRecording(); }
|
|
252
|
+
internal Task StopRecordingForTest() { return StopRecording(true); }
|
|
253
|
+
internal Task RecordingTickForTest() { return Tick(); }
|
|
254
|
+
internal bool RecordingBusyForTest { get { return recorder != null || countIn != null; } }
|
|
255
|
+
internal EditorView RecordingEditorForTest { get { return editorView; } }
|
|
247
256
|
internal void OpenHomeForTest() { OpenHome(); }
|
|
248
257
|
internal void EditorFixtureForTest(EditorSession session, IDictionary<string, Clip> clips) { editorView.SetForTest(session, clips); editorView.ShowSelectionForTest(Audio.Rate, Audio.Rate * 4L); }
|
|
249
258
|
internal void RecordingPanelForTest()
|
|
@@ -291,7 +300,7 @@ namespace DrumBuddy
|
|
|
291
300
|
editorView = new EditorView(window, System.IO.Path.Combine(cache, "editor"), Status, delegate { if (mediaPlayerView != null) mediaPlayerView.Stop(); });
|
|
292
301
|
C<Grid>("EditorHost").Children.Add(editorView);
|
|
293
302
|
var panel = C<StackPanel>("RecordPage"); var parent = panel.Parent as Panel; if (parent != null) parent.Children.Remove(panel);
|
|
294
|
-
panel.Visibility = Visibility.Visible; editorView.AttachRecordingPanel(panel); editorView.RecordRequested = async delegate { if (recorder == null) StartRecording(); else await StopRecording(true); };
|
|
303
|
+
panel.Visibility = Visibility.Visible; editorView.AttachRecordingPanel(panel); editorView.RecordRequested = async delegate { if (recorder == null) await StartRecording(); else await StopRecording(true); };
|
|
295
304
|
editorView.StopRequested = async delegate { if (recorder != null) await StopRecording(true); };
|
|
296
305
|
editorView.RecordingOptionsRequested = delegate { var input = C<ComboBox>("InputDevice").SelectedItem as InputChoice; if (input != null && configuredDriver != input.Key && recorder == null) ConfigureRecordingDevice(); };
|
|
297
306
|
editorView.GridSettingsRequested = delegate { OpenSettings("timeline"); };
|
|
@@ -450,6 +459,7 @@ namespace DrumBuddy
|
|
|
450
459
|
private async void HandleInterfaceKey(object sender, System.Windows.Input.KeyEventArgs e)
|
|
451
460
|
{
|
|
452
461
|
var key = e.Key == System.Windows.Input.Key.System ? e.SystemKey : e.Key;
|
|
462
|
+
if (key == System.Windows.Input.Key.Escape && countIn != null) { e.Handled = true; await StopRecording(true); return; }
|
|
453
463
|
if (key == System.Windows.Input.Key.Escape && workspace == 3 && editorView != null && editorView.HandleKey(e)) { e.Handled = true; return; }
|
|
454
464
|
string action = InterfacePreferences.Resolve(key, System.Windows.Input.Keyboard.Modifiers);
|
|
455
465
|
if (action == null) return;
|
|
@@ -472,7 +482,7 @@ namespace DrumBuddy
|
|
|
472
482
|
case "keybinds": OpenSettings("keybinds"); return;
|
|
473
483
|
case "control": C<Button>("ControlTab").RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); return;
|
|
474
484
|
case "recordOptions": if (workspace != 3) OpenWorkspace(true); if (editorView != null) editorView.ShowRecordingOptions(true); return;
|
|
475
|
-
case "record": if (recorder != null) await StopRecording(true); else StartRecording(); return;
|
|
485
|
+
case "record": if (recorder != null) await StopRecording(true); else await StartRecording(); return;
|
|
476
486
|
case "play": if (recorder != null) { await StopRecording(true); return; } break;
|
|
477
487
|
case "stop": if (recorder != null) { await StopRecording(true); return; } break;
|
|
478
488
|
}
|
|
@@ -610,70 +620,112 @@ namespace DrumBuddy
|
|
|
610
620
|
}
|
|
611
621
|
finally { configuringCapture = false; }
|
|
612
622
|
}
|
|
613
|
-
private
|
|
623
|
+
private Task StartRecording()
|
|
614
624
|
{
|
|
615
|
-
if (busy || recorder != null) return;
|
|
625
|
+
if (busy || recorder != null || stopping) return Task.FromResult(0);
|
|
626
|
+
recordingStartup = StartRecordingCore(); return recordingStartup;
|
|
627
|
+
}
|
|
628
|
+
private void LockRecordingControls(bool locked)
|
|
629
|
+
{
|
|
630
|
+
foreach (string name in new[] { "RecordTab", "StartRecordButton", "InputDevice", "InputMode", "Record16", "Record24", "RecordFloat", "RecordRate", "RefreshDevicesButton", "ChangeRecordFolderButton" }) C<Control>(name).IsEnabled = !locked;
|
|
631
|
+
if (!locked) C<Button>("StartRecordButton").IsEnabled = C<ComboBox>("InputDevice").SelectedItem != null;
|
|
632
|
+
C<Button>("StopRecordButton").IsEnabled = locked;
|
|
633
|
+
}
|
|
634
|
+
private async Task StartRecordingCore()
|
|
635
|
+
{
|
|
636
|
+
if (workspace != 3 || editorView == null) OpenWorkspace(true);
|
|
637
|
+
if (editorView == null || editorView.IsBusy) { Status("Finish the editor task before recording."); return; }
|
|
616
638
|
var input = C<ComboBox>("InputDevice").SelectedItem as InputChoice;
|
|
617
|
-
if (input == null) {
|
|
639
|
+
if (input == null) { editorView.ShowRecordingOptions(true); editorView.ShowRecordingError("Choose a recording input under Audio devices or Record options, then click Refresh."); return; }
|
|
618
640
|
if (configuredDriver != input.Key) ConfigureRecordingDevice();
|
|
619
|
-
if (!C<Button>("StartRecordButton").IsEnabled) {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
641
|
+
if (!C<Button>("StartRecordButton").IsEnabled) { editorView.ShowRecordingOptions(true); editorView.ShowRecordingError(C<TextBlock>("CaptureFormatText").Text); return; }
|
|
642
|
+
var mode = C<ComboBox>("InputMode").SelectedItem as ModeChoice;
|
|
643
|
+
var rate = C<ComboBox>("RecordRate").SelectedItem as RateChoice;
|
|
644
|
+
if (mode == null || rate == null) { editorView.ShowRecordingError("Choose input channels and a sample rate in Record options."); return; }
|
|
623
645
|
int bits = C<RadioButton>("RecordFloat").IsChecked == true ? 32 : C<RadioButton>("Record16").IsChecked == true ? 16 : 24;
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
646
|
+
StopWorkspaces(); RecordingPlan plan;
|
|
647
|
+
try { plan = editorView.PlanRecording(input, mode.Mode, bits, rate.Rate, configuredInputChannels); }
|
|
648
|
+
catch (Exception ex) { editorView.ShowRecordingError(ex.Message); return; }
|
|
649
|
+
recordingTrack = plan.FirstTrack; recordingAnchor = plan.AnchorFrame;
|
|
650
|
+
string folder = System.IO.Path.Combine(prefs.RecordFolder, "Take_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture) + "_" + Guid.NewGuid().ToString("N").Substring(0, 6));
|
|
651
|
+
var capture = new Recorder(); recorder = capture; captureActive = false;
|
|
652
|
+
var countdown = new CancellationTokenSource(); countIn = countdown;
|
|
653
|
+
LockRecordingControls(true); C<Button>("StopRecordButton").Content = "Cancel count-in";
|
|
654
|
+
C<TextBlock>("RecordTime").Text = "00:00.00";
|
|
655
|
+
foreach (var meter in meters) meter.Value = 0;
|
|
656
|
+
C<TextBlock>("RecordingState").Text = "Preparing input"; C<TextBlock>("RecordingState").Foreground = Brush("#B52C36");
|
|
657
|
+
editorView.BeginRecordingCountdown(plan);
|
|
658
|
+
Status("Preparing " + plan.Routing);
|
|
659
|
+
Exception startError = null; bool cancelled = false;
|
|
635
660
|
try {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
661
|
+
// Let the armed tracks and connecting feedback render before a driver call.
|
|
662
|
+
await window.Dispatcher.InvokeAsync((Action)delegate { }, DispatcherPriority.Background);
|
|
663
|
+
countdown.Token.ThrowIfCancellationRequested();
|
|
664
|
+
capture.Prepare(plan.Input, plan.Mode, plan.Bits, folder, plan.SampleRate);
|
|
665
|
+
if (capture.ChannelCount != plan.InputChannels) throw new IOException("The device's input layout changed. Refresh Record options, check the destination tracks, and try again.");
|
|
666
|
+
await RecordingCountdown.Run(plan.Beats, plan.BeatSeconds, delegate(int remaining) {
|
|
667
|
+
editorView.ShowCountdownBeat(remaining);
|
|
668
|
+
C<TextBlock>("RecordingState").Text = "Recording in " + remaining;
|
|
669
|
+
C<TextBlock>("RecordTime").Text = remaining.ToString(CultureInfo.InvariantCulture);
|
|
670
|
+
Status("Count-in: " + remaining + " | " + plan.Routing + " | Stop or Escape cancels.");
|
|
671
|
+
}, countdown.Token);
|
|
672
|
+
countdown.Token.ThrowIfCancellationRequested();
|
|
673
|
+
capture.BeginCapture(); captureStarted = DateTime.UtcNow; captureActive = true;
|
|
674
|
+
editorView.BeginRecordingCapture();
|
|
675
|
+
C<Button>("StopRecordButton").Content = "Stop & save";
|
|
676
|
+
C<TextBlock>("RecordingState").Text = "Recording"; C<TextBlock>("RecordTime").Text = "00:00.00";
|
|
677
|
+
Status("Recording | " + plan.Routing + " | Click Stop & save when you finish.");
|
|
643
678
|
}
|
|
679
|
+
catch (OperationCanceledException) { cancelled = true; }
|
|
644
680
|
catch (Exception ex) { startError = ex; }
|
|
645
|
-
if (
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
681
|
+
finally { if (ReferenceEquals(countIn, countdown)) countIn = null; countdown.Dispose(); }
|
|
682
|
+
if (startError == null && !cancelled) return;
|
|
683
|
+
captureActive = false; capture.Stop(); await capture.Finish();
|
|
684
|
+
recorder = null; editorView.SetRecordingState(false); LockRecordingControls(false);
|
|
685
|
+
C<Button>("StopRecordButton").Content = "Stop & save";
|
|
686
|
+
C<TextBlock>("RecordTime").Text = "00:00.00";
|
|
687
|
+
C<TextBlock>("RecordingState").Text = cancelled ? "Count-in cancelled" : "Could not start recording";
|
|
688
|
+
if (cancelled) Status("Count-in cancelled. No recording was started.");
|
|
689
|
+
else {
|
|
690
|
+
editorView.ShowRecordingError(startError.Message);
|
|
691
|
+
Status("Could not start recording: " + startError.Message + " Check the selected input and close other audio apps.");
|
|
651
692
|
}
|
|
652
693
|
}
|
|
653
694
|
private async Task StopRecording(bool addFiles)
|
|
654
695
|
{
|
|
696
|
+
if (countIn != null) {
|
|
697
|
+
var startup = recordingStartup; countIn.Cancel(); if (startup != null) await startup; return;
|
|
698
|
+
}
|
|
655
699
|
if (recorder == null || stopping) return;
|
|
656
|
-
stopping = true; C<Button>("StopRecordButton").IsEnabled = false;
|
|
700
|
+
stopping = true; captureActive = false; C<Button>("StopRecordButton").IsEnabled = false;
|
|
657
701
|
Recorder finished = recorder;
|
|
658
|
-
Status("Saving your recording
|
|
659
|
-
finished.Stop();
|
|
702
|
+
Status("Saving your recording...");
|
|
703
|
+
finished.Stop();
|
|
704
|
+
long frames = Interlocked.Read(ref finished.Frames);
|
|
705
|
+
if (editorView != null) { editorView.UpdateRecordingProgress(frames, finished.SampleRate); editorView.ShowRecordingSaving(); }
|
|
706
|
+
await finished.Finish(); recorder = null;
|
|
660
707
|
if (editorView != null) editorView.SetRecordingState(false);
|
|
661
|
-
|
|
662
|
-
C<TextBlock>("RecordingState").Text = finished.Error
|
|
708
|
+
LockRecordingControls(false); C<Button>("StopRecordButton").Content = "Stop & save";
|
|
709
|
+
C<TextBlock>("RecordingState").Text = finished.Error != null ? "Recording stopped - check status" : frames > 0 ? "Take saved" : "No audio captured";
|
|
663
710
|
C<TextBlock>("RecordingState").Foreground = Brush(finished.Error == null ? "#006D68" : "#B52C36");
|
|
664
711
|
stopping = false;
|
|
665
|
-
if (
|
|
666
|
-
try { await editorView.AddRecordedFiles(finished.Paths, recordingAnchor, recordingTrack); }
|
|
667
|
-
catch (Exception ex) {
|
|
712
|
+
if (addFiles && frames > 0 && finished.Paths.Count > 0 && editorView != null) {
|
|
713
|
+
try { await editorView.AddRecordedFiles(finished.Paths, recordingAnchor, recordingTrack, true); }
|
|
714
|
+
catch (Exception ex) { editorView.ShowRecordingError("Take saved to its folder, but could not be added to the timeline: " + ex.Message); return; }
|
|
668
715
|
}
|
|
669
|
-
|
|
716
|
+
if (finished.Error != null) {
|
|
717
|
+
editorView.ShowRecordingError(finished.Error.Message);
|
|
718
|
+
Status("Recording stopped: " + finished.Error.Message + " Captured audio was kept.");
|
|
719
|
+
}
|
|
720
|
+
else Status(frames == 0 ? "No audio was captured. Check the selected input device and microphone permissions." : addFiles ? "Take saved and added to the timeline. Original recording files are preserved." : "Take saved. Original recording files are preserved.");
|
|
670
721
|
}
|
|
671
722
|
private async Task Tick()
|
|
672
723
|
{
|
|
673
724
|
if (closeWhenIdle && !busy && !WorkspacesBusy && recorder == null) { closeWhenIdle = false; window.Close(); return; }
|
|
674
|
-
if (recorder == null || stopping) return;
|
|
725
|
+
if (recorder == null || stopping || !captureActive) return;
|
|
675
726
|
long frames = Interlocked.Read(ref recorder.Frames);
|
|
676
727
|
C<TextBlock>("RecordTime").Text = WorkspaceUi.Clock(frames / (double)recorder.SampleRate);
|
|
728
|
+
if (editorView != null) editorView.UpdateRecordingProgress(frames, recorder.SampleRate);
|
|
677
729
|
if (frames == 0 && (DateTime.UtcNow - captureStarted).TotalSeconds > 8 && recorder.Error == null)
|
|
678
730
|
recorder.Error = new IOException("No audio arrived from the selected input. Check its connection, microphone permissions and selected channels.");
|
|
679
731
|
if (recorder.SourceFormat != null)
|
|
@@ -711,7 +763,7 @@ namespace DrumBuddy
|
|
|
711
763
|
"HOME AND CONVERTER\nOpen Converter from View or Home. Drop audio or video, choose an output type and click Convert files. WAV PCM, WAV float, AIFF, FLAC, MP3 and AAC copies are supported. Output settings can be resized, collapsed, or displayed on the side or bottom. The source is preserved.\n\n" +
|
|
712
764
|
"Set safe level brings the loudest peak to −1 dBFS. Prevent clipping caps excessive gain evenly; it preserves the dynamics of your drums.\n\n" +
|
|
713
765
|
"ZOOM R16\nExports are 44.1 kHz PCM WAV, mono or stereo, with names up to 8 uppercase characters. Use the bit depth of your R16 project. Copy WAVs to that project's AUDIO folder, then assign them to tracks on the R16.\n\n" +
|
|
714
|
-
"EDITOR + RECORD\nThe Editor opens by default. Record options contains input, channels, WAV format and sample rate. Record or R
|
|
766
|
+
"EDITOR + RECORD\nThe Editor opens by default. Record options contains input, channels, WAV format and sample rate. Record or R arms the selected track and shows one bar of countdown at the project tempo. Separate inputs reserve consecutive tracks. Stop, Record again or Escape cancels the countdown. Capture then starts through the selected input; the red playhead and elapsed timer follow captured audio. Stop & save preserves the take and adds it at the original starting point, leaving the playhead at its end. Use hardware direct monitoring while recording; software overdub synchronisation is not provided.\n\n" +
|
|
715
767
|
"H5STUDIO FLOAT\nOn the recorder choose USB > Audio Interface > Connect. Audio I/F Only supports 44.1, 48 and 96 kHz. Stereo mix exposes a mixed stereo signal; Multi Track exposes separate inputs. Choose 32-bit float to keep the original float samples, including peaks above 0 dBFS. The app checks that the H5studio driver is actually sending float samples.\n\n" +
|
|
716
768
|
"PLAYBACK COMPATIBILITY\nSome players reject float or multichannel WAV files. Open Converter from View, enable Prevent PCM clipping and choose 16-bit or 24-bit WAV PCM. The original float master remains unchanged.\n\n" +
|
|
717
769
|
"FULL CONTROL\nAn R16 front-panel view with PC multitrack playback, mixer, EQ, recording and Mackie MIDI control. Open Menu reveals files, track settings, saved effects and devices. Escape returns home.\n\n" +
|
|
@@ -721,7 +773,7 @@ namespace DrumBuddy
|
|
|
721
773
|
"MEDIA PLAYER\nOpen audio or video to listen. Gain / Save amplifies or reduces playback and saves adjusted WAV copies; Playback volume changes listening level only. Reset, Set safe level and Prevent clipping are available. Choose speakers/headphones under Audio output. Gain / Save can be resized, collapsed, or displayed on the side or bottom.\n\n" +
|
|
722
774
|
"EDITOR\nA 16-track audio timeline. Drag the upper ruler and release to place the playhead. The left edge snaps to zero; Home/To start goes to zero and End goes to the timeline end. Hold Alt to bypass snapping. Drag across a track to select time; two thinner blue sticks mark the selection start/end. Drag their lower-ruler handles to adjust the boundaries. Cut/copy/paste, split, undo/redo, Space to play/pause and Ctrl+S to save. Cuts leave a gap. Choose Selected track or All 16 tracks for edits. Save .dbedit sessions and export PCM or float WAV mixes. Original source files are preserved.\n\n" +
|
|
723
775
|
"THEME AND SETTINGS\nUse the top-right switch for dark or light theme. Settings has categories for General, Audio and Keybinds. Click Edit on a keybind and press the new shortcut immediately. Save writes the changes; leaving with pending changes asks whether to save them. S splits at the playhead; V returns to the selection cursor.\n\n" +
|
|
724
|
-
"Basic tools run locally without an account. This release is for Windows; macOS is planned.\n\nFlux 1.6.
|
|
776
|
+
"Basic tools run locally without an account. This release is for Windows; macOS is planned.\n\nFlux 1.6.2 · FFmpeg + NAudio",
|
|
725
777
|
"Flux — quick help",MessageBoxButton.OK,MessageBoxImage.Information);
|
|
726
778
|
}
|
|
727
779
|
}
|
|
@@ -735,6 +787,7 @@ namespace DrumBuddy
|
|
|
735
787
|
{
|
|
736
788
|
if (args.Length > 0 && args[0] == "--self-test") return SelfTest.Run(args.Skip(1).ToArray());
|
|
737
789
|
if (args.Length > 0 && args[0] == "--float-self-test") return FloatRecordingTests.Run(args.Skip(1).ToArray());
|
|
790
|
+
if (args.Length > 0 && args[0] == "--recording-self-test") return RecordingTests.Run(args.Skip(1).ToArray());
|
|
738
791
|
if (args.Length > 0 && args[0] == "--grid-self-test") return TempoGridTests.Run(args.Skip(1).ToArray());
|
|
739
792
|
if (args.Length > 0 && args[0] == "--zoom-self-test") return ZoomTests.Run(args.Skip(1).ToArray());
|
|
740
793
|
if (args.Length > 0 && args[0] == "--selection-self-test") return EditorSelectionTests.Run(args.Skip(1).ToArray());
|
package/source/Main.xaml
CHANGED
|
@@ -261,7 +261,7 @@
|
|
|
261
261
|
<TextBlock x:Name="RecordTime" Grid.Column="1" Text="00:00" FontSize="22" FontFamily="Consolas" />
|
|
262
262
|
</Grid>
|
|
263
263
|
<UniformGrid x:Name="MeterPanel" Columns="4" Margin="0,7,0,8" />
|
|
264
|
-
<TextBlock x:Name="RecordHint" Text="
|
|
264
|
+
<TextBlock x:Name="RecordHint" Text="One bar count-in at the project tempo, then record at the playhead on the selected track. Stop or Escape cancels the count-in." FontSize="11" Foreground="{DynamicResource Theme526771}" TextWrapping="Wrap" Margin="0,0,0,10" />
|
|
265
265
|
<Grid>
|
|
266
266
|
<Grid.ColumnDefinitions>
|
|
267
267
|
<ColumnDefinition />
|
package/source/Quick Start.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
FLUX 1.6.
|
|
1
|
+
FLUX 1.6.2
|
|
2
2
|
|
|
3
3
|
INSTALL / UPDATE
|
|
4
4
|
Close the app. Run npm.cmd install -g @pro-gram/flux@latest
|
|
@@ -32,8 +32,14 @@ Fit shows the full project; Selection fills the view with the selected range.
|
|
|
32
32
|
Ctrl+= / Ctrl+- zoom; Ctrl+0 fits the project; Ctrl+Shift+0 fits the selection.
|
|
33
33
|
Zoom can reveal individual samples. Close-up waveforms load in the background.
|
|
34
34
|
Record options opens the sidebar. Select input, channels, rate and format.
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
Record or R arms the selected track (consecutive tracks for separate inputs),
|
|
36
|
+
then shows a one-bar countdown at the project tempo and time signature.
|
|
37
|
+
Record again, Stop, or Escape cancels the countdown without starting capture.
|
|
38
|
+
Capture begins after the last beat using the selected input device.
|
|
39
|
+
The red playhead, growing recording highlights and elapsed timer follow the
|
|
40
|
+
captured audio. Stop saves and inserts the take at its original starting point,
|
|
41
|
+
leaving the playhead at its end. Source files are preserved.
|
|
42
|
+
Bar lines are labeled 1, 2, 3; beat labels remain available when zoomed in.
|
|
37
43
|
Use direct headphone monitoring. Software overdub synchronization is not included.
|
|
38
44
|
|
|
39
45
|
AUDIO DEVICES
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Diagnostics;
|
|
3
|
+
using System.Globalization;
|
|
4
|
+
using System.IO;
|
|
5
|
+
using System.Threading;
|
|
6
|
+
using System.Threading.Tasks;
|
|
7
|
+
|
|
8
|
+
namespace DrumBuddy
|
|
9
|
+
{
|
|
10
|
+
// Snapshot the routing before the count-in; later selection changes cannot reroute a take.
|
|
11
|
+
public sealed class RecordingPlan
|
|
12
|
+
{
|
|
13
|
+
private readonly string inputName, inputDriver, inputEndpoint;
|
|
14
|
+
public readonly int Mode, Bits, SampleRate, FirstTrack, TrackCount, InputChannels, Beats;
|
|
15
|
+
public readonly long AnchorFrame;
|
|
16
|
+
public readonly double BeatSeconds;
|
|
17
|
+
public InputChoice Input { get { return new InputChoice { Name = inputName, Driver = inputDriver, EndpointId = inputEndpoint }; } }
|
|
18
|
+
public string Tracks { get { return TrackCount == 1 ? "Track " + (FirstTrack + 1) : "Tracks " + (FirstTrack + 1) + "-" + (FirstTrack + TrackCount); } }
|
|
19
|
+
public string Routing { get; private set; }
|
|
20
|
+
|
|
21
|
+
public RecordingPlan(InputChoice input, int mode, int bits, int rate, int availableInputs, int firstTrack, long anchor, TempoGridOptions grid)
|
|
22
|
+
{
|
|
23
|
+
if (input == null) throw new IOException("Choose a recording input in Record options.");
|
|
24
|
+
grid.Validate(); var layout = CaptureLayout.Create(availableInputs, mode);
|
|
25
|
+
if (bits != 16 && bits != 24 && bits != 32) throw new IOException("Choose a WAV recording format.");
|
|
26
|
+
if (rate <= 0) throw new IOException("Choose a recording sample rate.");
|
|
27
|
+
int tracks = layout.Separate ? layout.Channels : 1;
|
|
28
|
+
if (firstTrack < 0 || firstTrack + tracks > 16) throw new IOException("Select an earlier track so all recording channels fit within the 16-track timeline.");
|
|
29
|
+
if (anchor < 0 || anchor >= EditorSession.MaxFrame) throw new IOException("Move the playhead before the end of the timeline to record.");
|
|
30
|
+
inputName = input.Name; inputDriver = input.Driver; inputEndpoint = input.EndpointId;
|
|
31
|
+
Mode = mode; Bits = bits; SampleRate = rate; FirstTrack = firstTrack; TrackCount = tracks; InputChannels = layout.Channels; AnchorFrame = anchor;
|
|
32
|
+
Beats = grid.BeatsPerBar; BeatSeconds = TempoGrid.BeatSeconds(grid);
|
|
33
|
+
string channels = layout.Channels == 1 ? "Input " + (layout.FirstInput + 1) : "Inputs " + (layout.FirstInput + 1) + "-" + (layout.FirstInput + layout.Channels);
|
|
34
|
+
Routing = Tracks + " | " + channels + " | " + inputName + " | " + (rate / 1000.0).ToString("0.#", CultureInfo.InvariantCulture) + " kHz";
|
|
35
|
+
}
|
|
36
|
+
public long TimelineFrame(long capturedFrames, int sampleRate)
|
|
37
|
+
{
|
|
38
|
+
if (sampleRate <= 0) throw new ArgumentOutOfRangeException("sampleRate");
|
|
39
|
+
double elapsed = Math.Max(0, capturedFrames) * (double)Audio.Rate / sampleRate;
|
|
40
|
+
return AnchorFrame + (long)Math.Round(Math.Min(EditorSession.MaxFrame - AnchorFrame, elapsed));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public static class RecordingCountdown
|
|
45
|
+
{
|
|
46
|
+
public static async Task Run(int beats, double beatSeconds, Action<int> showBeat, CancellationToken token)
|
|
47
|
+
{
|
|
48
|
+
if (beats < 1 || beats > 32 || beatSeconds <= 0 || double.IsNaN(beatSeconds) || double.IsInfinity(beatSeconds))
|
|
49
|
+
throw new ArgumentOutOfRangeException("beats");
|
|
50
|
+
var clock = Stopwatch.StartNew();
|
|
51
|
+
for (int beat = 0; beat < beats; beat++) {
|
|
52
|
+
token.ThrowIfCancellationRequested(); showBeat(beats - beat);
|
|
53
|
+
double remaining = (beat + 1) * beatSeconds - clock.Elapsed.TotalSeconds;
|
|
54
|
+
if (remaining > 0) await Task.Delay(TimeSpan.FromSeconds(remaining), token);
|
|
55
|
+
}
|
|
56
|
+
token.ThrowIfCancellationRequested();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Generic;
|
|
3
|
+
using System.Diagnostics;
|
|
4
|
+
using System.IO;
|
|
5
|
+
using System.Linq;
|
|
6
|
+
using System.Text;
|
|
7
|
+
using System.Threading;
|
|
8
|
+
using System.Threading.Tasks;
|
|
9
|
+
using System.Windows;
|
|
10
|
+
using System.Windows.Controls;
|
|
11
|
+
using System.Windows.Media;
|
|
12
|
+
using System.Windows.Media.Imaging;
|
|
13
|
+
using System.Windows.Threading;
|
|
14
|
+
|
|
15
|
+
namespace DrumBuddy
|
|
16
|
+
{
|
|
17
|
+
public static class RecordingTests
|
|
18
|
+
{
|
|
19
|
+
private static readonly List<string> log = new List<string>();
|
|
20
|
+
private static void Check(bool value, string message) { if (!value) throw new Exception(message); log.Add("PASS " + message); }
|
|
21
|
+
private static void RunUi(Func<Task> action)
|
|
22
|
+
{
|
|
23
|
+
var previous = SynchronizationContext.Current; var dispatcher = Dispatcher.CurrentDispatcher;
|
|
24
|
+
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(dispatcher));
|
|
25
|
+
try {
|
|
26
|
+
var task = action(); var frame = new DispatcherFrame();
|
|
27
|
+
task.ContinueWith(delegate { dispatcher.BeginInvoke((Action)delegate { frame.Continue = false; }); });
|
|
28
|
+
if (!task.IsCompleted) Dispatcher.PushFrame(frame); task.GetAwaiter().GetResult();
|
|
29
|
+
}
|
|
30
|
+
finally { SynchronizationContext.SetSynchronizationContext(previous); }
|
|
31
|
+
}
|
|
32
|
+
private static void Picture(FrameworkElement view, string file, int width, int height)
|
|
33
|
+
{
|
|
34
|
+
view.Measure(new Size(width, height)); view.Arrange(new Rect(0, 0, width, height)); view.UpdateLayout();
|
|
35
|
+
var image = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); image.Render(view);
|
|
36
|
+
var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(image)); using (var stream = File.Create(file)) encoder.Save(stream);
|
|
37
|
+
}
|
|
38
|
+
public static int Run(string[] args)
|
|
39
|
+
{
|
|
40
|
+
string root = Path.GetFullPath(args.Length > 0 ? args[0] : Path.Combine(Path.GetTempPath(), "FluxRecording_" + Guid.NewGuid().ToString("N")));
|
|
41
|
+
Directory.CreateDirectory(root); log.Clear(); var old = InterfacePreferences.Current;
|
|
42
|
+
try {
|
|
43
|
+
InterfacePreferences.Current = InterfacePreferences.Defaults(); ThemePalette.Apply(true);
|
|
44
|
+
var input = new InputChoice { Name = "Selected USB microphone", EndpointId = "selected-endpoint" };
|
|
45
|
+
var grid = new TempoGridOptions();
|
|
46
|
+
var plan = new RecordingPlan(input, -1, 32, 48000, 2, 5, Audio.Rate * 2L, grid);
|
|
47
|
+
input.EndpointId = "other-endpoint"; grid.Tempo = 200; grid.BeatsPerBar = 3;
|
|
48
|
+
Check(plan.Input.EndpointId == "selected-endpoint" && plan.FirstTrack == 5 && plan.TrackCount == 2 && plan.InputChannels == 2 && plan.Beats == 4 && plan.BeatSeconds == .5,
|
|
49
|
+
"Freeze the selected endpoint, input layout, destination tracks, anchor and project tempo before counting in");
|
|
50
|
+
var stereo = new RecordingPlan(plan.Input, 0, 24, 96000, 2, 15, 0, grid);
|
|
51
|
+
Check(stereo.TrackCount == 1 && stereo.Beats == 3 && Math.Abs(stereo.BeatSeconds - .3) < 1e-12,
|
|
52
|
+
"Route a stereo file to one track and use one bar of the actual project meter");
|
|
53
|
+
bool rejected = false;
|
|
54
|
+
try { new RecordingPlan(plan.Input, -1, 24, 48000, 8, 10, 0, grid); } catch (IOException) { rejected = true; }
|
|
55
|
+
Check(rejected, "Reject an overflowing multitrack take before countdown or capture");
|
|
56
|
+
Check(plan.TimelineFrame(48000, 96000) == plan.AnchorFrame + Audio.Rate / 2 && plan.TimelineFrame(long.MaxValue, 48000) == EditorSession.MaxFrame,
|
|
57
|
+
"Convert captured frames to the editor rate without exceeding the timeline limit");
|
|
58
|
+
RunUi(async delegate {
|
|
59
|
+
var beats = new List<int>(); var watch = Stopwatch.StartNew(); bool began = false;
|
|
60
|
+
await RecordingCountdown.Run(4, .04, value => beats.Add(value), CancellationToken.None); began = true;
|
|
61
|
+
Check(began && beats.SequenceEqual(new[] { 4, 3, 2, 1 }) && watch.Elapsed.TotalSeconds >= .145,
|
|
62
|
+
"Display every count-in beat and wait the full bar before starting capture");
|
|
63
|
+
var cancelled = new CancellationTokenSource(); began = false; bool stopped = false;
|
|
64
|
+
try { await RecordingCountdown.Run(4, .02, value => { if (value == 1) cancelled.Cancel(); }, cancelled.Token); began = true; }
|
|
65
|
+
catch (OperationCanceledException) { stopped = true; }
|
|
66
|
+
Check(stopped && !began, "Cancelling on the last countdown beat prevents recording from starting"); cancelled.Dispose();
|
|
67
|
+
cancelled = new CancellationTokenSource(); cancelled.Cancel(); beats.Clear();
|
|
68
|
+
try { await RecordingCountdown.Run(4, .02, beats.Add, cancelled.Token); } catch (OperationCanceledException) { }
|
|
69
|
+
Check(beats.Count == 0, "A cancelled startup cannot display another beat or restart capture"); cancelled.Dispose();
|
|
70
|
+
|
|
71
|
+
// Prepare a real Windows endpoint, but cancel before it is ever opened for capture.
|
|
72
|
+
var main = new MainWindow(Path.Combine(root, "shell-preferences.xml"), true);
|
|
73
|
+
main.OpenEditorForTest(); main.RecordingPanelForTest();
|
|
74
|
+
if (((Button)main.NativeWindow.FindName("StartRecordButton")).IsEnabled) {
|
|
75
|
+
var startup = main.StartRecordingForTest();
|
|
76
|
+
await main.RecordingTickForTest();
|
|
77
|
+
Check(main.RecordingBusyForTest && main.RecordingEditorForTest.IsBusy,
|
|
78
|
+
"Lock the actual recording controller and editor before driver preparation or the first countdown beat");
|
|
79
|
+
await main.StartRecordingForTest();
|
|
80
|
+
await main.NativeWindow.Dispatcher.InvokeAsync((Action)delegate { }, DispatcherPriority.Background);
|
|
81
|
+
Check(main.RecordingEditorForTest.CountdownForTest == "4", "The actual Record action displays the first countdown beat");
|
|
82
|
+
Picture((FrameworkElement)main.NativeWindow.Content, Path.Combine(root, "countdown-shell-dark.png"), 1280, 800);
|
|
83
|
+
await main.StopRecordingForTest(); await startup;
|
|
84
|
+
Check(!main.RecordingBusyForTest && !main.RecordingEditorForTest.IsBusy && ((TextBlock)main.NativeWindow.FindName("RecordingState")).Text == "Count-in cancelled",
|
|
85
|
+
"Stop cancels the actual startup, releases the prepared device and restores the recording controls");
|
|
86
|
+
}
|
|
87
|
+
else log.Add("SKIP No ready Windows endpoint for the controller preparation/cancellation check; generated-buffer coverage still runs.");
|
|
88
|
+
main.NativeWindow.Close();
|
|
89
|
+
|
|
90
|
+
var owner = new Window(); ThemePalette.Install(owner);
|
|
91
|
+
using (var editor = new EditorView(owner, Path.Combine(root, "cache"), delegate(string message) { }, delegate { })) {
|
|
92
|
+
owner.Content = editor; editor.BeginRecordingCountdown(plan); editor.ShowCountdownBeat(4);
|
|
93
|
+
Check(editor.IsBusy && editor.CountdownForTest == "4" && editor.ArmedTracksForTest.SequenceEqual(new[] { 5, 6 }) && editor.RoutingForTest.Contains("Selected USB microphone"),
|
|
94
|
+
"Show the countdown and exact armed tracks with the selected device in the editor");
|
|
95
|
+
Picture(editor, Path.Combine(root, "countdown-dark.png"), 1280, 760);
|
|
96
|
+
Check(!await editor.ExecuteAction("start") && editor.PlayheadFrame == plan.AnchorFrame,
|
|
97
|
+
"Protect the recording anchor from transport edits during count-in");
|
|
98
|
+
editor.SetRecordingState(false);
|
|
99
|
+
Check(!editor.IsBusy && editor.ArmedTracksForTest.Length == 0 && editor.RecordingOverlaysForTest == 0,
|
|
100
|
+
"Release reserved tracks and recording visuals after cancellation");
|
|
101
|
+
editor.BeginRecordingCountdown(plan); editor.BeginRecordingCapture();
|
|
102
|
+
var capture = new Recorder(); capture.StartBuffered(CaptureLayout.Create(2, -1), 32, Path.Combine(root, "take"), 48000, false);
|
|
103
|
+
byte[] pcm = new byte[48000 * 2 * 2];
|
|
104
|
+
for (int frame = 0; frame < 48000; frame++) {
|
|
105
|
+
Buffer.BlockCopy(BitConverter.GetBytes((short)8192), 0, pcm, frame * 4, 2);
|
|
106
|
+
Buffer.BlockCopy(BitConverter.GetBytes((short)-4096), 0, pcm, frame * 4 + 2, 2);
|
|
107
|
+
}
|
|
108
|
+
editor.UpdateRecordingProgress(0, 48000);
|
|
109
|
+
Check(editor.PlayheadFrame == plan.AnchorFrame, "Keep the playhead still until input frames actually arrive");
|
|
110
|
+
for (int second = 1; second <= 3; second++) {
|
|
111
|
+
capture.ProcessWindowsPcm(pcm, pcm.Length, 2); editor.UpdateRecordingProgress(Interlocked.Read(ref capture.Frames), capture.SampleRate);
|
|
112
|
+
Check(editor.PlayheadFrame == plan.AnchorFrame + second * Audio.Rate, "Advance the recording playhead by captured audio at second " + second);
|
|
113
|
+
}
|
|
114
|
+
Check(editor.RecordingOverlaysForTest == 2 && editor.RecordingHeadlineForTest.Contains("00:03.00"),
|
|
115
|
+
"Show a growing recording on both destination tracks and the elapsed take time");
|
|
116
|
+
Picture(editor, Path.Combine(root, "recording-dark.png"), 1280, 760);
|
|
117
|
+
Picture(editor, Path.Combine(root, "recording-compact-dark.png"), 850, 560);
|
|
118
|
+
ThemePalette.Apply(false); Picture(editor, Path.Combine(root, "recording-light.png"), 1280, 760); ThemePalette.Apply(true);
|
|
119
|
+
capture.Stop(); await capture.Finish(); editor.ShowRecordingSaving();
|
|
120
|
+
var originals = capture.Paths.Select(File.ReadAllBytes).ToArray();
|
|
121
|
+
editor.SetRecordingState(false); await editor.AddRecordedFiles(capture.Paths, plan.AnchorFrame, plan.FirstTrack, true);
|
|
122
|
+
var session = editor.SessionForTest;
|
|
123
|
+
Check(capture.Error == null && session.Regions.Count == 2 && session.Regions.All(r => r.Start == plan.AnchorFrame && r.Length == Audio.Rate * 3L) && session.Regions.Select(r => r.Track).OrderBy(t => t).SequenceEqual(new[] { 5, 6 }),
|
|
124
|
+
"Save and insert both recorded inputs at the registered start and tracks");
|
|
125
|
+
Check(editor.PlayheadFrame == plan.AnchorFrame + Audio.Rate * 3L && editor.RecordingOverlaysForTest == 0 && !editor.IsBusy,
|
|
126
|
+
"Leave the playhead at the end of the saved take and clear live recording state");
|
|
127
|
+
Check(capture.Paths.Select((file, index) => File.ReadAllBytes(file).SequenceEqual(originals[index])).All(value => value),
|
|
128
|
+
"Preserve original recording files while importing the take");
|
|
129
|
+
await editor.ExecuteAction("undo");
|
|
130
|
+
Check(editor.SessionForTest.Regions.Count == 0 && capture.Paths.All(File.Exists), "Undo the recording insertion without deleting captured audio");
|
|
131
|
+
}
|
|
132
|
+
owner.Content = null; owner.Close();
|
|
133
|
+
});
|
|
134
|
+
log.Add("Generated audio buffers and offscreen UI. A physical microphone/ASIO device still requires an acceptance recording.");
|
|
135
|
+
File.WriteAllLines(Path.Combine(root, "recording-test-results.txt"), log, Encoding.UTF8); return 0;
|
|
136
|
+
}
|
|
137
|
+
catch (Exception ex) { log.Add("FAIL " + ex); File.WriteAllLines(Path.Combine(root, "recording-test-results.txt"), log, Encoding.UTF8); return 1; }
|
|
138
|
+
finally { InterfacePreferences.Current = old; ThemePalette.Apply(old.Theme == "Dark"); }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
package/source/Setup.cs
CHANGED
|
@@ -14,7 +14,7 @@ using System.Windows.Markup;
|
|
|
14
14
|
using Microsoft.Win32;
|
|
15
15
|
|
|
16
16
|
[assembly: AssemblyTitle("Flux Setup")]
|
|
17
|
-
[assembly: AssemblyVersion("1.6.
|
|
17
|
+
[assembly: AssemblyVersion("1.6.2.0")]
|
|
18
18
|
|
|
19
19
|
namespace DrumBuddySetup
|
|
20
20
|
{
|
|
@@ -101,7 +101,7 @@ namespace DrumBuddySetup
|
|
|
101
101
|
Directory.CreateDirectory(Path.GetDirectoryName(destination));
|
|
102
102
|
File.Copy(source,destination,true);
|
|
103
103
|
}
|
|
104
|
-
File.WriteAllText(Path.Combine(InstallPath,".drumbuddy-installed"),"Flux 1.6.
|
|
104
|
+
File.WriteAllText(Path.Combine(InstallPath,".drumbuddy-installed"),"Flux 1.6.2\n" + InstallPath,Encoding.UTF8);
|
|
105
105
|
string[] files = Directory.GetFiles(stage,"*",SearchOption.AllDirectories).Select(f => f.Substring(stage.Length + 1)).Concat(new[] { ".drumbuddy-installed","installed-files.txt" }).ToArray();
|
|
106
106
|
File.WriteAllLines(Path.Combine(InstallPath,"installed-files.txt"),files,Encoding.UTF8);
|
|
107
107
|
string exe = Path.Combine(InstallPath,"DrumBuddy.exe");
|
|
@@ -112,7 +112,7 @@ namespace DrumBuddySetup
|
|
|
112
112
|
RemoveLegacyShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","Drum Buddy.lnk"),exe);
|
|
113
113
|
RemoveLegacyShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),"Drum Buddy.lnk"),exe);
|
|
114
114
|
using (var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(RegistryKey)) {
|
|
115
|
-
key.SetValue("DisplayName","Flux"); key.SetValue("DisplayVersion","1.6.
|
|
115
|
+
key.SetValue("DisplayName","Flux"); key.SetValue("DisplayVersion","1.6.2"); key.SetValue("Publisher","Flux");
|
|
116
116
|
key.SetValue("InstallLocation",InstallPath); key.SetValue("DisplayIcon",BrandIcon);
|
|
117
117
|
key.SetValue("UninstallString","\"" + Path.Combine(InstallPath,"Uninstall.exe") + "\"");
|
|
118
118
|
key.SetValue("NoModify",1); key.SetValue("NoRepair",1);
|
|
@@ -137,17 +137,18 @@ namespace DrumBuddySetup
|
|
|
137
137
|
foreach (var entry in zip.Entries) {
|
|
138
138
|
if (string.IsNullOrEmpty(entry.Name)) continue;
|
|
139
139
|
string relative = entry.FullName.Replace('/',Path.DirectorySeparatorChar);
|
|
140
|
+
string manifestName = entry.FullName.Replace('\\','/');
|
|
140
141
|
string target = Path.GetFullPath(Path.Combine(root,relative));
|
|
141
142
|
if (!target.StartsWith(root,StringComparison.OrdinalIgnoreCase) || relative.Contains(":")) throw new IOException("Invalid installer file path.");
|
|
142
143
|
string expected;
|
|
143
|
-
if (!hashes.TryGetValue(
|
|
144
|
+
if (!hashes.TryGetValue(manifestName,out expected)) throw new IOException("An installer file is not in the integrity manifest.");
|
|
144
145
|
Directory.CreateDirectory(Path.GetDirectoryName(target));
|
|
145
146
|
using (var input = entry.Open()) using (var output = new FileStream(target,FileMode.Create,FileAccess.Write)) input.CopyTo(output);
|
|
146
147
|
using (var sha = SHA256.Create()) using (var file = File.OpenRead(target)) {
|
|
147
148
|
string actual = BitConverter.ToString(sha.ComputeHash(file)).Replace("-","");
|
|
148
149
|
if (!string.Equals(actual,expected,StringComparison.OrdinalIgnoreCase)) throw new IOException("An installer file failed its integrity check. Please obtain a fresh copy.");
|
|
149
150
|
}
|
|
150
|
-
hashes.Remove(
|
|
151
|
+
hashes.Remove(manifestName);
|
|
151
152
|
if (progress != null) progress((double)++count / zip.Entries.Count);
|
|
152
153
|
}
|
|
153
154
|
}
|
package/source/TempoGrid.cs
CHANGED
|
@@ -86,7 +86,7 @@ namespace DrumBuddy
|
|
|
86
86
|
int count = 0;
|
|
87
87
|
for (long index = first; index <= last && count++ < 5000; index += stride) {
|
|
88
88
|
double frame = index * seconds * Audio.Rate; if (frame > EditorSession.MaxFrame) break;
|
|
89
|
-
string label = kind == 2 && index % labelStride == 0 ? (index + 1).ToString(CultureInfo.InvariantCulture)
|
|
89
|
+
string label = kind == 2 && index % labelStride == 0 ? (index + 1).ToString(CultureInfo.InvariantCulture) : null;
|
|
90
90
|
if (kind == 1 && spacing >= 58 && index % options.BeatsPerBar != 0) label = (index / options.BeatsPerBar + 1) + "." + (index % options.BeatsPerBar + 1);
|
|
91
91
|
yield return new TempoGridTick { Frame = FrameAt(frame), Kind = kind, Label = label };
|
|
92
92
|
}
|
package/source/TempoGridTests.cs
CHANGED
|
@@ -49,6 +49,8 @@ namespace DrumBuddy
|
|
|
49
49
|
long distant = (long)Math.Round(100000 * 240.0 / 123 / 128 * Audio.Rate);
|
|
50
50
|
Check(Math.Abs(TempoGrid.Snap(distant, grid) - distant) <= 1, "Avoid accumulated frame rounding drift far into a fine grid");
|
|
51
51
|
grid = new TempoGridOptions { Division = 32 };
|
|
52
|
+
var bars = TempoGrid.VisibleTicks(grid, 0, 400, 40).Where(t => t.Kind == 2 && t.Label != null).ToArray();
|
|
53
|
+
Check(bars.Length >= 2 && bars[0].Label == "1" && bars[1].Label == "2", "Show whole bar numbers on the musical ruler");
|
|
52
54
|
long first = TimelineGesture.FrameAt(2.5, 40, new long[] { 0 }, false, grid, true, true);
|
|
53
55
|
Check(first == 2756, "Do not swallow the first 1/32 grid line with the start-of-track magnet");
|
|
54
56
|
Check(TimelineGesture.FrameAt(21.4, 40, new long[0], false, new TempoGridOptions(), true, false) == 22050, "Snap a playhead gesture to the nearest sixteenth-note frame");
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
The independent C# R16 project container reader/writer uses file-format facts
|
|
2
|
-
researched with zoomrlib 1.1.0 by Remy Taymans and Glenn Boysko:
|
|
3
|
-
https://gitlab.com/remytms/zoomrlib
|
|
4
|
-
https://pypi.org/project/zoomrlib/1.1.0/
|
|
5
|
-
|
|
6
|
-
Its unchanged binary fixture projects were used for round-trip verification.
|
|
7
|
-
The upstream Python implementation is not bundled or linked into Drum Buddy.
|
|
8
|
-
Download the upstream release's tests/data to reproduce the native fixture tests:
|
|
9
|
-
DrumBuddy.exe --control-self-test TEST_OUTPUT PATH_TO_TEST_FIXTURES
|
|
10
|
-
|
|
11
|
-
Mackie control protocol reference (message assignments only):
|
|
12
|
-
https://github.com/justinfrankel/reaper-sdk/blob/main/reaper-plugins/reaper_csurf/csurf_mcu.cpp
|
|
13
|
-
|
|
14
|
-
Official R16 manuals:
|
|
15
|
-
https://zoomcorp.com/media/documents/E_R16_.pdf
|
|
16
|
-
https://zoomcorp.com/media/documents/E_R16_AIF_.pdf
|