360 lines
11 KiB
GDScript
360 lines
11 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
|
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
|
|
## Handles AudioStreamPlayer / 2D / 3D authoring — node creation, stream
|
|
## assignment, playback-property edits, and real editor preview playback.
|
|
##
|
|
## Stream assignment loads a Godot-imported AudioStream resource from
|
|
## res:// (the editor's import step converts .ogg / .wav / .mp3 into a
|
|
## streamable AudioStream subclass before we ever see it).
|
|
##
|
|
## play() / stop() call the live node method directly — no undo, no
|
|
## persistence; they match what the inspector's play button does.
|
|
|
|
|
|
const _VALID_TYPES := {
|
|
"1d": "AudioStreamPlayer",
|
|
"2d": "AudioStreamPlayer2D",
|
|
"3d": "AudioStreamPlayer3D",
|
|
}
|
|
|
|
## Whitelist of playback properties settable via audio_player_set_playback.
|
|
## Each value is the expected Variant type of the param dict value.
|
|
const _PLAYBACK_KEYS := {
|
|
"volume_db": TYPE_FLOAT,
|
|
"pitch_scale": TYPE_FLOAT,
|
|
"autoplay": TYPE_BOOL,
|
|
"bus": TYPE_STRING,
|
|
}
|
|
|
|
|
|
var _undo_redo: EditorUndoRedoManager
|
|
|
|
|
|
func _init(undo_redo: EditorUndoRedoManager) -> void:
|
|
_undo_redo = undo_redo
|
|
|
|
|
|
# ============================================================================
|
|
# audio_player_create
|
|
# ============================================================================
|
|
|
|
func create_player(params: Dictionary) -> Dictionary:
|
|
var parent_path: String = params.get("parent_path", "")
|
|
var node_name: String = params.get("name", "AudioStreamPlayer")
|
|
var type_str: String = params.get("type", "1d")
|
|
|
|
if not _VALID_TYPES.has(type_str):
|
|
return ErrorCodes.make(
|
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"Invalid audio player type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
|
|
)
|
|
|
|
var _scene_check := McpNodeValidator.require_scene_or_error()
|
|
if _scene_check.has("error"):
|
|
return _scene_check
|
|
var scene_root: Node = _scene_check.scene_root
|
|
|
|
var parent: Node = scene_root
|
|
if not parent_path.is_empty():
|
|
parent = McpScenePath.resolve(parent_path, scene_root)
|
|
if parent == null:
|
|
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
|
|
|
|
var node := _instantiate_player(type_str)
|
|
if node == null:
|
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate audio player")
|
|
if not node_name.is_empty():
|
|
node.name = node_name
|
|
|
|
_undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name])
|
|
_undo_redo.add_do_method(parent, "add_child", node, true)
|
|
_undo_redo.add_do_method(node, "set_owner", scene_root)
|
|
_undo_redo.add_do_reference(node)
|
|
_undo_redo.add_undo_method(parent, "remove_child", node)
|
|
_undo_redo.commit_action()
|
|
|
|
return {
|
|
"data": {
|
|
"path": McpScenePath.from_node(node, scene_root),
|
|
"parent_path": McpScenePath.from_node(parent, scene_root),
|
|
"name": String(node.name),
|
|
"type": type_str,
|
|
"class": _VALID_TYPES[type_str],
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# audio_player_set_stream
|
|
# ============================================================================
|
|
|
|
func set_stream(params: Dictionary) -> Dictionary:
|
|
var player_path: String = params.get("player_path", "")
|
|
var stream_path: String = params.get("stream_path", "")
|
|
|
|
if player_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
|
|
if stream_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: stream_path")
|
|
|
|
var stream_path_err = McpPathValidator.loadable_error(stream_path, "stream_path")
|
|
if stream_path_err != null:
|
|
return stream_path_err
|
|
|
|
var resolved := _resolve_player(player_path)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var player: Node = resolved.player
|
|
|
|
if not ResourceLoader.exists(stream_path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "AudioStream not found: %s" % stream_path)
|
|
var loaded := ResourceLoader.load(stream_path)
|
|
if loaded == null:
|
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load AudioStream: %s" % stream_path)
|
|
if not (loaded is AudioStream):
|
|
return ErrorCodes.make(
|
|
ErrorCodes.WRONG_TYPE,
|
|
"Resource at %s is not an AudioStream (got %s)" % [stream_path, loaded.get_class()]
|
|
)
|
|
|
|
var old_stream: AudioStream = player.stream
|
|
|
|
_undo_redo.create_action("MCP: Set audio stream on %s" % player.name)
|
|
_undo_redo.add_do_property(player, "stream", loaded)
|
|
_undo_redo.add_undo_property(player, "stream", old_stream)
|
|
_undo_redo.commit_action()
|
|
|
|
return {
|
|
"data": {
|
|
"player_path": player_path,
|
|
"stream_path": stream_path,
|
|
"stream_class": loaded.get_class(),
|
|
"duration_seconds": float(loaded.get_length()),
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# audio_player_set_playback
|
|
# ============================================================================
|
|
|
|
func set_playback(params: Dictionary) -> Dictionary:
|
|
var player_path: String = params.get("player_path", "")
|
|
if player_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
|
|
|
|
var resolved := _resolve_player(player_path)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var player: Node = resolved.player
|
|
|
|
var updates: Dictionary = {}
|
|
for key in _PLAYBACK_KEYS:
|
|
if params.has(key):
|
|
var expected_type: int = _PLAYBACK_KEYS[key]
|
|
var value = params.get(key)
|
|
var coerced = _coerce_playback_value(value, expected_type)
|
|
if coerced == null:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.INVALID_PARAMS,
|
|
"Invalid value for %s: expected %s, got %s" % [
|
|
key, type_string(expected_type), type_string(typeof(value))
|
|
]
|
|
)
|
|
updates[key] = coerced
|
|
|
|
if updates.is_empty():
|
|
return ErrorCodes.make(
|
|
ErrorCodes.MISSING_REQUIRED_PARAM,
|
|
"At least one of %s is required" % ", ".join(_PLAYBACK_KEYS.keys())
|
|
)
|
|
|
|
var old_values: Dictionary = {}
|
|
for key in updates:
|
|
old_values[key] = player.get(key)
|
|
|
|
_undo_redo.create_action("MCP: Update playback on %s" % player.name)
|
|
for key in updates:
|
|
_undo_redo.add_do_property(player, key, updates[key])
|
|
_undo_redo.add_undo_property(player, key, old_values[key])
|
|
_undo_redo.commit_action()
|
|
|
|
return {
|
|
"data": {
|
|
"player_path": player_path,
|
|
"applied": updates.keys(),
|
|
"values": updates,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# audio_play (runtime preview — not saved with scene)
|
|
# ============================================================================
|
|
|
|
func play(params: Dictionary) -> Dictionary:
|
|
var player_path: String = params.get("player_path", "")
|
|
var from_position: float = float(params.get("from_position", 0.0))
|
|
|
|
if player_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
|
|
|
|
var resolved := _resolve_player(player_path)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var player: Node = resolved.player
|
|
|
|
if player.stream == null:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.MISSING_REQUIRED_PARAM,
|
|
"Player has no stream assigned — call audio_player_set_stream first"
|
|
)
|
|
|
|
player.play(from_position)
|
|
|
|
return {
|
|
"data": {
|
|
"player_path": player_path,
|
|
"from_position": from_position,
|
|
"playing": bool(player.playing),
|
|
"undoable": false,
|
|
"reason": "Runtime playback state — not saved with scene",
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# audio_stop (runtime preview — not saved with scene)
|
|
# ============================================================================
|
|
|
|
func stop(params: Dictionary) -> Dictionary:
|
|
var player_path: String = params.get("player_path", "")
|
|
if player_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
|
|
|
|
var resolved := _resolve_player(player_path)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var player: Node = resolved.player
|
|
|
|
player.stop()
|
|
|
|
return {
|
|
"data": {
|
|
"player_path": player_path,
|
|
"playing": bool(player.playing),
|
|
"undoable": false,
|
|
"reason": "Runtime playback state — not saved with scene",
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# audio_list (read — scan project for AudioStream resources)
|
|
# ============================================================================
|
|
|
|
func list_streams(params: Dictionary) -> Dictionary:
|
|
var root: String = params.get("root", "res://")
|
|
var include_duration: bool = bool(params.get("include_duration", true))
|
|
|
|
var root_err = McpPathValidator.path_error(root, "root")
|
|
if root_err != null:
|
|
return root_err
|
|
|
|
var efs := EditorInterface.get_resource_filesystem()
|
|
if efs == null:
|
|
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
|
|
|
|
var results: Array[Dictionary] = []
|
|
var start_dir := efs.get_filesystem_path(root)
|
|
if start_dir == null:
|
|
start_dir = efs.get_filesystem()
|
|
_scan_audio(start_dir, root, include_duration, results)
|
|
return {
|
|
"data": {
|
|
"root": root,
|
|
"streams": results,
|
|
"count": results.size(),
|
|
}
|
|
}
|
|
|
|
|
|
func _scan_audio(dir: EditorFileSystemDirectory, root: String, include_duration: bool, out: Array[Dictionary]) -> void:
|
|
if dir == null:
|
|
return
|
|
for i in dir.get_file_count():
|
|
var file_path := dir.get_file_path(i)
|
|
if not file_path.begins_with(root):
|
|
continue
|
|
var file_type := dir.get_file_type(i)
|
|
var is_audio := file_type == "AudioStream" or ClassDB.is_parent_class(file_type, "AudioStream")
|
|
if not is_audio:
|
|
continue
|
|
var entry: Dictionary = {
|
|
"path": file_path,
|
|
"class": file_type,
|
|
}
|
|
if include_duration:
|
|
var res := ResourceLoader.load(file_path)
|
|
if res is AudioStream:
|
|
entry["duration_seconds"] = float((res as AudioStream).get_length())
|
|
else:
|
|
entry["duration_seconds"] = 0.0
|
|
out.append(entry)
|
|
for i in dir.get_subdir_count():
|
|
_scan_audio(dir.get_subdir(i), root, include_duration, out)
|
|
|
|
|
|
# ============================================================================
|
|
# Helpers
|
|
# ============================================================================
|
|
|
|
static func _instantiate_player(type_str: String) -> Node:
|
|
match type_str:
|
|
"1d":
|
|
return AudioStreamPlayer.new()
|
|
"2d":
|
|
return AudioStreamPlayer2D.new()
|
|
"3d":
|
|
return AudioStreamPlayer3D.new()
|
|
return null
|
|
|
|
|
|
func _resolve_player(player_path: String) -> Dictionary:
|
|
var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path")
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var node: Node = resolved.node
|
|
var is_player := node is AudioStreamPlayer \
|
|
or node is AudioStreamPlayer2D \
|
|
or node is AudioStreamPlayer3D
|
|
if not is_player:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.WRONG_TYPE,
|
|
"Node at %s is not an AudioStreamPlayer/2D/3D (got %s)" % [player_path, node.get_class()]
|
|
)
|
|
return {"player": node}
|
|
|
|
|
|
## Coerce a playback param value to the expected type. int→float is allowed
|
|
## so JSON integers pass through; everything else requires the exact type.
|
|
## Returns the coerced value, or null on type mismatch.
|
|
static func _coerce_playback_value(value: Variant, expected_type: int) -> Variant:
|
|
match expected_type:
|
|
TYPE_FLOAT:
|
|
if value is float or value is int:
|
|
return float(value)
|
|
TYPE_BOOL:
|
|
if value is bool:
|
|
return value
|
|
TYPE_STRING:
|
|
if value is String:
|
|
return value
|
|
return null
|