Skip to content

Commit 6957df6

Browse files
Loop mode, UI improvements
1 parent 8684ab9 commit 6957df6

17 files changed

+419
-26
lines changed

docs/documentation.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ Minimal format:
9090
{
9191
"note": 60,
9292
"sample_path": "/samples/kick.wav",
93-
"volume": 100
93+
"volume": 100,
94+
"playback_mode": "shot"
9495
}
9596
]
9697
}
@@ -100,6 +101,7 @@ Notes:
100101

101102
- `note`: `0..127`
102103
- `panic_note`: optional `0..127`; when received as MIDI NOTE ON, all active voices are stopped immediately
104+
- `playback_mode`: optional `"shot"` or `"loop"` stored per assignment/sample
103105
- `volume`: clamped to `0..100`
104106
- `volume = 100`: full per-voice level (before fixed mixer headroom)
105107
- `sample_path`: full SD path, for example `/samples/snare.wav`
@@ -149,6 +151,7 @@ UI states:
149151
Flow:
150152

151153
- in `Library`, you select a sample and trigger preview,
154+
- in `Main`, `SHOT/LOOP` toggles one-shot vs loop for the currently active sample,
152155
- `L rotate` in `Library` toggles assignment mode between `Sample` and `Panic`,
153156
- long-pressing the right button enters `AssignNote` for the current mode,
154157
- the first received MIDI note assigns either the current sample or the global panic note (depending on selected mode),

include/audio.h

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,21 @@ class Audio {
2020

2121
void begin();
2222
void update();
23-
void playSamplePath(const String &samplePath, uint8_t volume = 100, int16_t retriggerGroupId = -1);
23+
void playSamplePath(const String &samplePath,
24+
uint8_t volume = 100,
25+
int16_t retriggerGroupId = -1,
26+
bool loopEnabled = false);
2427
void stopAllVoices();
28+
void stopLoopingVoicesForGroup(int16_t retriggerGroupId);
29+
void setLoopEnabledForGroup(int16_t retriggerGroupId, bool loopEnabled);
2530
bool playSampleRam(const uint8_t *pcmData,
2631
uint32_t dataBytes,
2732
uint16_t channelCount,
2833
uint32_t sampleRate,
2934
uint16_t bitsPerSample,
3035
uint8_t volume = 100,
31-
int16_t retriggerGroupId = -1);
36+
int16_t retriggerGroupId = -1,
37+
bool loopEnabled = false);
3238
RuntimeStats runtimeStats() const;
3339
uint32_t voiceStealCount() const;
3440

include/sampler_callback_binder.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class SamplerCallbackBinder {
2020
static void onPreviewSample(int sampleIndex, void *context);
2121
static void onAssignedMidiNoteOn(int midiNote, void *context);
2222
static bool onSaveConfiguration(void *context);
23+
static void onPlaybackModeChanged(Ui::PlaybackMode mode, int sampleIndex, void *context);
2324

2425
Ui *ui_ = nullptr;
2526
Midi *midi_ = nullptr;

include/sampler_playback_router.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class SamplerPlaybackRouter {
1414

1515
void onPreviewSample(int sampleIndex) const;
1616
void onAssignedMidiNoteOn(int midiNote) const;
17+
void onPlaybackModeChanged(Ui::PlaybackMode mode, int sampleIndex) const;
1718

1819
private:
1920
Ui *ui_ = nullptr;

include/settings_store.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ struct MidiAssignment {
99
uint8_t note = 0;
1010
String samplePath;
1111
uint8_t volume = 100;
12+
bool loopPlaybackEnabled = false;
1213
};
1314

1415
struct SamplerSettings {

include/trigger_engine.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ enum class TriggerSourceType : uint8_t {
1212
StreamPath,
1313
RamData,
1414
PanicAll,
15+
StopLoopingVoicesForGroup,
16+
SetLoopEnabledForGroup,
1517
};
1618

1719
struct TriggerEvent {
1820
TriggerSourceType source = TriggerSourceType::StreamPath;
1921
uint8_t volume = 100;
2022
int16_t retriggerGroupId = -1;
23+
bool loopEnabled = false;
2124
char path[128] = {0};
2225

2326
const uint8_t *ramData = nullptr;
@@ -37,6 +40,8 @@ class TriggerEngine {
3740
QueueHandle_t uiStatusQueue = nullptr);
3841
bool enqueue(const TriggerEvent &event);
3942
bool panicAll();
43+
bool stopLoopingVoicesForGroup(int16_t retriggerGroupId);
44+
bool setLoopEnabledForGroup(int16_t retriggerGroupId, bool loopEnabled);
4045
bool waitForIdle(uint32_t timeoutMs) const;
4146

4247
private:

include/ui.h

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ class Ui {
66
public:
77
static constexpr int kMaxSamples = 32;
88

9+
enum class PlaybackMode : uint8_t {
10+
OneShot,
11+
Loop,
12+
};
13+
914
enum class State : uint8_t {
1015
Main,
1116
Library,
@@ -31,7 +36,7 @@ class Ui {
3136
State state = State::Main;
3237
bool dirty = true;
3338

34-
int mainSelection = 0; // 0:LIB 1:VOL 2:SAVE
39+
int mainSelection = 0; // 0:LIB 1:VOL 2:SHOT/LOOP 3:SAVE
3540
int currentSampleIndex = -1;
3641
int sampleCount = 0;
3742

@@ -40,6 +45,7 @@ class Ui {
4045
int lastMidiNote = -1;
4146

4247
int currentVolume = 0;
48+
PlaybackMode playbackMode = PlaybackMode::OneShot;
4349

4450
int libraryWindowStart = 0;
4551
int assignedNoteForSelectedSample = -1;
@@ -54,10 +60,14 @@ class Ui {
5460

5561
using OnPreviewSampleCallback = void (*)(int sampleIndex, void *context);
5662
using OnSaveCallback = bool (*)(void *context);
63+
using OnPlaybackModeChangedCallback = void (*)(PlaybackMode mode,
64+
int sampleIndex,
65+
void *context);
5766

5867
void begin(const String *sampleNames, const String *samplePaths, int sampleCount);
5968
void setPreviewCallback(OnPreviewSampleCallback callback, void *context);
6069
void setSaveCallback(OnSaveCallback callback, void *context);
70+
void setPlaybackModeChangedCallback(OnPlaybackModeChangedCallback callback, void *context);
6171
void handleEvent(const Event &event);
6272
void update();
6373
bool consumeDirty();
@@ -72,12 +82,14 @@ class Ui {
7282
int panicMidiNote() const;
7383
bool setSampleVolume(int sampleIndex, int volume);
7484
int sampleVolumeForSample(int sampleIndex) const;
85+
bool setSamplePlaybackMode(int sampleIndex, PlaybackMode mode);
86+
PlaybackMode playbackModeForSample(int sampleIndex) const;
87+
bool sampleLoopPlaybackEnabled(int sampleIndex) const;
7588
void reportTriggeredSample(int sampleIndex);
7689
void clearTriggeredSample();
7790
void clearUnsavedChanges();
7891

7992
private:
80-
static constexpr int kMenuItems = 3;
8193
static constexpr int kVolumeMin = 0;
8294
static constexpr int kVolumeMax = 100;
8395
static constexpr int kVolumeStep = 5;
@@ -110,6 +122,7 @@ class Ui {
110122
int sampleVolumes_[kMaxSamples] = {0};
111123
int sampleForMidiNote_[128] = {0}; // -1 means unassigned.
112124
int panicMidiNote_ = -1;
125+
PlaybackMode samplePlaybackModes_[kMaxSamples] = {PlaybackMode::OneShot};
113126
bool libraryAssignsPanic_ = false;
114127
bool assigningPanic_ = false;
115128

@@ -128,4 +141,6 @@ class Ui {
128141
void *previewContext_ = nullptr;
129142
OnSaveCallback onSave_ = nullptr;
130143
void *saveContext_ = nullptr;
144+
OnPlaybackModeChangedCallback onPlaybackModeChanged_ = nullptr;
145+
void *playbackModeChangedContext_ = nullptr;
131146
};

src/audio.cpp

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,12 @@ float voiceGainFromVolume(uint8_t volume) {
190190
// Keep disabled by default to preserve percussive transients.
191191
constexpr uint32_t kVoiceFadeInUs = 0;
192192

193+
enum class VoiceSourceType : uint8_t {
194+
None,
195+
StreamPath,
196+
RamData,
197+
};
198+
193199
} // namespace
194200

195201
struct Audio::Impl {
@@ -204,6 +210,15 @@ struct Audio::Impl {
204210
AudioOutputMixerStub *stub = nullptr;
205211
float targetGain = 0.0f;
206212
float currentGain = 0.0f;
213+
uint8_t volume = 100;
214+
bool loopEnabled = false;
215+
VoiceSourceType sourceType = VoiceSourceType::None;
216+
char path[128] = {0};
217+
const uint8_t *ramData = nullptr;
218+
uint32_t ramDataBytes = 0;
219+
uint16_t channelCount = 0;
220+
uint32_t sampleRate = 0;
221+
uint16_t bitsPerSample = 0;
207222
};
208223

209224
StableAudioOutputI2S *out = nullptr;
@@ -243,6 +258,15 @@ void stopVoice(Audio::Impl::Voice &voice) {
243258
voice.retriggerGroupId = -1;
244259
voice.targetGain = 0.0f;
245260
voice.currentGain = 0.0f;
261+
voice.volume = 100;
262+
voice.loopEnabled = false;
263+
voice.sourceType = VoiceSourceType::None;
264+
voice.path[0] = '\0';
265+
voice.ramData = nullptr;
266+
voice.ramDataBytes = 0;
267+
voice.channelCount = 0;
268+
voice.sampleRate = 0;
269+
voice.bitsPerSample = 0;
246270
}
247271

248272
void refreshStats(Audio::Impl *impl) {
@@ -378,7 +402,8 @@ bool beginVoiceFromPath(Audio::Impl *impl,
378402
int voiceIndex,
379403
const String &samplePath,
380404
uint8_t volume,
381-
int16_t retriggerGroupId) {
405+
int16_t retriggerGroupId,
406+
bool loopEnabled) {
382407
if (!impl || voiceIndex < 0 || voiceIndex >= Audio::kVoiceCount) return false;
383408

384409
Audio::Impl::Voice &voice = impl->voices[voiceIndex];
@@ -407,6 +432,15 @@ bool beginVoiceFromPath(Audio::Impl *impl,
407432
impl->nextStartOrder = 1;
408433
}
409434
voice.active = true;
435+
voice.volume = volume;
436+
voice.loopEnabled = loopEnabled;
437+
voice.sourceType = VoiceSourceType::StreamPath;
438+
samplePath.toCharArray(voice.path, sizeof(voice.path));
439+
voice.ramData = nullptr;
440+
voice.ramDataBytes = 0;
441+
voice.channelCount = 0;
442+
voice.sampleRate = 0;
443+
voice.bitsPerSample = 0;
410444
voice.retriggerGroupId = retriggerGroupId;
411445
return true;
412446
}
@@ -419,7 +453,8 @@ bool beginVoiceFromRam(Audio::Impl *impl,
419453
uint32_t sampleRate,
420454
uint16_t bitsPerSample,
421455
uint8_t volume,
422-
int16_t retriggerGroupId) {
456+
int16_t retriggerGroupId,
457+
bool loopEnabled) {
423458
if (!impl || voiceIndex < 0 || voiceIndex >= Audio::kVoiceCount) return false;
424459

425460
Audio::Impl::Voice &voice = impl->voices[voiceIndex];
@@ -445,10 +480,56 @@ bool beginVoiceFromRam(Audio::Impl *impl,
445480
impl->nextStartOrder = 1;
446481
}
447482
voice.active = true;
483+
voice.volume = volume;
484+
voice.loopEnabled = loopEnabled;
485+
voice.sourceType = VoiceSourceType::RamData;
486+
voice.path[0] = '\0';
487+
voice.ramData = pcmData;
488+
voice.ramDataBytes = dataBytes;
489+
voice.channelCount = channelCount;
490+
voice.sampleRate = sampleRate;
491+
voice.bitsPerSample = bitsPerSample;
448492
voice.retriggerGroupId = retriggerGroupId;
449493
return true;
450494
}
451495

496+
bool restartVoiceLoop(Audio::Impl *impl, int voiceIndex) {
497+
if (!impl || voiceIndex < 0 || voiceIndex >= Audio::kVoiceCount) return false;
498+
499+
const Audio::Impl::Voice &voice = impl->voices[voiceIndex];
500+
if (!voice.loopEnabled) return false;
501+
502+
const VoiceSourceType sourceType = voice.sourceType;
503+
const String path(voice.path);
504+
const uint8_t volume = voice.volume;
505+
const int16_t retriggerGroupId = voice.retriggerGroupId;
506+
const bool loopEnabled = voice.loopEnabled;
507+
const uint8_t *ramData = voice.ramData;
508+
const uint32_t ramDataBytes = voice.ramDataBytes;
509+
const uint16_t channelCount = voice.channelCount;
510+
const uint32_t sampleRate = voice.sampleRate;
511+
const uint16_t bitsPerSample = voice.bitsPerSample;
512+
513+
stopVoice(impl->voices[voiceIndex]);
514+
515+
if (sourceType == VoiceSourceType::StreamPath && path.length() > 0) {
516+
return beginVoiceFromPath(impl, voiceIndex, path, volume, retriggerGroupId, loopEnabled);
517+
}
518+
if (sourceType == VoiceSourceType::RamData && ramData && ramDataBytes > 0) {
519+
return beginVoiceFromRam(impl,
520+
voiceIndex,
521+
ramData,
522+
ramDataBytes,
523+
channelCount,
524+
sampleRate,
525+
bitsPerSample,
526+
volume,
527+
retriggerGroupId,
528+
loopEnabled);
529+
}
530+
return false;
531+
}
532+
452533
} // namespace
453534

454535
Audio::Audio() = default;
@@ -566,8 +647,10 @@ void Audio::update() {
566647
updateVoiceFadeIn(voice, nowUs);
567648

568649
if (!voice.wav->loop()) {
569-
stopVoice(voice);
570-
stateChanged = true;
650+
if (!voice.loopEnabled || !restartVoiceLoop(impl_, i)) {
651+
stopVoice(voice);
652+
stateChanged = true;
653+
}
571654
}
572655
}
573656

@@ -581,7 +664,10 @@ void Audio::update() {
581664
maybeLogRuntimeDiagnostics(impl_);
582665
}
583666

584-
void Audio::playSamplePath(const String &samplePath, uint8_t volume, int16_t retriggerGroupId) {
667+
void Audio::playSamplePath(const String &samplePath,
668+
uint8_t volume,
669+
int16_t retriggerGroupId,
670+
bool loopEnabled) {
585671
if (!impl_ || samplePath.length() == 0) return;
586672
const uint32_t playStartUs = micros();
587673

@@ -601,7 +687,7 @@ void Audio::playSamplePath(const String &samplePath, uint8_t volume, int16_t ret
601687
}
602688

603689
stopVoice(voice);
604-
if (!beginVoiceFromPath(impl_, voiceIndex, samplePath, volume, retriggerGroupId)) {
690+
if (!beginVoiceFromPath(impl_, voiceIndex, samplePath, volume, retriggerGroupId, loopEnabled)) {
605691
Serial.println("Sample open/start failed");
606692
refreshStats(impl_);
607693
recordPlayCost(impl_, micros() - playStartUs);
@@ -620,13 +706,39 @@ void Audio::stopAllVoices() {
620706
refreshStats(impl_);
621707
}
622708

709+
void Audio::stopLoopingVoicesForGroup(int16_t retriggerGroupId) {
710+
if (!impl_ || retriggerGroupId < 0) return;
711+
bool changed = false;
712+
for (int i = 0; i < kVoiceCount; i++) {
713+
Impl::Voice &voice = impl_->voices[i];
714+
if (!voice.active || !voice.loopEnabled) continue;
715+
if (voice.retriggerGroupId != retriggerGroupId) continue;
716+
stopVoice(voice);
717+
changed = true;
718+
}
719+
if (changed) {
720+
refreshStats(impl_);
721+
}
722+
}
723+
724+
void Audio::setLoopEnabledForGroup(int16_t retriggerGroupId, bool loopEnabled) {
725+
if (!impl_ || retriggerGroupId < 0) return;
726+
for (int i = 0; i < kVoiceCount; i++) {
727+
Impl::Voice &voice = impl_->voices[i];
728+
if (!voice.active) continue;
729+
if (voice.retriggerGroupId != retriggerGroupId) continue;
730+
voice.loopEnabled = loopEnabled;
731+
}
732+
}
733+
623734
bool Audio::playSampleRam(const uint8_t *pcmData,
624735
uint32_t dataBytes,
625736
uint16_t channelCount,
626737
uint32_t sampleRate,
627738
uint16_t bitsPerSample,
628739
uint8_t volume,
629-
int16_t retriggerGroupId) {
740+
int16_t retriggerGroupId,
741+
bool loopEnabled) {
630742
if (!impl_ || !pcmData || dataBytes == 0) return false;
631743
const uint32_t playStartUs = micros();
632744

@@ -653,7 +765,8 @@ bool Audio::playSampleRam(const uint8_t *pcmData,
653765
sampleRate,
654766
bitsPerSample,
655767
volume,
656-
retriggerGroupId);
768+
retriggerGroupId,
769+
loopEnabled);
657770
refreshStats(impl_);
658771
recordPlayCost(impl_, micros() - playStartUs);
659772
return started;

0 commit comments

Comments
 (0)