├── src └── echos │ ├── __init__.py │ ├── backends │ ├── __init__.py │ ├── common │ │ ├── __init__.py │ │ └── message_queue.py │ ├── mock │ │ ├── __init__.py │ │ ├── engine.py │ │ └── sync_controller.py │ └── pedalboard │ │ ├── context.py │ │ ├── __init__.py │ │ ├── timeline.py │ │ ├── plugin_ins_manager.py │ │ ├── factory.py │ │ ├── messages.py │ │ ├── message_handler.py │ │ └── sync_controller.py │ ├── agent │ └── __init__.py │ ├── interfaces │ ├── __init__.py │ ├── services │ │ ├── ibase_service.py │ │ ├── ipersistence_service.py │ │ ├── iproject_service.py │ │ ├── __init__.py │ │ ├── itransport_service.py │ │ ├── irouting_service.py │ │ ├── ihistory_service.py │ │ ├── iediting_service.py │ │ ├── isystem_service.py │ │ ├── iquery_service.py │ │ └── inode_service.py │ └── system │ │ ├── ievent_bus.py │ │ ├── ipersistence.py │ │ ├── iserializable.py │ │ ├── imanager.py │ │ ├── ifactory.py │ │ ├── inode.py │ │ ├── iproject.py │ │ ├── __init__.py │ │ ├── irouter.py │ │ ├── iparameter.py │ │ ├── icommand.py │ │ ├── imixer.py │ │ ├── itimeline.py │ │ ├── iengine.py │ │ ├── isync.py │ │ ├── ilifecycle.py │ │ └── iplugin.py │ ├── models │ ├── state_model │ │ ├── base_state.py │ │ ├── timeline_state.py │ │ ├── router_state.py │ │ ├── plugin_state.py │ │ ├── project_state.py │ │ ├── parameter_state.py │ │ ├── __init__.py │ │ ├── node_state.py │ │ └── mixer_state.py │ ├── api_model.py │ ├── lifecycle_model.py │ ├── mixer_model.py │ ├── timeline_model.py │ ├── engine_model.py │ ├── router_model.py │ ├── node_model.py │ ├── parameter_model.py │ ├── plugin_model.py │ ├── clip_model.py │ ├── __init__.py │ └── event_model.py │ ├── core │ ├── history │ │ ├── __init__.py │ │ ├── commands │ │ │ ├── __init__.py │ │ │ ├── routing_commands.py │ │ │ ├── transport_command.py │ │ │ ├── editing_commands.py │ │ │ └── node_commands.py │ │ └── command_base.py │ ├── plugin │ │ ├── __init__.py │ │ ├── cache.py │ │ ├── registry.py │ │ ├── plugin.py │ │ └── scanner.py │ ├── persistence.py │ ├── __init__.py │ ├── event_bus.py │ ├── engine_controller.py │ ├── manager.py │ ├── project.py │ ├── track.py │ └── router.py │ ├── services │ ├── __init__.py │ ├── project_service.py │ ├── routing_service.py │ ├── system_service.py │ ├── transport_service.py │ ├── query_service.py │ ├── history_service.py │ └── editing_service.py │ ├── utils │ └── scan_worker.py │ └── facade.py ├── midi └── Sacred Play Secret Place.mid ├── .gitignore ├── pyproject.toml ├── architecture.mmd └── Readme.md /src/echos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/echos/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/echos/backends/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/echos/backends/mock/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/echos/agent/__init__.py: -------------------------------------------------------------------------------- 1 | from .tools import AgentToolkit 2 | -------------------------------------------------------------------------------- /src/echos/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | from .system import * 2 | from .services import * 3 | -------------------------------------------------------------------------------- /midi/Sacred Play Secret Place.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Linzwcs/echos/HEAD/midi/Sacred Play Secret Place.mid -------------------------------------------------------------------------------- /src/echos/models/state_model/base_state.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(frozen=True) 5 | class BaseState: 6 | pass 7 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/ibase_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/IEditingService.py 2 | from abc import ABC 3 | 4 | 5 | class IService(ABC): 6 | pass 7 | -------------------------------------------------------------------------------- /src/echos/core/history/__init__.py: -------------------------------------------------------------------------------- 1 | from .command_manager import CommandManager, MacroCommand 2 | from .command_base import BaseCommand 3 | 4 | __all__ = [ 5 | "CommandManager", 6 | "BaseCommand", 7 | "MacroCommand", 8 | ] 9 | -------------------------------------------------------------------------------- /src/echos/models/api_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional, Dict, Any 3 | 4 | 5 | @dataclass(frozen=True) 6 | class ToolResponse: 7 | status: str 8 | data: Optional[Dict[str, Any]] 9 | message: str 10 | -------------------------------------------------------------------------------- /src/echos/models/lifecycle_model.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class LifecycleState(Enum): 5 | 6 | CREATED = "created" 7 | MOUNTING = "mounting" 8 | MOUNTED = "mounted" 9 | UNMOUNTING = "unmounting" 10 | DISPOSED = "disposed" 11 | -------------------------------------------------------------------------------- /src/echos/models/mixer_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Send: 6 | 7 | send_id: str 8 | target_bus_node_id: str 9 | level: "IParameter" 10 | is_post_fader: bool = True 11 | is_enabled: bool = True 12 | -------------------------------------------------------------------------------- /src/echos/models/timeline_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Tempo: 6 | beat: float 7 | bpm: float 8 | 9 | 10 | @dataclass 11 | class TimeSignature: 12 | beat: float 13 | numerator: int 14 | denominator: int 15 | -------------------------------------------------------------------------------- /src/echos/backends/pedalboard/context.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from .render_graph import PedalboardRenderGraph 3 | from .timeline import RealTimeTimeline 4 | 5 | 6 | @dataclass 7 | class AudioEngineContext: 8 | graph: PedalboardRenderGraph 9 | timeline: RealTimeTimeline 10 | -------------------------------------------------------------------------------- /src/echos/models/state_model/timeline_state.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from .base_state import BaseState 3 | from ..timeline_model import Tempo, TimeSignature 4 | 5 | 6 | @dataclass(frozen=True) 7 | class TimelineState(BaseState): 8 | tempos: list[Tempo] 9 | time_signatures: list[TimeSignature] 10 | -------------------------------------------------------------------------------- /src/echos/core/plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from .registry import PluginRegistry 2 | from .scanner import PluginScanner, BackgroundScanner 3 | from .cache import PluginCache 4 | from .plugin import Plugin 5 | 6 | __all__ = [ 7 | 'PluginRegistry', 8 | 'PluginScanner', 9 | 'BackgroundScanner', 10 | "PluginCache", 11 | "Plugin", 12 | ] 13 | -------------------------------------------------------------------------------- /src/echos/models/engine_model.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from dataclasses import dataclass 3 | 4 | 5 | class TransportStatus(Enum): 6 | STOPPED = "stopped" 7 | PLAYING = "playing" 8 | PAUSED = "paused" 9 | 10 | 11 | @dataclass(frozen=True) 12 | class TransportContext: 13 | current_beat: float 14 | sample_rate: int 15 | block_size: int 16 | tempo: float 17 | -------------------------------------------------------------------------------- /src/echos/models/state_model/router_state.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from dataclasses import dataclass 3 | from typing import List 4 | from .node_state import NodeState 5 | from ..router_model import Connection 6 | from .base_state import BaseState 7 | 8 | 9 | @dataclass(frozen=True) 10 | class RouterState(BaseState): 11 | nodes: List[NodeState] 12 | connections: List[Connection] 13 | -------------------------------------------------------------------------------- /src/echos/models/state_model/plugin_state.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Dict 3 | from .parameter_state import ParameterState 4 | from .base_state import BaseState 5 | 6 | 7 | @dataclass(frozen=True) 8 | class PluginState(BaseState): 9 | instance_id: str 10 | unique_plugin_id: str 11 | is_enabled: bool = True 12 | parameters: Dict[str, ParameterState] = field(default_factory=dict) 13 | -------------------------------------------------------------------------------- /src/echos/backends/pedalboard/__init__.py: -------------------------------------------------------------------------------- 1 | from .engine import PedalboardEngine 2 | from .render_graph import PedalboardRenderGraph 3 | from .sync_controller import PedalboardSyncController 4 | from .factory import PedalboardNodeFactory, PedalboardEngineFactory 5 | 6 | __all__ = [ 7 | "PedalboardEngine", 8 | "PedalboardEngineFactory", 9 | "PedalboardRenderGraph", 10 | "PedalboardSyncController", 11 | "PedalboardNodeFactory", 12 | ] 13 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/ievent_bus.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IEventBus(ABC): 5 | 6 | @abstractmethod 7 | def subscribe(self, event_type, handler): 8 | pass 9 | 10 | @abstractmethod 11 | def unsubscribe(self, event_type, handler): 12 | pass 13 | 14 | @abstractmethod 15 | def publish(self, event): 16 | pass 17 | 18 | @abstractmethod 19 | def clear(self): 20 | pass 21 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/ipersistence.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/interfaces/IPersistence.py 2 | from abc import ABC, abstractmethod 3 | from ...models.state_model import ProjectState 4 | from .iproject import IProject 5 | 6 | 7 | class IProjectSerializer(ABC): 8 | 9 | @abstractmethod 10 | def serialize(self, project: 'IProject') -> ProjectState: 11 | pass 12 | 13 | @abstractmethod 14 | def deserialize(self, state: ProjectState) -> 'IProject': 15 | pass 16 | -------------------------------------------------------------------------------- /src/echos/models/state_model/project_state.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from .router_state import RouterState 3 | from .timeline_state import TimelineState 4 | from .base_state import BaseState 5 | 6 | 7 | @dataclass(frozen=True) 8 | class ProjectState(BaseState): 9 | 10 | project_id: str 11 | name: str 12 | 13 | router: RouterState 14 | timeline: TimelineState 15 | 16 | sample_rate: int = 48000 17 | block_size: int = 512 18 | output_channels: int = 2 19 | -------------------------------------------------------------------------------- /src/echos/models/state_model/parameter_state.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Any, Optional 3 | from .base_state import BaseState 4 | from ..parameter_model import AutomationLane 5 | 6 | 7 | @dataclass(frozen=True) 8 | class ParameterState(BaseState): 9 | 10 | name: str 11 | value: Any 12 | default_value: Any 13 | min_value: Optional[Any] 14 | max_value: Optional[Any] 15 | unit: str 16 | automation_lane: AutomationLane = field(default_factory=AutomationLane) 17 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/iserializable.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import TypeVar, Type, Any 3 | from ...models.state_model.base_state import BaseState 4 | 5 | S = TypeVar('S', bound=BaseState) 6 | T = TypeVar('T', bound='ISerializable') 7 | 8 | 9 | class ISerializable(ABC): 10 | 11 | @abstractmethod 12 | def to_state(self) -> S: 13 | pass 14 | 15 | @classmethod 16 | @abstractmethod 17 | def from_state(cls: Type[T], state: S, **kwargs: Any) -> T: 18 | pass 19 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/ipersistence_service.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | from ...models.state_model import ProjectState 4 | 5 | 6 | class IPersistenceService(ABC): 7 | 8 | @abstractmethod 9 | def save(self, state: ProjectState, file_path: str) -> None: 10 | """Saves a ProjectState DTO to a specified path.""" 11 | pass 12 | 13 | @abstractmethod 14 | def load(self, file_path: str) -> Optional[ProjectState]: 15 | """Loads a ProjectState DTO from a specified path.""" 16 | pass 17 | -------------------------------------------------------------------------------- /src/echos/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .editing_service import EditingService 2 | from .history_service import HistoryService 3 | from .node_service import NodeService 4 | from .project_service import ProjectService 5 | from .query_service import QueryService 6 | from .routing_service import RoutingService 7 | from .system_service import SystemService 8 | from .transport_service import TransportService 9 | 10 | __all__ = [ 11 | "EditingService", 12 | "HistoryService", 13 | "NodeService", 14 | "ProjectService", 15 | "QueryService", 16 | "RoutingService", 17 | "SystemService", 18 | "TransportService", 19 | ] 20 | -------------------------------------------------------------------------------- /src/echos/models/router_model.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from dataclasses import dataclass 3 | 4 | 5 | class PortType(Enum): 6 | 7 | AUDIO = "audio" 8 | MIDI = "midi" 9 | 10 | 11 | class PortDirection(Enum): 12 | 13 | INPUT = "input" 14 | OUTPUT = "output" 15 | 16 | 17 | @dataclass(frozen=True) 18 | class Port: 19 | 20 | port_id: str 21 | port_type: PortType 22 | direction: PortDirection 23 | channels: int = 2 24 | 25 | 26 | @dataclass(frozen=True) 27 | class Connection: 28 | 29 | source_node_id: str 30 | dest_node_id: str 31 | source_port_id: str = "main_out" 32 | dest_port_id: str = "main_in" 33 | -------------------------------------------------------------------------------- /src/echos/models/node_model.py: -------------------------------------------------------------------------------- 1 | from enum import Flag, auto, Enum 2 | 3 | 4 | class VCAControlMode(Flag): 5 | NONE = 0 6 | VOLUME = auto() 7 | PAN = auto() 8 | MUTE = auto() 9 | ALL = VOLUME | PAN | MUTE 10 | 11 | def controls_volume(self) -> bool: 12 | return bool(self & VCAControlMode.VOLUME) 13 | 14 | def controls_pan(self) -> bool: 15 | return bool(self & VCAControlMode.PAN) 16 | 17 | def controls_mute(self) -> bool: 18 | return bool(self & VCAControlMode.MUTE) 19 | 20 | 21 | class TrackRecordMode(Enum): 22 | NORMAL = "normal" 23 | OVERDUB = "overdub" 24 | REPLACE = "replace" 25 | LOOP = "loop" 26 | -------------------------------------------------------------------------------- /src/echos/models/state_model/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_state import BaseState 2 | from .mixer_state import MixerState, SendState 3 | from .node_state import NodeState, TrackState, VCATrackState 4 | from .parameter_state import ParameterState 5 | from .plugin_state import PluginState 6 | from .project_state import ProjectState 7 | from .router_state import RouterState 8 | from .timeline_state import TimelineState 9 | 10 | __all__ = [ 11 | "BaseState", 12 | "MixerState", 13 | "SendState", 14 | "NodeState", 15 | "TrackState", 16 | "VCATrackState", 17 | "ParameterState", 18 | "PluginState", 19 | "ProjectState", 20 | "RouterState", 21 | "TimelineState", 22 | ] 23 | -------------------------------------------------------------------------------- /src/echos/models/state_model/node_state.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List 3 | from .base_state import BaseState 4 | from .mixer_state import MixerState 5 | from ..clip_model import AnyClip 6 | 7 | 8 | @dataclass(frozen=True) 9 | class NodeState(BaseState): 10 | node_id: str 11 | node_type: str 12 | 13 | 14 | @dataclass(frozen=True) 15 | class TrackState(NodeState): 16 | 17 | name: str 18 | track_type: str 19 | mixer_state: MixerState 20 | clips: List[AnyClip] = field(default_factory=list) 21 | 22 | 23 | @dataclass(frozen=True) 24 | class VCATrackState(NodeState): 25 | name: str 26 | controlled_track_ids: List[str] = field(default_factory=list) 27 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/iproject_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/IProjectService.py 2 | from abc import ABC, abstractmethod 3 | from echos.models import ToolResponse 4 | from .ibase_service import IService 5 | 6 | 7 | class IProjectService(IService): 8 | 9 | @abstractmethod 10 | def create_project(self, name: str) -> ToolResponse: 11 | pass 12 | 13 | @abstractmethod 14 | def save_project(self, project_id: str, file_path: str) -> ToolResponse: 15 | pass 16 | 17 | @abstractmethod 18 | def load_project(self, file_path: str) -> ToolResponse: 19 | pass 20 | 21 | @abstractmethod 22 | def close_project(self, project_id: str) -> ToolResponse: 23 | pass 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | __pycache__/ 6 | *.pkl 7 | *.db 8 | 9 | # Java 10 | *.class 11 | *.log 12 | *.war 13 | *.ear 14 | *.jar 15 | target/ 16 | *.iml 17 | .idea/ 18 | 19 | # Node.js 20 | node_modules/ 21 | npm-debug.log 22 | yarn-debug.log 23 | yarn-error.log 24 | dist/ 25 | build/ 26 | 27 | # MacOS 28 | .DS_Store 29 | 30 | # Windows 31 | Thumbs.db 32 | Desktop.ini 33 | 34 | # VSCode 35 | .vscode/ 36 | 37 | # IntelliJ IDEA 38 | .idea/ 39 | 40 | # Eclipse 41 | .project 42 | .classpath 43 | .settings/ 44 | 45 | # PyCharm 46 | .idea/ 47 | 48 | # Docker 49 | *.dockerignore 50 | Dockerfile 51 | docker-compose.yml 52 | 53 | # Log files 54 | *.log 55 | *.out 56 | 57 | # Environment files 58 | .env 59 | .env.local 60 | .env.*.local 61 | 62 | # OS generated files 63 | .DS_Store 64 | Thumbs.db 65 | -------------------------------------------------------------------------------- /src/echos/models/state_model/mixer_state.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List 3 | from .parameter_state import ParameterState 4 | from .plugin_state import PluginState 5 | from .base_state import BaseState 6 | 7 | 8 | @dataclass(frozen=True) 9 | class SendState(BaseState): 10 | 11 | send_id: str 12 | target_bus_node_id: str 13 | level: ParameterState 14 | is_post_fader: bool 15 | is_enabled: bool 16 | 17 | 18 | @dataclass(frozen=True) 19 | class MixerState(BaseState): 20 | 21 | channel_id: str 22 | volume: ParameterState 23 | pan: ParameterState 24 | input_gain: ParameterState 25 | is_muted: bool 26 | is_solo: bool 27 | inserts: List[PluginState] = field(default_factory=list) 28 | sends: List[SendState] = field(default_factory=list) 29 | -------------------------------------------------------------------------------- /src/echos/models/parameter_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List 3 | from enum import Enum 4 | 5 | 6 | class ParameterType(Enum): 7 | 8 | FLOAT = "float" 9 | INT = "int" 10 | BOOL = "bool" 11 | ENUM = "enum" 12 | STRING = "string" 13 | 14 | 15 | class AutomationCurveType: 16 | 17 | LINEAR = "linear" 18 | EXPONENTIAL = "exponential" 19 | LOGARITHMIC = "logarithmic" 20 | SINE = "sine" 21 | SQUARE = "square" 22 | BEZIER = "bezier" 23 | 24 | 25 | @dataclass 26 | class AutomationPoint: 27 | 28 | beat: float 29 | value: float 30 | curve_type: str = AutomationCurveType.LINEAR 31 | curve_shape: float = 0.0 32 | 33 | 34 | @dataclass 35 | class AutomationLane: 36 | 37 | is_enabled: bool = True 38 | points: List[AutomationPoint] = field(default_factory=list) 39 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/__init__.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/interfaces/__init__.py 2 | from .ibase_service import IService 3 | from .iediting_service import IEditingService 4 | from .ihistory_service import IHistoryService 5 | from .inode_service import INodeService 6 | from .iproject_service import IProjectService 7 | from .iquery_service import IQueryService 8 | from .irouting_service import IRoutingService 9 | from .isystem_service import ISystemService 10 | from .itransport_service import ITransportService 11 | from .ipersistence_service import IPersistenceService 12 | 13 | __all__ = [ 14 | "IService", 15 | "IEditingService", 16 | "IHistoryService", 17 | "INodeService", 18 | "IProjectService", 19 | "IQueryService", 20 | "IRoutingService", 21 | "ISystemService", 22 | "ITransportService", 23 | "IPersistenceService", 24 | ] 25 | -------------------------------------------------------------------------------- /src/echos/models/plugin_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Dict, Any, Optional 3 | from enum import Enum 4 | 5 | 6 | @dataclass 7 | class PluginScanResult: 8 | success: bool 9 | plugin_info: Optional[Dict] = None 10 | error: Optional[str] = None 11 | 12 | 13 | class PluginCategory(Enum): 14 | INSTRUMENT = "instrument" 15 | EFFECT = "effect" 16 | 17 | 18 | @dataclass(frozen=True) 19 | class PluginDescriptor: 20 | 21 | unique_plugin_id: str 22 | name: str 23 | vendor: str 24 | path: str 25 | is_instrument: bool 26 | plugin_format: str 27 | reports_latency: bool = True 28 | latency_samples: int = 0 29 | #available_ports: List[Port] = field(default_factory=list) 30 | default_parameters: Dict[str, Any] = field(default_factory=dict) 31 | 32 | 33 | @dataclass 34 | class CachedPluginInfo: 35 | descriptor: PluginDescriptor 36 | file_mod_time: float 37 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/itransport_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/ITransportService.py 2 | from abc import ABC, abstractmethod 3 | from echos.models import ToolResponse 4 | from .ibase_service import IService 5 | 6 | 7 | class ITransportService(IService): 8 | 9 | @abstractmethod 10 | def play(self, project_id: str) -> ToolResponse: 11 | pass 12 | 13 | @abstractmethod 14 | def stop(self, project_id: str) -> ToolResponse: 15 | pass 16 | 17 | @abstractmethod 18 | def pause(self, project_id: str) -> ToolResponse: 19 | pass 20 | 21 | @abstractmethod 22 | def set_tempo(self, project_id: str, bpm: float) -> ToolResponse: 23 | pass 24 | 25 | @abstractmethod 26 | def set_time_signature(self, project_id: str, numerator: int, 27 | denominator: int) -> ToolResponse: 28 | pass 29 | 30 | @abstractmethod 31 | def get_transport_state(self, project_id: str) -> ToolResponse: 32 | pass 33 | -------------------------------------------------------------------------------- /src/echos/core/history/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .editing_commands import ( 2 | AddNotesToClipCommand, 3 | CreateMidiClipCommand, 4 | SetParameterCommand, 5 | ) 6 | from .node_commands import ( 7 | AddInsertPluginCommand, 8 | CreateTrackCommand, 9 | DeleteNodeCommand, 10 | RemoveInsertPluginCommand, 11 | RenameNodeCommand, 12 | ) 13 | from .routing_commands import ConnectCommand, CreateSendCommand 14 | from .transport_command import SetTempoCommand, SetTimeSignatureCommand 15 | 16 | __all__ = [ 17 | # Editing Commands 18 | "SetParameterCommand", 19 | "CreateMidiClipCommand", 20 | "AddNotesToClipCommand", 21 | 22 | # Node Management Commands 23 | "CreateTrackCommand", 24 | "RenameNodeCommand", 25 | "DeleteNodeCommand", 26 | "AddInsertPluginCommand", 27 | "RemoveInsertPluginCommand", 28 | 29 | # Routing Commands 30 | "CreateSendCommand", 31 | "ConnectCommand", 32 | 33 | # Transport Commands 34 | "SetTempoCommand", 35 | "SetTimeSignatureCommand", 36 | ] 37 | -------------------------------------------------------------------------------- /src/echos/models/clip_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Set, Union 3 | import uuid 4 | 5 | 6 | @dataclass(frozen=True) 7 | class Note: 8 | pitch: int 9 | velocity: int 10 | start_beat: float 11 | duration_beats: float 12 | 13 | note_id: str = field(default_factory=lambda: str(uuid.uuid4())) 14 | 15 | 16 | @dataclass 17 | class Clip: 18 | 19 | start_beat: float 20 | duration_beats: float 21 | clip_id: str = field(default_factory=lambda: str(uuid.uuid4())) 22 | name: str = "clip" 23 | is_looped: bool = False 24 | loop_start_beat: float = 0.0 25 | loop_duration_beats: float = 0.0 26 | 27 | 28 | @dataclass 29 | class MIDIClip(Clip): 30 | 31 | notes: Set[Note] = field(default_factory=set) 32 | 33 | 34 | @dataclass 35 | class AudioClip(Clip): 36 | 37 | source_file_path: str = None 38 | gain_db: float = 0.0 39 | original_tempo: float = 120.0 40 | warp_markers: dict = field(default_factory=dict) 41 | 42 | 43 | AnyClip = Union[MIDIClip, AudioClip] 44 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/irouting_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/IRoutingService.py 2 | from abc import ABC, abstractmethod 3 | from echos.models import ToolResponse 4 | from .ibase_service import IService 5 | 6 | 7 | class IRoutingService(IService): 8 | 9 | @abstractmethod 10 | def connect(self, project_id: str, source_node_id: str, 11 | source_port_id: str, dest_node_id: str, 12 | dest_port_id: str) -> ToolResponse: 13 | pass 14 | 15 | @abstractmethod 16 | def disconnect(self, project_id: str, source_node_id: str, 17 | dest_node_id: str) -> ToolResponse: 18 | pass 19 | 20 | @abstractmethod 21 | def list_connections(self, project_id: str) -> ToolResponse: 22 | pass 23 | 24 | @abstractmethod 25 | def create_send(self, 26 | project_id: str, 27 | source_track_id: str, 28 | dest_bus_id: str, 29 | is_post_fader: bool = True) -> ToolResponse: 30 | pass 31 | -------------------------------------------------------------------------------- /src/echos/core/persistence.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict, List, cast 2 | from ..interfaces.system import (IProject, INode, IPlugin, ITrack, 3 | INodeFactory, IProjectSerializer, 4 | IPluginRegistry, IEventBus) 5 | from ..models.state_model import (ProjectState, TrackState, PluginState, 6 | ParameterState, NodeState) 7 | from ..models import Connection 8 | from ..core.timeline import Timeline 9 | from ..core.project import Project 10 | from ..core.track import Track 11 | from ..core.parameter import Parameter 12 | 13 | 14 | class ProjectSerializer(IProjectSerializer): 15 | 16 | def __init__(self, node_factory: INodeFactory, 17 | plugin_registry: IPluginRegistry): 18 | 19 | self._node_factory = node_factory 20 | self._registry = plugin_registry 21 | 22 | def serialize(self, project: IProject) -> dict: 23 | return {} 24 | 25 | def deserialize(self, state: ProjectState) -> IProject: 26 | 27 | return Project("mock") 28 | -------------------------------------------------------------------------------- /src/echos/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .event_bus import EventBus 2 | from .history import BaseCommand, CommandManager, MacroCommand 3 | from .manager import DAWManager 4 | from .mixer import MixerChannel 5 | from .parameter import Parameter 6 | from .persistence import ProjectSerializer 7 | from .plugin import Plugin, PluginCache 8 | from .project import Project 9 | from .router import Router 10 | from .timeline import Timeline 11 | from .track import ( 12 | AudioTrack, 13 | BusTrack, 14 | InstrumentTrack, 15 | MasterTrack, 16 | Track, 17 | VCATrack, 18 | ) 19 | 20 | __all__ = [ 21 | # Main entry points 22 | "DAWManager", 23 | "Project", 24 | 25 | # Core components 26 | "Router", 27 | "Timeline", 28 | "EventBus", 29 | "MixerChannel", 30 | "Parameter", 31 | "Plugin", 32 | "PluginCache", 33 | "ProjectSerializer", 34 | 35 | # Track types 36 | "Track", 37 | "InstrumentTrack", 38 | "AudioTrack", 39 | "BusTrack", 40 | "MasterTrack", 41 | "VCATrack", 42 | 43 | # History and Command Pattern 44 | "CommandManager", 45 | "BaseCommand", 46 | "MacroCommand", 47 | ] 48 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/ihistory_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/IHistoryService.py 2 | from abc import ABC, abstractmethod 3 | from echos.models import ToolResponse 4 | from .ibase_service import IService 5 | 6 | 7 | class IHistoryService(IService): 8 | 9 | @abstractmethod 10 | def undo(self, project_id: str) -> ToolResponse: 11 | pass 12 | 13 | @abstractmethod 14 | def redo(self, project_id: str) -> ToolResponse: 15 | pass 16 | 17 | @abstractmethod 18 | def begin_macro(self, project_id: str, description: str) -> ToolResponse: 19 | pass 20 | 21 | @abstractmethod 22 | def end_macro(self, project_id: str) -> ToolResponse: 23 | pass 24 | 25 | @abstractmethod 26 | def cancel_macro(self, project_id: str) -> ToolResponse: 27 | pass 28 | 29 | @abstractmethod 30 | def get_undo_history(self, project_id: str) -> ToolResponse: 31 | pass 32 | 33 | @abstractmethod 34 | def get_redo_history(self, project_id: str) -> ToolResponse: 35 | pass 36 | 37 | @abstractmethod 38 | def can_undo(self, project_id: str) -> ToolResponse: 39 | pass 40 | 41 | @abstractmethod 42 | def can_redo(self, project_id: str) -> ToolResponse: 43 | pass 44 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/iediting_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/IEditingService.py 2 | from abc import ABC, abstractmethod 3 | from typing import Any, List, Dict 4 | from echos.models import ToolResponse 5 | from .ibase_service import IService 6 | 7 | 8 | class IEditingService(IService): 9 | 10 | @abstractmethod 11 | def set_parameter_value(self, project_id: str, node_id: str, 12 | parameter_name: str, value: Any) -> ToolResponse: 13 | pass 14 | 15 | @abstractmethod 16 | def add_automation_point(self, project_id: str, node_id: str, 17 | parameter_name: str, beat: float, 18 | value: Any) -> ToolResponse: 19 | pass 20 | 21 | @abstractmethod 22 | def create_midi_clip(self, 23 | project_id: str, 24 | track_id: str, 25 | start_beat: float, 26 | duration_beats: float, 27 | name: str = "MIDI Clip") -> ToolResponse: 28 | pass 29 | 30 | @abstractmethod 31 | def add_notes_to_clip(self, project_id: str, clip_id: str, 32 | notes: List[Dict[str, Any]]) -> ToolResponse: 33 | pass 34 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/imanager.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/interfaces/IDAWManager.py 2 | from abc import ABC, abstractmethod 3 | from typing import Optional 4 | 5 | from .iproject import IProject 6 | from .ifactory import INodeFactory, IPluginRegistry 7 | from ...models.state_model import ProjectState 8 | 9 | 10 | class IDAWManager(ABC): 11 | 12 | @property 13 | @abstractmethod 14 | def node_factory(self) -> INodeFactory: 15 | pass 16 | 17 | @property 18 | @abstractmethod 19 | def plugin_registry(self) -> IPluginRegistry: 20 | pass 21 | 22 | @abstractmethod 23 | def create_project( 24 | self, 25 | name: str, 26 | project_id: str, 27 | sample_rate: int, 28 | block_size: int, 29 | output_channels: int, 30 | ) -> IProject: 31 | pass 32 | 33 | @abstractmethod 34 | def get_project(self, project_id: str) -> Optional[IProject]: 35 | pass 36 | 37 | @abstractmethod 38 | def close_project(self, project_id: str) -> bool: 39 | pass 40 | 41 | @abstractmethod 42 | def load_project_from_state(self, state: ProjectState) -> IProject: 43 | pass 44 | 45 | @abstractmethod 46 | def get_project_state(self, project_id: str) -> Optional[ProjectState]: 47 | pass 48 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/ifactory.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from .inode import ITrack 3 | from .iplugin import IPlugin 4 | from .iengine import IEngine 5 | from .iplugin import IPluginRegistry 6 | from ...models import PluginDescriptor 7 | 8 | 9 | class IEngineFactory(ABC): 10 | 11 | @abstractmethod 12 | def create_engine( 13 | self, 14 | plugin_registry: IPluginRegistry, 15 | sample_rate: int = 48000, 16 | block_size: int = 512, 17 | output_channels: int = 2, 18 | device_id=None, 19 | ) -> IEngine: 20 | pass 21 | 22 | 23 | class INodeFactory(ABC): 24 | 25 | @abstractmethod 26 | def create_instrument_track(self, name: str, track_id=None) -> ITrack: 27 | pass 28 | 29 | @abstractmethod 30 | def create_audio_track(self, name: str, track_id=None) -> ITrack: 31 | pass 32 | 33 | @abstractmethod 34 | def create_bus_track(self, name: str, track_id=None) -> ITrack: 35 | pass 36 | 37 | @abstractmethod 38 | def create_vca_track(self, name: str, track_id=None) -> ITrack: 39 | pass 40 | 41 | @abstractmethod 42 | def create_plugin_instance( 43 | self, 44 | descriptor: PluginDescriptor, 45 | plugin_instance_id: str = None, 46 | ) -> IPlugin: 47 | pass 48 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/isystem_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/ISystemService.py 2 | from abc import ABC, abstractmethod 3 | from typing import Optional 4 | from echos.models import ToolResponse 5 | from .ibase_service import IService 6 | 7 | 8 | class ISystemService(IService): 9 | """A service for interacting with system-level resources like plugins and devices.""" 10 | 11 | @abstractmethod 12 | def list_available_plugins(self, 13 | category: Optional[str] = None) -> ToolResponse: 14 | """ 15 | Lists all discovered plugins, optionally filtered by category 16 | ('instrument', 'effect'). 17 | """ 18 | pass 19 | 20 | @abstractmethod 21 | def get_plugin_details(self, plugin_unique_id: str) -> ToolResponse: 22 | """Returns the descriptor of a specific plugin (its parameters, ports, etc.).""" 23 | pass 24 | 25 | @abstractmethod 26 | def get_system_info(self) -> ToolResponse: 27 | pass 28 | 29 | @abstractmethod 30 | def list_audio_devices(self) -> ToolResponse: 31 | """Lists available audio input and output devices.""" 32 | pass 33 | 34 | @abstractmethod 35 | def list_midi_devices(self) -> ToolResponse: 36 | """Lists available MIDI input devices.""" 37 | pass 38 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/inode.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Dict, List 3 | from .ilifecycle import ILifecycleAware 4 | from .iserializable import ISerializable 5 | from .iparameter import IParameter 6 | from .imixer import IMixerChannel 7 | from ...models import AnyClip 8 | 9 | 10 | class INode( 11 | ILifecycleAware, 12 | ISerializable, 13 | ABC, 14 | ): 15 | 16 | @property 17 | @abstractmethod 18 | def node_id(self) -> str: 19 | pass 20 | 21 | @property 22 | @abstractmethod 23 | def node_type(self) -> str: 24 | pass 25 | 26 | @abstractmethod 27 | def get_parameters(self) -> Dict[str, IParameter]: 28 | pass 29 | 30 | @abstractmethod 31 | def to_dict(self) -> dict: 32 | pass 33 | 34 | 35 | class ITrack(INode): 36 | 37 | @property 38 | @abstractmethod 39 | def name(self) -> str: 40 | pass 41 | 42 | @name.setter 43 | @abstractmethod 44 | def name(self, value: str): 45 | pass 46 | 47 | @property 48 | @abstractmethod 49 | def clips(self) -> List[AnyClip]: 50 | pass 51 | 52 | @property 53 | @abstractmethod 54 | def mixer_channel(self) -> IMixerChannel: 55 | pass 56 | 57 | @abstractmethod 58 | def add_clip(self, clip: AnyClip): 59 | pass 60 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/iproject.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/interfaces/IProject.py 2 | from abc import ABC, abstractmethod 3 | from .irouter import IRouter 4 | from .itimeline import IDomainTimeline 5 | from .icommand import ICommandManager 6 | from .ievent_bus import IEventBus 7 | from .ilifecycle import ILifecycleAware 8 | from .iengine import IEngineController 9 | from .iserializable import ISerializable 10 | 11 | 12 | class IProject( 13 | ILifecycleAware, 14 | ISerializable, 15 | ABC, 16 | ): 17 | 18 | @property 19 | @abstractmethod 20 | def project_id(self) -> str: 21 | pass 22 | 23 | @property 24 | @abstractmethod 25 | def name(self) -> str: 26 | pass 27 | 28 | @property 29 | @abstractmethod 30 | def router(self) -> IRouter: 31 | pass 32 | 33 | @property 34 | @abstractmethod 35 | def timeline(self) -> IDomainTimeline: 36 | pass 37 | 38 | @property 39 | @abstractmethod 40 | def engine_controller(self) -> IEngineController: 41 | pass 42 | 43 | @property 44 | @abstractmethod 45 | def command_manager(self) -> ICommandManager: 46 | pass 47 | 48 | @property 49 | @abstractmethod 50 | def event_bus(self) -> IEventBus: 51 | pass 52 | 53 | @abstractmethod 54 | def cleanup(self): 55 | pass 56 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/__init__.py: -------------------------------------------------------------------------------- 1 | from .icommand import ICommand, ICommandManager 2 | from .imanager import IDAWManager 3 | from .iengine import IEngine, IEngineController 4 | from .ievent_bus import IEventBus 5 | from .ifactory import IEngineFactory, INodeFactory 6 | from .ilifecycle import ILifecycleAware 7 | from .inode import ITrack, INode, IMixerChannel 8 | from .imixer import IMixerChannel 9 | from .iplugin import IPlugin, IPluginCache, IPluginInstanceManager, IPluginRegistry 10 | from .iparameter import IParameter 11 | from .ipersistence import IProjectSerializer 12 | from .iproject import IProject 13 | from .irouter import IRouter 14 | from .isync import ISyncController 15 | from .itimeline import IDomainTimeline, IEngineTimeline, IReadonlyTimeline 16 | 17 | __all__ = [ 18 | "ICommand", 19 | "ICommandManager", 20 | "IDAWManager", 21 | "IDomainTimeline", 22 | "IEngine", 23 | "IEngineController", 24 | "IEngineFactory", 25 | "IEngineTimeline", 26 | "IEventBus", 27 | "ILifecycleAware", 28 | "IMixerChannel", 29 | "INode", 30 | "INodeFactory", 31 | "IParameter", 32 | "IProjectSerializer", 33 | "IPlugin", 34 | "IPluginCache", 35 | "IPluginInstanceManager", 36 | "IPluginRegistry", 37 | "IProject", 38 | "IProjectSerializer", 39 | "IReadonlyTimeline", 40 | "IRouter", 41 | "ISyncController", 42 | "ITrack", 43 | ] 44 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/irouter.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List 3 | from .inode import ITrack 4 | from .ilifecycle import ILifecycleAware 5 | from .ievent_bus import IEventBus 6 | from .iserializable import ISerializable 7 | from ...models import Port, Connection 8 | 9 | 10 | class IRouter( 11 | ILifecycleAware, 12 | ISerializable, 13 | ABC, 14 | ): 15 | 16 | @property 17 | def nodes(self) -> dict[str, ITrack]: 18 | pass 19 | 20 | @abstractmethod 21 | def add_node(self, node: ITrack): 22 | pass 23 | 24 | @abstractmethod 25 | def remove_node(self, node_id: str): 26 | pass 27 | 28 | @abstractmethod 29 | def connect(self, source_port: Port, dest_port: Port) -> bool: 30 | pass 31 | 32 | @abstractmethod 33 | def disconnect(self, source_port: Port, dest_port: Port) -> bool: 34 | pass 35 | 36 | @abstractmethod 37 | def get_processing_order(self) -> List[str]: 38 | pass 39 | 40 | @abstractmethod 41 | def get_inputs_for_node(self, node_id: str) -> List[Connection]: 42 | pass 43 | 44 | @abstractmethod 45 | def get_all_connections(self) -> List[Connection]: 46 | pass 47 | 48 | def _on_mount(self, event_bus: IEventBus): 49 | self._event_bus = event_bus 50 | 51 | def _on_unmount(self): 52 | self._event_bus = None 53 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/iparameter.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/interfaces/system/iparameter.py 2 | from abc import ABC, abstractmethod 3 | from typing import Any, Optional 4 | from .ilifecycle import ILifecycleAware 5 | from ...models.parameter_model import AutomationLane 6 | from ...models.engine_model import TransportContext 7 | 8 | 9 | class IParameter(ILifecycleAware, ABC): 10 | 11 | @property 12 | @abstractmethod 13 | def name(self) -> str: 14 | pass 15 | 16 | @property 17 | @abstractmethod 18 | def min_value(self) -> Optional[Any]: 19 | pass 20 | 21 | @property 22 | @abstractmethod 23 | def max_value(self) -> Optional[Any]: 24 | pass 25 | 26 | @property 27 | @abstractmethod 28 | def unit(self) -> str: 29 | pass 30 | 31 | @property 32 | @abstractmethod 33 | def value(self) -> Any: 34 | pass 35 | 36 | @abstractmethod 37 | def set_value(self, value: Any): 38 | pass 39 | 40 | @abstractmethod 41 | def get_value_at(self, context: TransportContext) -> Any: 42 | pass 43 | 44 | @property 45 | @abstractmethod 46 | def automation_lane(self) -> AutomationLane: 47 | pass 48 | 49 | @abstractmethod 50 | def add_automation_point( 51 | self, 52 | beat: float, 53 | value: Any, 54 | curve_type: str, 55 | curve_shape: float, 56 | ): 57 | pass 58 | 59 | @abstractmethod 60 | def remove_automation_point_at( 61 | self, 62 | beat: float, 63 | tolerance: float = 0.01, 64 | ) -> bool: 65 | pass 66 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/icommand.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, Dict 3 | 4 | 5 | class ICommand(ABC): 6 | 7 | @abstractmethod 8 | def execute(self) -> bool: 9 | pass 10 | 11 | @abstractmethod 12 | def undo(self) -> bool: 13 | pass 14 | 15 | @abstractmethod 16 | def can_merge_with(self, other: 'ICommand') -> bool: 17 | pass 18 | 19 | @abstractmethod 20 | def merge_with(self, other: 'ICommand'): 21 | pass 22 | 23 | @property 24 | @abstractmethod 25 | def description(self) -> str: 26 | pass 27 | 28 | 29 | class ICommandManager(ABC): 30 | 31 | @abstractmethod 32 | def execute_command(self, command: ICommand): 33 | pass 34 | 35 | @abstractmethod 36 | def undo(self) -> None: 37 | pass 38 | 39 | @abstractmethod 40 | def redo(self) -> None: 41 | pass 42 | 43 | @abstractmethod 44 | def begin_macro_command(self, description: str): 45 | pass 46 | 47 | @abstractmethod 48 | def end_macro_command(self): 49 | pass 50 | 51 | @abstractmethod 52 | def cancel_macro_command(self): 53 | pass 54 | 55 | @abstractmethod 56 | def get_undo_history(self) -> List[str]: 57 | pass 58 | 59 | @abstractmethod 60 | def get_redo_history(self) -> List[str]: 61 | pass 62 | 63 | @abstractmethod 64 | def get_statistics(self) -> Dict[str, int]: 65 | pass 66 | 67 | @abstractmethod 68 | def can_undo(self) -> bool: 69 | pass 70 | 71 | @abstractmethod 72 | def can_redo(self) -> bool: 73 | pass 74 | -------------------------------------------------------------------------------- /src/echos/backends/pedalboard/timeline.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | import bisect 3 | import math 4 | from ...interfaces.system import IEngineTimeline, IDomainTimeline 5 | from ...models import Tempo, TimeSignature 6 | from ...models.state_model import TimelineState 7 | 8 | 9 | class RealTimeTimeline(IEngineTimeline): 10 | 11 | def __init__(self): 12 | self._tempos: List[Tempo] = [Tempo(beat=0, bpm=120)] 13 | self._time_signatures: List[TimeSignature] = [ 14 | TimeSignature(beat=0, numerator=4, denominator=4) 15 | ] 16 | 17 | @property 18 | def tempos(self) -> List[Tempo]: 19 | return list(self._tempos) 20 | 21 | @property 22 | def time_signatures(self) -> List[TimeSignature]: 23 | return list(self._time_signatures) 24 | 25 | def set_state(self, new_state: TimelineState) -> TimelineState: 26 | self._tempos = new_state.tempos 27 | self._time_signatures = new_state.time_signatures 28 | 29 | def get_tempo_at_beat(self, beat: float) -> Tempo: 30 | 31 | idx = bisect.bisect_right(self._tempos, beat, key=lambda t: t.beat) 32 | return self._tempos[idx - 1] 33 | 34 | def get_time_signature_at_beat(self, beat: float) -> TimeSignature: 35 | if not self._time_signatures: 36 | return TimeSignature(beat=0.0, numerator=4, denominator=4) 37 | 38 | idx = bisect.bisect_right(self._time_signatures, 39 | beat, 40 | key=lambda t: t.beat) 41 | if idx == 0: 42 | return self._time_signatures[0] 43 | 44 | return self._time_signatures[idx - 1] 45 | -------------------------------------------------------------------------------- /src/echos/core/history/commands/routing_commands.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from ....interfaces import IMixerChannel, IRouter 3 | from ....models import Send, Port 4 | from ..command_base import BaseCommand 5 | 6 | 7 | class CreateSendCommand(BaseCommand): 8 | 9 | def __init__(self, mixer_channel: IMixerChannel, target_bus_id: str, 10 | is_post_fader: bool): 11 | super().__init__(f"Create send to bus '{target_bus_id[:8]}...'") 12 | self._mixer_channel = mixer_channel 13 | self._target_bus_id = target_bus_id 14 | self._is_post_fader = is_post_fader 15 | self._created_send: Optional[Send] = None 16 | 17 | def _do_execute(self) -> bool: 18 | self._created_send = self._mixer_channel.add_send( 19 | target_bus_id=self._target_bus_id, 20 | is_post_fader=self._is_post_fader) 21 | return self._created_send is not None 22 | 23 | def _do_undo(self) -> bool: 24 | if self._created_send: 25 | return self._mixer_channel.remove_send(self._created_send.send_id) 26 | return False 27 | 28 | 29 | # --- (结束已有代码) --- 30 | 31 | 32 | class ConnectCommand(BaseCommand): 33 | """连接两个节点端口的命令。""" 34 | 35 | def __init__(self, router: IRouter, source_port: Port, dest_port: Port): 36 | super().__init__( 37 | f"Connect {source_port.owner_node_id[:6]}... to {dest_port.owner_node_id[:6]}..." 38 | ) 39 | self._router = router 40 | self._source_port = source_port 41 | self._dest_port = dest_port 42 | 43 | def _do_execute(self) -> bool: 44 | return self._router.connect(self._source_port, self._dest_port) 45 | 46 | def _do_undo(self) -> bool: 47 | return self._router.disconnect(self._source_port, self._dest_port) 48 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/iquery_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/IQueryService.py 2 | from abc import ABC, abstractmethod 3 | from typing import Optional 4 | from echos.models import ToolResponse 5 | from .ibase_service import IService 6 | 7 | 8 | class IQueryService(IService): 9 | """A dedicated service for all read-only operations to inspect project state.""" 10 | 11 | @abstractmethod 12 | def get_project_overview(self, project_id: str) -> ToolResponse: 13 | """Returns a high-level summary of the project (tempo, tracks, etc.).""" 14 | pass 15 | 16 | @abstractmethod 17 | def get_full_project_tree(self, project_id: str) -> ToolResponse: 18 | """Returns a detailed, hierarchical view of the entire project state.""" 19 | pass 20 | 21 | @abstractmethod 22 | def find_node_by_name(self, project_id: str, name: str) -> ToolResponse: 23 | """Finds the first node (track, bus) that matches the given name.""" 24 | pass 25 | 26 | @abstractmethod 27 | def get_node_details(self, project_id: str, node_id: str) -> ToolResponse: 28 | """Returns detailed information about a specific node (plugins, sends, params).""" 29 | pass 30 | 31 | @abstractmethod 32 | def get_connections_for_node(self, project_id: str, 33 | node_id: str) -> ToolResponse: 34 | """Returns all input and output connections for a given node.""" 35 | pass 36 | 37 | @abstractmethod 38 | def get_parameter_value(self, project_id: str, node_id: str, 39 | parameter_path: str) -> ToolResponse: 40 | """ 41 | Gets the current value of a parameter, e.g., parameter_path='volume' 42 | or 'insert_0_cutoff'. 43 | """ 44 | pass 45 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/imixer.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Dict, List, Optional 3 | from .ilifecycle import ILifecycleAware 4 | from .iparameter import IParameter 5 | from .iplugin import IPlugin 6 | from .iserializable import ISerializable 7 | from ...models import Send 8 | 9 | 10 | class IMixerChannel( 11 | ILifecycleAware, 12 | ISerializable, 13 | ABC, 14 | ): 15 | 16 | @property 17 | @abstractmethod 18 | def channel_id(self) -> str: 19 | pass 20 | 21 | @property 22 | @abstractmethod 23 | def volume(self) -> IParameter: 24 | pass 25 | 26 | @property 27 | @abstractmethod 28 | def pan(self) -> IParameter: 29 | pass 30 | 31 | @property 32 | @abstractmethod 33 | def inserts(self) -> List[IPlugin]: 34 | pass 35 | 36 | @property 37 | @abstractmethod 38 | def sends(self) -> List[Send]: 39 | pass 40 | 41 | @abstractmethod 42 | def get_parameters(self) -> Dict[str, IParameter]: 43 | pass 44 | 45 | @abstractmethod 46 | def add_insert(self, plugin: IPlugin, index: Optional[int] = None): 47 | pass 48 | 49 | @abstractmethod 50 | def remove_insert(self, plugin_instance_id: str) -> bool: 51 | pass 52 | 53 | @abstractmethod 54 | def move_insert(self, plugin_instance_id: str, new_index: int) -> bool: 55 | pass 56 | 57 | @abstractmethod 58 | def add_send( 59 | self, 60 | target_bus_node_id: str, 61 | is_post_fader: bool = True, 62 | ) -> Send: 63 | pass 64 | 65 | @abstractmethod 66 | def remove_send(self, send_id: str) -> bool: 67 | pass 68 | 69 | @abstractmethod 70 | def to_dict(self) -> Dict[str, Any]: 71 | pass 72 | -------------------------------------------------------------------------------- /src/echos/interfaces/services/inode_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/INodeService.py 2 | from abc import ABC, abstractmethod 3 | from typing import Optional 4 | from echos.models import ToolResponse 5 | from .ibase_service import IService 6 | 7 | 8 | class INodeService(IService): 9 | 10 | @abstractmethod 11 | def create_instrument_track(self, project_id: str, 12 | name: str) -> ToolResponse: 13 | pass 14 | 15 | @abstractmethod 16 | def create_audio_track(self, project_id: str, name: str) -> ToolResponse: 17 | pass 18 | 19 | @abstractmethod 20 | def create_bus_track(self, project_id: str, name: str) -> ToolResponse: 21 | pass 22 | 23 | @abstractmethod 24 | def create_vca_track(self, project_id: str, name: str) -> ToolResponse: 25 | pass 26 | 27 | @abstractmethod 28 | def delete_node(self, project_id: str, node_id: str) -> ToolResponse: 29 | pass 30 | 31 | @abstractmethod 32 | def rename_node(self, project_id: str, node_id: str, 33 | new_name: str) -> ToolResponse: 34 | pass 35 | 36 | @abstractmethod 37 | def add_insert_plugin(self, 38 | project_id: str, 39 | target_node_id: str, 40 | plugin_unique_id: str, 41 | index: Optional[int] = None) -> ToolResponse: 42 | pass 43 | 44 | @abstractmethod 45 | def remove_insert_plugin(self, project_id: str, target_node_id: str, 46 | plugin_instance_id: str) -> ToolResponse: 47 | pass 48 | 49 | @abstractmethod 50 | def list_nodes(self, 51 | project_id: str, 52 | node_type: Optional[str] = None) -> ToolResponse: 53 | pass 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | [project] 5 | name = "echos" 6 | version = "v0.0.1" 7 | authors = [ 8 | { name="Zhilin Wang", email="zhilin.nlp@gmail.com" }, 9 | ] 10 | description = "A modular, interface-driven AI agent for music co-production." 11 | readme = "Readme.md" 12 | requires-python = ">=3.10" 13 | license = { file="LICENSE" } 14 | keywords = ["ai", "music", "daw", "midi", "agent", "llm", "composition"] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: Apache 2.0 License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3", 21 | "Topic :: Multimedia :: Sound/Audio :: MIDI", 22 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 23 | ] 24 | 25 | dependencies = [ 26 | "pedalboard", 27 | "sounddevice", 28 | "mido", 29 | "python-rtmidi", 30 | "networkx", 31 | ] 32 | 33 | [project.optional-dependencies] 34 | realtime = [ 35 | "pyfluidsynth >= 1.3.0", 36 | ] 37 | notebook = [ 38 | "jupyterlab", 39 | "ipykernel", 40 | ] 41 | dev = [ 42 | "build", 43 | "pytest", # Recommended for testing src layouts 44 | ] 45 | 46 | # --- Project URLs --- 47 | [project.urls] 48 | 49 | Homepage = "https://github.com/linzwcs/echos" 50 | 51 | "Bug Tracker" = "https://github.com/linzwcs/echos/issues" 52 | 53 | # ============================================================================== 54 | # Tool Configuration for Setuptools 55 | # ============================================================================== 56 | [tool.setuptools.packages.find] 57 | # CRITICAL CHANGE FOR SRC LAYOUT: 58 | # Tell setuptools to look for packages inside the 'src' directory. 59 | 60 | where = ["src"] 61 | # IMPORTANT: Explicitly exclude the tests package from the final build. 62 | exclude = [""] -------------------------------------------------------------------------------- /src/echos/core/history/commands/transport_command.py: -------------------------------------------------------------------------------- 1 | from ..command_base import BaseCommand 2 | from ....interfaces.system import IDomainTimeline 3 | 4 | 5 | class SetTempoCommand(BaseCommand): 6 | 7 | def __init__(self, timeline: IDomainTimeline, beat: float, new_bpm: float): 8 | super().__init__(f"Set Tempo to {new_bpm:.2f} BPM") 9 | self._timeline = timeline 10 | self._beat = beat 11 | self._new_bpm = new_bpm 12 | self._old_state = timeline.timeline_state 13 | 14 | def _do_execute(self) -> bool: 15 | self._timeline.set_tempo(beat=self._beat, bpm=self._new_bpm) 16 | return True 17 | 18 | def _do_undo(self) -> bool: 19 | self._timeline.set_state(self._old_state) 20 | return True 21 | 22 | def can_merge_with(self, other: BaseCommand) -> bool: 23 | 24 | return isinstance( 25 | other, SetTempoCommand) and self._timeline is other._timeline 26 | 27 | def merge_with(self, other: 'SetTempoCommand'): 28 | self._new_bpm = other._new_bpm 29 | self.description = f"Set Tempo to {self._new_bpm:.2f} BPM" 30 | 31 | 32 | class SetTimeSignatureCommand(BaseCommand): 33 | 34 | def __init__(self, timeline: IDomainTimeline, beat: float, numerator: int, 35 | denominator: int): 36 | super().__init__( 37 | f"Set Time Signature to {numerator}/{denominator} at beat {beat}") 38 | self._timeline = timeline 39 | self._beat = beat 40 | self._new_ts = (numerator, denominator) 41 | self._old_state = timeline.timeline_state 42 | 43 | def _do_execute(self) -> bool: 44 | self._timeline.set_time_signature(beat=self._beat, 45 | numerator=self._new_ts[0], 46 | denominator=self._new_ts[1]) 47 | return True 48 | 49 | def _do_undo(self) -> bool: 50 | self._timeline.set_state(self._old_state) 51 | return True 52 | -------------------------------------------------------------------------------- /src/echos/backends/pedalboard/plugin_ins_manager.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, Tuple 2 | import pedalboard as pb 3 | from ...interfaces.system import IPluginRegistry, IPluginInstanceManager 4 | 5 | 6 | class PedalboardPluginInstanceManager(IPluginInstanceManager): 7 | 8 | def __init__(self, registry: IPluginRegistry): 9 | self._registry = registry 10 | self._active_instances: Dict[str, pb.Plugin] = {} 11 | print("PluginInstanceManager: Initialized") 12 | 13 | def create_instance( 14 | self, instance_id: str, 15 | unique_plugin_id: str) -> Optional[Tuple[str, pb.Plugin]]: 16 | descriptor = self._registry.find_by_id(unique_plugin_id) 17 | if not descriptor: 18 | print( 19 | f"Error: Plugin with ID '{unique_plugin_id}' not found in registry." 20 | ) 21 | return None 22 | 23 | try: 24 | print(f"Creating instance for: {descriptor.name}") 25 | plugin_instance = pb.load_plugin(descriptor.path) 26 | self._active_instances[instance_id] = plugin_instance 27 | return instance_id, plugin_instance 28 | except Exception as e: 29 | print( 30 | f"Error: Failed to create instance of {descriptor.name}. Reason: {e}" 31 | ) 32 | return None 33 | 34 | def get_instance(self, instance_id: str) -> Optional[pb.Plugin]: 35 | return self._active_instances.get(instance_id) 36 | 37 | def release_instance(self, instance_id: str) -> bool: 38 | 39 | if instance_id in self._active_instances: 40 | print(f"Releasing instance: {instance_id}") 41 | del self._active_instances[instance_id] 42 | return True 43 | print(f"Warning: Instance ID '{instance_id}' not found.") 44 | return False 45 | 46 | def release_all(self) -> None: 47 | 48 | print("Releasing all active instances.") 49 | self._active_instances.clear() 50 | -------------------------------------------------------------------------------- /src/echos/backends/pedalboard/factory.py: -------------------------------------------------------------------------------- 1 | from .engine import PedalboardEngine 2 | from .plugin_ins_manager import PedalboardPluginInstanceManager 3 | from ...core.plugin import PluginRegistry 4 | from ...interfaces.system.ifactory import INodeFactory 5 | from ...interfaces.system import IPlugin, ITrack, IEngine 6 | from ...models import PluginDescriptor 7 | from ...core.track import InstrumentTrack, AudioTrack, BusTrack, VCATrack 8 | from ...core.plugin import Plugin 9 | from ...interfaces.system.ifactory import IEngineFactory 10 | 11 | 12 | class PedalboardEngineFactory(IEngineFactory): 13 | 14 | def create_engine( 15 | self, 16 | plugin_registry: PluginRegistry, 17 | sample_rate: int = 48000, 18 | block_size: int = 512, 19 | output_channels: int = 2, 20 | device_id=None, 21 | ) -> IEngine: 22 | plugin_ins_manager = PedalboardPluginInstanceManager( 23 | registry=plugin_registry) 24 | return PedalboardEngine( 25 | sample_rate=sample_rate, 26 | block_size=block_size, 27 | output_channels=output_channels, 28 | plugin_ins_manager=plugin_ins_manager, 29 | device_id=device_id, 30 | ) 31 | 32 | 33 | class PedalboardNodeFactory(INodeFactory): 34 | 35 | def create_instrument_track(self, name: str, track_id=None) -> ITrack: 36 | return InstrumentTrack(name=name, node_id=track_id) 37 | 38 | def create_audio_track(self, name: str, track_id=None) -> ITrack: 39 | return AudioTrack(name=name, node_id=track_id) 40 | 41 | def create_bus_track(self, name: str, track_id=None) -> ITrack: 42 | return BusTrack(name=name, node_id=track_id) 43 | 44 | def create_vca_track(self, name: str, track_id=None) -> ITrack: 45 | return VCATrack(name=name, node_id=track_id) 46 | 47 | def create_plugin_instance(self, descriptor: PluginDescriptor, 48 | plugin_instance_id: str) -> IPlugin: 49 | return Plugin(descriptor=descriptor, 50 | event_bus=None, 51 | plugin_instance_id=plugin_instance_id) 52 | -------------------------------------------------------------------------------- /src/echos/core/event_bus.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Callable, List, Dict, Type 3 | from ..interfaces.system.ievent_bus import IEventBus 4 | from ..models.event_model import BaseEvent 5 | 6 | 7 | class EventBus(IEventBus): 8 | 9 | def __init__(self): 10 | self._subscribers: Dict[Type[BaseEvent], 11 | List[Callable]] = defaultdict(list) 12 | print("EventBus: Initialized.") 13 | 14 | def subscribe(self, event_type: Type[BaseEvent], handler: Callable): 15 | self._subscribers[event_type].append(handler) 16 | print( 17 | f"EventBus: Handler '{handler.__name__}' subscribed to '{event_type.__name__}'" 18 | ) 19 | 20 | def unsubscribe(self, event_type: Type[BaseEvent], handler: Callable): 21 | 22 | if event_type in self._subscribers: 23 | try: 24 | self._subscribers[event_type].remove(handler) 25 | print( 26 | f"EventBus: Handler '{handler.__name__}' unsubscribed from '{event_type.__name__}'" 27 | ) 28 | except ValueError: 29 | pass 30 | 31 | def publish(self, event: BaseEvent): 32 | 33 | event_type = type(event) 34 | if event_type in self._subscribers: 35 | for handler in self._subscribers[event_type]: 36 | try: 37 | handler(event) 38 | except Exception as e: 39 | print( 40 | f"EventBus Error: Handler '{handler.__name__}' failed for {event_type.__name__}: {e}" 41 | ) 42 | 43 | if BaseEvent in self._subscribers and event_type != BaseEvent: 44 | for handler in self._subscribers[BaseEvent]: 45 | try: 46 | handler(event) 47 | except Exception as e: 48 | print( 49 | f"EventBus Error: Catch-all handler '{handler.__name__}' failed: {e}" 50 | ) 51 | 52 | def clear(self): 53 | self._subscribers.clear() 54 | print("EventBus: All subscriptions cleared.") 55 | -------------------------------------------------------------------------------- /src/echos/backends/common/message_queue.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/backends/common/message_queue.py 2 | """ 3 | A simple, thread-safe message queue for communication between the main 4 | thread and the real-time audio thread. 5 | """ 6 | import queue 7 | from typing import Callable, Any 8 | 9 | 10 | class RealTimeMessageQueue: 11 | """ 12 | A wrapper around Python's standard queue to provide a clear interface 13 | for SPSC (Single-Producer, Single-Consumer) communication. 14 | 15 | In a production C++ application, this would be a lock-free ring buffer. 16 | For Python, queue.Queue is a robust and sufficient choice. 17 | """ 18 | 19 | def __init__(self): 20 | self._queue = queue.Queue() 21 | 22 | def push(self, message: Any): 23 | """ 24 | Pushes a message onto the queue. Called by the main thread (producer). 25 | This is a non-blocking operation. 26 | """ 27 | try: 28 | self._queue.put_nowait(message) 29 | except queue.Full: 30 | # This should ideally never happen with an unbounded queue. 31 | # In a real-world bounded queue, this indicates a performance problem. 32 | print("Warning: Real-time message queue is full!") 33 | 34 | def drain(self, handler: Callable[[Any], None]): 35 | """ 36 | Drains all pending messages from the queue and applies a handler to each. 37 | Called by the audio thread (consumer) at the start of a processing cycle. 38 | This is non-blocking and processes only what's currently in the queue. 39 | """ 40 | while True: 41 | try: 42 | message = self._queue.get_nowait() 43 | handler(message) 44 | except queue.Empty: 45 | # The queue is empty, we've processed all pending messages. 46 | break 47 | 48 | def __len__(self): 49 | """修复:返回队列大小""" 50 | return self._queue.qsize() 51 | 52 | def is_empty(self): 53 | """检查队列是否为空""" 54 | return self._queue.empty() 55 | 56 | def get_dropped_count(self): 57 | """获取丢弃的消息数""" 58 | with self._lock: 59 | return self._dropped_count 60 | -------------------------------------------------------------------------------- /src/echos/core/engine_controller.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from ..interfaces.system import IEngineController, IEngine, IEventBus, IDomainTimeline, IRouter 3 | 4 | 5 | class EngineController(IEngineController): 6 | 7 | def __init__(self, router: IRouter, timeline: IDomainTimeline): 8 | super().__init__() 9 | self._audio_engine = None 10 | self._router = router 11 | self._timeline = timeline 12 | 13 | @property 14 | def engine(self) -> Optional[IEngine]: 15 | return self._audio_engine 16 | 17 | def attach_engine(self, engine: IEngine) -> bool: 18 | 19 | if not self.is_mounted: 20 | raise RuntimeError( 21 | "Project must be initialized before attaching engine") 22 | 23 | if self._audio_engine: 24 | print("Project: Replacing existing engine") 25 | self.detach_engine() 26 | 27 | self._audio_engine = engine 28 | engine.mount(self._event_bus) 29 | 30 | from ..models.event_model import ProjectLoaded 31 | self._event_bus.publish( 32 | ProjectLoaded(timeline_state=self._timeline.timeline_state)) 33 | 34 | def detach_engine(self) -> bool: 35 | if not self._audio_engine: 36 | return 37 | 38 | from ..models.event_model import ProjectClosed 39 | self._event_bus.publish(ProjectClosed()) 40 | 41 | self._audio_engine.unmount() 42 | self._audio_engine = None 43 | print(f"Project '{self._name}': ✓ Engine detached") 44 | 45 | def play(self): 46 | self._audio_engine.play() 47 | 48 | def stop(self): 49 | self._audio_engine.stop() 50 | 51 | def pause(self): 52 | self._audio_engine.pause() 53 | 54 | def seek(self, beat: float): 55 | self._audio_engine.seek(beat=beat) 56 | 57 | @property 58 | def is_playing(self) -> bool: 59 | return self._audio_engine.is_playing 60 | 61 | @property 62 | def current_beat(self) -> float: 63 | return self._audio_engine.current_beat 64 | 65 | def _get_children(self): 66 | return [] 67 | 68 | def _on_mount(self, bus: IEventBus): 69 | self._event_bus = bus 70 | 71 | def _on_unmount(self): 72 | self._event_bus = None 73 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/itimeline.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List 3 | from .ievent_bus import IEventBus 4 | from .ilifecycle import ILifecycleAware 5 | from .iserializable import ISerializable 6 | from ...models.timeline_model import Tempo, TimeSignature 7 | from ...models.state_model import TimelineState 8 | 9 | 10 | class IReadonlyTimeline(ABC): 11 | 12 | @property 13 | @abstractmethod 14 | def tempos(self) -> List[Tempo]: 15 | pass 16 | 17 | @property 18 | @abstractmethod 19 | def time_signatures(self) -> List[TimeSignature]: 20 | pass 21 | 22 | @abstractmethod 23 | def get_tempo_at_beat(self, beat: float) -> float: 24 | pass 25 | 26 | @abstractmethod 27 | def get_time_signature_at_beat(self, beat: float) -> TimeSignature: 28 | pass 29 | 30 | 31 | class IWritableTimeline(ABC): 32 | 33 | @abstractmethod 34 | def set_state(self, new_state: TimelineState) -> TimelineState: 35 | pass 36 | 37 | 38 | class IMusicalTimeConverter(ABC): 39 | 40 | @abstractmethod 41 | def beats_to_seconds(self, beats: float) -> float: 42 | pass 43 | 44 | @abstractmethod 45 | def seconds_to_beats(self, seconds: float) -> float: 46 | pass 47 | 48 | 49 | class IDomainTimeline( 50 | ILifecycleAware, 51 | ISerializable, 52 | IReadonlyTimeline, 53 | IMusicalTimeConverter, 54 | IWritableTimeline, 55 | ABC, 56 | ): 57 | 58 | @property 59 | @abstractmethod 60 | def timeline_state(self) -> TimelineState: 61 | pass 62 | 63 | @abstractmethod 64 | def set_tempo(self, beat: float, bpm: float): 65 | pass 66 | 67 | @abstractmethod 68 | def set_time_signature(self, beat: float, bpm: float): 69 | pass 70 | 71 | @abstractmethod 72 | def remove_tempo( 73 | self, 74 | beat: float, 75 | numerator: int, 76 | denominator: int, 77 | ): 78 | pass 79 | 80 | @abstractmethod 81 | def remove_time_signature( 82 | self, 83 | beat: float, 84 | numerator: int, 85 | denominator: int, 86 | ): 87 | pass 88 | 89 | 90 | class IEngineTimeline( 91 | IReadonlyTimeline, 92 | IWritableTimeline, 93 | ABC, 94 | ): 95 | pass 96 | -------------------------------------------------------------------------------- /src/echos/backends/mock/engine.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from .sync_controller import MockSyncController 3 | from ...interfaces.system.iengine import IEngine 4 | from ...interfaces.system.itimeline import ITimeline 5 | from ...models import TransportStatus 6 | 7 | 8 | class Engine(IEngine): 9 | 10 | def __init__( 11 | self, 12 | sample_rate: int = 44100, 13 | block_size: int = 512, 14 | ): 15 | super().__init__() 16 | self._sample_rate = sample_rate 17 | self._block_size = block_size 18 | self._sync_controller = MockSyncController() 19 | self._current_beat: int = 0 20 | self._status: TransportStatus = TransportStatus.STOPPED 21 | self._timeline = None 22 | print( 23 | f"MockEngine initialized: SR={self.sample_rate}, BS={self.block_size}, Tempo= None BPM" 24 | ) 25 | 26 | @property 27 | def sync_controller(self) -> MockSyncController: 28 | return self._sync_controller 29 | 30 | @property 31 | def timeline(self) -> ITimeline: 32 | return self._timeline 33 | 34 | def set_timeline(self, timeline: ITimeline): 35 | self._timeline = timeline 36 | print( 37 | f"MockEngine initialized: SR={self.sample_rate}, BS={self.block_size}, Tempo={self.timeline.tempo} BPM" 38 | ) 39 | 40 | def play(self): 41 | if self._status != TransportStatus.PLAYING: 42 | self._status = TransportStatus.PLAYING 43 | print( 44 | f"MockEngine: Playback started at beat {self.current_beat:.2f}" 45 | ) 46 | 47 | def stop(self): 48 | if self._status != TransportStatus.STOPPED: 49 | self._status = TransportStatus.STOPPED 50 | self._playhead_position_samples = 0 51 | print("MockEngine: Playback stopped and playhead reset.") 52 | 53 | def report_latency(self) -> float: 54 | return 0.0 55 | 56 | @property 57 | def is_playing(self) -> bool: 58 | return self._status == TransportStatus.PLAYING 59 | 60 | @property 61 | def current_beat(self) -> float: 62 | return self._current_beat 63 | 64 | @property 65 | def block_size(self) -> int: 66 | return self._block_size 67 | 68 | @property 69 | def sample_rate(self) -> int: 70 | return self._sample_rate 71 | 72 | @property 73 | def transport_status(self) -> TransportStatus: 74 | return self._status 75 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/iengine.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/interfaces/IAudioEngine.py 2 | from abc import ABC, abstractmethod 3 | from typing import Optional 4 | 5 | from .ilifecycle import ILifecycleAware 6 | from .isync import ISyncController 7 | from .itimeline import IEngineTimeline 8 | from ...models.engine_model import TransportStatus 9 | 10 | 11 | class IEngine(ILifecycleAware, ABC): 12 | 13 | @property 14 | @abstractmethod 15 | def sync_controller(self) -> ISyncController: 16 | pass 17 | 18 | @property 19 | @abstractmethod 20 | def timeline(self) -> IEngineTimeline: 21 | pass 22 | 23 | @abstractmethod 24 | def play(self): 25 | pass 26 | 27 | @abstractmethod 28 | def stop(self): 29 | pass 30 | 31 | @abstractmethod 32 | def pause(self): 33 | pass 34 | 35 | @abstractmethod 36 | def seek(self, beat: float): 37 | pass 38 | 39 | @abstractmethod 40 | def report_latency(self) -> float: 41 | pass 42 | 43 | @property 44 | @abstractmethod 45 | def is_playing(self) -> bool: 46 | pass 47 | 48 | @property 49 | @abstractmethod 50 | def current_beat(self) -> float: 51 | pass 52 | 53 | @property 54 | @abstractmethod 55 | def block_size(self) -> bool: 56 | pass 57 | 58 | @property 59 | @abstractmethod 60 | def sample_rate(self) -> float: 61 | pass 62 | 63 | @property 64 | @abstractmethod 65 | def transport_status(self) -> TransportStatus: 66 | pass 67 | 68 | 69 | class IEngineController(ILifecycleAware, ABC): 70 | 71 | @property 72 | @abstractmethod 73 | def engine(self) -> Optional[IEngine]: 74 | pass 75 | 76 | @abstractmethod 77 | def attach_engine(self, engine: IEngine) -> bool: 78 | pass 79 | 80 | @abstractmethod 81 | def detach_engine(self) -> bool: 82 | pass 83 | 84 | @abstractmethod 85 | def play(self): 86 | pass 87 | 88 | @abstractmethod 89 | def stop(self): 90 | pass 91 | 92 | @abstractmethod 93 | def pause(self): 94 | pass 95 | 96 | @abstractmethod 97 | def seek(self, beat: float): 98 | pass 99 | 100 | @property 101 | @abstractmethod 102 | def is_playing(self) -> bool: 103 | pass 104 | 105 | @property 106 | @abstractmethod 107 | def current_beat(self) -> float: 108 | pass 109 | -------------------------------------------------------------------------------- /src/echos/services/project_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/project_service.py 2 | from ..agent.tools import tool 3 | from ..interfaces import IDAWManager, IProjectService 4 | from ..models import ToolResponse 5 | 6 | 7 | class ProjectService(IProjectService): 8 | 9 | def __init__(self, manager: IDAWManager): 10 | self._manager = manager 11 | 12 | @tool( 13 | category="project", 14 | description="Create a new music project", 15 | returns="Project ID and basic information", 16 | examples=['create_project(name="My Song")'], 17 | ) 18 | def create_project( 19 | self, 20 | name: str, 21 | project_id: str = None, 22 | sample_rate: int = 48000, 23 | block_size: int = 8192, 24 | output_channels: int = 2, 25 | ) -> ToolResponse: 26 | try: 27 | project = self._manager.create_project( 28 | name=name, 29 | project_id=project_id, 30 | sample_rate=sample_rate, 31 | block_size=block_size, 32 | output_channels=output_channels) 33 | return ToolResponse( 34 | status="success", 35 | data={ 36 | "project_id": project.project_id, 37 | "name": project.name 38 | }, 39 | message=f"Project '{name}' created successfully.") 40 | except Exception as e: 41 | return ToolResponse("error", None, str(e)) 42 | 43 | @tool(category="project", 44 | description="Save project to file", 45 | returns="Save operation result") 46 | def save_project(self, project_id: str, file_path: str) -> ToolResponse: 47 | return ToolResponse("error", None, 48 | "Save functionality not implemented yet.") 49 | 50 | @tool(category="project", 51 | description="Load project from file", 52 | returns="Loaded project information") 53 | def load_project(self, file_path: str) -> ToolResponse: 54 | return ToolResponse("error", None, 55 | "Load functionality not implemented yet.") 56 | 57 | @tool(category="project", 58 | description="Close an open project", 59 | returns="Close operation result") 60 | def close_project(self, project_id: str) -> ToolResponse: 61 | if self._manager.close_project(project_id): 62 | return ToolResponse("success", {"project_id": project_id}, 63 | f"Project '{project_id}' closed.") 64 | return ToolResponse("error", None, 65 | f"Project '{project_id}' not found.") 66 | -------------------------------------------------------------------------------- /src/echos/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_model import ToolResponse 2 | from .clip_model import AnyClip, AudioClip, Clip, MIDIClip, Note 3 | from .engine_model import TransportContext, TransportStatus 4 | from .event_model import ( 5 | BaseEvent, 6 | ClipAdded, 7 | ClipRemoved, 8 | ConnectionAdded, 9 | ConnectionRemoved, 10 | InsertAdded, 11 | InsertMoved, 12 | InsertRemoved, 13 | NodeAdded, 14 | NodeRemoved, 15 | NodeRenamed, 16 | NoteAdded, 17 | NoteRemoved, 18 | ParameterChanged, 19 | PluginEnabledChanged, 20 | ProjectClosed, 21 | ProjectLoaded, 22 | SendAdded, 23 | SendRemoved, 24 | TempoChanged, 25 | TimeSignatureChanged, 26 | TimelineStateChanged, 27 | ) 28 | from .lifecycle_model import LifecycleState 29 | from .mixer_model import Send 30 | from .node_model import TrackRecordMode, VCAControlMode 31 | from .parameter_model import ( 32 | AutomationCurveType, 33 | AutomationLane, 34 | AutomationPoint, 35 | ParameterType, 36 | ) 37 | from .plugin_model import CachedPluginInfo, PluginCategory, PluginDescriptor, PluginScanResult 38 | from .router_model import Connection, Port, PortDirection, PortType 39 | from .timeline_model import Tempo, TimeSignature 40 | 41 | __all__ = [ 42 | # api_model 43 | "ToolResponse", 44 | # clip_model 45 | "AnyClip", 46 | "AudioClip", 47 | "Clip", 48 | "MIDIClip", 49 | "Note", 50 | # engine_model 51 | "TransportContext", 52 | "TransportStatus", 53 | # event_model 54 | "BaseEvent", 55 | "ClipAdded", 56 | "ClipRemoved", 57 | "ConnectionAdded", 58 | "ConnectionRemoved", 59 | "InsertAdded", 60 | "InsertMoved", 61 | "InsertRemoved", 62 | "NodeAdded", 63 | "NodeRemoved", 64 | "NodeRenamed", 65 | "NoteAdded", 66 | "NoteRemoved", 67 | "ParameterChanged", 68 | "PluginEnabledChanged", 69 | "ProjectClosed", 70 | "ProjectLoaded", 71 | "SendAdded", 72 | "SendRemoved", 73 | "TempoChanged", 74 | "TimeSignatureChanged", 75 | "TimelineStateChanged", 76 | # lifecycle_model 77 | "LifecycleState", 78 | # mixer_model 79 | "Send", 80 | # node_model 81 | "TrackRecordMode", 82 | "VCAControlMode", 83 | # parameter_model 84 | "AutomationCurveType", 85 | "AutomationLane", 86 | "AutomationPoint", 87 | "ParameterType", 88 | # plugin_model 89 | "CachedPluginInfo", 90 | "PluginCategory", 91 | "PluginDescriptor", 92 | # router_model 93 | "Connection", 94 | "Port", 95 | "PortDirection", 96 | "PortType", 97 | # timeline_model 98 | "Tempo", 99 | "TimeSignature", 100 | ] 101 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/isync.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from .ilifecycle import ILifecycleAware 3 | from ...models import event_model 4 | 5 | 6 | class IGraphSync(ABC): 7 | 8 | @abstractmethod 9 | def on_node_added(self, event: event_model.NodeAdded): 10 | pass 11 | 12 | @abstractmethod 13 | def on_node_removed(self, event: event_model.NodeRemoved): 14 | pass 15 | 16 | @abstractmethod 17 | def on_connection_added(self, event: event_model.ConnectionAdded): 18 | pass 19 | 20 | @abstractmethod 21 | def on_connection_removed(self, event: event_model.ConnectionRemoved): 22 | pass 23 | 24 | 25 | class IMixerSync(ABC): 26 | 27 | @abstractmethod 28 | def on_insert_added(self, event: event_model.InsertAdded): 29 | pass 30 | 31 | @abstractmethod 32 | def on_insert_removed(self, event: event_model.InsertRemoved): 33 | pass 34 | 35 | @abstractmethod 36 | def on_insert_moved(self, event: event_model.InsertMoved): 37 | pass 38 | 39 | @abstractmethod 40 | def on_plugin_enabled_changed( 41 | self, 42 | event: event_model.PluginEnabledChanged, 43 | ): 44 | pass 45 | 46 | @abstractmethod 47 | def on_parameter_changed( 48 | self, 49 | event: event_model.ParameterChanged, 50 | ): 51 | pass 52 | 53 | 54 | class ITransportSync(ABC): 55 | 56 | @abstractmethod 57 | def on_timeline_state_changed(self, 58 | event: event_model.TimeSignatureChanged): 59 | pass 60 | 61 | 62 | class ITrackSync(ABC): 63 | 64 | @abstractmethod 65 | def on_clip_added(self, event: event_model.ClipAdded): 66 | pass 67 | 68 | @abstractmethod 69 | def on_clip_removed(self, event: event_model.ClipRemoved): 70 | pass 71 | 72 | 73 | class IClipSync(ABC): 74 | 75 | @abstractmethod 76 | def on_notes_added(self, event: event_model.NoteAdded): 77 | pass 78 | 79 | @abstractmethod 80 | def on_notes_removed(self, event: event_model.NoteRemoved): 81 | pass 82 | 83 | 84 | class IProjectSync(ABC): 85 | 86 | @abstractmethod 87 | def on_project_loaded(self, event: event_model.ProjectLoaded): 88 | pass 89 | 90 | @abstractmethod 91 | def on_project_closed(self, event: event_model.ProjectClosed): 92 | pass 93 | 94 | 95 | class ISyncController( 96 | IGraphSync, 97 | IMixerSync, 98 | ITransportSync, 99 | IClipSync, 100 | IProjectSync, 101 | ILifecycleAware, 102 | ABC, 103 | ): 104 | 105 | def _get_children(self): 106 | return [] 107 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/ilifecycle.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, Optional 3 | from .ievent_bus import IEventBus 4 | from ...models.lifecycle_model import LifecycleState 5 | 6 | 7 | class ILifecycleAware(ABC): 8 | 9 | def __init__(self): 10 | self._lifecycle_state = LifecycleState.CREATED 11 | self._event_bus: Optional['IEventBus'] = None 12 | 13 | @property 14 | def lifecycle_state(self) -> LifecycleState: 15 | return self._lifecycle_state 16 | 17 | @property 18 | def is_mounted(self) -> bool: 19 | return self._lifecycle_state == LifecycleState.MOUNTED 20 | 21 | @property 22 | def event_bus(self) -> Optional['IEventBus']: 23 | return self._event_bus 24 | 25 | def mount(self, event_bus: 'IEventBus'): 26 | if self._lifecycle_state == LifecycleState.MOUNTED: 27 | return 28 | 29 | if self._lifecycle_state == LifecycleState.DISPOSED: 30 | raise RuntimeError( 31 | f"{self.__class__.__name__}: Cannot mount disposed component") 32 | 33 | try: 34 | self._lifecycle_state = LifecycleState.MOUNTING 35 | self._event_bus = event_bus 36 | 37 | self._on_mount(event_bus) 38 | 39 | for child in self._get_children(): 40 | if isinstance(child, ILifecycleAware): 41 | print(f"mount: {child}") 42 | child.mount(event_bus) 43 | 44 | self._lifecycle_state = LifecycleState.MOUNTED 45 | 46 | except Exception as e: 47 | self._lifecycle_state = LifecycleState.CREATED 48 | self._event_bus = None 49 | raise RuntimeError( 50 | f"{self.__class__.__name__}: Mount failed: {e}") from e 51 | 52 | def unmount(self): 53 | 54 | if self._lifecycle_state != LifecycleState.MOUNTED: 55 | return 56 | try: 57 | self._lifecycle_state = LifecycleState.UNMOUNTING 58 | 59 | for child in reversed(self._get_children()): 60 | if isinstance(child, ILifecycleAware): 61 | child.unmount() 62 | 63 | self._on_unmount() 64 | 65 | self._event_bus = None 66 | self._lifecycle_state = LifecycleState.CREATED 67 | 68 | except Exception as e: 69 | self._event_bus = None 70 | self._lifecycle_state = LifecycleState.CREATED 71 | print(f"{self.__class__.__name__}: Unmount error: {e}") 72 | 73 | def dispose(self): 74 | 75 | self.unmount() 76 | self._lifecycle_state = LifecycleState.DISPOSED 77 | 78 | @abstractmethod 79 | def _on_mount(self, event_bus: 'IEventBus'): 80 | pass 81 | 82 | @abstractmethod 83 | def _on_unmount(self): 84 | 85 | pass 86 | 87 | @abstractmethod 88 | def _get_children(self) -> List['ILifecycleAware']: 89 | 90 | return [] 91 | -------------------------------------------------------------------------------- /src/echos/services/routing_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/routing_service.py 2 | from ..interfaces import IDAWManager, IRoutingService, ITrack 3 | from ..models import ToolResponse 4 | from ..core.history.commands.routing_commands import CreateSendCommand 5 | 6 | 7 | class RoutingService(IRoutingService): 8 | 9 | def __init__(self, manager: IDAWManager): 10 | self._manager = manager 11 | 12 | def create_send(self, 13 | project_id: str, 14 | source_track_id: str, 15 | dest_bus_id: str, 16 | is_post_fader: bool = True) -> ToolResponse: 17 | project = self._manager.get_project(project_id) 18 | if not project: 19 | return ToolResponse("error", None, 20 | f"Project '{project_id}' not found.") 21 | 22 | source_node = project.get_node_by_id(source_track_id) 23 | dest_node = project.get_node_by_id(dest_bus_id) 24 | 25 | if not isinstance(source_node, ITrack) or not hasattr( 26 | source_node, 'mixer_channel'): 27 | return ToolResponse( 28 | "error", None, 29 | f"Source node '{source_track_id}' is not a valid track with a mixer channel." 30 | ) 31 | if not (isinstance(dest_node, ITrack) 32 | and dest_node.node_type in ["BusTrack", "MasterTrack"]): 33 | return ToolResponse( 34 | "error", None, 35 | f"Destination node '{dest_bus_id}' is not a valid bus track.") 36 | 37 | mixer_channel = source_node.mixer_channel 38 | command = CreateSendCommand(mixer_channel, dest_bus_id, is_post_fader) 39 | project.command_manager.execute_command(command) 40 | 41 | if command.is_executed: 42 | send = command._created_send 43 | data = { 44 | "send_id": send.send_id, 45 | "source_track": source_track_id, 46 | "dest_bus": dest_bus_id, 47 | "post_fader": send.is_post_fader 48 | } 49 | return ToolResponse("success", data, command.description) 50 | return ToolResponse("error", None, command.error) 51 | 52 | def connect(self, project_id: str, source_node_id: str, 53 | source_port_id: str, dest_node_id: str, 54 | dest_port_id: str) -> ToolResponse: 55 | return ToolResponse("error", None, 56 | "Direct connect not implemented via command yet.") 57 | 58 | def disconnect(self, project_id: str, source_node_id: str, 59 | dest_node_id: str) -> ToolResponse: 60 | return ToolResponse( 61 | "error", None, 62 | "Direct disconnect not implemented via command yet.") 63 | 64 | def list_connections(self, project_id: str) -> ToolResponse: 65 | return ToolResponse("error", None, "List connections not implemented.") 66 | -------------------------------------------------------------------------------- /src/echos/core/plugin/cache.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import json 3 | from dataclasses import asdict 4 | from typing import Dict, List, Optional, Union 5 | from ...interfaces.system import IPluginCache 6 | from ...models import PluginDescriptor, CachedPluginInfo, PluginDescriptor 7 | 8 | 9 | class PluginCache(IPluginCache): 10 | 11 | def __init__(self, 12 | cache_file_path: Path = Path.home() / ".muzaicache.json"): 13 | self._cache_file = Path(cache_file_path) 14 | self._cache: Dict[str, CachedPluginInfo] = {} 15 | print(f"Cache file will be stored at: {self._cache_file}") 16 | 17 | def load(self) -> None: 18 | if not self._cache_file.exists(): 19 | self._cache = {} 20 | return 21 | try: 22 | with open(self._cache_file, 'r') as f: 23 | data = json.load(f) 24 | self._cache = { 25 | path: 26 | CachedPluginInfo( 27 | descriptor=PluginDescriptor(**info['descriptor']), 28 | file_mod_time=info['file_mod_time']) 29 | for path, info in data.items() 30 | } 31 | except (json.JSONDecodeError, KeyError) as e: 32 | print( 33 | f"Warning: Could not load cache file, it might be corrupt. Error: {e}" 34 | ) 35 | self._cache = {} 36 | 37 | def persist(self) -> None: 38 | try: 39 | data_to_persist = { 40 | path: { 41 | 'descriptor': asdict(info.descriptor), 42 | 'file_mod_time': info.file_mod_time 43 | } 44 | for path, info in self._cache.items() 45 | } 46 | with open(self._cache_file, 'w') as f: 47 | json.dump(data_to_persist, f, indent=4) 48 | except Exception as e: 49 | print(f"Error: Could not persist cache. Reason: {e}") 50 | 51 | def get_valid_entry(self, 52 | path: Union[Path | str]) -> Optional[CachedPluginInfo]: 53 | path_str = path if type(path) is str else str(path.resolve()) 54 | cached_info = self._cache.get(path_str) 55 | 56 | if not cached_info: 57 | return None 58 | 59 | if not path.exists(): 60 | return None 61 | 62 | current_mtime = path.stat().st_mtime 63 | if cached_info.file_mod_time == current_mtime: 64 | return cached_info 65 | 66 | return None 67 | 68 | def store_entry(self, path: Path, info: CachedPluginInfo) -> None: 69 | self._cache[str(path.resolve())] = info 70 | 71 | def get_all_cached_paths(self) -> List[Path]: 72 | return [Path(p) for p in self._cache.keys()] 73 | 74 | def remove_entry(self, path: Path) -> None: 75 | path_str = str(path.resolve()) 76 | if path_str in self._cache: 77 | del self._cache[path_str] 78 | -------------------------------------------------------------------------------- /src/echos/core/history/commands/editing_commands.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | from ....interfaces import IParameter, ITrack 3 | from ....models import MIDIClip, Note 4 | from ..command_base import BaseCommand 5 | 6 | 7 | class SetParameterCommand(BaseCommand): 8 | 9 | def __init__(self, parameter: IParameter, new_value: Any): 10 | super().__init__(f"Set {parameter.name} to {new_value}") 11 | self._parameter = parameter 12 | self._new_value = new_value 13 | self._old_value = parameter.value 14 | 15 | def _do_execute(self) -> bool: 16 | self._parameter.set_value(self._new_value, immediate=True) 17 | return True 18 | 19 | def _do_undo(self) -> bool: 20 | self._parameter.set_value(self._old_value, immediate=True) 21 | return True 22 | 23 | def can_merge_with(self, other: BaseCommand) -> bool: 24 | return (isinstance(other, SetParameterCommand) 25 | and other._parameter is self._parameter) 26 | 27 | def merge_with(self, other: 'SetParameterCommand'): 28 | self._new_value = other._new_value 29 | self.description = f"Set {self._parameter.name} to {self._new_value}" 30 | 31 | 32 | class CreateMidiClipCommand(BaseCommand): 33 | 34 | def __init__(self, 35 | track: ITrack, 36 | start_beat: float, 37 | duration_beats: float, 38 | name: str, 39 | clip_id: str = None): 40 | 41 | super().__init__(f"Create MIDI Clip '{name}'") 42 | self._track = track 43 | self._clip_data = { 44 | "start_beat": start_beat, 45 | "duration_beats": duration_beats, 46 | "name": name, 47 | } 48 | if clip_id: 49 | self._clip_data["clip_id"] = clip_id 50 | self._created_clip: Optional[MIDIClip] = None 51 | 52 | def _do_execute(self) -> bool: 53 | 54 | self._created_clip = MIDIClip(**self._clip_data) 55 | self._track.add_clip(self._created_clip) 56 | return True 57 | 58 | def _do_undo(self) -> bool: 59 | if self._created_clip: 60 | return self._track.remove_clip(self._created_clip.clip_id) 61 | return False 62 | 63 | 64 | class AddNotesToClipCommand(BaseCommand): 65 | 66 | def __init__(self, clip: MIDIClip, notes_to_add: List[Note]): 67 | note_count = len(notes_to_add) 68 | super().__init__( 69 | f"Add {note_count} note{'s' if note_count > 1 else ''} to clip '{clip.name}'" 70 | ) 71 | self._clip = clip 72 | self._notes_to_add = notes_to_add 73 | 74 | def _do_execute(self) -> bool: 75 | initial_count = len(self._clip.notes) 76 | for note in self._notes_to_add: 77 | self._clip.notes.add(note) 78 | return len(self._clip.notes) > initial_count 79 | 80 | def _do_undo(self) -> bool: 81 | initial_count = len(self._clip.notes) 82 | for note in self._notes_to_add: 83 | if note in self._clip.notes: 84 | self._clip.notes.remove(note) 85 | return len(self._clip.notes) < initial_count 86 | -------------------------------------------------------------------------------- /src/echos/core/manager.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict 2 | from .project import Project 3 | from ..interfaces import ( 4 | IDAWManager, 5 | IProject, 6 | IProjectSerializer, 7 | IPluginRegistry, 8 | ) 9 | from ..interfaces.system.ifactory import IEngineFactory, INodeFactory 10 | from ..models.state_model import ProjectState 11 | 12 | 13 | class DAWManager(IDAWManager): 14 | 15 | def __init__( 16 | self, 17 | project_serializer: IProjectSerializer, 18 | plugin_registry: IPluginRegistry, 19 | engine_factory: IEngineFactory, 20 | node_factory: INodeFactory, 21 | ): 22 | print("=" * 70) 23 | print("Initializing Generic Core DAW Manager...") 24 | print("=" * 70) 25 | 26 | self._projects: Dict[str, IProject] = {} 27 | self._engine_factory = engine_factory 28 | self._project_serializer = project_serializer 29 | self._plugin_registry = plugin_registry 30 | self._node_factory = node_factory 31 | 32 | print( 33 | " ✓ Core Manager is ready. Backend is determined by injected factories." 34 | ) 35 | print("=" * 70 + "\n") 36 | 37 | @property 38 | def node_factory(self) -> INodeFactory: 39 | return self._node_factory 40 | 41 | @property 42 | def plugin_registry(self) -> IPluginRegistry: 43 | return self._plugin_registry 44 | 45 | @property 46 | def node_factory(self) -> INodeFactory: 47 | return self._node_factory 48 | 49 | def create_project( 50 | self, 51 | name: str, 52 | project_id: str = None, 53 | sample_rate: int = 48000, 54 | block_size: int = 512, 55 | output_channels: int = 2, 56 | ) -> IProject: 57 | 58 | project = Project(name=name, project_id=project_id) 59 | engine = self._engine_factory.create_engine( 60 | plugin_registry=self._plugin_registry, 61 | sample_rate=sample_rate, 62 | block_size=block_size, 63 | output_channels=output_channels) 64 | project.engine_controller.attach_engine(engine) 65 | self._projects[project.project_id] = project 66 | 67 | return project 68 | 69 | def load_project_from_state(self, state: ProjectState) -> IProject: 70 | 71 | project = self._project_serializer.deserialize(state) 72 | self._projects[project.project_id] = project 73 | return project 74 | 75 | def close_project(self, project_id: str) -> bool: 76 | 77 | if project_id not in self._projects: 78 | return False 79 | 80 | print(f"\nClosing project and destroying its stack ({project_id})...") 81 | project = self._projects[project_id] 82 | 83 | project.cleanup() 84 | 85 | del self._projects[project_id] 86 | return True 87 | 88 | def get_project(self, project_id: str) -> Optional[IProject]: 89 | return self._projects.get(project_id) 90 | 91 | def get_project_state(self, project_id: str) -> Optional[ProjectState]: 92 | 93 | project = self.get_project(project_id) 94 | if not project: 95 | return None 96 | 97 | return self._project_serializer.serialize(project) 98 | -------------------------------------------------------------------------------- /src/echos/models/event_model.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from dataclasses import dataclass, field 3 | from datetime import datetime 4 | from typing import Any, List 5 | 6 | from .clip_model import Note, AnyClip 7 | from .router_model import Connection 8 | from .mixer_model import Send 9 | from .timeline_model import Tempo, TimeSignature 10 | from .state_model import TimelineState 11 | 12 | 13 | @dataclass 14 | class BaseEvent: 15 | 16 | timestamp: datetime = field(default_factory=datetime.now) 17 | event_id: uuid.UUID = field(default_factory=uuid.uuid4) 18 | 19 | 20 | @dataclass(kw_only=True) 21 | class ProjectLoaded(BaseEvent): 22 | timeline_state: TimelineState 23 | 24 | 25 | @dataclass(kw_only=True) 26 | class ProjectClosed(BaseEvent): 27 | pass 28 | 29 | 30 | @dataclass(kw_only=True) 31 | class NodeAdded(BaseEvent): 32 | node_id: str 33 | node_type: str 34 | 35 | 36 | @dataclass(kw_only=True) 37 | class NodeRemoved(BaseEvent): 38 | node_id: str 39 | 40 | 41 | @dataclass(kw_only=True) 42 | class NodeRenamed: 43 | node_id: str 44 | old_name: str 45 | new_name: str 46 | 47 | 48 | @dataclass(kw_only=True) 49 | class ConnectionAdded(BaseEvent): 50 | connection: "Connection" 51 | 52 | 53 | @dataclass(kw_only=True) 54 | class ConnectionRemoved(BaseEvent): 55 | connection: "Connection" 56 | 57 | 58 | @dataclass(kw_only=True) 59 | class InsertAdded(BaseEvent): 60 | owner_node_id: str 61 | plugin_instance_id: str 62 | plugin_unique_id: str 63 | index: int 64 | 65 | 66 | @dataclass(kw_only=True) 67 | class InsertRemoved(BaseEvent): 68 | 69 | owner_node_id: str 70 | plugin_instance_id: str 71 | 72 | 73 | @dataclass(kw_only=True) 74 | class InsertMoved(BaseEvent): 75 | 76 | owner_node_id: str 77 | plugin_instance_id: str 78 | old_index: int 79 | new_index: int 80 | 81 | 82 | @dataclass(kw_only=True) 83 | class PluginEnabledChanged(BaseEvent): 84 | 85 | plugin_id: str 86 | is_enabled: bool 87 | 88 | 89 | @dataclass(kw_only=True) 90 | class ParameterChanged(BaseEvent): 91 | 92 | owner_node_id: str 93 | param_name: str 94 | new_value: Any 95 | 96 | 97 | @dataclass(kw_only=True) 98 | class TimelineStateChanged(BaseEvent): 99 | timeline_state: TimelineState 100 | 101 | 102 | @dataclass(kw_only=True) 103 | class TempoChanged(BaseEvent): 104 | tempos: tuple[Tempo] 105 | 106 | 107 | @dataclass(kw_only=True) 108 | class TimeSignatureChanged(BaseEvent): 109 | time_signatures: tuple[TimeSignature] 110 | 111 | 112 | @dataclass(kw_only=True) 113 | class ClipAdded(BaseEvent): 114 | 115 | owner_track_id: str 116 | clip: AnyClip 117 | 118 | 119 | @dataclass(kw_only=True) 120 | class ClipRemoved(BaseEvent): 121 | 122 | owner_track_id: str 123 | clip_id: str 124 | 125 | 126 | @dataclass(kw_only=True) 127 | class NoteAdded(BaseEvent): 128 | 129 | owner_clip_id: str 130 | notes: List[Note] 131 | 132 | 133 | @dataclass(kw_only=True) 134 | class NoteRemoved(BaseEvent): 135 | 136 | owner_clip_id: str 137 | notes: List[Note] 138 | 139 | 140 | @dataclass(kw_only=True) 141 | class SendAdded(BaseEvent): 142 | 143 | owner_node_id: str 144 | send: Send 145 | 146 | 147 | @dataclass(kw_only=True) 148 | class SendRemoved(BaseEvent): 149 | 150 | owner_node_id: str 151 | send_id: str 152 | target_bus_node_id: str 153 | -------------------------------------------------------------------------------- /src/echos/interfaces/system/iplugin.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Dict, List, Optional, Tuple 3 | from pathlib import Path 4 | from .ilifecycle import ILifecycleAware 5 | from .iparameter import IParameter 6 | from .iserializable import ISerializable 7 | from ...models import PluginDescriptor, CachedPluginInfo 8 | 9 | 10 | class IPlugin( 11 | ILifecycleAware, 12 | ISerializable, 13 | ABC, 14 | ): 15 | 16 | @property 17 | @abstractmethod 18 | def descriptor(self) -> 'PluginDescriptor': 19 | pass 20 | 21 | @property 22 | @abstractmethod 23 | def plugin_instance_id(self) -> str: 24 | pass 25 | 26 | @property 27 | @abstractmethod 28 | def is_enabled(self) -> bool: 29 | pass 30 | 31 | @abstractmethod 32 | def get_parameters(self) -> Dict[str, IParameter]: 33 | pass 34 | 35 | @abstractmethod 36 | def to_state(self) -> Dict[str, Any]: 37 | pass 38 | 39 | @abstractmethod 40 | def to_dict(self) -> Dict[str, Any]: 41 | pass 42 | 43 | 44 | class IPluginCache(ABC): 45 | 46 | @abstractmethod 47 | def load(self) -> None: 48 | pass 49 | 50 | @abstractmethod 51 | def persist(self) -> None: 52 | pass 53 | 54 | @abstractmethod 55 | def get_valid_entry(self, path: Path) -> Optional[CachedPluginInfo]: 56 | pass 57 | 58 | @abstractmethod 59 | def store_entry(self, path: Path, info: CachedPluginInfo) -> None: 60 | pass 61 | 62 | @abstractmethod 63 | def get_all_cached_paths(self) -> List[Path]: 64 | pass 65 | 66 | @abstractmethod 67 | def remove_entry(self, path: Path) -> None: 68 | pass 69 | 70 | 71 | class IPluginRegistry(ABC): 72 | 73 | @abstractmethod 74 | def __init__(self, cache: IPluginCache): 75 | pass 76 | 77 | @abstractmethod 78 | def load(self) -> None: 79 | pass 80 | 81 | @abstractmethod 82 | def list_all(self) -> List[PluginDescriptor]: 83 | pass 84 | 85 | @abstractmethod 86 | def find_by_id(self, unique_plugin_id: str) -> Optional[PluginDescriptor]: 87 | pass 88 | 89 | @abstractmethod 90 | def find_by_path(self, path: str) -> Optional[PluginDescriptor]: 91 | pass 92 | 93 | 94 | class IPluginInstanceManager(ABC): 95 | 96 | @abstractmethod 97 | def __init__(self, registry: IPluginRegistry): 98 | pass 99 | 100 | @abstractmethod 101 | def create_instance( 102 | self, 103 | plugin_instance_id: str, 104 | unique_plugin_id: str, 105 | ) -> Optional[Tuple[str, Any]]: 106 | pass 107 | 108 | @abstractmethod 109 | def get_instance(self, instance_id: str) -> Optional[Any]: 110 | pass 111 | 112 | @abstractmethod 113 | def release_instance(self, instance_id: str) -> bool: 114 | pass 115 | 116 | @abstractmethod 117 | def release_all(self) -> None: 118 | pass 119 | 120 | 121 | class IPluginScanner(ABC): 122 | 123 | @abstractmethod 124 | def start_scan(self): 125 | pass 126 | 127 | @abstractmethod 128 | def is_scanning(self) -> bool: 129 | pass 130 | 131 | @abstractmethod 132 | def get_results(self) -> Optional[List[PluginDescriptor]]: 133 | pass 134 | 135 | @abstractmethod 136 | def terminate(self): 137 | pass 138 | -------------------------------------------------------------------------------- /src/echos/services/system_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/system_service.py 2 | from typing import Optional 3 | import dataclasses 4 | from ..interfaces import IDAWManager, ISystemService, IPluginRegistry 5 | from ..models import ToolResponse 6 | 7 | 8 | class SystemService(ISystemService): 9 | 10 | def __init__(self, manager: IDAWManager): 11 | self._manager = manager 12 | self._plugin_registry: IPluginRegistry = manager.plugin_registry 13 | 14 | def list_available_plugins(self, 15 | category: Optional[str] = None) -> ToolResponse: 16 | plugins = self._plugin_registry.list_plugins() 17 | if category: 18 | plugins = [p for p in plugins if p.category.value == category] 19 | data = [{ 20 | "id": p.unique_plugin_id, 21 | "name": p.name, 22 | "vendor": p.vendor, 23 | "category": p.category.value 24 | } for p in plugins] 25 | return ToolResponse("success", {"plugins": data}, 26 | f"Found {len(data)} available plugins.") 27 | 28 | def get_plugin_details(self, plugin_unique_id: str) -> ToolResponse: 29 | descriptor = self._plugin_registry.get_plugin_descriptor( 30 | plugin_unique_id) 31 | if not descriptor: 32 | return ToolResponse("error", None, 33 | f"Plugin '{plugin_unique_id}' not found.") 34 | 35 | # Dataclasses.asdict is useful for serialization 36 | data = dataclasses.asdict(descriptor) 37 | # Convert enum to string for clean JSON output 38 | data['category'] = descriptor.category.value 39 | return ToolResponse( 40 | "success", data, 41 | f"Details for plugin '{descriptor.name}' retrieved.") 42 | 43 | def get_system_info(self) -> ToolResponse: 44 | info = { 45 | "core_version": 46 | "0.1.0-alpha", 47 | "status": 48 | "Operational", 49 | "active_projects": 50 | len(getattr(self._manager, '_projects', {})), 51 | "backend": 52 | self._manager.node_factory.__class__.__name__.replace( 53 | "NodeFactory", "") 54 | } 55 | return ToolResponse("success", info, "System information retrieved.") 56 | 57 | def list_audio_devices(self) -> ToolResponse: 58 | # In a real app, this would use a DeviceManager. Here we mock it. 59 | mock_devices = [{ 60 | "id": "0", 61 | "name": "Built-in Microphone", 62 | "inputs": 2, 63 | "outputs": 0 64 | }, { 65 | "id": "1", 66 | "name": "Built-in Output", 67 | "inputs": 0, 68 | "outputs": 2 69 | }, { 70 | "id": "2", 71 | "name": "Focusrite Scarlett 2i2", 72 | "inputs": 2, 73 | "outputs": 2 74 | }] 75 | return ToolResponse("success", {"devices": mock_devices}, 76 | "Mock audio devices listed.") 77 | 78 | def list_midi_devices(self) -> ToolResponse: 79 | # Mocked response 80 | mock_devices = [{ 81 | "id": "midi_in_0", 82 | "name": "Keystation Mini 32" 83 | }, { 84 | "id": "midi_in_1", 85 | "name": "IAC Driver Bus 1" 86 | }] 87 | return ToolResponse("success", {"devices": mock_devices}, 88 | "Mock MIDI devices listed.") 89 | -------------------------------------------------------------------------------- /src/echos/core/history/command_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | from datetime import datetime 4 | import uuid 5 | 6 | 7 | class CommandState: 8 | CREATED = "created" 9 | EXECUTED = "executed" 10 | UNDONE = "undone" 11 | FAILED = "failed" 12 | 13 | 14 | class BaseCommand(ABC): 15 | 16 | def __init__(self, description: str): 17 | self._command_id = str(uuid.uuid4()) 18 | self._description = description 19 | self._state = CommandState.CREATED 20 | self._executed_at: Optional[datetime] = None 21 | self._undone_at: Optional[datetime] = None 22 | self._error: Optional[str] = None 23 | 24 | @property 25 | def command_id(self) -> str: 26 | return self._command_id 27 | 28 | @property 29 | def description(self) -> str: 30 | return self._description 31 | 32 | @property 33 | def state(self) -> str: 34 | return self._state 35 | 36 | @property 37 | def is_executed(self) -> bool: 38 | return self._state == CommandState.EXECUTED 39 | 40 | @property 41 | def error(self) -> Optional[str]: 42 | return self._error 43 | 44 | def execute(self) -> bool: 45 | 46 | if self._state == CommandState.EXECUTED: 47 | print(f"Command Warning: {self.description} already executed") 48 | return True 49 | 50 | try: 51 | result = self._do_execute() 52 | if result: 53 | self._state = CommandState.EXECUTED 54 | self._executed_at = datetime.now() 55 | print(f"Command: ✓ {self.description}") 56 | else: 57 | self._state = CommandState.FAILED 58 | self._error = "Execution returned False" 59 | print(f"Command: ✗ {self.description} failed") 60 | return result 61 | except Exception as e: 62 | self._state = CommandState.FAILED 63 | self._error = str(e) 64 | print(f"Command: ✗ {self.description} raised exception: {e}") 65 | return False 66 | 67 | def undo(self) -> bool: 68 | 69 | if self._state != CommandState.EXECUTED: 70 | print( 71 | f"Command Warning: Cannot undo {self.description} (state: {self._state})" 72 | ) 73 | return False 74 | 75 | try: 76 | result = self._do_undo() 77 | if result: 78 | self._state = CommandState.UNDONE 79 | self._undone_at = datetime.now() 80 | print(f"Command: ↶ Undone {self.description}") 81 | else: 82 | self._error = "Undo returned False" 83 | print(f"Command: ✗ Failed to undo {self.description}") 84 | return result 85 | except Exception as e: 86 | self._error = str(e) 87 | print(f"Command: ✗ Undo raised exception: {e}") 88 | return False 89 | 90 | @abstractmethod 91 | def _do_execute(self) -> bool: 92 | 93 | pass 94 | 95 | @abstractmethod 96 | def _do_undo(self) -> bool: 97 | 98 | pass 99 | 100 | def can_merge_with(self, other: 'BaseCommand') -> bool: 101 | 102 | return False 103 | 104 | def merge_with(self, other: 'BaseCommand'): 105 | 106 | raise NotImplementedError( 107 | f"{type(self).__name__} does not support merging") 108 | 109 | def __repr__(self) -> str: 110 | return f"Command(desc='{self.description}', state={self._state})" 111 | -------------------------------------------------------------------------------- /src/echos/utils/scan_worker.py: -------------------------------------------------------------------------------- 1 | """ 2 | 插件扫描工作脚本 - 在隔离的子进程中运行 3 | 此脚本会被主进程调用来安全地扫描单个插件 4 | """ 5 | import sys 6 | import json 7 | from pathlib import Path 8 | from typing import Dict, List, Any 9 | 10 | 11 | def extract_port_info(plugin) -> List[Dict[str, Any]]: 12 | 13 | ports = [] 14 | 15 | try: 16 | 17 | if hasattr(plugin, 'num_input_channels'): 18 | for i in range(plugin.num_input_channels): 19 | ports.append({ 20 | "type": "audio", 21 | "direction": "input", 22 | "index": i, 23 | "name": f"Audio In {i + 1}" 24 | }) 25 | 26 | if hasattr(plugin, 'num_output_channels'): 27 | for i in range(plugin.num_output_channels): 28 | ports.append({ 29 | "type": "audio", 30 | "direction": "output", 31 | "index": i, 32 | "name": f"Audio Out {i + 1}" 33 | }) 34 | 35 | if hasattr(plugin, 'is_instrument') and plugin.is_instrument: 36 | ports.append({ 37 | "type": "midi", 38 | "direction": "input", 39 | "index": 0, 40 | "name": "MIDI In" 41 | }) 42 | except Exception as e: 43 | print(f"Warning: Could not extract port info: {e}", file=sys.stderr) 44 | 45 | return ports 46 | 47 | 48 | def extract_latency_info(plugin) -> tuple[bool, int]: 49 | 50 | try: 51 | 52 | if hasattr(plugin, 'latency_samples'): 53 | return True, int(plugin.latency_samples) 54 | elif hasattr(plugin, 'get_latency'): 55 | latency = plugin.get_latency() 56 | return True, int(latency) if latency else 0 57 | except Exception: 58 | pass 59 | 60 | return False, 0 61 | 62 | 63 | def scan_plugin(plugin_path: str) -> dict: 64 | 65 | import pedalboard as pb 66 | 67 | path = Path(plugin_path) 68 | 69 | plugin = pb.load_plugin(plugin_path) 70 | 71 | unique_id = f"{plugin.manufacturer_name}::{plugin.name}::{path.suffix}" 72 | 73 | parameters = {} 74 | for p_name, p in plugin.parameters.items(): 75 | try: 76 | parameters[p_name] = { 77 | "min": float(p.range[0]) if hasattr(p, 'range') else 0.0, 78 | "max": float(p.range[1]) if hasattr(p, 'range') else 1.0, 79 | "default": 80 | float(p.raw_value) if hasattr(p, 'raw_value') else 0.0 81 | } 82 | except (AttributeError, TypeError, ValueError): 83 | parameters[p_name] = {"min": 0.0, "max": 1.0, "default": 0.0} 84 | 85 | reports_latency, latency_samples = extract_latency_info(plugin) 86 | 87 | plugin_info = { 88 | "unique_plugin_id": unique_id, 89 | "name": plugin.name, 90 | "vendor": plugin.manufacturer_name, 91 | "path": plugin_path, 92 | "is_instrument": plugin.is_instrument, 93 | "plugin_format": path.suffix, 94 | "reports_latency": reports_latency, 95 | "latency_samples": latency_samples, 96 | "default_parameters": parameters 97 | } 98 | 99 | return plugin_info 100 | 101 | 102 | def main(): 103 | if len(sys.argv) < 2: 104 | print("Usage: scan_worker.py ", file=sys.stderr) 105 | sys.exit(1) 106 | 107 | plugin_path = sys.argv[1] 108 | try: 109 | plugin_info = scan_plugin(plugin_path) 110 | print(json.dumps(plugin_info, indent=2)) 111 | sys.exit(0) 112 | 113 | except Exception as e: 114 | # 错误信息输出到stderr 115 | error_info = { 116 | "error": str(e), 117 | "type": type(e).__name__, 118 | "plugin_path": plugin_path 119 | } 120 | print(json.dumps(error_info), file=sys.stderr) 121 | sys.exit(1) 122 | 123 | 124 | if __name__ == "__main__": 125 | main() 126 | -------------------------------------------------------------------------------- /src/echos/backends/pedalboard/messages.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Tuple, Union, Any 3 | 4 | from ...models import Note, AnyClip, PluginDescriptor 5 | from ...models.state_model import TimelineState 6 | 7 | 8 | @dataclass(frozen=True) 9 | class BaseMessage: 10 | pass 11 | 12 | 13 | @dataclass(frozen=True) 14 | class TimelineMessage(BaseMessage): 15 | pass 16 | 17 | 18 | @dataclass(frozen=True) 19 | class GraphMessage(BaseMessage): 20 | pass 21 | 22 | 23 | @dataclass(frozen=True) 24 | class EngineMessage(BaseMessage): 25 | pass 26 | 27 | 28 | @dataclass(frozen=True) 29 | class RealTimeMessage(BaseMessage): 30 | pass 31 | 32 | 33 | @dataclass(frozen=True) 34 | class NonRealTimeMessage(BaseMessage): 35 | pass 36 | 37 | 38 | @dataclass(frozen=True) 39 | class ClearProject(NonRealTimeMessage): 40 | pass 41 | 42 | 43 | @dataclass(frozen=True) 44 | class AddNode(NonRealTimeMessage, GraphMessage): 45 | node_id: str 46 | node_type: str 47 | 48 | 49 | @dataclass(frozen=True) 50 | class RemoveNode(NonRealTimeMessage, GraphMessage): 51 | node_id: str 52 | 53 | 54 | @dataclass(frozen=True) 55 | class AddConnection(NonRealTimeMessage, GraphMessage): 56 | 57 | source_node_id: str 58 | dest_node_id: str 59 | 60 | 61 | @dataclass(frozen=True) 62 | class RemoveConnection(NonRealTimeMessage, GraphMessage): 63 | 64 | source_node_id: str 65 | dest_node_id: str 66 | 67 | 68 | @dataclass(frozen=True) 69 | class AddPlugin(NonRealTimeMessage, GraphMessage): 70 | 71 | owner_node_id: str 72 | plugin_instance_id: str 73 | plugin_unique_id: str 74 | index: int 75 | 76 | 77 | @dataclass(frozen=True) 78 | class RemovePlugin(NonRealTimeMessage, GraphMessage): 79 | 80 | owner_node_id: str 81 | plugin_instance_id: str 82 | 83 | 84 | @dataclass(frozen=True) 85 | class MovePlugin(NonRealTimeMessage, GraphMessage): 86 | 87 | owner_node_id: str 88 | plugin_instance_id: str 89 | old_index: int 90 | new_index: int 91 | 92 | 93 | @dataclass(frozen=True) 94 | class UpdateTrackClips(NonRealTimeMessage, GraphMessage): 95 | 96 | track_id: str 97 | clips: Tuple[AnyClip, ...] 98 | 99 | 100 | @dataclass(frozen=True) 101 | class AddTrackClip(NonRealTimeMessage, GraphMessage): 102 | track_id: str 103 | clip: AnyClip 104 | 105 | 106 | @dataclass(frozen=True) 107 | class AddNotesToClip(NonRealTimeMessage, GraphMessage): 108 | 109 | owner_node_id: str 110 | clip_id: str 111 | notes: Tuple[Note, ...] 112 | 113 | 114 | @dataclass(frozen=True) 115 | class RemoveNotesFromClip(NonRealTimeMessage, GraphMessage): 116 | 117 | owner_node_id: str 118 | clip_id: str 119 | note_ids: Tuple[int, ...] 120 | 121 | 122 | @dataclass(frozen=True) 123 | class SetPluginBypass(RealTimeMessage, GraphMessage): 124 | 125 | owner_node_id: str 126 | plugin_instance_id: str 127 | is_bypassed: bool 128 | 129 | 130 | @dataclass(frozen=True) 131 | class SetParameter(RealTimeMessage, GraphMessage): 132 | 133 | owner_node_id: str 134 | parameter_path: str 135 | value: Any 136 | 137 | 138 | @dataclass(frozen=True) 139 | class SetBypass(RealTimeMessage, GraphMessage): 140 | 141 | owner_node_id: str 142 | plugin_instance_id: str 143 | is_bypassed: bool 144 | 145 | 146 | @dataclass(frozen=True) 147 | class SetTimelineState(NonRealTimeMessage, TimelineMessage): 148 | timeline_state: TimelineState 149 | 150 | 151 | @dataclass(frozen=True) 152 | class UpdatePluginRegistry(NonRealTimeMessage): 153 | descriptors: Tuple[PluginDescriptor, ...] 154 | 155 | 156 | AnyMessage = Union[ClearProject, AddNode, RemoveNode, AddConnection, 157 | RemoveConnection, AddPlugin, RemovePlugin, MovePlugin, 158 | SetPluginBypass, SetParameter, SetBypass, UpdateTrackClips, 159 | AddTrackClip, AddNotesToClip, RemoveNotesFromClip, 160 | SetTimelineState] 161 | -------------------------------------------------------------------------------- /architecture.mmd: -------------------------------------------------------------------------------- 1 | graph TB 2 | subgraph "Client Layer" 3 | WEB[Web UI
React/Vue/Svelte] 4 | DESKTOP[Desktop UI
Electron/Tauri] 5 | MOBILE[Mobile UI
React Native] 6 | AI_OPENAI[OpenAI
Function Calling] 7 | AI_CLAUDE[Anthropic
Claude Tools] 8 | AI_CUSTOM[Custom Agents
Your Model] 9 | end 10 | 11 | subgraph "API Layer" 12 | AGENT_TOOLKIT[Agent Toolkit
Tool Registration & Execution] 13 | FACADE[DAW Facade
Unified API Entry Point] 14 | 15 | subgraph "Service APIs" 16 | SVC_PROJECT[Project Service] 17 | SVC_TRANSPORT[Transport Service] 18 | SVC_NODE[Node Service] 19 | SVC_EDITING[Editing Service] 20 | SVC_ROUTING[Routing Service] 21 | SVC_QUERY[Query Service] 22 | SVC_HISTORY[History Service] 23 | SVC_SYSTEM[System Service] 24 | end 25 | end 26 | 27 | subgraph "Core Domain Model" 28 | MANAGER[DAW Manager
Orchestrator] 29 | 30 | subgraph "Project Context" 31 | PROJECT[Project
Container] 32 | TIMELINE[Timeline
Tempo & Time Signature] 33 | ROUTER[Router
Audio Graph DAG] 34 | CMD_MGR[Command Manager
Undo/Redo] 35 | ENGINE_CTRL[Engine Controller
Audio Lifecycle] 36 | end 37 | 38 | subgraph "Audio Nodes" 39 | TRACKS[Tracks
Instrument/Audio/Bus] 40 | MIXER[Mixer Channels
Volume/Pan/FX] 41 | PLUGINS[Plugins
VST3/AU Instances] 42 | end 43 | 44 | EVENT_BUS[Event Bus
Pub/Sub Communication] 45 | end 46 | 47 | subgraph "Backend Abstraction" 48 | SYNC[Sync Controller
Domain → Backend Sync] 49 | 50 | subgraph "Engine Implementations" 51 | ENGINE_MOCK[Mock Engine
Testing] 52 | ENGINE_PB[Pedalboard Engine
Production] 53 | ENGINE_OTHER[Other Engines
Extensible...] 54 | end 55 | 56 | PLUGIN_REGISTRY[Plugin Registry
VST3/AU Discovery] 57 | end 58 | 59 | %% Client → API connections 60 | WEB --> FACADE 61 | DESKTOP --> FACADE 62 | MOBILE --> FACADE 63 | AI_OPENAI --> AGENT_TOOLKIT 64 | AI_CLAUDE --> AGENT_TOOLKIT 65 | AI_CUSTOM --> AGENT_TOOLKIT 66 | 67 | AGENT_TOOLKIT --> FACADE 68 | 69 | %% API Layer internal 70 | FACADE --> SVC_PROJECT 71 | FACADE --> SVC_TRANSPORT 72 | FACADE --> SVC_NODE 73 | FACADE --> SVC_EDITING 74 | FACADE --> SVC_ROUTING 75 | FACADE --> SVC_QUERY 76 | FACADE --> SVC_HISTORY 77 | FACADE --> SVC_SYSTEM 78 | 79 | %% Services → Core 80 | SVC_PROJECT --> MANAGER 81 | SVC_TRANSPORT --> MANAGER 82 | SVC_NODE --> MANAGER 83 | SVC_EDITING --> MANAGER 84 | SVC_ROUTING --> MANAGER 85 | SVC_QUERY --> MANAGER 86 | SVC_HISTORY --> MANAGER 87 | SVC_SYSTEM --> MANAGER 88 | 89 | %% Core internal structure 90 | MANAGER --> PROJECT 91 | PROJECT --> TIMELINE 92 | PROJECT --> ROUTER 93 | PROJECT --> CMD_MGR 94 | PROJECT --> ENGINE_CTRL 95 | PROJECT --> EVENT_BUS 96 | 97 | ROUTER --> TRACKS 98 | TRACKS --> MIXER 99 | MIXER --> PLUGINS 100 | 101 | %% Event flow 102 | EVENT_BUS -.->|Domain Events| SYNC 103 | TIMELINE -.->|Events| EVENT_BUS 104 | ROUTER -.->|Events| EVENT_BUS 105 | MIXER -.->|Events| EVENT_BUS 106 | TRACKS -.->|Events| EVENT_BUS 107 | PROJECT -.->|Events| EVENT_BUS 108 | 109 | %% Backend connections 110 | SYNC --> ENGINE_MOCK 111 | SYNC --> ENGINE_PB 112 | SYNC --> ENGINE_OTHER 113 | 114 | ENGINE_CTRL --> ENGINE_MOCK 115 | ENGINE_CTRL --> ENGINE_PB 116 | ENGINE_CTRL --> ENGINE_OTHER 117 | 118 | ENGINE_PB --> PLUGIN_REGISTRY 119 | PLUGIN_REGISTRY -.->|Plugin Discovery| MANAGER 120 | 121 | %% Styling 122 | classDef client fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,stroke-dasharray: 5 5 123 | classDef api fill:#f3e5f5,stroke:#6a1b9a,stroke-width:3px 124 | classDef core fill:#fff3e0,stroke:#e65100,stroke-width:3px 125 | classDef backend fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px 126 | classDef bus fill:#ffebee,stroke:#c62828,stroke-width:2px 127 | 128 | class WEB,DESKTOP,MOBILE client 129 | class AI_OPENAI,AI_CLAUDE,AI_CUSTOM,AGENT_TOOLKIT,FACADE,SVC_PROJECT,SVC_TRANSPORT,SVC_NODE,SVC_EDITING,SVC_ROUTING,SVC_QUERY,SVC_HISTORY,SVC_SYSTEM api 130 | class MANAGER,PROJECT,TIMELINE,ROUTER,CMD_MGR,ENGINE_CTRL,TRACKS,MIXER,PLUGINS core 131 | class SYNC,ENGINE_MOCK,ENGINE_PB,ENGINE_OTHER,PLUGIN_REGISTRY backend 132 | class EVENT_BUS bus -------------------------------------------------------------------------------- /src/echos/core/plugin/registry.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | from typing import Dict, List, Optional 4 | from .scanner import PluginScanner 5 | from .cache import PluginCache 6 | from ...interfaces.system import IPluginRegistry 7 | from ...models import PluginDescriptor, CachedPluginInfo 8 | 9 | 10 | class PluginRegistry(IPluginRegistry): 11 | 12 | def __init__(self, scanner: PluginScanner, cache: PluginCache): 13 | self._scanner = scanner 14 | self._cache = cache 15 | 16 | self._registry_by_id: Dict[str, PluginDescriptor] = {} 17 | self._registry_by_path: Dict[Path, PluginDescriptor] = {} 18 | 19 | def load(self) -> None: 20 | print("Loading registry from cache...") 21 | self._cache.load() 22 | self.clear() 23 | 24 | for path in self._cache.get_all_cached_paths(): 25 | cached_info = self._cache.get_entry(path) 26 | if cached_info: 27 | self._add_to_memory(cached_info.descriptor) 28 | 29 | print(f"Registry loaded with {len(self._registry_by_id)} plugins.") 30 | 31 | def update(self, force_rescan: bool = False) -> None: 32 | print("\n--- Starting Registry Update ---") 33 | start_time = time.time() 34 | 35 | search_paths = self._scanner.get_default_search_paths() 36 | paths_on_disk = set(self._scanner.scan_plugin_paths(search_paths)) 37 | cached_paths = set(self._cache.get_all_cached_paths()) 38 | 39 | for path in (cached_paths - paths_on_disk): 40 | print(f" [REMOVED] Forgetting '{path.name}'") 41 | self._remove_plugin(path) 42 | 43 | for path in paths_on_disk: 44 | try: 45 | current_mod_time = path.stat().st_mtime 46 | cached_entry = self._cache.get_valid_entry(path) 47 | 48 | is_new = cached_entry is None 49 | is_updated = not is_new and cached_entry.file_mod_time != current_mod_time 50 | 51 | if force_rescan or is_new or is_updated: 52 | status = "NEW" if is_new else "UPDATED" 53 | print(f" [{status}] Scanning '{path.name}'...") 54 | self._scan_and_update_plugin(path, current_mod_time) 55 | except FileNotFoundError: 56 | if path in cached_paths: 57 | self._remove_plugin(path) 58 | 59 | self._cache.persist() 60 | end_time = time.time() 61 | print( 62 | f"--- Registry Update Complete in {end_time - start_time:.2f}s ---" 63 | ) 64 | 65 | def _scan_and_update_plugin(self, path: Path, mod_time: float): 66 | 67 | scan_result = self._scanner.scan_plugin_safe(path) 68 | 69 | if scan_result.success: 70 | try: 71 | descriptor = PluginDescriptor(**scan_result.plugin_info) 72 | cached_info = CachedPluginInfo(descriptor=descriptor, 73 | file_mod_time=mod_time) 74 | 75 | self._cache.store_entry(path, cached_info) 76 | 77 | self._remove_from_memory(path) 78 | self._add_to_memory(descriptor) 79 | 80 | print(f" -> Success: Registered '{descriptor.name}'") 81 | except (TypeError, KeyError) as e: 82 | print( 83 | f" -> Error: Scan data for '{path.name}' is malformed. {e}" 84 | ) 85 | else: 86 | print(f" -> Failed: {scan_result.error}") 87 | 88 | self._remove_plugin(path) 89 | 90 | def _add_to_memory(self, descriptor: PluginDescriptor): 91 | path = Path(descriptor.path).resolve() 92 | self._registry_by_id[descriptor.unique_plugin_id] = descriptor 93 | self._registry_by_path[path] = descriptor 94 | 95 | def _remove_from_memory(self, path: Path): 96 | resolved_path = path.resolve() 97 | descriptor = self._registry_by_path.pop(resolved_path, None) 98 | if descriptor: 99 | self._registry_by_id.pop(descriptor.unique_plugin_id, None) 100 | 101 | def _remove_plugin(self, path: Path): 102 | self._remove_from_memory(path) 103 | self._cache.remove_entry(path) 104 | 105 | def clear(self): 106 | self._registry_by_id.clear() 107 | self._registry_by_path.clear() 108 | 109 | def list_all(self) -> List[PluginDescriptor]: 110 | return list(self._registry_by_id.values()) 111 | 112 | def find_by_id(self, unique_plugin_id: str) -> Optional[PluginDescriptor]: 113 | return self._registry_by_id.get(unique_plugin_id) 114 | 115 | def find_by_path(self, path: Path) -> Optional[PluginDescriptor]: 116 | return self._registry_by_path.get(path.resolve()) 117 | -------------------------------------------------------------------------------- /src/echos/core/plugin/plugin.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any, Dict, List, Optional 3 | from echos.interfaces.system import IPluginRegistry 4 | from ..parameter import Parameter 5 | from ...interfaces.system import IPlugin, IParameter, IEventBus 6 | from ...models import PluginDescriptor, Port, PluginDescriptor 7 | from ...models.event_model import PluginEnabledChanged 8 | from ...models.state_model import PluginState 9 | 10 | 11 | class Plugin(IPlugin): 12 | 13 | def __init__(self, 14 | descriptor: PluginDescriptor, 15 | event_bus: IEventBus, 16 | plugin_instance_id: Optional[str] = None): 17 | super().__init__() 18 | self._plugin_instance_id = plugin_instance_id or f"plugin_{uuid.uuid4()}" 19 | self._descriptor = descriptor 20 | self._event_bus = event_bus 21 | self._is_enabled = True 22 | self._parameters: Dict[str, IParameter] = { 23 | name: 24 | Parameter( 25 | owner_node_id=self._plugin_instance_id, 26 | name=name, 27 | default_value=default_value, 28 | ) 29 | for name, default_value in descriptor.default_parameters.items() 30 | } 31 | 32 | @property 33 | def descriptor(self): 34 | return self._descriptor 35 | 36 | @property 37 | def plugin_instance_id(self) -> str: 38 | return self._plugin_instance_id 39 | 40 | @property 41 | def node_type(self) -> str: 42 | return "Plugin" 43 | 44 | @property 45 | def is_enabled(self) -> bool: 46 | return self._is_enabled 47 | 48 | def set_enabled(self, enabled: bool): 49 | if self._is_enabled != enabled: 50 | self._is_enabled = enabled 51 | self._event_bus.publish( 52 | PluginEnabledChanged(plugin_id=self.node_id, 53 | is_enabled=enabled)) 54 | 55 | def get_parameters(self) -> Dict[str, IParameter]: 56 | return self._parameters 57 | 58 | def get_ports(self, port_type: Optional[str] = None) -> List[Port]: 59 | return self.descriptor.available_ports 60 | 61 | def get_latency_samples(self) -> int: 62 | 63 | if self.is_enabled and self.descriptor.reports_latency: 64 | return self.descriptor.latency_samples 65 | return 0 66 | 67 | def _on_mount(self, event_bus: IEventBus): 68 | self._event_bus = event_bus 69 | 70 | def _on_unmount(self): 71 | self._event_bus = None 72 | 73 | def _get_children(self): 74 | return list(self._parameters.values()) 75 | 76 | def get_parameter_values(self) -> Dict[str, Any]: 77 | 78 | return {name: param.value for name, param in self._parameters.items()} 79 | 80 | def set_parameter_value(self, name: str, value: Any): 81 | 82 | if name in self._parameters: 83 | self._parameters[name].set_value(value) 84 | else: 85 | 86 | raise KeyError( 87 | f"Plugin '{self.node_id}' has no parameter named '{name}'") 88 | 89 | def to_state(self) -> PluginState: 90 | return PluginState(instance_id=self._node_id, 91 | unique_plugin_id=self.descriptor.unique_plugin_id, 92 | is_enabled=self._is_enabled, 93 | parameters={ 94 | name: param.to_state() 95 | for name, param in self._parameters.items() 96 | }) 97 | 98 | @classmethod 99 | def from_state(cls, state: PluginState, 100 | registry: IPluginRegistry) -> 'Plugin': 101 | 102 | if not registry: 103 | raise ValueError("Plugin.from_state requires a 'plugin_registry'") 104 | 105 | descriptor = registry.find_by_id(state.unique_plugin_id) 106 | if not descriptor: 107 | raise ValueError( 108 | f"Plugin descriptor '{state.unique_plugin_id}' not found") 109 | 110 | plugin = cls(descriptor=descriptor, 111 | event_bus=None, 112 | node_id=state.instance_id) 113 | plugin._is_enabled = state.is_enabled 114 | 115 | # Restore parameter values 116 | for param_name, param_state in state.parameters.items(): 117 | if param_name in plugin._parameters: 118 | plugin._parameters[param_name]._base_value = param_state.value 119 | plugin._parameters[ 120 | param_name]._automation_lane = param_state.automation_lane 121 | 122 | return plugin 123 | 124 | def to_dict(self) -> Dict[str, Any]: 125 | 126 | return { 127 | "node_id": self.node_id, 128 | "descriptor_uri": self.descriptor.uri, 129 | "is_enabled": self.is_enabled, 130 | "parameters": self.get_parameter_values() 131 | } 132 | -------------------------------------------------------------------------------- /src/echos/core/project.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any, Optional, List, Tuple 3 | from .router import Router 4 | from .timeline import Timeline 5 | from .history.command_manager import CommandManager 6 | from .event_bus import EventBus 7 | from .parameter import Parameter 8 | from .engine_controller import EngineController 9 | from ..interfaces.system import IProject 10 | from ..models.state_model import ProjectState 11 | 12 | 13 | class Project(IProject): 14 | 15 | def __init__(self, name: str, project_id: Optional[str] = None): 16 | super().__init__() 17 | self._project_id = project_id or f"project_{uuid.uuid4()}" 18 | self._name = name 19 | 20 | self._event_bus_instance = EventBus() 21 | self._router = Router() 22 | self._timeline = Timeline() 23 | self._command_manager = CommandManager() 24 | self._engine_controller = EngineController(router=self._router, 25 | timeline=self._timeline) 26 | 27 | self.initialize() 28 | 29 | @property 30 | def project_id(self) -> str: 31 | return self._project_id 32 | 33 | @property 34 | def name(self) -> str: 35 | return self._name 36 | 37 | @property 38 | def router(self) -> Router: 39 | return self._router 40 | 41 | @property 42 | def timeline(self) -> Timeline: 43 | return self._timeline 44 | 45 | @property 46 | def command_manager(self) -> CommandManager: 47 | return self._command_manager 48 | 49 | @property 50 | def engine_controller(self) -> EngineController: 51 | return self._engine_controller 52 | 53 | @property 54 | def event_bus(self) -> EventBus: 55 | return self._event_bus_instance 56 | 57 | def initialize(self): 58 | self.mount(self._event_bus_instance) 59 | Parameter.initialize_batch_updater(self._event_bus_instance) 60 | print(f"Project '{self._name}': ✓ Initialized") 61 | 62 | def cleanup(self): 63 | print(f"Project '{self._name}': Cleaning up...") 64 | 65 | self.unmount() 66 | 67 | Parameter.shutdown_batch_updater() 68 | 69 | self._command_manager.clear() 70 | 71 | self._event_bus_instance.clear() 72 | 73 | print(f"Project '{self._name}': ✓ Cleaned up") 74 | 75 | def validate(self) -> Tuple[bool, List[str]]: 76 | errors = [] 77 | 78 | if self._router.has_cycle(): 79 | errors.append("Graph contains cycles") 80 | 81 | if self.tempo <= 0: 82 | errors.append(f"Invalid tempo: {self.tempo}") 83 | 84 | for node in self.get_all_nodes(): 85 | if not node.node_id: 86 | errors.append(f"Node without ID: {type(node).__name__}") 87 | 88 | return len(errors) == 0, errors 89 | 90 | def get_statistics(self) -> dict: 91 | 92 | nodes = self.get_all_nodes() 93 | connections = self._router.get_all_connections() 94 | 95 | node_types = {} 96 | for node in nodes: 97 | node_type = type(node).__name__ 98 | node_types[node_type] = node_types.get(node_type, 0) + 1 99 | 100 | return { 101 | "project_id": self._project_id, 102 | "name": self.name, 103 | "tempo": self.tempo, 104 | "time_signature": self.time_signature, 105 | "transport_status": self._transport_status.value, 106 | "current_beat": self._current_beat, 107 | "node_count": len(nodes), 108 | "node_types": node_types, 109 | "connection_count": len(connections), 110 | "has_cycle": self._router.has_cycle(), 111 | "has_audio_engine": self._audio_engine is not None, 112 | } 113 | 114 | def to_state(self) -> ProjectState: 115 | return ProjectState( 116 | project_id=self._project_id, 117 | name=self._name, 118 | sample_rate=self.sample_rate, 119 | block_size=self.block_size, 120 | output_channels=self.output_channels, 121 | router=self.router.to_state(), 122 | timeline=self.timeline.timeline_state, 123 | ) 124 | 125 | @classmethod 126 | def from_state(cls, state: ProjectState, **kwargs: Any) -> 'Project': 127 | pass 128 | 129 | def _on_mount(self, event_bus: EventBus = None): 130 | self._event_bus = self._event_bus_instance 131 | 132 | def _on_unmount(self): 133 | self._event_bus = None 134 | 135 | def _get_children(self): 136 | return [self._router, self._timeline, self._engine_controller] 137 | 138 | def __repr__(self) -> str: 139 | stats = self.get_statistics() 140 | engine_status = "attached" if self._audio_engine else "no_engine" 141 | return (f"Project(name='{self.name}', " 142 | f"nodes={stats['node_count']}, " 143 | f"tempo={self.tempo}BPM, " 144 | f"status={self._transport_status.value}, " 145 | f"engine={engine_status})") 146 | -------------------------------------------------------------------------------- /src/echos/backends/pedalboard/message_handler.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Callable, Any 2 | 3 | from .messages import (AnyMessage, AddNode, RemoveNode, AddConnection, 4 | RemoveConnection, SetParameter, AddPlugin, RemovePlugin, 5 | SetBypass, ClearProject, UpdateTrackClips, AddTrackClip, 6 | MovePlugin, SetPluginBypass, AddNotesToClip, 7 | RemoveNotesFromClip, SetTimelineState, GraphMessage, 8 | TimelineMessage) 9 | 10 | from .render_graph import PedalboardRenderGraph 11 | from .timeline import RealTimeTimeline 12 | from .context import AudioEngineContext 13 | 14 | 15 | def _handle_clear_project(msg: ClearProject, graph: PedalboardRenderGraph): 16 | """清空整个项目""" 17 | graph.clear() 18 | 19 | 20 | def _handle_add_node(msg: AddNode, graph: PedalboardRenderGraph): 21 | """添加节点""" 22 | graph.add_node(msg.node_id, msg.node_type) 23 | 24 | 25 | def _handle_remove_node(msg: RemoveNode, graph: PedalboardRenderGraph): 26 | """移除节点""" 27 | graph.remove_node(msg.node_id) 28 | 29 | 30 | def _handle_add_connection(msg: AddConnection, graph: PedalboardRenderGraph): 31 | """添加音频连接""" 32 | graph.add_connection(msg.source_node_id, msg.dest_node_id) 33 | 34 | 35 | def _handle_remove_connection(msg: RemoveConnection, 36 | graph: PedalboardRenderGraph): 37 | """移除音频连接""" 38 | graph.remove_connection(msg.source_node_id, msg.dest_node_id) 39 | 40 | 41 | def _handle_add_plugin(msg: AddPlugin, graph: PedalboardRenderGraph): 42 | """添加插件到节点""" 43 | graph.add_plugin_to_node(msg.owner_node_id, msg.plugin_instance_id, 44 | msg.plugin_unique_id, msg.index) 45 | 46 | 47 | def _handle_remove_plugin(msg: RemovePlugin, graph: PedalboardRenderGraph): 48 | graph.remove_plugin_from_node(msg.owner_node_id, msg.plugin_instance_id) 49 | 50 | 51 | def _handle_move_plugin(msg: MovePlugin, graph: PedalboardRenderGraph): 52 | graph.move_plugin_in_node(msg.owner_node_id, msg.plugin_instance_id, 53 | msg.new_index) 54 | 55 | 56 | def _handle_set_parameter(msg: SetParameter, graph: PedalboardRenderGraph): 57 | 58 | graph.set_parameter(msg.node_id, msg.parameter_path, msg.value) 59 | 60 | 61 | def _handle_update_track_clips(msg: UpdateTrackClips, 62 | graph: PedalboardRenderGraph): 63 | graph.update_clips_for_track(msg.track_id, msg.clips) 64 | 65 | 66 | def _handle_add_track_clip(msg: AddTrackClip, graph: PedalboardRenderGraph): 67 | graph.add_clip_for_track(msg.track_id, msg.clip) 68 | 69 | 70 | def _handle_timeline_state_changed(msg: SetTimelineState, 71 | timeline: RealTimeTimeline): 72 | """设置tempo变化""" 73 | timeline.set_state(msg.timeline_state) 74 | print(f"[Handler] Info: Tempo change message received (handled by Engine)") 75 | 76 | 77 | # 消息处理器映射表 78 | _MESSAGE_HANDLERS: Dict[Any, Callable] = { 79 | # 项目管理 80 | ClearProject: _handle_clear_project, 81 | 82 | # 节点管理 83 | AddNode: _handle_add_node, 84 | RemoveNode: _handle_remove_node, 85 | 86 | # 连接管理 87 | AddConnection: _handle_add_connection, 88 | RemoveConnection: _handle_remove_connection, 89 | 90 | # 插件管理 91 | AddPlugin: _handle_add_plugin, 92 | RemovePlugin: _handle_remove_plugin, 93 | MovePlugin: _handle_move_plugin, 94 | 95 | # 参数设置(新消息类型) 96 | SetParameter: _handle_set_parameter, 97 | 98 | # Clip管理 99 | UpdateTrackClips: _handle_update_track_clips, 100 | AddTrackClip: _handle_add_track_clip, 101 | #AddNotesToClip: _handle_add_notes_to_clip, 102 | #RemoveNotesFromClip: _handle_remove_notes_from_clip, 103 | 104 | # Timeline管理 105 | SetTimelineState: _handle_timeline_state_changed 106 | } 107 | 108 | 109 | def process_message(msg: AnyMessage, context: AudioEngineContext): 110 | 111 | handler = _MESSAGE_HANDLERS.get(type(msg)) 112 | 113 | if handler: 114 | try: 115 | if isinstance(msg, GraphMessage): 116 | handler(msg, context.graph) 117 | elif isinstance(msg, TimelineMessage): 118 | handler(msg, context.timeline) 119 | except Exception as e: 120 | print( 121 | f"[Audio Thread Handler] CRITICAL: Error handling {type(msg).__name__}: {e}" 122 | ) 123 | import traceback 124 | traceback.print_exc() 125 | else: 126 | print( 127 | f"[Audio Thread Handler] WARNING: No handler for '{type(msg).__name__}'" 128 | ) 129 | 130 | 131 | def register_custom_handler(message_type: type, handler: Callable): 132 | _MESSAGE_HANDLERS[message_type] = handler 133 | print(f"[Handler] Registered custom handler for {message_type.__name__}") 134 | 135 | 136 | def unregister_handler(message_type: type): 137 | if message_type in _MESSAGE_HANDLERS: 138 | del _MESSAGE_HANDLERS[message_type] 139 | print(f"[Handler] Unregistered handler for {message_type.__name__}") 140 | 141 | 142 | def get_supported_message_types() -> list: 143 | return list(_MESSAGE_HANDLERS.keys()) 144 | -------------------------------------------------------------------------------- /src/echos/services/transport_service.py: -------------------------------------------------------------------------------- 1 | from ..agent.tools import tool 2 | from ..interfaces import IDAWManager, ITransportService 3 | from ..models import ToolResponse, TransportStatus 4 | from ..core.history.commands.transport_command import SetTempoCommand, SetTimeSignatureCommand 5 | 6 | 7 | class TransportService(ITransportService): 8 | 9 | def __init__(self, manager: IDAWManager): 10 | self._manager = manager 11 | 12 | @tool(category="transport", 13 | description="Start playback", 14 | returns="Playback status", 15 | examples=["play(project_id='...')"]) 16 | def play(self, project_id: str) -> ToolResponse: 17 | 18 | project = self._manager.get_project(project_id) 19 | if not project: 20 | return ToolResponse("error", None, 21 | f"Project '{project_id}' not found.") 22 | 23 | if project.engine_controller: 24 | project.engine_controller.play() 25 | return ToolResponse("success", {"status": "playing"}, 26 | "Playback started.") 27 | 28 | return ToolResponse("error", None, "No audio engine attached.") 29 | 30 | @tool(category="transport", 31 | description="Stop playback", 32 | returns="Playback status") 33 | def stop(self, project_id: str) -> ToolResponse: 34 | 35 | project = self._manager.get_project(project_id) 36 | if not project: 37 | return ToolResponse("error", None, 38 | f"Project '{project_id}' not found.") 39 | 40 | if project.engine_controller: 41 | project.engine_controller.stop() 42 | return ToolResponse("success", {"status": "stopped"}, 43 | "Playback stopped.") 44 | return ToolResponse("error", None, "No audio engine attached.") 45 | 46 | @tool(category="transport", 47 | description="Pause playback", 48 | returns="Playback status") 49 | def pause(self, project_id: str) -> ToolResponse: 50 | 51 | return ToolResponse("error", None, "Pause not implemented in engine.") 52 | 53 | @tool(category="transport", 54 | description="Set project tempo in BPM", 55 | returns="Updated tempo", 56 | examples=[ 57 | "set_tempo(project_id='...', bpm=120.0)", 58 | "set_tempo(project_id='...', bpm=140.0)" 59 | ]) 60 | def set_tempo(self, project_id: str, beat: float, 61 | bpm: float) -> ToolResponse: 62 | 63 | project = self._manager.get_project(project_id) 64 | if not project: 65 | return ToolResponse("error", None, 66 | f"Project '{project_id}' not found.") 67 | 68 | from echos.core.history.commands.transport_command import SetTempoCommand 69 | command = SetTempoCommand(project.timeline, beat, bpm) 70 | project.command_manager.execute_command(command) 71 | 72 | if command.is_executed: 73 | return ToolResponse("success", {"tempo": bpm}, command.description) 74 | return ToolResponse("error", None, command.error) 75 | 76 | @tool( 77 | category="transport", 78 | description="Set project time signature", 79 | returns="Updated time signature", 80 | examples=[ 81 | "set_time_signature(project_id='...', numerator=4, denominator=4)", 82 | "set_time_signature(project_id='...', numerator=3, denominator=4)" 83 | ]) 84 | def set_time_signature(self, project_id: str, beat: float, numerator: int, 85 | denominator: int) -> ToolResponse: 86 | 87 | project = self._manager.get_project(project_id) 88 | if not project: 89 | return ToolResponse("error", None, 90 | f"Project '{project_id}' not found.") 91 | 92 | from echos.core.history.commands.transport_command import SetTimeSignatureCommand 93 | command = SetTimeSignatureCommand(project.timeline, beat, numerator, 94 | denominator) 95 | project.command_manager.execute_command(command) 96 | 97 | if command.is_executed: 98 | return ToolResponse("success", { 99 | "numerator": numerator, 100 | "denominator": denominator 101 | }, command.description) 102 | return ToolResponse("error", None, command.error) 103 | 104 | @tool( 105 | category="transport", 106 | description="Get current transport state", 107 | returns= 108 | "Transport state including tempo, time signature, and playback status") 109 | def get_transport_state(self, project_id: str) -> ToolResponse: 110 | 111 | project = self._manager.get_project(project_id) 112 | if not project: 113 | return ToolResponse("error", None, 114 | f"Project '{project_id}' not found.") 115 | 116 | state = { 117 | "status": project.transport_status.value, 118 | "tempo": project.tempo, 119 | "time_signature": project.time_signature, 120 | "current_beat": project.current_beat 121 | } 122 | return ToolResponse("success", state, "Transport state retrieved.") 123 | -------------------------------------------------------------------------------- /src/echos/services/query_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/query_service.py 2 | from ..interfaces import IDAWManager, IQueryService, INode 3 | from ..models import ToolResponse 4 | 5 | 6 | class QueryService(IQueryService): 7 | 8 | def __init__(self, manager: IDAWManager): 9 | self._manager = manager 10 | 11 | def get_project_overview(self, project_id: str) -> ToolResponse: 12 | project = self._manager.get_project(project_id) 13 | if not project: 14 | return ToolResponse("error", None, 15 | f"Project '{project_id}' not found.") 16 | stats = project.get_statistics() 17 | return ToolResponse("success", stats, "Project overview retrieved.") 18 | 19 | def get_full_project_tree(self, project_id: str) -> ToolResponse: 20 | project = self._manager.get_project(project_id) 21 | if not project: 22 | return ToolResponse("error", None, 23 | f"Project '{project_id}' not found.") 24 | 25 | def node_to_dict(node: INode): 26 | if hasattr(node, 'to_dict'): return node.to_dict() 27 | return { 28 | "node_id": node.node_id, 29 | "name": getattr(node, 'name', 'N/A'), 30 | "type": node.node_type 31 | } 32 | 33 | tree = { 34 | "project_info": 35 | project.get_statistics(), 36 | "nodes": [node_to_dict(n) for n in project.get_all_nodes()], 37 | "connections": 38 | [c.__dict__ for c in project.router.get_all_connections()] 39 | } 40 | return ToolResponse("success", tree, "Full project tree retrieved.") 41 | 42 | def find_node_by_name(self, project_id: str, name: str) -> ToolResponse: 43 | project = self._manager.get_project(project_id) 44 | if not project: 45 | return ToolResponse("error", None, 46 | f"Project '{project_id}' not found.") 47 | 48 | found_nodes = [] 49 | for node in project.get_all_nodes(): 50 | if hasattr(node, 'name') and node.name == name: 51 | found_nodes.append({ 52 | "node_id": node.node_id, 53 | "name": node.name, 54 | "type": node.node_type 55 | }) 56 | 57 | if not found_nodes: 58 | return ToolResponse("success", {"nodes": []}, 59 | f"No node with name '{name}' found.") 60 | return ToolResponse( 61 | "success", {"nodes": found_nodes}, 62 | f"Found {len(found_nodes)} node(s) with name '{name}'.") 63 | 64 | def get_node_details(self, project_id: str, node_id: str) -> ToolResponse: 65 | project = self._manager.get_project(project_id) 66 | if not project: 67 | return ToolResponse("error", None, 68 | f"Project '{project_id}' not found.") 69 | node = project.get_node_by_id(node_id) 70 | if not node: 71 | return ToolResponse("error", None, f"Node '{node_id}' not found.") 72 | if hasattr(node, 'to_dict'): 73 | details = node.to_dict() 74 | return ToolResponse( 75 | "success", details, 76 | f"Details for node '{getattr(node, 'name', node_id)}' retrieved." 77 | ) 78 | return ToolResponse("error", None, 79 | f"Node '{node_id}' cannot be serialized.") 80 | 81 | def get_connections_for_node(self, project_id: str, 82 | node_id: str) -> ToolResponse: 83 | project = self._manager.get_project(project_id) 84 | if not project: 85 | return ToolResponse("error", None, 86 | f"Project '{project_id}' not found.") 87 | if not project.get_node_by_id(node_id): 88 | return ToolResponse("error", None, f"Node '{node_id}' not found.") 89 | 90 | router = project.router 91 | inputs = router.get_inputs_for_node(node_id) 92 | outputs = router.get_outputs_for_node(node_id) 93 | 94 | def format_conn(c): 95 | return { 96 | "source": f"{c.source_port.owner_node_id}", 97 | "dest": f"{c.dest_port.owner_node_id}" 98 | } 99 | 100 | data = { 101 | "inputs": [format_conn(c) for c in inputs], 102 | "outputs": [format_conn(c) for c in outputs] 103 | } 104 | return ToolResponse("success", data, 105 | f"Connections for node '{node_id}' retrieved.") 106 | 107 | def get_parameter_value(self, project_id: str, node_id: str, 108 | parameter_path: str) -> ToolResponse: 109 | project = self._manager.get_project(project_id) 110 | if not project: 111 | return ToolResponse("error", None, 112 | f"Project '{project_id}' not found.") 113 | node = project.get_node_by_id(node_id) 114 | if not node: 115 | return ToolResponse("error", None, f"Node '{node_id}' not found.") 116 | 117 | params = node.get_parameters() if hasattr(node, 118 | 'get_parameters') else {} 119 | param = params.get(parameter_path) 120 | 121 | if not param: 122 | return ToolResponse( 123 | "error", None, 124 | f"Parameter '{parameter_path}' not found on node '{node_id}'.") 125 | data = {"name": param.name, "value": param.value, "unit": param.unit} 126 | return ToolResponse("success", data, "Parameter value retrieved.") 127 | -------------------------------------------------------------------------------- /src/echos/core/plugin/scanner.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from pathlib import Path 3 | import json 4 | import os 5 | import platform 6 | import sys 7 | import subprocess 8 | from typing import List 9 | from ...models import PluginScanResult 10 | 11 | 12 | class PluginScanner: 13 | 14 | def __init__(self, worker_path: Path, timeout: int = 10): 15 | self.timeout = timeout 16 | self.plugin_extensions = {".vst3", ".component"} 17 | 18 | self._script_path = worker_path.resolve() 19 | if not self._script_path.is_file(): 20 | raise FileNotFoundError( 21 | f"Scan worker script not found at the specified path: {self._script_path}" 22 | ) 23 | 24 | def scan_plugin_paths(self, paths: List[Path]) -> List[Path]: 25 | 26 | found_plugins = [] 27 | 28 | for folder in paths: 29 | if not folder.exists(): 30 | continue 31 | 32 | try: 33 | for root, dirs, _ in os.walk(folder): 34 | for d in dirs: 35 | path = Path(root) / d 36 | if path.suffix.lower() in self.plugin_extensions: 37 | found_plugins.append(path) 38 | except Exception as e: 39 | print(f"Warning: Error scanning {folder}: {e}") 40 | 41 | return found_plugins 42 | 43 | def scan_plugin_safe(self, plugin_path: Path) -> PluginScanResult: 44 | try: 45 | script_path = self._script_path 46 | 47 | if not script_path.exists(): 48 | return PluginScanResult( 49 | success=False, 50 | error=f"Scan worker script not found: {script_path}") 51 | 52 | result = subprocess.run( 53 | [sys.executable, 54 | str(script_path), 55 | str(plugin_path.resolve())], 56 | capture_output=True, 57 | text=True, 58 | timeout=self.timeout) 59 | 60 | if result.returncode == 0: 61 | plugin_info = json.loads(result.stdout) 62 | return PluginScanResult(success=True, plugin_info=plugin_info) 63 | else: 64 | error_msg = result.stderr or "Unknown error" 65 | return PluginScanResult(success=False, error=error_msg) 66 | 67 | except subprocess.TimeoutExpired: 68 | return PluginScanResult(success=False, 69 | error=f"Timeout after {self.timeout}s") 70 | except Exception as e: 71 | return PluginScanResult(success=False, error=str(e)) 72 | 73 | def _get_scan_script(self) -> str: 74 | script_path = Path(__file__).parent / "scan_worker.py" 75 | return f"import sys; exec(open('{script_path}').read())" 76 | 77 | def get_default_search_paths(self) -> List[Path]: 78 | system = platform.system() 79 | paths = [] 80 | 81 | if system == "Windows": 82 | common_paths = [ 83 | Path( 84 | os.environ.get("COMMONPROGRAMFILES", 85 | "C:/Program Files/Common Files")) / "VST3" 86 | ] 87 | paths.extend(common_paths) 88 | elif system == "Darwin": 89 | paths.extend([ 90 | Path("/Library/Audio/Plug-Ins/VST3"), 91 | Path.home() / "Library/Audio/Plug-Ins/VST3", 92 | Path("/Library/Audio/Plug-Ins/Components"), 93 | Path.home() / "Library/Audio/Plug-Ins/Components", 94 | ]) 95 | else: # Linux 96 | paths.extend([ 97 | Path("/usr/lib/vst3"), 98 | Path.home() / ".vst3", 99 | ]) 100 | 101 | return [p for p in paths if p.exists()] 102 | 103 | 104 | class BackgroundScanner: 105 | 106 | def __init__(self, scanner: PluginScanner, scan_interval: int = 300): 107 | 108 | self.scanner = scanner 109 | self.scan_interval = scan_interval 110 | self._scanning = False 111 | self._last_scan_time = 0 112 | self._known_plugins: Set[Path] = set() 113 | 114 | def start_scan(self, search_paths: List[Path], on_plugin_found: callable, 115 | on_plugin_removed: callable): 116 | 117 | if self._scanning: 118 | print("Scan already in progress") 119 | return 120 | 121 | self._scanning = True 122 | current_time = time.time() 123 | 124 | if current_time - self._last_scan_time < self.scan_interval: 125 | print( 126 | f"Skipping scan, last scan was {int(current_time - self._last_scan_time)}s ago" 127 | ) 128 | self._scanning = False 129 | return 130 | 131 | print("Starting background plugin scan...") 132 | found_plugins = self.scanner.scan_plugin_paths(search_paths) 133 | current_plugins = set(found_plugins) 134 | 135 | new_plugins = current_plugins - self._known_plugins 136 | for plugin_path in new_plugins: 137 | print(f" [NEW] Scanning {plugin_path.name}...") 138 | result = self.scanner.scan_plugin_safe(plugin_path) 139 | 140 | if result.success: 141 | on_plugin_found(plugin_path, result.plugin_info) 142 | else: 143 | print(f" -> Failed: {result.error}") 144 | 145 | removed_plugins = self._known_plugins - current_plugins 146 | for plugin_path in removed_plugins: 147 | print(f" [REMOVED] {plugin_path.name}") 148 | on_plugin_removed(plugin_path) 149 | 150 | self._known_plugins = current_plugins 151 | self._last_scan_time = current_time 152 | self._scanning = False 153 | print(f"Scan complete. Total plugins: {len(self._known_plugins)}") 154 | -------------------------------------------------------------------------------- /src/echos/core/track.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import List, Optional, Dict 3 | import dataclasses 4 | from .mixer import MixerChannel 5 | from ..interfaces.system import ITrack 6 | from ..models import AnyClip, PortType, PortDirection, Port 7 | from ..models.state_model import TrackState 8 | from ..interfaces.system.ilifecycle import ILifecycleAware, IEventBus 9 | from ..interfaces.system.iparameter import IParameter 10 | 11 | 12 | class Track(ITrack): 13 | 14 | def __init__(self, name: str, node_id: Optional[str] = None): 15 | super().__init__() 16 | self._node_id = node_id or f"track_{uuid.uuid4()}" 17 | self._name = name 18 | self._clips: Dict[str, AnyClip] = {} 19 | self._mixer_channel = MixerChannel(self._node_id) 20 | self._color: Optional[str] = None 21 | self._icon: Optional[str] = None 22 | self._ports: dict[str, Port] = { 23 | "main_in": 24 | Port(port_id="main_in", 25 | port_type=PortType.AUDIO, 26 | direction=PortDirection.INPUT, 27 | channels=2), 28 | "main_out": 29 | Port(port_id="main_out", 30 | port_type=PortType.AUDIO, 31 | direction=PortDirection.OUTPUT, 32 | channels=2) 33 | } 34 | 35 | @property 36 | def node_id(self) -> str: 37 | return self._node_id 38 | 39 | @property 40 | def node_type(self) -> str: 41 | return "track" 42 | 43 | @property 44 | def name(self) -> str: 45 | return self._name 46 | 47 | @property 48 | def clips(self) -> List[AnyClip]: 49 | return sorted(list(self._clips.values()), key=lambda c: c.start_beat) 50 | 51 | @property 52 | def mixer_channel(self) -> MixerChannel: 53 | return self._mixer_channel 54 | 55 | @property 56 | def color(self) -> Optional[str]: 57 | return self._color 58 | 59 | @color.setter 60 | def color(self, value: Optional[str]): 61 | self._color = value 62 | 63 | def set_name(self, value: str): 64 | if self._name == value: 65 | return 66 | old_name = self._name 67 | self._name = value 68 | return old_name 69 | 70 | def add_clip(self, clip: AnyClip): 71 | 72 | self._clips[clip.clip_id] = clip 73 | 74 | if self.is_mounted: 75 | from ..models.event_model import ClipAdded 76 | self._event_bus.publish( 77 | ClipAdded(owner_track_id=self._node_id, clip=clip)) 78 | 79 | def remove_clip(self, clip_id: str) -> bool: 80 | 81 | clip = self._clips.pop(clip_id, None) 82 | if clip: 83 | if self.is_mounted: 84 | from ..models.event_model import ClipRemoved 85 | self._event_bus.publish( 86 | ClipRemoved(owner_track_id=self._node_id, clip_id=clip_id)) 87 | return True 88 | return False 89 | 90 | def get_parameters(self) -> Dict[str, IParameter]: 91 | return self._mixer_channel.get_parameters() 92 | 93 | def to_state(self) -> TrackState: 94 | return TrackState( 95 | node_id=self._node_id, 96 | node_type=self.node_type, 97 | name=self._name, 98 | clips=self.clips, 99 | mixer_state=self.mixer_channel.to_state(), 100 | ) 101 | 102 | @classmethod 103 | def from_state(cls, state: TrackState, **kwargs) -> 'Track': 104 | track = cls(name=state.name, node_id=state.node_id) 105 | track._clips = {c.clip_id: c for c in state.clips} 106 | track._mixer_channel = MixerChannel.from_state(state.mixer_state, 107 | **kwargs) 108 | return track 109 | 110 | def to_dict(self) -> dict: 111 | 112 | return { 113 | "node_id": self._node_id, 114 | "name": self._name, 115 | "color": self._color, 116 | "clips": [dataclasses.asdict(c) for c in self.clips], 117 | "mixer_channel": self._mixer_channel.to_dict() 118 | } 119 | 120 | def get_ports(self) -> dict[str, Port]: 121 | return self._ports 122 | 123 | def get_port_by_id(self, port_id: str) -> Optional[Port]: 124 | return self._ports.get(port_id, None) 125 | 126 | def _on_mount(self, event_bus: IEventBus): 127 | self._event_bus = event_bus 128 | 129 | def _on_unmount(self): 130 | self._event_bus = None 131 | 132 | def _get_children(self) -> List[ILifecycleAware]: 133 | return [self._mixer_channel] 134 | 135 | 136 | class InstrumentTrack(Track): 137 | 138 | def __init__(self, name: str, node_id: Optional[str] = None): 139 | super().__init__(name, node_id) 140 | 141 | @property 142 | def node_type(self) -> str: 143 | return "InstrumentTrack" 144 | 145 | 146 | class AudioTrack(Track): 147 | 148 | def __init__(self, name: str, node_id: Optional[str] = None): 149 | super().__init__(name, node_id) 150 | 151 | @property 152 | def node_type(self) -> str: 153 | return "AudioTrack" 154 | 155 | 156 | class BusTrack(Track): 157 | 158 | def __init__(self, name: str, node_id: Optional[str] = None): 159 | super().__init__(name, node_id) 160 | 161 | @property 162 | def node_type(self) -> str: 163 | return "BusTrack" 164 | 165 | 166 | class MasterTrack(BusTrack): 167 | 168 | def __init__(self, name: str, node_id: Optional[str] = None): 169 | super().__init__(name, node_id) 170 | 171 | @property 172 | def node_type(self) -> str: 173 | return "MasterTrack" 174 | 175 | 176 | class VCATrack(ITrack): 177 | 178 | def __init__(self, name: str, node_id: Optional[str] = None): 179 | super().__init__(name, node_id) 180 | 181 | @property 182 | def node_type(self) -> str: 183 | return "VCATrack" 184 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # 🎼 Echos: An API-First DAW Engine Where AI Manages the Entire Music Production Lifecycle 2 | 3 | ### Echos is not a traditional Digital Audio Workstation (DAW). It has no graphical user interface. 4 | 5 | Instead, Echos is a headless kernel: a powerful, API-driven engine designed from the ground up to serve as the core for autonomous music production systems. It provides the foundational building blocks for anyone to create their own AI-powered music tools, services, or even full-fledged DAWs. 6 | 7 | [![License](https://img.shields.io/badge/License-Apache%202.0-red.svg)](https://opensource.org/license/apache-2-0) 8 | [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) 9 | [![Status](https://img.shields.io/badge/Status-Alpha-orange.svg)]() 10 | 11 | --- 12 | ## 🌟 Our Vision: The Autonomous Producer 13 | 14 | Our vision is to empower AI to act as a complete music producer. Through a comprehensive API, an AI agent can command the entire workflow: 15 | 16 | - **Project Management:** Create, save, load, and configure projects. 17 | - **Composition & Arrangement:** Add tracks, instruments, and plugins; write MIDI notes and arrange clips. 18 | - **Sound Design:** Tweak synth parameters, configure effects chains, and create automation. 19 | - **Mixing & Mastering:** Control faders, panning, sends, and buses to achieve a perfect mix. 20 | 21 | From a single prompt, an AI can orchestrate a complete song from scratch. 22 | 23 | --- 24 | 25 | ## ✨ The Architecture of Full Control 26 | 27 | Echos is designed to make this vision a reality. 28 | 29 | ### 🤖 **A Comprehensive API for Total Control** 30 | - Our goal is to expose **every function** of a traditional DAW through a clean, programmatic toolkit. What you can click in a GUI, an AI can call via our API. 31 | - The toolkit is constantly expanding, giving AI agents ever-increasing creative and technical capabilities. 32 | 33 | ### 🎹 **A Flexible, Pluggable Audio Engine** 34 | - The core logic is **decoupled** from the audio backend via an event bus. This allows for ultimate flexibility and ensures the architecture is future-proof. 35 | - We currently ship with a high-performance backend using **Spotify's Pedalboard**, but new engines can be easily integrated. 36 | 37 | ![arch](./assets/arch.svg) 38 | --- 39 | 40 | ## 🚀 Quick Start: A Glimpse of Autonomy 41 | 42 | This example shows an AI performing a full, albeit simple, production workflow. 43 | 44 | ### Installation 45 | ```bash 46 | git clone https://github.com/linzwcs/echos.git 47 | cd echos 48 | pip install -e . 49 | pip install pedalboard sounddevice 50 | ``` 51 | 52 | ### Create A Project 53 | 54 | ```python 55 | import sys 56 | from pathlib import Path 57 | import json 58 | from echos.core import DAWManager 59 | from echos.backends.pedalboard import ( 60 | PedalboardEngineFactory, 61 | PedalboardNodeFactory, 62 | PedalboardPluginRegistry, 63 | ) 64 | from echos.core.plugin import PluginCache 65 | from echos.core.persistence import ProjectSerializer 66 | from echos.facade import DAWFacade 67 | from echos.services import * 68 | from echos.agent.tools import AgentToolkit, tool 69 | 70 | 71 | 72 | def initialize_daw_system(): 73 | 74 | print("\n" + "=" * 70) 75 | print("Initializing DAW system...") 76 | print("=" * 70) 77 | 78 | plugin_cache = PluginCache() 79 | plugin_registry = PedalboardPluginRegistry(plugin_cache) 80 | engine_factory = PedalboardEngineFactory() 81 | node_factory = PedalboardNodeFactory() 82 | serializer = ProjectSerializer(node_factory, plugin_registry) 83 | 84 | manager = DAWManager( 85 | serializer, 86 | plugin_registry, 87 | engine_factory, 88 | node_factory, 89 | ) 90 | 91 | services = { 92 | "project": ProjectService(manager), 93 | "node": NodeService(manager), 94 | "transport": TransportService(manager), 95 | "editing": EditingService(manager), 96 | "history": HistoryService(manager), 97 | "query": QueryService(manager), 98 | "system": SystemService(manager), 99 | "routing": RoutingService(manager), 100 | } 101 | 102 | # Create the Facade 103 | facade = DAWFacade(manager, services) 104 | 105 | print("✓ DAW system initialized successfully") 106 | 107 | return facade, manager 108 | 109 | facade, manager = initialize_daw_system() 110 | toolkit = create_agent_toolkit(facade) 111 | 112 | result = toolkit.execute("project.create_project", name="Pop Song") 113 | print(f" ✓ {result.message}") 114 | ``` 115 | 116 | For a detailed example of how to create a song, please refer to `demo/song_create_demo.py`. 117 | 118 | **Notice**: Before creating a song, you need to install the required VST3 instrument plugins. 119 | 120 | --- 121 | 122 | ## 🛣️ Our Roadmap: The Path to Full Autonomy 123 | 124 | Our journey is about progressively expanding the AI's capabilities across the entire production lifecycle. 125 | 126 | ### Phase 1: Core Capabilities (Current Focus) 127 | 128 | - 🔄 Solidify the API for core project, track, and plugin management. 129 | - 🔄 Improve VST3/AU hosting reliability. 130 | - 🔄 **Expand the Toolkit:** Add more essential tools for editing, mixing, and routing. 131 | 132 | ### Phase 2: Expanding the Creative Palette 133 | 134 | - 🎯 Implement robust audio and MIDI file import/export. 135 | - 🎯 Develop a comprehensive automation system via the API. 136 | - 🎯 Introduce more complex routing options like sends and groups. 137 | 138 | ### Phase 3: Intelligence and Refinement 139 | 140 | - 🔮 Build higher-level tools for common tasks (e.g., "create a drum beat," "master for Spotify"). 141 | - 🔮 Introduce a simple UI for visualizing the AI's actions. 142 | - 🔮 Explore ML-powered tools for intelligent decision-making. 143 | 144 | --- 145 | 146 | ## 🤝 Let's Build the Future Together 147 | 148 | Echos presents a unique opportunity to define the future of music production. We are looking for passionate contributors to help us build out the AI's arsenal of creative tools. Every function you expose is a new power you grant to the autonomous producer. 149 | 150 | Whether you're defining the contract for a foundational component, crafting a clean, robust programmatic API, architecting a complex and scalable new feature, or pioneering research into autonomous agents and novel AI architectures for composition, your contribution is invaluable. Join us in building the core infrastructure that will power the next generation of musical creativity. 151 | 152 | To join us, please reach out to [zhilin.nlp@gmail.com](mailto:zhilin.nlp@gmail.com). 153 | 154 | --- 155 | 156 | ## 📄 License 157 | 158 | This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. 159 | -------------------------------------------------------------------------------- /src/echos/core/history/commands/node_commands.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from ....interfaces import IRouter, INode, ITrack, IPlugin, IProject 3 | from ....interfaces.system.ifactory import INodeFactory 4 | from ....models import Connection, PluginDescriptor 5 | from ..command_base import BaseCommand 6 | 7 | 8 | class CreateTrackCommand(BaseCommand): 9 | 10 | def __init__(self, router: IRouter, node_factory: INodeFactory, 11 | track_type: str, name: str, track_id: str): 12 | super().__init__(f"Create {track_type} '{name}'") 13 | self._router = router 14 | self._node_factory = node_factory 15 | self._track_type = track_type 16 | self._name = name 17 | self._created_track: Optional[ITrack] = None 18 | 19 | def _do_execute(self) -> bool: 20 | if self._track_type == "InstrumentTrack": 21 | self._created_track = self._node_factory.create_instrument_track( 22 | self._name) 23 | elif self._track_type == "AudioTrack": 24 | self._created_track = self._node_factory.create_audio_track( 25 | self._name) 26 | elif self._track_type == "BusTrack": 27 | self._created_track = self._node_factory.create_bus_track( 28 | self._name) 29 | else: 30 | self._error = f"Unknown track type: {self._track_type}" 31 | return False 32 | self._router.add_node(self._created_track) 33 | return True 34 | 35 | def _do_undo(self) -> bool: 36 | if self._created_track: 37 | self._router.remove_node(self._created_track.node_id) 38 | return True 39 | return False 40 | 41 | 42 | class RenameNodeCommand(BaseCommand): 43 | """重命名节点的命令。""" 44 | 45 | def __init__(self, node: INode, new_name: str): 46 | self._node = node 47 | self._old_name = node.name 48 | self._new_name = new_name 49 | super().__init__(f"Rename '{self._old_name}' to '{self._new_name}'") 50 | 51 | def _do_execute(self) -> bool: 52 | self._node.name = self._new_name 53 | return True 54 | 55 | def _do_undo(self) -> bool: 56 | self._node.name = self._old_name 57 | return True 58 | 59 | 60 | class DeleteNodeCommand(BaseCommand): 61 | """删除一个节点的命令。""" 62 | 63 | def __init__(self, project: IProject, node_id: str): 64 | self._project = project 65 | self._router = project.router 66 | self._node_id = node_id 67 | self._deleted_node: Optional[INode] = None 68 | self._connections: List[Connection] = [] 69 | node_to_delete = self._router.get_node_by_id(node_id) 70 | super().__init__( 71 | f"Delete Node '{node_to_delete.name if node_to_delete else node_id}'" 72 | ) 73 | 74 | def _do_execute(self) -> bool: 75 | self._deleted_node = self._router.get_node_by_id(self._node_id) 76 | if not self._deleted_node: 77 | self._error = f"Node '{self._node_id}' not found for deletion." 78 | return False 79 | 80 | # 保存与该节点相关的连接,以便撤销 81 | self._connections.extend( 82 | self._router.get_inputs_for_node(self._node_id)) 83 | self._connections.extend( 84 | self._router.get_outputs_for_node(self._node_id)) 85 | 86 | self._router.remove_node(self._node_id) 87 | return True 88 | 89 | def _do_undo(self) -> bool: 90 | if self._deleted_node: 91 | self._router.add_node(self._deleted_node) 92 | # 重新建立连接 93 | for conn in self._connections: 94 | self._router.connect(conn.source_port, conn.dest_port) 95 | return True 96 | return False 97 | 98 | 99 | class AddInsertPluginCommand(BaseCommand): 100 | 101 | def __init__(self, track: ITrack, node_factory: INodeFactory, 102 | plugin_descriptor: PluginDescriptor, index: Optional[int]): 103 | super().__init__( 104 | f"Add Plugin '{plugin_descriptor.name}' to '{track.name}'") 105 | self._track = track 106 | self._node_factory = node_factory 107 | self._plugin_descriptor = plugin_descriptor 108 | self._index = index 109 | self._added_plugin: Optional[IPlugin] = None 110 | 111 | def _do_execute(self) -> bool: 112 | if not hasattr(self._track, 'mixer_channel'): 113 | self._error = f"Track '{self._track.name}' has no mixer channel." 114 | return False 115 | 116 | self._added_plugin = self._node_factory.create_plugin_instance( 117 | self._plugin_descriptor, self._track.event_bus) 118 | self._track.mixer_channel.add_insert(self._added_plugin, self._index) 119 | return True 120 | 121 | def _do_undo(self) -> bool: 122 | if self._added_plugin and hasattr(self._track, 'mixer_channel'): 123 | return self._track.mixer_channel.remove_insert( 124 | self._added_plugin.node_id) 125 | return False 126 | 127 | 128 | class RemoveInsertPluginCommand(BaseCommand): 129 | 130 | def __init__(self, track: ITrack, plugin_instance_id: str): 131 | self._track = track 132 | self._plugin_instance_id = plugin_instance_id 133 | self._removed_plugin: Optional[IPlugin] = None 134 | self._removed_index: Optional[int] = None 135 | 136 | plugin = next((p for p in track.mixer_channel.inserts 137 | if p.node_id == plugin_instance_id), None) 138 | super().__init__( 139 | f"Remove Plugin '{plugin.descriptor.name if plugin else plugin_instance_id}' from '{track.name}'" 140 | ) 141 | 142 | def _do_execute(self) -> bool: 143 | if not hasattr(self._track, 'mixer_channel'): 144 | self._error = f"Track '{self._track.name}' has no mixer channel." 145 | return False 146 | 147 | mixer = self._track.mixer_channel 148 | for i, plugin in enumerate(mixer.inserts): 149 | if plugin.node_id == self._plugin_instance_id: 150 | self._removed_index = i 151 | self._removed_plugin = plugin 152 | break 153 | 154 | if self._removed_plugin: 155 | return mixer.remove_insert(self._plugin_instance_id) 156 | 157 | self._error = f"Plugin instance '{self._plugin_instance_id}' not found." 158 | return False 159 | 160 | def _do_undo(self) -> bool: 161 | if self._removed_plugin and self._removed_index is not None and hasattr( 162 | self._track, 'mixer_channel'): 163 | self._track.mixer_channel.add_insert(self._removed_plugin, 164 | self._removed_index) 165 | return True 166 | return False 167 | -------------------------------------------------------------------------------- /src/echos/backends/mock/sync_controller.py: -------------------------------------------------------------------------------- 1 | from ...interfaces.system.isync import ISyncController 2 | from ...models import event_model 3 | from ...interfaces.system.ievent_bus import IEventBus 4 | 5 | 6 | class MockSyncController(ISyncController): 7 | 8 | def _on_mount(self, event_bus: IEventBus): 9 | self._event_bus = event_bus 10 | 11 | event_bus.subscribe(event_model.ProjectLoaded, self.on_project_loaded) 12 | event_bus.subscribe(event_model.ProjectClosed, self.on_project_closed) 13 | 14 | event_bus.subscribe(event_model.NodeAdded, self.on_node_added) 15 | event_bus.subscribe(event_model.NodeRemoved, self.on_node_removed) 16 | event_bus.subscribe(event_model.ConnectionAdded, 17 | self.on_connection_added) 18 | event_bus.subscribe(event_model.ConnectionRemoved, 19 | self.on_connection_removed) 20 | 21 | event_bus.subscribe(event_model.InsertAdded, self.on_insert_added) 22 | event_bus.subscribe(event_model.InsertRemoved, self.on_insert_removed) 23 | event_bus.subscribe(event_model.PluginEnabledChanged, 24 | self.on_plugin_enabled_changed) 25 | event_bus.subscribe(event_model.ParameterChanged, 26 | self.on_parameter_changed) 27 | 28 | event_bus.subscribe(event_model.TempoChanged, self.on_tempo_changed) 29 | event_bus.subscribe(event_model.TimeSignatureChanged, 30 | self.on_time_signature_changed) 31 | 32 | event_bus.subscribe(event_model.ClipAdded, self.on_clip_added) 33 | event_bus.subscribe(event_model.ClipRemoved, self.on_clip_removed) 34 | 35 | event_bus.subscribe(event_model.NoteAdded, self.on_notes_added) 36 | event_bus.subscribe(event_model.NoteRemoved, self.on_notes_removed) 37 | 38 | print( 39 | "MockSyncController: All ISyncController methods have been registered as event handlers." 40 | ) 41 | 42 | def _on_unmount(self): 43 | event_bus = self._event_bus 44 | event_bus.unsubscribe(event_model.ProjectLoaded, 45 | self.on_project_loaded) 46 | event_bus.unsubscribe(event_model.ProjectClosed, 47 | self.on_project_closed) 48 | 49 | # IGraphSync Events 50 | event_bus.unsubscribe(event_model.NodeAdded, self.on_node_added) 51 | event_bus.unsubscribe(event_model.NodeRemoved, self.on_node_removed) 52 | event_bus.unsubscribe(event_model.ConnectionAdded, 53 | self.on_connection_added) 54 | event_bus.unsubscribe(event_model.ConnectionRemoved, 55 | self.on_connection_removed) 56 | 57 | # IMixerSync Events 58 | event_bus.unsubscribe(event_model.InsertAdded, self.on_insert_added) 59 | event_bus.unsubscribe(event_model.InsertRemoved, 60 | self.on_insert_removed) 61 | event_bus.unsubscribe(event_model.PluginEnabledChanged, 62 | self.on_plugin_enabled_changed) 63 | event_bus.unsubscribe(event_model.ParameterChanged, 64 | self.on_parameter_changed) 65 | 66 | # ITransportSync Events 67 | event_bus.unsubscribe(event_model.TempoChanged, self.on_tempo_changed) 68 | event_bus.unsubscribe(event_model.TimeSignatureChanged, 69 | self.on_time_signature_changed) 70 | 71 | # ITrackSync Events 72 | event_bus.unsubscribe(event_model.ClipAdded, self.on_clip_added) 73 | event_bus.unsubscribe(event_model.ClipRemoved, self.on_clip_removed) 74 | 75 | # IClipSync Events 76 | event_bus.unsubscribe(event_model.NoteAdded, self.on_notes_added) 77 | event_bus.unsubscribe(event_model.NoteRemoved, self.on_notes_removed) 78 | 79 | self._event_bus = None 80 | print( 81 | "DawDreamerSyncController: All ISyncController methods have been registered as event handlers." 82 | ) 83 | 84 | def on_project_loaded(self, event: event_model.ProjectLoaded): 85 | print(f"Mock Sync: on_project_loaded called with event: {event}") 86 | 87 | def on_project_closed(self, event: event_model.ProjectClosed): 88 | print(f"Mock Sync: on_project_closed called with event: {event}") 89 | 90 | def on_node_added(self, event: event_model.NodeAdded): 91 | print(f"Mock Sync: on_node_added called with event: {event}") 92 | 93 | def on_node_removed(self, event: event_model.NodeRemoved): 94 | print(f"Mock Sync: on_node_removed called with event: {event}") 95 | 96 | def on_connection_added(self, event: event_model.ConnectionAdded): 97 | print(f"Mock Sync: on_connection_added called with event: {event}") 98 | 99 | def on_connection_removed(self, event: event_model.ConnectionRemoved): 100 | print(f"Mock Sync: on_connection_removed called with event: {event}") 101 | 102 | def on_insert_added(self, event: event_model.InsertAdded): 103 | print(f"Mock Sync: on_insert_added called with event: {event}") 104 | 105 | def on_insert_removed(self, event: event_model.InsertRemoved): 106 | print(f"Mock Sync: on_insert_removed called with event: {event}") 107 | 108 | def on_insert_moved(self, event: event_model.InsertMoved): 109 | print(f"Mock Sync: on_insert_moved called with event: {event}") 110 | 111 | def on_plugin_enabled_changed(self, 112 | event: event_model.PluginEnabledChanged): 113 | print( 114 | f"Mock Sync: on_plugin_enabled_changed called with event: {event}") 115 | 116 | def on_parameter_changed(self, event: event_model.ParameterChanged): 117 | print(f"Mock Sync: on_parameter_changed called with event: {event}") 118 | 119 | def on_tempo_changed(self, event: event_model.TempoChanged): 120 | print(f"Mock Sync: on_tempo_changed called with event: {event}") 121 | 122 | def on_time_signature_changed(self, 123 | event: event_model.TimeSignatureChanged): 124 | print( 125 | f"Mock Sync: on_time_signature_changed called with event: {event}") 126 | 127 | def on_clip_added(self, event: event_model.ClipAdded): 128 | print(f"Mock Sync: on_clip_added called with event: {event}") 129 | 130 | def on_clip_removed(self, event: event_model.ClipRemoved): 131 | print(f"Mock Sync: on_clip_removed called with event: {event}") 132 | 133 | def on_notes_added(self, event: event_model.NoteAdded): 134 | print(f"Mock Sync: on_notes_added called with event: {event}") 135 | 136 | def on_notes_removed(self, event: event_model.NoteRemoved): 137 | print(f"Mock Sync: on_notes_removed called with event: {event}") 138 | -------------------------------------------------------------------------------- /src/echos/services/history_service.py: -------------------------------------------------------------------------------- 1 | from ..agent.tools import tool 2 | from ..interfaces import IDAWManager, IHistoryService 3 | from ..models import ToolResponse 4 | 5 | 6 | class HistoryService: 7 | 8 | def __init__(self, manager: IDAWManager): 9 | self._manager = manager 10 | 11 | @tool(category="history", 12 | description="Undo the last operation", 13 | returns="Undo result", 14 | examples=["undo(project_id='...')"]) 15 | def undo(self, project_id: str) -> ToolResponse: 16 | """ 17 | Undo the last operation. 18 | 19 | Args: 20 | project_id: ID of the project 21 | 22 | Returns: 23 | Undo result with can_undo status 24 | """ 25 | project = self._manager.get_project(project_id) 26 | if not project: 27 | return ToolResponse("error", None, 28 | f"Project '{project_id}' not found.") 29 | 30 | if not project.command_manager.can_undo(): 31 | return ToolResponse("success", {"can_undo": False}, 32 | "Nothing to undo.") 33 | 34 | project.command_manager.undo() 35 | return ToolResponse("success", 36 | {"can_undo": project.command_manager.can_undo()}, 37 | "Undo successful.") 38 | 39 | @tool(category="history", 40 | description="Redo the last undone operation", 41 | returns="Redo result") 42 | def redo(self, project_id: str) -> ToolResponse: 43 | """ 44 | Redo the last undone operation. 45 | 46 | Args: 47 | project_id: ID of the project 48 | 49 | Returns: 50 | Redo result with can_redo status 51 | """ 52 | project = self._manager.get_project(project_id) 53 | if not project: 54 | return ToolResponse("error", None, 55 | f"Project '{project_id}' not found.") 56 | 57 | if not project.command_manager.can_redo(): 58 | return ToolResponse("success", {"can_redo": False}, 59 | "Nothing to redo.") 60 | 61 | project.command_manager.redo() 62 | return ToolResponse("success", 63 | {"can_redo": project.command_manager.can_redo()}, 64 | "Redo successful.") 65 | 66 | @tool(category="history", 67 | description="Get undo history", 68 | returns="List of undoable operations") 69 | def get_undo_history(self, project_id: str) -> ToolResponse: 70 | """ 71 | Get undo history. 72 | 73 | Args: 74 | project_id: ID of the project 75 | 76 | Returns: 77 | List of undoable operations 78 | """ 79 | project = self._manager.get_project(project_id) 80 | if not project: 81 | return ToolResponse("error", None, 82 | f"Project '{project_id}' not found.") 83 | 84 | history = project.command_manager.get_undo_history() 85 | return ToolResponse("success", {"history": history}, 86 | "Undo history retrieved.") 87 | 88 | @tool(category="history", 89 | description="Get redo history", 90 | returns="List of redoable operations") 91 | def get_redo_history(self, project_id: str) -> ToolResponse: 92 | """ 93 | Get redo history. 94 | 95 | Args: 96 | project_id: ID of the project 97 | 98 | Returns: 99 | List of redoable operations 100 | """ 101 | project = self._manager.get_project(project_id) 102 | if not project: 103 | return ToolResponse("error", None, 104 | f"Project '{project_id}' not found.") 105 | 106 | history = project.command_manager.get_redo_history() 107 | return ToolResponse("success", {"history": history}, 108 | "Redo history retrieved.") 109 | 110 | @tool(category="history", 111 | description="Check if undo is possible", 112 | returns="Boolean indicating if undo is available") 113 | def can_undo(self, project_id: str) -> ToolResponse: 114 | """ 115 | Check if undo is possible. 116 | 117 | Args: 118 | project_id: ID of the project 119 | 120 | Returns: 121 | Boolean can_undo status 122 | """ 123 | project = self._manager.get_project(project_id) 124 | if not project: 125 | return ToolResponse("error", None, 126 | f"Project '{project_id}' not found.") 127 | 128 | return ToolResponse("success", 129 | {"can_undo": project.command_manager.can_undo()}, 130 | "") 131 | 132 | @tool(category="history", 133 | description="Check if redo is possible", 134 | returns="Boolean indicating if redo is available") 135 | def can_redo(self, project_id: str) -> ToolResponse: 136 | """ 137 | Check if redo is possible. 138 | 139 | Args: 140 | project_id: ID of the project 141 | 142 | Returns: 143 | Boolean can_redo status 144 | """ 145 | project = self._manager.get_project(project_id) 146 | if not project: 147 | return ToolResponse("error", None, 148 | f"Project '{project_id}' not found.") 149 | 150 | return ToolResponse("success", 151 | {"can_redo": project.command_manager.can_redo()}, 152 | "") 153 | 154 | @tool(category="history", 155 | description="Begin a macro command (group multiple operations)", 156 | returns="Macro start confirmation") 157 | def begin_macro(self, project_id: str, description: str) -> ToolResponse: 158 | """ 159 | Begin a macro command. 160 | 161 | Args: 162 | project_id: ID of the project 163 | description: Description of the macro operation 164 | 165 | Returns: 166 | Success confirmation 167 | """ 168 | return ToolResponse("error", None, 169 | "Macro commands not exposed via service yet") 170 | 171 | @tool(category="history", 172 | description="End the current macro command", 173 | returns="Macro end confirmation") 174 | def end_macro(self, project_id: str) -> ToolResponse: 175 | """ 176 | End the current macro command. 177 | 178 | Args: 179 | project_id: ID of the project 180 | 181 | Returns: 182 | Success confirmation 183 | """ 184 | return ToolResponse("error", None, 185 | "Macro commands not exposed via service yet") 186 | -------------------------------------------------------------------------------- /src/echos/core/router.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional 2 | import networkx as nx 3 | 4 | from ..interfaces.system import IRouter, IPlugin, IEventBus, INode 5 | from ..models.router_model import Port, Connection, PortDirection 6 | from ..interfaces.system.ilifecycle import ILifecycleAware 7 | from ..models.state_model import RouterState 8 | 9 | 10 | class Router(IRouter): 11 | 12 | def __init__(self): 13 | super().__init__() 14 | self._nodes: Dict[str, INode] = {} 15 | self._connections: List[Connection] = [] 16 | self._graph = nx.DiGraph() 17 | 18 | @property 19 | def nodes(self): 20 | return self._nodes 21 | 22 | def add_node(self, node: 'INode'): 23 | if isinstance(node, IPlugin): 24 | raise ValueError( 25 | "can not add plugin to graph, you should add to mixer") 26 | 27 | node_id = node.node_id 28 | if node_id in self._nodes: 29 | print(f"Router: Node {node_id[:8]} already exists") 30 | return 31 | self._nodes[node_id] = node 32 | self._graph.add_node(node_id) 33 | 34 | if self.is_mounted: 35 | node.mount(self._event_bus) 36 | from ..models.event_model import NodeAdded 37 | self._event_bus.publish( 38 | NodeAdded( 39 | node_id=node.node_id, 40 | node_type=node.node_type, 41 | )) 42 | 43 | def remove_node(self, node_id: str): 44 | 45 | if node_id not in self._nodes: 46 | return 47 | 48 | connections_to_remove = [ 49 | c for c in self._connections 50 | if c.source_port.owner_node_id == node_id 51 | or c.dest_port.owner_node_id == node_id 52 | ] 53 | 54 | for conn in connections_to_remove: 55 | self.disconnect(conn.source_port, conn.dest_port) 56 | 57 | node = self._nodes.pop(node_id) 58 | self._graph.remove_node(node_id) 59 | node.unmount() 60 | 61 | if self.is_mounted: 62 | from ..models.event_model import NodeRemoved 63 | self._event_bus.publish(NodeRemoved(node_id=node_id)) 64 | 65 | def get_node_by_id(self, node_id: str) -> Optional['INode']: 66 | 67 | return self._nodes.get(node_id) 68 | 69 | def get_all_nodes(self) -> List['INode']: 70 | 71 | return list(self._nodes.values()) 72 | 73 | def connect(self, 74 | source_node_id: str, 75 | dest_node_id: str, 76 | source_port_id: str = "main_out", 77 | dest_port_id: str = "main_in") -> bool: 78 | 79 | source_node = self.get_node_by_id(source_node_id) 80 | dest_node = self.get_node_by_id(dest_node_id) 81 | if not source_node or not dest_node: 82 | print(f"Router: Source or destination node not found.") 83 | return False 84 | 85 | source_port: Optional[Port] = source_node.get_port_by_id( 86 | source_port_id) 87 | dest_port: Optional[Port] = dest_node.get_port_by_id(dest_port_id) 88 | if not source_port or not dest_port: 89 | print( 90 | f"Router: Source or destination port not found on the respective nodes." 91 | ) 92 | return False 93 | 94 | if source_port.direction != PortDirection.OUTPUT or dest_port.direction != PortDirection.INPUT: 95 | print( 96 | f"Router: Port direction mismatch (must be OUTPUT -> INPUT).") 97 | return False 98 | 99 | if source_port.port_type != dest_port.port_type: 100 | print( 101 | f"Router: Port type mismatch ({source_port.port_type} -> {dest_port.port_type})." 102 | ) 103 | return False 104 | 105 | new_connection = Connection(source_node_id, dest_node_id, 106 | source_port_id, dest_port_id) 107 | 108 | if new_connection in self._connections: 109 | print("Router: Connection already exists.") 110 | return False 111 | 112 | if self._would_create_cycle(source_node_id, dest_node_id): 113 | print( 114 | f"Router: Connection from {source_node_id[:6]} to {dest_node_id[:6]} would create a cycle." 115 | ) 116 | return False 117 | 118 | self._graph.add_edge(source_node_id, dest_node_id) 119 | self._connections.append(new_connection) 120 | 121 | if self.is_mounted: 122 | from ..models.event_model import ConnectionAdded 123 | self._event_bus.publish(ConnectionAdded(connection=new_connection)) 124 | 125 | return True 126 | 127 | def disconnect(self, 128 | source_node_id: str, 129 | dest_node_id: str, 130 | source_port_id: str = "main_out", 131 | dest_port_id: str = "main_in") -> bool: 132 | 133 | connection_to_remove = Connection(source_node_id, dest_node_id, 134 | source_port_id, dest_port_id) 135 | 136 | if connection_to_remove not in self._connections: 137 | return False 138 | 139 | self._connections.remove(connection_to_remove) 140 | 141 | if not any(c.source_node_id == source_node_id 142 | and c.dest_node_id == dest_node_id 143 | for c in self._connections): 144 | if self._graph.has_edge(source_node_id, dest_node_id): 145 | self._graph.remove_edge(source_node_id, dest_node_id) 146 | 147 | if self.is_mounted: 148 | from ..models.event_model import ConnectionRemoved 149 | self._event_bus.publish( 150 | ConnectionRemoved(connection=connection_to_remove)) 151 | 152 | return True 153 | 154 | def get_all_connections(self) -> List[Connection]: 155 | 156 | return self._connections.copy() 157 | 158 | def get_inputs_for_node(self, node_id: str) -> List[Connection]: 159 | 160 | if node_id not in self._nodes: 161 | return [] 162 | return [ 163 | c for c in self._connections 164 | if c.dest_port.owner_node_id == node_id 165 | ] 166 | 167 | def get_outputs_for_node(self, node_id: str) -> List[Connection]: 168 | 169 | if node_id not in self._nodes: 170 | return [] 171 | return [ 172 | c for c in self._connections 173 | if c.source_port.owner_node_id == node_id 174 | ] 175 | 176 | def get_processing_order(self) -> List[str]: 177 | 178 | try: 179 | return list(nx.topological_sort(self._graph)) 180 | except nx.NetworkXError: 181 | print( 182 | "Router: Graph has cycles, cannot determine processing order") 183 | return [] 184 | 185 | def has_cycle(self) -> bool: 186 | 187 | try: 188 | nx.find_cycle(self._graph) 189 | return True 190 | except nx.NetworkXNoCycle: 191 | return False 192 | 193 | def to_state(self) -> RouterState: 194 | return RouterState( 195 | nodes=[node.to_state() for node in self._nodes.values()], 196 | connections=self._connections[:], 197 | ) 198 | 199 | @classmethod 200 | def from_state(cls, state: RouterState) -> 'Router': 201 | pass 202 | 203 | def _on_mount(self, event_bus: IEventBus): 204 | self._event_bus = event_bus 205 | 206 | def _on_unmount(self): 207 | self._event_bus = None 208 | 209 | def _get_children(self) -> List[ILifecycleAware]: 210 | return list(self._nodes.values()) 211 | 212 | def __repr__(self) -> str: 213 | return (f"Router(nodes={len(self._nodes)}, " 214 | f"connections={len(self._connections)}, " 215 | f"has_cycle={self.has_cycle()})") 216 | -------------------------------------------------------------------------------- /src/echos/services/editing_service.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/editing_service.py 2 | from typing import Any, List, Dict 3 | from ..agent.tools import tool 4 | from ..interfaces import IDAWManager, IEditingService, ITrack 5 | from ..models import ToolResponse, Note, MIDIClip 6 | from ..core.history.commands.editing_commands import (SetParameterCommand, 7 | CreateMidiClipCommand, 8 | AddNotesToClipCommand) 9 | 10 | 11 | class EditingService(IEditingService): 12 | 13 | def __init__(self, manager: IDAWManager): 14 | self._manager = manager 15 | 16 | @tool( 17 | category="editing", 18 | description="Set a parameter value (volume, pan, etc.)", 19 | returns="Updated parameter value", 20 | examples=[ 21 | "set_parameter_value(project_id='...', node_id='...', parameter_name='volume', value=-6.0)", 22 | "set_parameter_value(project_id='...', node_id='...', parameter_name='pan', value=0.5)" 23 | ]) 24 | def set_parameter_value( 25 | self, 26 | project_id: str, 27 | node_id: str, 28 | parameter_name: str, 29 | value: Any, 30 | ) -> ToolResponse: 31 | 32 | project = self._manager.get_project(project_id) 33 | if not project: 34 | return ToolResponse("error", None, 35 | f"Project '{project_id}' not found.") 36 | 37 | node = project.get_node_by_id(node_id) 38 | if not node: 39 | return ToolResponse("error", None, f"Node '{node_id}' not found.") 40 | 41 | # Get parameters 42 | params = {} 43 | if hasattr(node, 'get_parameters'): 44 | params = node.get_parameters() 45 | elif hasattr(node, 'mixer_channel') and hasattr( 46 | node.mixer_channel, 'get_parameters'): 47 | params = node.mixer_channel.get_parameters() 48 | 49 | parameter = params.get(parameter_name) 50 | if not parameter: 51 | return ToolResponse( 52 | "error", None, 53 | f"Parameter '{parameter_name}' not found on node '{node_id}'.") 54 | 55 | from echos.core.history.commands.editing_commands import SetParameterCommand 56 | command = SetParameterCommand(parameter, value) 57 | project.command_manager.execute_command(command) 58 | 59 | if command.is_executed: 60 | return ToolResponse("success", { 61 | "node_id": node_id, 62 | "parameter": parameter_name, 63 | "value": value 64 | }, command.description) 65 | return ToolResponse("error", None, command.error) 66 | 67 | @tool(category="editing", 68 | description="Add an automation point for a parameter", 69 | returns="Automation point information") 70 | def add_automation_point( 71 | self, 72 | project_id: str, 73 | node_id: str, 74 | parameter_name: str, 75 | beat: float, 76 | value: Any, 77 | ) -> ToolResponse: 78 | return ToolResponse( 79 | "error", None, 80 | "Add automation point not implemented via command yet.") 81 | 82 | @tool( 83 | category="editing", 84 | description="Create a MIDI clip on a track", 85 | returns="Created clip information", 86 | examples=[ 87 | "create_midi_clip(project_id='...', track_id='...', start_beat=0.0, duration_beats=4.0, name='Piano Melody', clip_id=None)" 88 | ]) 89 | def create_midi_clip(self, 90 | project_id: str, 91 | track_id: str, 92 | start_beat: float, 93 | duration_beats: float, 94 | name: str = "MIDI Clip", 95 | clip_id: str = None) -> ToolResponse: 96 | project = self._manager.get_project(project_id) 97 | if not project: 98 | return ToolResponse("error", None, 99 | f"Project '{project_id}' not found.") 100 | 101 | node = project.router.nodes.get(track_id) 102 | from echos.interfaces import ITrack 103 | 104 | if not isinstance(node, ITrack): 105 | 106 | return ToolResponse("error", None, 107 | f"Node '{track_id}' is not a valid track.") 108 | 109 | from echos.core.history.commands.editing_commands import CreateMidiClipCommand 110 | command = CreateMidiClipCommand(node, 111 | start_beat, 112 | duration_beats, 113 | name, 114 | clip_id=clip_id) 115 | 116 | project.command_manager.execute_command(command) 117 | 118 | if command.is_executed: 119 | clip = command._created_clip 120 | return ToolResponse("success", { 121 | "clip_id": clip.clip_id, 122 | "track_id": track_id 123 | }, command.description) 124 | print("herehereherehereherehere") 125 | return ToolResponse("error", None, command.error) 126 | 127 | @tool(category="editing", 128 | description="Add MIDI notes to a clip", 129 | returns="Number of notes added", 130 | examples=[ 131 | '''add_notes_to_clip( 132 | project_id='...', 133 | clip_id='...', 134 | notes=[ 135 | {"pitch": 60, "velocity": 100, "start_beat": 0.0, "duration_beats": 1.0}, 136 | {"pitch": 64, "velocity": 100, "start_beat": 1.0, "duration_beats": 1.0} 137 | ] 138 | )''' 139 | ]) 140 | def add_notes_to_clip( 141 | self, 142 | project_id: str, 143 | track_id: str, 144 | clip_id: str, 145 | notes: List[Dict[str, Any]], 146 | ) -> ToolResponse: 147 | 148 | project = self._manager.get_project(project_id) 149 | if not project: 150 | return ToolResponse("error", None, 151 | f"Project '{project_id}' not found.") 152 | target_clip = None 153 | from echos.interfaces import ITrack 154 | track = project.router.nodes.get(track_id, None) 155 | if isinstance(track, ITrack): 156 | for clip in track.clips: 157 | if clip.clip_id == clip_id: 158 | target_clip = clip 159 | break 160 | 161 | if not target_clip: 162 | return ToolResponse("error", None, f"Clip '{clip_id}' not found.") 163 | 164 | from echos.models import MIDIClip, Note 165 | if not isinstance(target_clip, MIDIClip): 166 | return ToolResponse("error", None, 167 | f"Clip '{clip_id}' is not a MIDI clip.") 168 | 169 | try: 170 | notes_to_add = [] 171 | for n in notes: 172 | if isinstance(n, Note): 173 | notes_to_add.append(n) 174 | elif isinstance(n, dict): 175 | notes_to_add.append(Note(**n)) 176 | else: 177 | raise ValueError() 178 | 179 | except TypeError as e: 180 | return ToolResponse("error", None, f"Invalid note data: {e}") 181 | 182 | from echos.core.history.commands.editing_commands import AddNotesToClipCommand 183 | command = AddNotesToClipCommand(target_clip, notes_to_add) 184 | project.command_manager.execute_command(command) 185 | 186 | if command.is_executed: 187 | return ToolResponse("success", { 188 | "clip_id": clip_id, 189 | "notes_added": len(notes_to_add) 190 | }, command.description) 191 | return ToolResponse("error", None, command.error) 192 | -------------------------------------------------------------------------------- /src/echos/facade.py: -------------------------------------------------------------------------------- 1 | # file: src/MuzaiCore/services/facade.py 2 | import inspect 3 | from typing import Dict, List, Any, Optional 4 | from .interfaces import IDAWManager, IService 5 | from .models import ToolResponse, PluginDescriptor 6 | 7 | 8 | class DAWFacade: 9 | 10 | def __init__(self, manager: IDAWManager, services: Dict[str, IService]): 11 | 12 | self._manager = manager 13 | self._services = services 14 | self._active_project_id: Optional[str] = None 15 | 16 | for name, service in self._services.items(): 17 | if hasattr(self, name): 18 | 19 | raise AttributeError( 20 | f"Service name '{name}' conflicts with an existing DAWFacade attribute." 21 | ) 22 | setattr(self, name, service) 23 | 24 | def _get_active_project_id(self) -> str: 25 | 26 | if not self._active_project_id: 27 | raise ValueError( 28 | "No active project. Use 'project.create_project' and 'facade.set_active_project', " 29 | "or 'project.load_project' and 'facade.set_active_project' first." 30 | ) 31 | if not self._manager.get_project(self._active_project_id): 32 | raise ValueError( 33 | f"Active project '{self._active_project_id}' no longer exists." 34 | ) 35 | return self._active_project_id 36 | 37 | def set_active_project(self, project_id: str) -> ToolResponse: 38 | 39 | if not self._manager.get_project(project_id): 40 | return ToolResponse("error", None, 41 | f"Project '{project_id}' not found.") 42 | self._active_project_id = project_id 43 | return ToolResponse( 44 | "success", {"active_project_id": project_id}, 45 | f"Project '{project_id}' is now the active context.") 46 | 47 | def get_active_project(self) -> ToolResponse: 48 | 49 | try: 50 | project_id = self._get_active_project_id() 51 | project = self._manager.get_project(project_id) 52 | return ToolResponse( 53 | "success", { 54 | "project_id": project_id, 55 | "project_name": project.name 56 | }, 57 | f"Current active project is '{project.name}' ({project_id}).") 58 | except ValueError as e: 59 | return ToolResponse("error", None, str(e)) 60 | 61 | def list_tools(self) -> Dict[str, str]: 62 | 63 | descriptions = {} 64 | for name, service in self._services.items(): 65 | doc = inspect.getdoc(service) or f"Tools for {name} operations." 66 | 67 | descriptions[name] = doc.split('\n')[0] 68 | return descriptions 69 | 70 | def list_plugins(self) -> str: 71 | return self._manager.plugin_registry.list_all() 72 | 73 | def get_available_methods(self) -> Dict[str, List[str]]: 74 | 75 | available_methods = {} 76 | for cat_name, service_obj in self._services.items(): 77 | methods = [] 78 | for name, method in inspect.getmembers(service_obj, 79 | inspect.ismethod): 80 | 81 | if not name.startswith('_'): 82 | try: 83 | sig = str(inspect.signature(method)) 84 | methods.append(f"{name}{sig}") 85 | except ValueError: 86 | 87 | methods.append(f"{name}()") 88 | available_methods[cat_name] = sorted(methods) 89 | return available_methods 90 | 91 | def execute_tool(self, tool_name: str, **kwargs: Any) -> ToolResponse: 92 | try: 93 | if '.' not in tool_name: 94 | return ToolResponse( 95 | "error", None, 96 | f"Invalid tool name format: '{tool_name}'. Expected 'category.method'." 97 | ) 98 | category, method = tool_name.split('.', 1) 99 | service = self._services.get(category) 100 | if not service: 101 | return ToolResponse("error", None, 102 | f"Unknown service category: '{category}'") 103 | 104 | method_func = getattr(service, method, None) 105 | if not method_func or not callable( 106 | method_func) or method.startswith('_'): 107 | return ToolResponse( 108 | "error", None, 109 | f"Unknown or private method: '{method}' in category '{category}'" 110 | ) 111 | 112 | sig = inspect.signature(method_func) 113 | if 'project_id' in sig.parameters and 'project_id' not in kwargs: 114 | # Exclude specific methods like project creation/loading and system services 115 | if not (category == 'project' and method in ['create_project', 'load_project_from_state']) \ 116 | and not category == 'system': 117 | kwargs['project_id'] = self._get_active_project_id() 118 | 119 | return method_func(**kwargs) 120 | 121 | except ValueError as e: 122 | return ToolResponse("error", None, str(e)) 123 | except TypeError as e: 124 | return ToolResponse("error", None, 125 | f"Invalid arguments for {tool_name}: {e}") 126 | except Exception as e: 127 | import traceback 128 | traceback.print_exc() 129 | return ToolResponse( 130 | "error", None, 131 | f"An unexpected error occurred while executing '{tool_name}': {str(e)}" 132 | ) 133 | 134 | def get_help(self, 135 | category: Optional[str] = None, 136 | method: Optional[str] = None) -> ToolResponse: 137 | 138 | if not category: 139 | return ToolResponse( 140 | "success", { 141 | "categories": 142 | self.list_tools(), 143 | "tip": 144 | "Use get_help(category='name') for details on a specific category." 145 | }, "Available tool categories") 146 | 147 | if category not in self._services: 148 | return ToolResponse("error", None, 149 | f"Unknown category: '{category}'") 150 | 151 | if not method: 152 | methods = self.get_available_methods().get(category, []) 153 | return ToolResponse( 154 | "success", { 155 | "category": 156 | category, 157 | "description": 158 | self.list_tools().get(category, ""), 159 | "methods": 160 | methods, 161 | "tip": 162 | f"Use get_help(category='{category}', method='name') for details on a specific method." 163 | }, f"Available methods in '{category}'") 164 | 165 | service = self._services.get(category) 166 | method_func = getattr(service, method, None) 167 | if not method_func or not callable(method_func) or method.startswith( 168 | '_'): 169 | return ToolResponse( 170 | "error", None, 171 | f"Unknown or private method: '{method}' in '{category}'") 172 | 173 | doc = inspect.getdoc(method_func) or "No documentation available." 174 | signature = str(inspect.signature(method_func)) 175 | 176 | return ToolResponse( 177 | "success", { 178 | "category": category, 179 | "method": method, 180 | "signature": f"{method}{signature}", 181 | "documentation": doc.strip() 182 | }, f"Details for {category}.{method}") 183 | 184 | def __repr__(self) -> str: 185 | active_project_info = f"active_project='{self._active_project_id}'" if self._active_project_id else "no_active_project" 186 | return f"DAWFacade(services={list(self._services.keys())}, {active_project_info})" 187 | -------------------------------------------------------------------------------- /src/echos/backends/pedalboard/sync_controller.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from .messages import (AnyMessage, AddNode, RemoveNode, AddConnection, 3 | RemoveConnection, AddPlugin, RemovePlugin, 4 | SetPluginBypass, ClearProject, UpdateTrackClips, 5 | AddTrackClip, MovePlugin, SetTimelineState, 6 | AddNotesToClip, RemoveNotesFromClip, SetParameter) 7 | from ...interfaces.system.isync import ISyncController 8 | from ...models import event_model 9 | from .messages import BaseMessage 10 | 11 | if TYPE_CHECKING: 12 | from .engine import PedalboardEngine 13 | 14 | 15 | class PedalboardSyncController(ISyncController): 16 | 17 | def __init__(self, engine: 'PedalboardEngine'): 18 | super().__init__() 19 | self._engine = engine 20 | print( 21 | "PedalboardSyncController: Created as an internal component of the Engine." 22 | ) 23 | 24 | def _post_command(self, msg: BaseMessage): 25 | 26 | if self._engine: 27 | self._engine.post_command(msg) 28 | else: 29 | print( 30 | f"Sync: Warning - Engine not available. Dropping message: {msg}" 31 | ) 32 | 33 | def _on_mount(self, event_bus): 34 | self._event_bus = event_bus 35 | 36 | event_bus.subscribe(event_model.ProjectLoaded, self.on_project_loaded) 37 | event_bus.subscribe(event_model.ProjectClosed, self.on_project_closed) 38 | 39 | event_bus.subscribe(event_model.NodeAdded, self.on_node_added) 40 | event_bus.subscribe(event_model.NodeRemoved, self.on_node_removed) 41 | event_bus.subscribe(event_model.ConnectionAdded, 42 | self.on_connection_added) 43 | event_bus.subscribe(event_model.ConnectionRemoved, 44 | self.on_connection_removed) 45 | 46 | event_bus.subscribe(event_model.InsertAdded, self.on_insert_added) 47 | event_bus.subscribe(event_model.InsertRemoved, self.on_insert_removed) 48 | event_bus.subscribe(event_model.InsertMoved, self.on_insert_moved) 49 | event_bus.subscribe(event_model.PluginEnabledChanged, 50 | self.on_plugin_enabled_changed) 51 | event_bus.subscribe(event_model.ParameterChanged, 52 | self.on_parameter_changed) 53 | 54 | event_bus.subscribe(event_model.TimelineStateChanged, 55 | self.on_timeline_state_changed) 56 | 57 | event_bus.subscribe(event_model.ClipAdded, self.on_clip_added) 58 | event_bus.subscribe(event_model.ClipRemoved, self.on_clip_removed) 59 | 60 | event_bus.subscribe(event_model.NoteAdded, self.on_notes_added) 61 | event_bus.subscribe(event_model.NoteRemoved, self.on_notes_removed) 62 | 63 | print("PedalboardSyncController: Mounted - all events subscribed") 64 | 65 | def _on_unmount(self): 66 | 67 | if not self._event_bus: 68 | return 69 | 70 | event_bus = self._event_bus 71 | event_bus.unsubscribe(event_model.ProjectLoaded, 72 | self.on_project_loaded) 73 | event_bus.unsubscribe(event_model.ProjectClosed, 74 | self.on_project_closed) 75 | event_bus.unsubscribe(event_model.NodeAdded, self.on_node_added) 76 | event_bus.unsubscribe(event_model.NodeRemoved, self.on_node_removed) 77 | event_bus.unsubscribe(event_model.ConnectionAdded, 78 | self.on_connection_added) 79 | event_bus.unsubscribe(event_model.ConnectionRemoved, 80 | self.on_connection_removed) 81 | event_bus.unsubscribe(event_model.InsertAdded, self.on_insert_added) 82 | event_bus.unsubscribe(event_model.InsertRemoved, 83 | self.on_insert_removed) 84 | event_bus.unsubscribe(event_model.InsertMoved, self.on_insert_moved) 85 | event_bus.unsubscribe(event_model.PluginEnabledChanged, 86 | self.on_plugin_enabled_changed) 87 | event_bus.unsubscribe(event_model.ParameterChanged, 88 | self.on_parameter_changed) 89 | event_bus.unsubscribe(event_model.TempoChanged, self.on_tempo_changed) 90 | event_bus.unsubscribe(event_model.TimeSignatureChanged, 91 | self.on_time_signature_changed) 92 | event_bus.unsubscribe(event_model.ClipAdded, self.on_clip_added) 93 | event_bus.unsubscribe(event_model.ClipRemoved, self.on_clip_removed) 94 | event_bus.unsubscribe(event_model.NoteAdded, self.on_notes_added) 95 | event_bus.unsubscribe(event_model.NoteRemoved, self.on_notes_removed) 96 | 97 | self._event_bus = None 98 | print("PedalboardSyncController: Unmounted") 99 | 100 | def on_project_loaded(self, event: event_model.ProjectLoaded): 101 | pass 102 | 103 | def on_project_closed(self, event: event_model.ProjectClosed): 104 | pass 105 | 106 | def on_node_added(self, event: event_model.NodeAdded): 107 | self._post_command( 108 | AddNode(node_id=event.node_id, node_type=event.node_type)) 109 | 110 | def on_node_removed(self, event: event_model.NodeRemoved): 111 | self._post_command(RemoveNode(node_id=event.node_id)) 112 | 113 | def on_connection_added(self, event: event_model.ConnectionAdded): 114 | conn = event.connection 115 | self._post_command( 116 | AddConnection(source_node_id=conn.source_port.owner_node_id, 117 | dest_node_id=conn.dest_port.owner_node_id)) 118 | 119 | def on_connection_removed(self, event: event_model.ConnectionRemoved): 120 | conn = event.connection 121 | self._post_command( 122 | RemoveConnection(source_node_id=conn.source_port.owner_node_id, 123 | dest_node_id=conn.dest_port.owner_node_id)) 124 | 125 | def on_insert_added(self, event: event_model.InsertAdded): 126 | self._post_command( 127 | AddPlugin(owner_node_id=event.owner_node_id, 128 | plugin_instance_id=event.plugin_instance_id, 129 | plugin_unique_id=event.plugin_unique_id, 130 | index=event.index)) 131 | 132 | def on_insert_removed(self, event: event_model.InsertRemoved): 133 | self._post_command( 134 | RemovePlugin(owner_node_id=event.owner_node_id, 135 | plugin_instance_id=event.plugin_id)) 136 | 137 | def on_insert_moved(self, event: event_model.InsertMoved): 138 | self._post_command( 139 | MovePlugin(owner_node_id=event.owner_node_id, 140 | plugin_instance_id=event.plugin_id, 141 | old_index=event.old_index, 142 | new_index=event.new_index)) 143 | print( 144 | f"Sync: Plugin move command posted for plugin {event.plugin_id}.") 145 | 146 | def on_plugin_enabled_changed(self, 147 | event: event_model.PluginEnabledChanged): 148 | 149 | self._post_command( 150 | SetPluginBypass(plugin_instance_id=event.plugin_id, 151 | is_bypassed=not event.is_enabled)) 152 | 153 | def on_parameter_changed(self, event: event_model.ParameterChanged): 154 | self._post_command( 155 | SetParameter(owner_node_id=event.owner_node_id, 156 | parameter_path=event.param_name, 157 | value=event.new_value)) 158 | 159 | def on_clip_added(self, event: event_model.ClipAdded): 160 | self._post_command( 161 | AddTrackClip(track_id=event.owner_track_id, clip=event.clip)) 162 | 163 | def on_clip_removed(self, event: event_model.ClipRemoved): 164 | self._post_command( 165 | UpdateTrackClips(track_id=event.owner_track_id, 166 | clips=event.remaining_clips)) 167 | print(f"Sync: Clip removal on track {event.owner_track_id} synced.") 168 | 169 | def on_notes_added(self, event: event_model.NoteAdded): 170 | pass 171 | 172 | def on_notes_removed(self, event: event_model.NoteRemoved): 173 | pass 174 | 175 | def on_timeline_state_changed(self, 176 | event: event_model.TimelineStateChanged): 177 | return self._post_command(SetTimelineState(event.timeline_state)) 178 | --------------------------------------------------------------------------------