145 lines
5.6 KiB
GDScript
145 lines
5.6 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
## sound.generate — procedural placeholder SFX (PCM → AudioStreamWAV → .wav).
|
|
##
|
|
## Closes the other half of the can't-produce-assets blindspot: the LLM can't
|
|
## synthesise audio (agents have reached for Python to fake it). This generates
|
|
## short sound effects — single-oscillator tones (sine/square/triangle/sawtooth)
|
|
## or noise, with an optional pitch sweep and a numeric de-click/decay envelope —
|
|
## as a mono 16-bit WAV. SFX only; music/melodies are out of scope.
|
|
|
|
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
|
|
const Helpers = Modules.CommandHelpers
|
|
|
|
const ALLOWED_EXTS := ["wav"]
|
|
const MIX_RATE := 44100
|
|
const MAX_DURATION := 5.0
|
|
const DEFAULT_DURATION := 0.3
|
|
const DEFAULT_DECLICK := 0.003 # 3 ms auto fade to avoid start/stop clicks
|
|
const WAVEFORMS := ["sine", "square", "triangle", "sawtooth", "noise"]
|
|
|
|
|
|
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
|
|
registry.add("sound.generate", func(parameters: Dictionary) -> Dictionary:
|
|
return await _cmd_generate(parameters)
|
|
, MCPToolkitCommandOptions.new()
|
|
.mark_scene_independent()
|
|
.guard_project_path("file_path")
|
|
.with_timeout_ms(40000)
|
|
.with_success_hint(
|
|
"Assign the stream: node_set_property on "
|
|
+ "AudioStreamPlayer/AudioStreamPlayer2D/AudioStreamPlayer3D.stream."))
|
|
|
|
|
|
static func _cmd_generate(parameters: Dictionary) -> Dictionary:
|
|
var file_path := str(parameters.get("file_path", ""))
|
|
if file_path == "":
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"file_path is required (res:// destination ending in .wav)", MCPToolkitError.HINT_FILE_PATH)
|
|
|
|
var waveform := str(parameters.get("waveform", "sine")).to_lower()
|
|
if waveform not in WAVEFORMS:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"waveform must be one of: %s (got '%s')" % [", ".join(WAVEFORMS), waveform])
|
|
|
|
var duration: float = clampf(float(parameters.get("duration", DEFAULT_DURATION)), 0.001, MAX_DURATION)
|
|
var frequency: float = clampf(float(parameters.get("frequency", 440.0)), 1.0, 20000.0)
|
|
var has_end := parameters.has("end_frequency")
|
|
var end_frequency: float = clampf(float(parameters.get("end_frequency", frequency)), 1.0, 20000.0)
|
|
var volume: float = clampf(float(parameters.get("volume", 0.8)), 0.0, 1.0)
|
|
var decay: float = maxf(0.0, float(parameters.get("decay", 0.0)))
|
|
|
|
# Envelope fades (default ~3 ms de-click; clamped so in+out <= duration).
|
|
var fade_in: float = maxf(0.0, float(parameters.get("fade_in", DEFAULT_DECLICK)))
|
|
var fade_out: float = maxf(0.0, float(parameters.get("fade_out", DEFAULT_DECLICK)))
|
|
if fade_in + fade_out > duration:
|
|
var scale := duration / (fade_in + fade_out)
|
|
fade_in *= scale
|
|
fade_out *= scale
|
|
|
|
var data := _build_pcm(
|
|
waveform, frequency, end_frequency, has_end, duration, volume, fade_in, fade_out, decay)
|
|
|
|
var stream := AudioStreamWAV.new()
|
|
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
|
stream.mix_rate = MIX_RATE
|
|
stream.stereo = false
|
|
stream.data = data
|
|
|
|
var write_fn := func(write_path: String) -> Dictionary:
|
|
var err := stream.save_to_wav(write_path)
|
|
if err != OK:
|
|
return MCPToolkitError.fail("WRITE_FAILED",
|
|
"AudioStreamWAV.save_to_wav failed for %s (err %d)" % [write_path, err])
|
|
return {}
|
|
|
|
var if_exists := str(parameters.get("if_exists", "return"))
|
|
# Default 0 = no import-settle poll: a generated WAV is an AudioStreamWAV by
|
|
# construction, so the class is reported directly (no FS round-trip). Pass an
|
|
# explicit wait_for_scan_ms > 0 to force a settle.
|
|
var wait_for_scan_ms := int(parameters.get("wait_for_scan_ms", 0))
|
|
var result := await Helpers.write_asset_with_settle(
|
|
file_path, PackedStringArray(ALLOWED_EXTS), if_exists, wait_for_scan_ms, "sound.generate", write_fn, "AudioStreamWAV")
|
|
if not result.get("success", false):
|
|
return result
|
|
|
|
if result.get("status") != "returned":
|
|
result["waveform"] = waveform
|
|
result["duration"] = snappedf(duration, 0.001)
|
|
result["frequency"] = snappedf(frequency, 0.01)
|
|
if has_end:
|
|
result["end_frequency"] = snappedf(end_frequency, 0.01)
|
|
return result
|
|
|
|
|
|
## Synthesize mono 16-bit little-endian PCM. Pure (no editor) — unit-testable.
|
|
static func _build_pcm(
|
|
waveform: String, frequency: float, end_frequency: float, has_end: bool,
|
|
duration: float, volume: float, fade_in: float, fade_out: float, decay: float,
|
|
) -> PackedByteArray:
|
|
var sample_count := maxi(1, int(duration * MIX_RATE))
|
|
var fade_in_samples := int(fade_in * MIX_RATE)
|
|
var fade_out_samples := int(fade_out * MIX_RATE)
|
|
|
|
var data := PackedByteArray()
|
|
data.resize(sample_count * 2)
|
|
|
|
var phase := 0.0
|
|
for i in range(sample_count):
|
|
var t := float(i) / MIX_RATE
|
|
var inst_freq := frequency
|
|
if has_end:
|
|
inst_freq = frequency + (end_frequency - frequency) * (t / duration)
|
|
|
|
var sample := _oscillator(waveform, phase)
|
|
phase += TAU * inst_freq / MIX_RATE
|
|
|
|
# Envelope: volume * fade * decay.
|
|
var env := volume
|
|
if fade_in_samples > 0 and i < fade_in_samples:
|
|
env *= float(i) / fade_in_samples
|
|
if fade_out_samples > 0 and i >= sample_count - fade_out_samples:
|
|
env *= float(sample_count - i) / fade_out_samples
|
|
if decay > 0.0:
|
|
env *= exp(-t / decay)
|
|
|
|
var value := int(clampf(sample * env, -1.0, 1.0) * 32767.0)
|
|
data.encode_s16(i * 2, value)
|
|
return data
|
|
|
|
|
|
## One oscillator sample in [-1, 1] for the given accumulated phase (radians).
|
|
static func _oscillator(waveform: String, phase: float) -> float:
|
|
match waveform:
|
|
"sine":
|
|
return sin(phase)
|
|
"square":
|
|
return 1.0 if sin(phase) >= 0.0 else -1.0
|
|
"triangle":
|
|
return 2.0 / PI * asin(sin(phase))
|
|
"sawtooth":
|
|
return 2.0 * (fposmod(phase, TAU) / TAU) - 1.0
|
|
"noise":
|
|
return randf_range(-1.0, 1.0)
|
|
return 0.0
|