Skip to content

Commit 1c84c00

Browse files
Merge pull request #4 from hTuneSys/main
sync: main → develop (v0.1.2)
2 parents 64729f5 + 6015069 commit 1c84c00

8 files changed

Lines changed: 412 additions & 65 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "hexatune-dsp-ffi"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
edition = "2024"
55
license = "MIT"
66
authors = ["hexaTune LLC"]

README.md

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,15 @@ A single step in the frequency cycle.
171171
typedef struct HtdCycleItem {
172172
float frequency_delta; // Hz delta added to carrier
173173
float duration_seconds; // How long this step lasts
174+
bool oneshot; // If true, plays only in the first cycle iteration
174175
} HtdCycleItem;
175176
```
176177

178+
**One-shot items:** When `oneshot` is `true`, the item plays during the first
179+
pass through the cycle and is skipped on all subsequent iterations. If every
180+
item in the cycle is one-shot, the binaural/tone layer goes silent after the
181+
first pass while ambience layers continue playing.
182+
177183
#### `HtdEngineConfig`
178184

179185
Configuration for engine initialization.
@@ -256,12 +262,21 @@ int32_t htd_engine_start(HtdEngine *engine); // Returns 0 on success
256262
257263
#### `htd_engine_stop`
258264
259-
Stop audio generation. Render will output silence.
265+
Stop audio generation immediately. Render will output silence.
260266
261267
```c
262268
int32_t htd_engine_stop(HtdEngine *engine); // Returns 0 on success
263269
```
264270

271+
#### `htd_engine_stop_graceful`
272+
273+
Request a graceful stop. The engine finishes playing all remaining items in the
274+
current cycle iteration, then automatically stops (including all layers).
275+
276+
```c
277+
int32_t htd_engine_stop_graceful(HtdEngine *engine); // Returns 0 on success
278+
```
279+
265280
#### `htd_engine_is_running`
266281
267282
Query engine state.
@@ -430,13 +445,16 @@ void startSoundscape() {
430445
final cycleItems = calloc<HtdCycleItem>(3);
431446
cycleItems[0]
432447
..frequency_delta = 3.0
433-
..duration_seconds = 30.0;
448+
..duration_seconds = 30.0
449+
..oneshot = false;
434450
cycleItems[1]
435451
..frequency_delta = 4.0
436-
..duration_seconds = 30.0;
452+
..duration_seconds = 30.0
453+
..oneshot = true; // plays only in the first cycle pass
437454
cycleItems[2]
438455
..frequency_delta = 5.0
439-
..duration_seconds = 30.0;
456+
..duration_seconds = 30.0
457+
..oneshot = false;
440458
441459
// 2. Build config
442460
final config = calloc<HtdEngineConfig>();
@@ -524,11 +542,11 @@ void audioCallback(Pointer<Float> buffer, int numFrames) {
524542
#include <stdio.h>
525543

526544
int main(void) {
527-
// Frequency cycle: 3Hz->4Hz->5Hz, 30s each
545+
// Frequency cycle: 3Hz->4Hz->5Hz, 30s each; 4Hz is one-shot
528546
HtdCycleItem cycle[] = {
529-
{ .frequency_delta = 3.0f, .duration_seconds = 30.0f },
530-
{ .frequency_delta = 4.0f, .duration_seconds = 30.0f },
531-
{ .frequency_delta = 5.0f, .duration_seconds = 30.0f },
547+
{ .frequency_delta = 3.0f, .duration_seconds = 30.0f, .oneshot = false },
548+
{ .frequency_delta = 4.0f, .duration_seconds = 30.0f, .oneshot = true },
549+
{ .frequency_delta = 5.0f, .duration_seconds = 30.0f, .oneshot = false },
532550
};
533551

534552
// Engine configuration
@@ -587,8 +605,10 @@ int main(void) {
587605
htd_engine_set_base_gain(engine, 0.7f);
588606
htd_engine_set_binaural_gain(engine, 0.2f);
589607

590-
// Cleanup
591-
htd_engine_stop(engine);
608+
// Cleanup — use stop_graceful to finish the current cycle first,
609+
// or stop for an immediate halt.
610+
htd_engine_stop_graceful(engine); // finishes current cycle, then stops
611+
// htd_engine_stop(engine); // alternative: immediate stop
592612
htd_engine_clear_all_layers(engine);
593613
htd_engine_destroy(engine);
594614
return 0;

include/hexatune_dsp_ffi.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ typedef struct Engine HtdEngine;
8282
typedef struct HtdCycleItem {
8383
float frequency_delta;
8484
float duration_seconds;
85+
/**
86+
* If true, this item plays only in the first cycle iteration.
87+
*/
88+
bool oneshot;
8589
} HtdCycleItem;
8690

8791
/**
@@ -216,6 +220,19 @@ int32_t htd_engine_start(HtdEngine *engine);
216220
*/
217221
int32_t htd_engine_stop(HtdEngine *engine);
218222

223+
/**
224+
* Request a graceful stop: the engine finishes the current cycle iteration
225+
* (all remaining items in this pass) and then automatically stops.
226+
*
227+
* Ambience, texture, and event layers will also go silent once the cycle
228+
* completes.
229+
*
230+
* # Safety
231+
*
232+
* `engine` must be a valid engine pointer.
233+
*/
234+
int32_t htd_engine_stop_graceful(HtdEngine *engine);
235+
219236
/**
220237
* Render `num_frames` of interleaved stereo f32 audio into `output`.
221238
*

src/engine.rs

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::mixer::{
1717
DEFAULT_TEXTURE_GAIN, LayerGains, Mixer,
1818
};
1919
use crate::sample_player::SamplePlayer;
20-
use crate::scheduler::{CycleItem, Scheduler};
20+
use crate::scheduler::{AdvanceResult, CycleItem, Scheduler};
2121

2222
/// Default sample rate for mobile audio.
2323
pub const DEFAULT_SAMPLE_RATE: f32 = 48000.0;
@@ -55,6 +55,8 @@ pub struct Engine {
5555
scheduler: Scheduler,
5656
mixer: Mixer,
5757
sample_rate: f32,
58+
/// Set when all cycle items are exhausted (all oneshot, past first iter).
59+
binaural_muted: bool,
5860

5961
// -- Atomic shared parameters (written by control, read by audio) --
6062
base_gain: AtomicU32,
@@ -63,6 +65,8 @@ pub struct Engine {
6365
binaural_gain: AtomicU32,
6466
master_gain: AtomicU32,
6567
running: AtomicBool,
68+
/// Atomic flag for graceful stop request from control thread.
69+
graceful_stop_requested: AtomicBool,
6670

6771
// -- Pending configuration (written by control, consumed by audio) --
6872
pending_config: Mutex<Option<PendingConfig>>,
@@ -95,14 +99,17 @@ impl Default for EngineConfig {
9599
CycleItem {
96100
frequency_delta: 3.0,
97101
duration_seconds: 30.0,
102+
oneshot: false,
98103
},
99104
CycleItem {
100105
frequency_delta: 4.0,
101106
duration_seconds: 30.0,
107+
oneshot: false,
102108
},
103109
CycleItem {
104110
frequency_delta: 5.0,
105111
duration_seconds: 30.0,
112+
oneshot: false,
106113
},
107114
],
108115
sample_rate: DEFAULT_SAMPLE_RATE,
@@ -152,12 +159,14 @@ impl Engine {
152159
scheduler,
153160
mixer,
154161
sample_rate: config.sample_rate,
162+
binaural_muted: false,
155163
base_gain: AtomicU32::new(config.base_gain.to_bits()),
156164
texture_gain: AtomicU32::new(config.texture_gain.to_bits()),
157165
event_gain: AtomicU32::new(config.event_gain.to_bits()),
158166
binaural_gain: AtomicU32::new(config.binaural_gain.to_bits()),
159167
master_gain: AtomicU32::new(config.master_gain.to_bits()),
160168
running: AtomicBool::new(false),
169+
graceful_stop_requested: AtomicBool::new(false),
161170
pending_config: Mutex::new(None),
162171
})
163172
}
@@ -166,14 +175,24 @@ impl Engine {
166175

167176
/// Mark the engine as running. Audio will be generated in [`render`].
168177
pub fn start(&self) {
178+
self.graceful_stop_requested.store(false, Ordering::Release);
169179
self.running.store(true, Ordering::Release);
170180
}
171181

172182
/// Mark the engine as stopped. [`render`] will output silence.
173183
pub fn stop(&self) {
184+
self.graceful_stop_requested.store(false, Ordering::Release);
174185
self.running.store(false, Ordering::Release);
175186
}
176187

188+
/// Request a graceful stop: the engine continues playing until the
189+
/// current cycle iteration completes, then stops automatically.
190+
/// The scheduler's `stop_at_cycle_end` flag is set via an atomic bool
191+
/// and applied on the next render call.
192+
pub fn stop_graceful(&self) {
193+
self.graceful_stop_requested.store(true, Ordering::Release);
194+
}
195+
177196
/// Whether the engine is currently running.
178197
pub fn is_running(&self) -> bool {
179198
self.running.load(Ordering::Acquire)
@@ -349,14 +368,43 @@ impl Engine {
349368
}
350369

351370
for i in 0..num_frames {
352-
// 1. Frequency scheduler update
353-
if self.scheduler.advance() {
354-
let new_delta = self.scheduler.current_delta();
355-
self.binaural.set_delta(new_delta);
371+
// 1. Apply graceful stop flag from control thread
372+
if self
373+
.graceful_stop_requested
374+
.compare_exchange(true, false, Ordering::Acquire, Ordering::Relaxed)
375+
.is_ok()
376+
{
377+
self.scheduler.set_stop_at_cycle_end(true);
378+
}
379+
380+
// 2. Frequency scheduler update
381+
match self.scheduler.advance() {
382+
AdvanceResult::ItemChanged => {
383+
let new_delta = self.scheduler.current_delta();
384+
self.binaural.set_delta(new_delta);
385+
}
386+
AdvanceResult::AllExhausted => {
387+
self.binaural_muted = true;
388+
}
389+
AdvanceResult::CycleCompleteStop => {
390+
self.running.store(false, Ordering::Release);
391+
// Fill remaining frames with silence and return
392+
for j in i..num_frames {
393+
let idx = j * 2;
394+
output[idx] = 0.0;
395+
output[idx + 1] = 0.0;
396+
}
397+
return;
398+
}
399+
AdvanceResult::NoChange => {}
356400
}
357401

358-
// 2. Generate binaural/AM tone
359-
let tone = self.binaural.generate();
402+
// 3. Generate binaural/AM tone (silent if exhausted)
403+
let tone = if self.binaural_muted {
404+
StereoSample::default()
405+
} else {
406+
self.binaural.generate()
407+
};
360408

361409
// 3. Base layer
362410
let base = match self.base_layer.as_mut() {
@@ -405,6 +453,7 @@ impl Engine {
405453
}
406454
if let Some(items) = config.cycle_items {
407455
self.scheduler.set_items(items);
456+
self.binaural_muted = false;
408457
let new_delta = self.scheduler.current_delta();
409458
self.binaural.set_delta(new_delta);
410459
}
@@ -445,10 +494,12 @@ mod tests {
445494
CycleItem {
446495
frequency_delta: 3.0,
447496
duration_seconds: 0.01,
497+
oneshot: false,
448498
},
449499
CycleItem {
450500
frequency_delta: 5.0,
451501
duration_seconds: 0.01,
502+
oneshot: false,
452503
},
453504
],
454505
sample_rate: 48000.0,

src/event_player.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,7 @@ impl EventSystem {
6969
pan_max: f32,
7070
) -> Result<(), String> {
7171
if index >= MAX_EVENT_SLOTS {
72-
return Err(format!(
73-
"event index {index} exceeds max {MAX_EVENT_SLOTS}"
74-
));
72+
return Err(format!("event index {index} exceeds max {MAX_EVENT_SLOTS}"));
7573
}
7674
if samples.is_empty() {
7775
return Err("event samples are empty".to_string());

src/ffi.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ pub type HtdEngine = Engine;
4242
pub struct HtdCycleItem {
4343
pub frequency_delta: f32,
4444
pub duration_seconds: f32,
45+
/// If true, this item plays only in the first cycle iteration.
46+
pub oneshot: bool,
4547
}
4648

4749
/// Engine configuration passed from Flutter.
@@ -138,6 +140,7 @@ unsafe fn ffi_config_to_rust(config: *const HtdEngineConfig) -> Result<EngineCon
138140
.map(|item| CycleItem {
139141
frequency_delta: item.frequency_delta,
140142
duration_seconds: item.duration_seconds,
143+
oneshot: item.oneshot,
141144
})
142145
.collect()
143146
};
@@ -273,6 +276,24 @@ pub unsafe extern "C" fn htd_engine_stop(engine: *mut HtdEngine) -> i32 {
273276
HtdError::Ok as i32
274277
}
275278

279+
/// Request a graceful stop: the engine finishes the current cycle iteration
280+
/// (all remaining items in this pass) and then automatically stops.
281+
///
282+
/// Ambience, texture, and event layers will also go silent once the cycle
283+
/// completes.
284+
///
285+
/// # Safety
286+
///
287+
/// `engine` must be a valid engine pointer.
288+
#[unsafe(no_mangle)]
289+
pub unsafe extern "C" fn htd_engine_stop_graceful(engine: *mut HtdEngine) -> i32 {
290+
if engine.is_null() {
291+
return HtdError::NullPointer as i32;
292+
}
293+
unsafe { &*engine }.stop_graceful();
294+
HtdError::Ok as i32
295+
}
296+
276297
// ---------------------------------------------------------------------------
277298
// FFI functions — Render
278299
// ---------------------------------------------------------------------------
@@ -603,6 +624,7 @@ pub unsafe extern "C" fn htd_engine_update_config(
603624
.map(|item| CycleItem {
604625
frequency_delta: item.frequency_delta,
605626
duration_seconds: item.duration_seconds,
627+
oneshot: item.oneshot,
606628
})
607629
.collect(),
608630
)

0 commit comments

Comments
 (0)