@devaloop/devalang 0.0.1-alpha.17 → 0.0.1-alpha.18

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.
Files changed (44) hide show
  1. package/.devalang +5 -1
  2. package/Cargo.toml +4 -4
  3. package/README.md +9 -6
  4. package/docs/CHANGELOG.md +46 -1
  5. package/docs/TODO.md +1 -1
  6. package/examples/index.deva +9 -6
  7. package/examples/pattern.deva +5 -5
  8. package/out-tsc/pkg/devalang_core.d.ts +1 -1
  9. package/out-tsc/pkg/devalang_core_bg.wasm.d.ts +7 -7
  10. package/package.json +1 -1
  11. package/project-version.json +3 -3
  12. package/rust/cli/build/commands.rs +10 -0
  13. package/rust/cli/install/addon.rs +84 -38
  14. package/rust/cli/telemetry/event_creator.rs +17 -17
  15. package/rust/core/audio/engine/helpers.rs +21 -9
  16. package/rust/core/audio/engine/sample.rs +68 -7
  17. package/rust/core/audio/engine/synth.rs +19 -4
  18. package/rust/core/audio/evaluator.rs +64 -26
  19. package/rust/core/audio/interpreter/arrow_call.rs +21 -16
  20. package/rust/core/audio/interpreter/call.rs +156 -1
  21. package/rust/core/audio/interpreter/spawn.rs +145 -1
  22. package/rust/core/audio/special/math.rs +22 -2
  23. package/rust/core/lexer/driver.rs +61 -0
  24. package/rust/core/lexer/handler/identifier.rs +3 -2
  25. package/rust/core/lexer/mod.rs +1 -62
  26. package/rust/core/lexer/token.rs +1 -0
  27. package/rust/core/parser/driver.rs +12 -9
  28. package/rust/core/parser/handler/dot.rs +3 -2
  29. package/rust/core/parser/handler/loop_.rs +2 -2
  30. package/rust/core/parser/handler/mod.rs +1 -0
  31. package/rust/core/parser/handler/pattern.rs +74 -0
  32. package/rust/core/preprocessor/loader.rs +87 -127
  33. package/rust/core/preprocessor/processor.rs +7 -7
  34. package/rust/core/preprocessor/resolver/call.rs +28 -0
  35. package/rust/core/preprocessor/resolver/driver.rs +15 -13
  36. package/rust/core/preprocessor/resolver/mod.rs +1 -0
  37. package/rust/core/preprocessor/resolver/pattern.rs +75 -0
  38. package/rust/core/preprocessor/resolver/spawn.rs +27 -0
  39. package/rust/core/store/variable.rs +15 -1
  40. package/rust/main.rs +4 -1
  41. package/rust/types/Cargo.toml +3 -0
  42. package/rust/types/src/ast.rs +4 -0
  43. package/rust/utils/Cargo.toml +4 -1
  44. package/rust/web/api.rs +2 -2
package/.devalang CHANGED
@@ -3,4 +3,8 @@ entry = "./examples"
3
3
  output = "./output"
4
4
  watch = false
5
5
  debug = true
6
- compress = true
6
+ compress = true
7
+
8
+ [[banks]]
9
+ path = "devalang://bank/devaloop.808"
10
+ version = "0.0.1"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "devalang"
3
- version = "0.0.1-alpha.17"
3
+ version = "0.0.1-alpha.18"
4
4
  authors = ["Devaloop <contact@devaloop.com>"]
5
5
  description = "Write music like code. Devalang is a domain-specific language (DSL) for sound designers and music hackers. Compose, automate, and control sound — in plain text."
6
6
  license = "MIT"
@@ -30,7 +30,7 @@ default = ["cli"]
30
30
  cli = ["crossterm", "indicatif", "inquire", "zip", "reqwest", "flate2", "tokio"]
31
31
 
32
32
  [dependencies]
33
- devalang_types = { path = "rust/types" }
33
+ devalang_types = { path = "rust/types", version = "0.0.1" }
34
34
  clap = { version = "4.5", features = ["derive"] }
35
35
  serde = { version = "1.0", features = ["derive"] }
36
36
  serde_json = "1.0"
@@ -68,9 +68,9 @@ getrandom = { version = "0.3", features = ["wasm_js"] }
68
68
  uuid = { version = "1.18.0", features = ["v4", "js", "rng-getrandom"] }
69
69
  # Keep a lightweight linkage to the utils crate for wasm builds without
70
70
  # enabling native CLI features.
71
- devalang_utils = { path = "rust/utils", default-features = false }
71
+ devalang_utils = { path = "rust/utils", default-features = false, version = "0.0.1" }
72
72
 
73
73
  # devalang_utils with CLI and wasmtime are only needed for native targets.
74
74
  [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
75
- devalang_utils = { path = "rust/utils", features = ["cli"] }
75
+ devalang_utils = { path = "rust/utils", features = ["cli"], version = "0.0.1" }
76
76
  wasmtime = "19"
package/README.md CHANGED
@@ -84,13 +84,16 @@ Create a new Devalang file `src/index.deva` in the project directory:
84
84
  # BPM definition
85
85
  bpm 125
86
86
 
87
- # Bank picking (make sure you installed it)
87
+ # Bank picking (make sure you've installed it)
88
88
  bank devaloop.808 as my808Bank
89
89
 
90
+ # Pattern literal without options
91
+ pattern kickPattern with my808Bank.kick = "x--- x--- x--- x---"
92
+
90
93
  group myGroup:
91
94
  # Rhythmic (each beat playing a kick)
92
- on beat:
93
- .my808Bank.kick 1/4
95
+ # on beat:
96
+ # .my808Bank.kick 1/4
94
97
 
95
98
  # Synth definition with ADSR
96
99
  let myLead = synth sine {
@@ -133,12 +136,12 @@ group myGroup:
133
136
  })
134
137
 
135
138
  # Notes with params
136
- myLead -> note(E4, { duration: 400 })
137
139
  myLead -> note(G4, { duration: 600, glide: true, target_freq: 659.25 })
138
140
  myLead -> note(B3, { duration: 400, slide: true, target_amp: 0.3 })
139
141
 
140
- # Calling the group to play it
141
- call myGroup
142
+ # Spawning the group & the pattern to play them in parallel
143
+ spawn myGroup
144
+ spawn kickPattern
142
145
  ```
143
146
 
144
147
  ### And the best part ? You can play it directly from the command line:
package/docs/CHANGELOG.md CHANGED
@@ -4,7 +4,52 @@
4
4
 
5
5
  # Changelog
6
6
 
7
- ## Version 0.0.1-alpha.17 (2025-08-31)
7
+ ## Version 0.0.1-alpha.18 (2025-09-02)
8
+
9
+ ### ✨ Language Features
10
+
11
+ - New `pattern` statement to define rhythmic patterns with an optional target entity.
12
+ - Example: `pattern kickPattern with my808.kick = "x--- x--- x--- x---"`
13
+ - Patterns can be invoked with `call` or `spawn` just like functions or groups.
14
+
15
+ ### 🧠 Core Engine
16
+
17
+ - Pattern playback: schedules steps across one bar (4 beats), computing per-step duration and triggering the target on non-rest characters.
18
+ - ADSR envelope: improved interpolation at segment boundaries to avoid clicks and handle 0/1-sample edge cases.
19
+ - Sample engine: robust stereo-to-mono mixdown with RMS-preserving scaling; applies a tiny automatic fade (~1 ms) when samples start/end abruptly to reduce clicks.
20
+
21
+ ### 🧩 Parser & Lexer
22
+
23
+ - Added `Pattern` token and parser handler; supports `pattern <name> [with <bank.trigger>] = "..."`.
24
+ - Introduced a dedicated lexer driver (`rust/core/lexer/driver.rs`) to separate file resolution from tokenization.
25
+ - Map/array parsing now logs structured errors via the shared logger instead of printing to stdout.
26
+
27
+ ### 🔁 Preprocessor & Resolution
28
+
29
+ - Pattern resolver stores definitions in the variable table, enabling later `call`/`spawn` usage.
30
+ - Variable lookup now walks parent scopes, fixing missed resolutions for outer-scope identifiers.
31
+
32
+ ### 🛠️ CLI & Telemetry
33
+
34
+ - `build`: non-watch mode now executes and surfaces errors correctly.
35
+ - `install`: requires authentication and reports API/JSON errors with clear messages.
36
+ - Telemetry: generates a stable UUID when missing; consistently records CLI version, OS, and args.
37
+ - Ensures the `.deva` directory exists at startup.
38
+
39
+ ### 📚 Examples
40
+
41
+ - Added `examples/pattern.deva`; updated `examples/index.deva` to demonstrate `pattern` and `spawn`.
42
+
43
+ ### 📦 Packaging
44
+
45
+ - Added crate metadata (description, license, authors) and pinned internal versions for `devalang_types` and `devalang_utils`.
46
+
47
+ ### 🐛 Fixes & Stability
48
+
49
+ - Safer `$math` parsing with diagnostics for malformed calls and argument evaluation failures.
50
+ - Minor parser fixes (loop body collection, clearer error messages) and logging cleanups across modules.
51
+
52
+ ## Version 0.0.1-alpha.17 (2025-08-30)
8
53
 
9
54
  ### ✨ Addons
10
55
 
package/docs/TODO.md CHANGED
@@ -73,7 +73,7 @@ This is a list of tasks and features to be implemented in Devalang. Note that th
73
73
 
74
74
  ## Addon generator
75
75
 
76
- - [ ] Implement addon generator
76
+ - [x] Implement addon generator
77
77
  - [ ] Create example addons
78
78
 
79
79
  ## Other TODOs
@@ -3,13 +3,16 @@
3
3
  # BPM definition
4
4
  bpm 125
5
5
 
6
- # Bank picking (make sure you have installed it)
6
+ # Bank picking (make sure you've installed it)
7
7
  bank devaloop.808 as my808Bank
8
8
 
9
+ # Pattern literal without options
10
+ pattern kickPattern with my808Bank.kick = "x--- x--- x--- x---"
11
+
9
12
  group myGroup:
10
13
  # Rhythmic (each beat playing a kick)
11
- on beat:
12
- .my808Bank.kick 1/4
14
+ # on beat:
15
+ # .my808Bank.kick 1/4
13
16
 
14
17
  # Synth definition with ADSR
15
18
  let myLead = synth sine {
@@ -52,9 +55,9 @@ group myGroup:
52
55
  })
53
56
 
54
57
  # Notes with params
55
- myLead -> note(E4, { duration: 400 })
56
58
  myLead -> note(G4, { duration: 600, glide: true, target_freq: 659.25 })
57
59
  myLead -> note(B3, { duration: 400, slide: true, target_amp: 0.3 })
58
60
 
59
- # Calling the group to play it
60
- call myGroup
61
+ # Spawning the group & the pattern to play them in parallel
62
+ spawn myGroup
63
+ spawn kickPattern
@@ -1,8 +1,8 @@
1
- # TODO planned for future release
1
+ # This file demonstrates the usage of patterns in Devaloop
2
2
 
3
- # bank devaloop.808 as 808
3
+ bank devaloop.808 as my808
4
4
 
5
- # # Pattern literal without options
6
- # pattern kickPattern with 808.kick = "x--- x--- x--- x---"
5
+ # Pattern literal without options
6
+ pattern kickPattern with my808.kick = "x--- x--- x--- x---"
7
7
 
8
- # call kickPattern
8
+ call kickPattern
@@ -1,7 +1,7 @@
1
1
  /* tslint:disable */
2
2
  /* eslint-disable */
3
+ export function register_playhead_callback(cb: Function): void;
3
4
  export function parse(entry_path: string, source: string): any;
4
5
  export function debug_render(user_code: string): any;
5
6
  export function render_audio(user_code: string): Float32Array;
6
- export function register_playhead_callback(cb: Function): void;
7
7
  export function unregister_playhead_callback(): void;
@@ -1,28 +1,28 @@
1
1
  /* tslint:disable */
2
2
  /* eslint-disable */
3
3
  export const memory: WebAssembly.Memory;
4
- export const parse: (a: number, b: number, c: number, d: number) => [number, number, number];
5
4
  export const debug_render: (a: number, b: number) => [number, number, number];
6
- export const render_audio: (a: number, b: number) => [number, number, number];
5
+ export const parse: (a: number, b: number, c: number, d: number) => [number, number, number];
7
6
  export const register_playhead_callback: (a: any) => void;
7
+ export const render_audio: (a: number, b: number) => [number, number, number];
8
8
  export const unregister_playhead_callback: () => void;
9
- export const rust_lzma_wasm_shim_malloc: (a: number) => number;
10
9
  export const rust_lzma_wasm_shim_calloc: (a: number, b: number) => number;
11
10
  export const rust_lzma_wasm_shim_free: (a: number) => void;
11
+ export const rust_lzma_wasm_shim_malloc: (a: number) => number;
12
+ export const rust_lzma_wasm_shim_memchr: (a: number, b: number, c: number) => number;
12
13
  export const rust_lzma_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
13
14
  export const rust_lzma_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
14
15
  export const rust_lzma_wasm_shim_memmove: (a: number, b: number, c: number) => number;
15
16
  export const rust_lzma_wasm_shim_memset: (a: number, b: number, c: number) => number;
16
17
  export const rust_lzma_wasm_shim_strlen: (a: number) => number;
17
- export const rust_lzma_wasm_shim_memchr: (a: number, b: number, c: number) => number;
18
- export const rust_zstd_wasm_shim_qsort: (a: number, b: number, c: number, d: number) => void;
19
- export const rust_zstd_wasm_shim_malloc: (a: number) => number;
20
- export const rust_zstd_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
21
18
  export const rust_zstd_wasm_shim_calloc: (a: number, b: number) => number;
22
19
  export const rust_zstd_wasm_shim_free: (a: number) => void;
20
+ export const rust_zstd_wasm_shim_malloc: (a: number) => number;
21
+ export const rust_zstd_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
23
22
  export const rust_zstd_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
24
23
  export const rust_zstd_wasm_shim_memmove: (a: number, b: number, c: number) => number;
25
24
  export const rust_zstd_wasm_shim_memset: (a: number, b: number, c: number) => number;
25
+ export const rust_zstd_wasm_shim_qsort: (a: number, b: number, c: number, d: number) => void;
26
26
  export const __wbindgen_malloc: (a: number, b: number) => number;
27
27
  export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
28
28
  export const __wbindgen_exn_store: (a: number) => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@devaloop/devalang",
3
3
  "private": false,
4
- "version": "0.0.1-alpha.17",
4
+ "version": "0.0.1-alpha.18",
5
5
  "description": "Write music like code. Devalang is a domain-specific language (DSL) for sound designers and music hackers. Compose, automate, and control sound — in plain text.",
6
6
  "main": "out-tsc/index.js",
7
7
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.0.1-alpha.17",
2
+ "version": "0.0.1-alpha.18",
3
3
  "channel": "alpha",
4
- "lastCommit": "b4c1bf479cbf2334b6732cc061a855ea527560f5",
5
- "build": 16
4
+ "lastCommit": "c57dff0f8018777c6ef04433701ed150e1255c51",
5
+ "build": 17
6
6
  }
@@ -91,6 +91,16 @@ pub fn handle_build_command(
91
91
  );
92
92
  })
93
93
  .unwrap();
94
+ } else {
95
+ let res = crate::cli::build::process::process_build(
96
+ entry_file,
97
+ fetched_output,
98
+ debug,
99
+ compress,
100
+ );
101
+ if let Err(e) = res {
102
+ return Err(e);
103
+ }
94
104
  }
95
105
 
96
106
  Ok(())
@@ -22,59 +22,105 @@ pub async fn install_addon(
22
22
  pub async fn ask_api_for_signed_url(addon_type: AddonType, slug: &str) -> Result<String, String> {
23
23
  let api_url = get_api_url();
24
24
 
25
- let user_config = get_user_config();
26
- if user_config.is_none() {
27
- return Err("User is not logged in".into());
28
- }
25
+ use devalang_utils::logger::Logger;
26
+ use devalang_utils::logger::LogLevel;
27
+
28
+ // Require an authenticated user for addon installation: token must be present and non-empty
29
+ let stored_token_opt = get_user_config()
30
+ .and_then(|cfg| {
31
+ let t = cfg.session.clone();
32
+ if t.trim().is_empty() {
33
+ None
34
+ } else {
35
+ Some(t)
36
+ }
37
+ });
29
38
 
30
- let stored_token = user_config.unwrap().session.clone();
39
+ if stored_token_opt.is_none() {
40
+ let logger = Logger::new();
41
+ let msg = "Authentication required — run `devalang login` to authenticate";
42
+ logger.log_message(LogLevel::Error, msg);
43
+ return Err("Authentication required: run 'devalang login' to authenticate".to_string());
44
+ }
31
45
 
32
- let request_url = format!(
33
- "{}/v1/assets/url?type={}&slug={}&token={}",
34
- api_url,
35
- match addon_type {
36
- AddonType::Bank => "bank",
37
- AddonType::Plugin => "plugin",
38
- AddonType::Preset => "preset",
39
- AddonType::Template => "template",
40
- },
41
- slug,
42
- stored_token
43
- );
46
+ let request_url = if let Some(token) = &stored_token_opt {
47
+ format!(
48
+ "{}/v1/assets/url?type={}&slug={}&token={}",
49
+ api_url,
50
+ match addon_type {
51
+ AddonType::Bank => "bank",
52
+ AddonType::Plugin => "plugin",
53
+ AddonType::Preset => "preset",
54
+ AddonType::Template => "template",
55
+ },
56
+ slug,
57
+ token
58
+ )
59
+ } else {
60
+ format!(
61
+ "{}/v1/assets/url?type={}&slug={}",
62
+ api_url,
63
+ match addon_type {
64
+ AddonType::Bank => "bank",
65
+ AddonType::Plugin => "plugin",
66
+ AddonType::Preset => "preset",
67
+ AddonType::Template => "template",
68
+ },
69
+ slug
70
+ )
71
+ };
44
72
 
45
73
  let mut headers = reqwest::header::HeaderMap::new();
46
-
47
- headers.insert(
48
- "Authorization",
49
- format!("Bearer {}", stored_token).parse().unwrap(),
50
- );
74
+ if let Some(token) = stored_token_opt {
75
+ headers.insert(
76
+ "Authorization",
77
+ format!("Bearer {}", token).parse().unwrap(),
78
+ );
79
+ }
51
80
 
52
81
  let client: reqwest::Client = reqwest::Client::builder()
53
82
  .default_headers(headers)
54
83
  .build()
55
84
  .map_err(|_| "Failed to build HTTP client".to_string())?;
56
85
 
57
- let req = client
86
+ let resp = client
58
87
  .get(&request_url)
59
88
  .send()
60
89
  .await
61
- .map_err(|_| "Failed to receive response".to_string())?;
90
+ .map_err(|e| format!("Failed to receive response: {}", e))?;
62
91
 
63
- let response_body: serde_json::Value = req
64
- .json()
92
+ let status = resp.status();
93
+ let body_text = resp
94
+ .text()
65
95
  .await
66
- .map_err(|_| "Failed to read response body".to_string())?;
96
+ .map_err(|e| format!("Failed to read response body: {}", e))?;
97
+
98
+ // Try to parse JSON; if parsing fails, return body for diagnostics
99
+ let json: serde_json::Value = match serde_json::from_str(&body_text) {
100
+ Ok(v) => v,
101
+ Err(_) => {
102
+ return Err(format!(
103
+ "Invalid JSON response (status {}): {}",
104
+ status, body_text
105
+ ));
106
+ }
107
+ };
67
108
 
68
- let signed_url: String = serde_json::from_value(
69
- response_body
70
- .get("payload")
71
- .cloned()
72
- .unwrap_or_default()
73
- .get("url")
74
- .cloned()
75
- .unwrap_or_default(),
76
- )
77
- .map_err(|_| "Failed to parse response body".to_string())?;
109
+ // Extract payload.url safely
110
+ let signed_url_opt = json
111
+ .get("payload")
112
+ .and_then(|p| p.get("url"))
113
+ .and_then(|u| u.as_str())
114
+ .map(|s| s.to_string());
78
115
 
79
- Ok(signed_url)
116
+ if let Some(signed_url) = signed_url_opt {
117
+ Ok(signed_url)
118
+ } else {
119
+ // Provide detailed diagnostics to help user understand why it's null
120
+ let err_msg = format!(
121
+ "API returned no URL (status {}): {}",
122
+ status, body_text
123
+ );
124
+ Err(err_msg)
125
+ }
80
126
  }
@@ -2,6 +2,7 @@ use crate::config::settings::get_user_config;
2
2
  use devalang_types::{
3
3
  TelemetryErrorLevel as SharedTelemetryErrorLevel, TelemetryEvent as SharedTelemetryEvent,
4
4
  };
5
+ use uuid::Uuid;
5
6
 
6
7
  pub type TelemetryEvent = SharedTelemetryEvent;
7
8
  pub type TelemetryErrorLevel = SharedTelemetryErrorLevel;
@@ -57,24 +58,23 @@ impl TelemetryEventCreator {
57
58
  }
58
59
 
59
60
  pub fn get_base_event(&self) -> TelemetryEvent {
60
- let mut default_event = TelemetryEvent::default();
61
+ let uuid = match get_user_config() {
62
+ Some(cfg) if !cfg.telemetry.uuid.is_empty() => cfg.telemetry.uuid.clone(),
63
+ _ => Uuid::new_v4().to_string(),
64
+ };
61
65
 
62
- if let Some(user_cfg) = get_user_config() {
63
- default_event = TelemetryEvent {
64
- uuid: user_cfg.telemetry.uuid.clone(),
65
- cli_version: env!("CARGO_PKG_VERSION").to_string(),
66
- os: std::env::consts::OS.to_string(),
67
- command: std::env::args().collect::<Vec<_>>(),
68
- project_info: None,
69
- error_level: TelemetryErrorLevel::None,
70
- error_message: None,
71
- exit_code: None,
72
- timestamp: chrono::Utc::now().to_string(),
73
- duration: 0,
74
- success: true,
75
- };
66
+ TelemetryEvent {
67
+ uuid,
68
+ cli_version: env!("CARGO_PKG_VERSION").to_string(),
69
+ os: std::env::consts::OS.to_string(),
70
+ command: std::env::args().collect::<Vec<_>>(),
71
+ project_info: None,
72
+ error_level: TelemetryErrorLevel::None,
73
+ error_message: None,
74
+ exit_code: None,
75
+ timestamp: chrono::Utc::now().to_string(),
76
+ duration: 0,
77
+ success: true,
76
78
  }
77
-
78
- default_event
79
79
  }
80
80
  }
@@ -105,17 +105,29 @@ pub fn adsr_envelope_value(
105
105
  release_samples: usize,
106
106
  sustain_level: f32,
107
107
  ) -> f32 {
108
- if i < attack_samples {
109
- (i as f32) / (attack_samples as f32)
110
- } else if i < attack_samples + decay_samples {
111
- 1.0 - (1.0 - sustain_level) * (((i - attack_samples) as f32) / (decay_samples as f32))
112
- } else if i < attack_samples + decay_samples + sustain_samples {
108
+ let attack_start = 0usize;
109
+ let decay_start = attack_samples;
110
+ let sustain_start = attack_samples + decay_samples;
111
+ let release_start = attack_samples + decay_samples + sustain_samples;
112
+
113
+ if i < attack_start + attack_samples && attack_samples > 0 {
114
+ let k = i - attack_start;
115
+ let denom = if attack_samples > 1 { (attack_samples - 1) as f32 } else { 1.0 };
116
+ (k as f32) / denom
117
+ } else if i < decay_start + decay_samples && decay_samples > 0 {
118
+ let k = i - decay_start;
119
+ let denom = if decay_samples > 1 { (decay_samples - 1) as f32 } else { 1.0 };
120
+ let ratio = (k as f32) / denom;
121
+ 1.0 - (1.0 - sustain_level) * ratio
122
+ } else if i < sustain_start + sustain_samples {
113
123
  sustain_level
114
124
  } else if release_samples > 0 {
115
- sustain_level
116
- * (1.0
117
- - ((i - attack_samples - decay_samples - sustain_samples) as f32)
118
- / (release_samples as f32))
125
+ // release: interpolate from sustain_level down to 0 inclusive
126
+ let k = i.saturating_sub(release_start);
127
+ let denom = if release_samples > 1 { (release_samples - 1) as f32 } else { 1.0 };
128
+ let ratio = (k as f32) / denom;
129
+ let val = sustain_level * (1.0 - ratio);
130
+ if val < 0.0 { 0.0 } else { val }
119
131
  } else {
120
132
  0.0
121
133
  }
@@ -153,8 +153,30 @@ impl super::synth::AudioEngine {
153
153
  }
154
154
  };
155
155
 
156
- let max_mono_samples = (dur_sec * (SAMPLE_RATE as f32)) as usize;
157
- let samples: Vec<i16> = decoder.convert_samples().take(max_mono_samples).collect();
156
+ // Read frames from decoder and convert to mono if needed.
157
+ let max_frames = (dur_sec * (SAMPLE_RATE as f32)) as usize;
158
+ let dec_channels = decoder.channels() as usize;
159
+ let max_raw_samples = max_frames.saturating_mul(dec_channels.max(1));
160
+ let raw_samples: Vec<i16> = decoder.convert_samples().take(max_raw_samples).collect();
161
+
162
+ // Convert interleaved channels to mono by averaging channels per frame.
163
+ // Apply a small RMS-preserving scale so mono level is similar to mixed stereo.
164
+ let actual_frames = if dec_channels > 0 { raw_samples.len() / dec_channels } else { 0 };
165
+ let mut samples: Vec<i16> = Vec::with_capacity(actual_frames);
166
+ let rms_scale = (dec_channels as f32).sqrt();
167
+ for frame in 0..actual_frames {
168
+ let mut sum: i32 = 0;
169
+ for ch in 0..dec_channels {
170
+ sum += raw_samples[frame * dec_channels + ch] as i32;
171
+ }
172
+ if dec_channels > 0 {
173
+ let avg = (sum / (dec_channels as i32)) as f32;
174
+ let scaled = (avg * rms_scale).clamp(i16::MIN as f32, i16::MAX as f32) as i16;
175
+ samples.push(scaled);
176
+ } else {
177
+ samples.push(0);
178
+ }
179
+ }
158
180
 
159
181
  if samples.is_empty() {
160
182
  eprintln!("❌ No samples read from {}", resolved_path);
@@ -228,6 +250,37 @@ impl super::synth::AudioEngine {
228
250
  let fade_in_samples = (fade_in * (SAMPLE_RATE as f32)) as usize;
229
251
  let fade_out_samples = (fade_out * (SAMPLE_RATE as f32)) as usize;
230
252
 
253
+ // If no fade specified, apply a tiny default fade (2 ms) when sample boundaries are non-zero
254
+ let default_boundary_fade_ms = 1.0_f32; // 1 ms
255
+ let default_fade_samples = (default_boundary_fade_ms * (SAMPLE_RATE as f32)) as usize;
256
+ let mut effective_fade_in = fade_in_samples;
257
+ let mut effective_fade_out = fade_out_samples;
258
+ if effective_fade_in == 0 {
259
+ if let Some(&first) = samples.first() {
260
+ if first.abs() > 64 { // increased threshold to detect only strong abrupt starts
261
+ effective_fade_in = default_fade_samples.max(1);
262
+ }
263
+ }
264
+ }
265
+ if effective_fade_out == 0 {
266
+ if let Some(&last) = samples.last() {
267
+ if last.abs() > 64 { // increased threshold to detect only strong abrupt ends
268
+ effective_fade_out = default_fade_samples.max(1);
269
+ }
270
+ }
271
+ }
272
+
273
+ // Ensure fades do not exceed half the sample length to avoid silencing short samples
274
+ if total_samples > 0 {
275
+ let cap = total_samples / 2;
276
+ if effective_fade_in > cap {
277
+ effective_fade_in = cap.max(1);
278
+ }
279
+ if effective_fade_out > cap {
280
+ effective_fade_out = cap.max(1);
281
+ }
282
+ }
283
+
231
284
  let delay_samples = if delay > 0.0 {
232
285
  (delay * (SAMPLE_RATE as f32)) as usize
233
286
  } else {
@@ -235,7 +288,7 @@ impl super::synth::AudioEngine {
235
288
  };
236
289
  let mut delay_buffer: Vec<f32> = vec![0.0; total_samples + delay_samples];
237
290
 
238
- for i in 0..total_samples {
291
+ for i in 0..total_samples {
239
292
  let pitch_index = if pitch != 1.0 {
240
293
  ((i as f32) / pitch) as usize
241
294
  } else {
@@ -250,11 +303,19 @@ impl super::synth::AudioEngine {
250
303
 
251
304
  adjusted *= gain;
252
305
 
253
- if fade_in_samples > 0 && i < fade_in_samples {
254
- adjusted *= (i as f32) / (fade_in_samples as f32);
306
+ if effective_fade_in > 0 && i < effective_fade_in {
307
+ if effective_fade_in == 1 {
308
+ adjusted *= 0.0;
309
+ } else {
310
+ adjusted *= (i as f32) / (effective_fade_in as f32);
311
+ }
255
312
  }
256
- if fade_out_samples > 0 && i >= total_samples.saturating_sub(fade_out_samples) {
257
- adjusted *= ((total_samples - i) as f32) / (fade_out_samples as f32);
313
+ if effective_fade_out > 0 && i >= total_samples.saturating_sub(effective_fade_out) {
314
+ if effective_fade_out == 1 {
315
+ adjusted *= 0.0;
316
+ } else {
317
+ adjusted *= ((total_samples - 1 - i) as f32) / ((effective_fade_out - 1) as f32);
318
+ }
258
319
  }
259
320
 
260
321
  if drive > 0.0 {
@@ -51,6 +51,12 @@ impl AudioEngine {
51
51
  }
52
52
 
53
53
  pub fn merge_with(&mut self, other: AudioEngine) {
54
+ // If the other buffer is empty, simply return without warning (common for spawns that produced nothing)
55
+ if other.buffer.is_empty() {
56
+ return;
57
+ }
58
+
59
+ // If the other buffer is present but contains only zeros, warn and skip merge
54
60
  if other.buffer.iter().all(|&s| s == 0) {
55
61
  eprintln!("⚠️ Skipping merge: other buffer is silent");
56
62
  return;
@@ -225,10 +231,19 @@ impl AudioEngine {
225
231
  );
226
232
 
227
233
  // Fade in/out
228
- if i < fade_len {
229
- value *= (i as f32) / (fade_len as f32);
230
- } else if i >= total_samples - fade_len {
231
- value *= ((total_samples - i) as f32) / (fade_len as f32);
234
+ if fade_len > 0 && i < fade_len {
235
+ if fade_len == 1 {
236
+ value *= 0.0;
237
+ } else {
238
+ value *= (i as f32) / (fade_len as f32);
239
+ }
240
+ } else if fade_len > 0 && i >= total_samples.saturating_sub(fade_len) {
241
+ if fade_len == 1 {
242
+ value *= 0.0;
243
+ } else {
244
+ // ensure last sample becomes exactly zero to avoid clicks
245
+ value *= ((total_samples - 1 - i) as f32) / ((fade_len - 1) as f32);
246
+ }
232
247
  }
233
248
 
234
249
  value *= envelope;