Files

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